Merge remote-tracking branch 'origin/master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts: # .hermes/shared-memory/decision-log.md # src/index.test.ts
This commit is contained in:
@@ -44,6 +44,7 @@ platform-image = { workspace = true }
|
||||
platform-llm = { workspace = true }
|
||||
platform-oss = { workspace = true }
|
||||
platform-speech = { workspace = true }
|
||||
platform-wechat = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
ring = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -55,7 +56,7 @@ shared-kernel = { workspace = true }
|
||||
shared-logging = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
spacetime-client = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal"] }
|
||||
tokio-stream = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
|
||||
@@ -26,7 +26,8 @@ use shared_contracts::admin::{
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
|
||||
AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||||
@@ -212,14 +213,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -237,14 +231,7 @@ pub async fn admin_upsert_creation_entry_config(
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -268,14 +255,45 @@ pub async fn admin_upsert_creation_entry_event_banners_config(
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存公开作品详情页点赞 / 改造能力配置。
|
||||
pub async fn admin_upsert_public_work_interaction_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertPublicWorkInteractionConfigRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let snapshots = payload
|
||||
.public_work_interactions
|
||||
.into_iter()
|
||||
.map(
|
||||
|entry| module_runtime::PublicWorkInteractionConfigSnapshot {
|
||||
source_type: entry.source_type,
|
||||
like_enabled: entry.like_enabled,
|
||||
remix_enabled: entry.remix_enabled,
|
||||
like_disabled_message: entry.like_disabled_message,
|
||||
remix_disabled_message: entry.remix_disabled_message,
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let public_work_interactions_json =
|
||||
module_runtime::encode_public_work_interaction_config_snapshots(&snapshots)
|
||||
.and_then(|json| module_runtime::normalize_public_work_interaction_config_json(&json))
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
||||
let config = state
|
||||
.upsert_public_work_interaction_config(
|
||||
module_runtime::PublicWorkInteractionConfigAdminUpsertInput {
|
||||
public_work_interactions_json,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_admin_creation_entry_config_response(config),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -313,6 +331,20 @@ pub async fn admin_update_work_visibility(
|
||||
))
|
||||
}
|
||||
|
||||
fn build_admin_creation_entry_config_response(
|
||||
config: shared_contracts::creation_entry_config::CreationEntryConfigResponse,
|
||||
) -> AdminCreationEntryConfigResponse {
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
public_work_interactions: config.public_work_interactions,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_creation_entry_type_config(
|
||||
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
|
||||
) -> AdminCreationEntryTypeConfigPayload {
|
||||
@@ -884,6 +916,7 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload {
|
||||
build_admin_database_table_row_for_table("", row, columns)
|
||||
}
|
||||
|
||||
@@ -542,8 +542,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_ai_task_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -605,8 +605,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_ai_task_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -652,8 +652,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_task_mutation_routes_return_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
for route in ai_task_mutation_route_cases() {
|
||||
@@ -763,21 +763,20 @@ mod tests {
|
||||
(status, payload)
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
async fn seed_authenticated_state() -> (AppState, String) {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
let user_id = state
|
||||
.seed_test_phone_user_with_password("13800138100", "secret123")
|
||||
.await
|
||||
.id;
|
||||
state
|
||||
(state, user_id)
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
fn issue_access_token(state: &AppState, user_id: &str) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"),
|
||||
user_id: user_id.to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(user_id, "sess_ai_tasks"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -15,33 +15,20 @@ use tower_http::{
|
||||
use tracing::{Level, Span, error, info_span};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
||||
auth::AuthenticatedAccessToken,
|
||||
backpressure::limit_concurrent_requests,
|
||||
creation_entry_config::require_creation_entry_route_enabled,
|
||||
creation_entry_config::{
|
||||
require_creation_entry_route_enabled, require_public_work_interaction_enabled,
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
http_error::AppError,
|
||||
modules,
|
||||
request_context::{RequestContext, attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
state::{AppState, BackpressureState},
|
||||
telemetry::record_http_observability,
|
||||
tracking::record_route_tracking_event_after_success,
|
||||
vector_engine_audio_generation::{
|
||||
create_background_music_task, create_sound_effect_task,
|
||||
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
|
||||
publish_background_music_asset, publish_sound_effect_asset,
|
||||
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
|
||||
},
|
||||
visual_novel::{
|
||||
compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work,
|
||||
execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session,
|
||||
get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history,
|
||||
list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run,
|
||||
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
|
||||
submit_visual_novel_message, update_visual_novel_work,
|
||||
},
|
||||
wechat_pay::{
|
||||
wechat::pay::{
|
||||
handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify,
|
||||
handle_wechat_virtual_payment_notify,
|
||||
},
|
||||
@@ -57,19 +44,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(modules::profile::router(state.clone()))
|
||||
.merge(modules::assets::router(state.clone()))
|
||||
.merge(modules::platform::router(state.clone()))
|
||||
.merge(modules::story::router(state.clone()))
|
||||
.merge(modules::edutainment::router(state.clone()))
|
||||
.merge(modules::custom_world::router(state.clone()))
|
||||
.merge(modules::big_fish::router(state.clone()))
|
||||
.merge(modules::bark_battle::router(state.clone()))
|
||||
.merge(modules::match3d::router(state.clone()))
|
||||
.merge(modules::square_hole::router(state.clone()))
|
||||
.merge(modules::jump_hop::router(state.clone()))
|
||||
.merge(modules::wooden_fish::router(state.clone()))
|
||||
.merge(modules::public_work::router(state.clone()))
|
||||
.merge(modules::puzzle_clear::router(state.clone()))
|
||||
.merge(modules::puzzle::router(state.clone()))
|
||||
.merge(visual_novel_router(state.clone()))
|
||||
.merge(modules::play_flow::router(state.clone()))
|
||||
.route(
|
||||
"/api/profile/recharge/wechat/notify",
|
||||
post(handle_wechat_pay_notify),
|
||||
@@ -79,18 +54,15 @@ pub fn build_router(state: AppState) -> Router {
|
||||
get(handle_wechat_virtual_payment_message_push_verify)
|
||||
.post(handle_wechat_virtual_payment_notify),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/sessions/{runtime_session_id}/inventory",
|
||||
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
// 后端创作/运行态 API 路由只按 open 做熔断;visible 仅控制创作页入口展示。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_creation_entry_route_enabled,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_public_work_interaction_enabled,
|
||||
))
|
||||
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
BackpressureState::from_ref(&state),
|
||||
@@ -290,166 +262,6 @@ async fn record_api_tracking_after_success(
|
||||
response
|
||||
}
|
||||
|
||||
fn visual_novel_router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions",
|
||||
post(create_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}",
|
||||
get(get_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/messages",
|
||||
post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/messages/stream",
|
||||
post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/actions",
|
||||
post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/compile",
|
||||
post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works",
|
||||
get(list_visual_novel_works).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works/{profile_id}",
|
||||
get(get_visual_novel_work)
|
||||
.put(update_visual_novel_work)
|
||||
.patch(update_visual_novel_work)
|
||||
.delete(delete_visual_novel_work)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works/{profile_id}/publish",
|
||||
post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/background-music",
|
||||
post(create_visual_novel_background_music_task).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/background-music/{task_id}/asset",
|
||||
post(publish_visual_novel_background_music_asset).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/sound-effect",
|
||||
post(create_visual_novel_sound_effect_task).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/sound-effect/{task_id}/asset",
|
||||
post(publish_visual_novel_sound_effect_asset).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/background-music",
|
||||
post(create_background_music_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/background-music/{task_id}/asset",
|
||||
post(publish_background_music_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/sound-effect",
|
||||
post(create_sound_effect_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/sound-effect/{task_id}/asset",
|
||||
post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/gallery",
|
||||
get(list_visual_novel_gallery),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/works/{profile_id}/runs",
|
||||
post(start_visual_novel_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}",
|
||||
get(get_visual_novel_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/actions/stream",
|
||||
post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/history",
|
||||
get(list_visual_novel_history).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/regenerate",
|
||||
post(regenerate_visual_novel_run)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
@@ -463,6 +275,7 @@ mod tests {
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use spacetime_client::{SpacetimeClientHealthSnapshot, SpacetimeClientStage};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
@@ -783,6 +596,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_public_work_like_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_public_work_interaction_enabled(
|
||||
"puzzle",
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Like,
|
||||
false,
|
||||
);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/puzzle/gallery/profile-1/like")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"public_work_interaction_disabled"
|
||||
);
|
||||
assert_eq!(body["error"]["details"]["sourceType"], "puzzle");
|
||||
assert_eq!(body["error"]["details"]["action"], "like");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -878,6 +722,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readyz_reports_readiness_and_draining_state() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let app = build_router(state.clone());
|
||||
|
||||
let ready_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/readyz")
|
||||
.header("x-request-id", "req-ready")
|
||||
.body(Body::empty())
|
||||
.expect("readyz request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("readyz request should succeed");
|
||||
assert_eq!(ready_response.status(), StatusCode::OK);
|
||||
let ready_body = read_json_response(ready_response).await;
|
||||
assert_eq!(ready_body["ok"], Value::Bool(true));
|
||||
assert_eq!(ready_body["ready"], Value::Bool(true));
|
||||
|
||||
state.mark_not_ready();
|
||||
let draining_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/readyz")
|
||||
.header("x-request-id", "req-draining")
|
||||
.body(Body::empty())
|
||||
.expect("readyz request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("readyz request should succeed");
|
||||
assert_eq!(draining_response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let draining_body = read_json_response(draining_response).await;
|
||||
assert_eq!(
|
||||
draining_body["error"]["details"]["reason"],
|
||||
"api_server_draining"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readyz_reports_spacetime_health_stage() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_spacetime_health(SpacetimeClientHealthSnapshot {
|
||||
ok: false,
|
||||
stage: SpacetimeClientStage::ProcedureResult,
|
||||
checked_at_micros: 1_713_680_000_000_000,
|
||||
elapsed_ms: 2_000,
|
||||
timeout_ms: 2_000,
|
||||
error: Some("SpacetimeDB procedure 调用超时".to_string()),
|
||||
last_success_at_micros: Some(1_713_679_999_000_000),
|
||||
last_error: Some("SpacetimeDB procedure 调用超时".to_string()),
|
||||
});
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/readyz")
|
||||
.header("x-request-id", "req-ready-spacetime")
|
||||
.body(Body::empty())
|
||||
.expect("readyz request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("readyz request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(body["error"]["details"]["reason"], "spacetime_unhealthy");
|
||||
assert_eq!(
|
||||
body["error"]["details"]["spacetime"]["stage"],
|
||||
"procedure_result"
|
||||
);
|
||||
assert_eq!(
|
||||
body["error"]["details"]["spacetime"]["timeoutMs"],
|
||||
Value::from(2_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
@@ -1464,6 +1387,86 @@ mod tests {
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wooden_fish_session_creation_accepts_legacy_audio_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_audio_body");
|
||||
let app = build_router(state);
|
||||
let request_body = format!(
|
||||
"{{\"templateId\":\"wooden-fish\",\"hitSoundAsset\":{{\"audioSrc\":\"data:audio/webm;base64,{}\"}}",
|
||||
"A".repeat(3 * 1024 * 1024)
|
||||
);
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/creation/wooden-fish/sessions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("hitSoundAsset") || body_text.contains("missing field"),
|
||||
"handler should parse the oversized wooden fish payload before rejecting invalid JSON fields: {body_text}"
|
||||
);
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wooden_fish_actions_accept_legacy_audio_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_action_body");
|
||||
let app = build_router(state);
|
||||
let request_body = format!(
|
||||
"{{\"actionType\":\"replace-hit-sound\",\"hitSoundAsset\":{{\"audioSrc\":\"data:audio/webm;base64,{}\"}}",
|
||||
"A".repeat(3 * 1024 * 1024)
|
||||
);
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/creation/wooden-fish/sessions/wooden-fish-session-large/actions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("hitSoundAsset") || body_text.contains("missing field"),
|
||||
"handler should parse the oversized wooden fish action payload before rejecting invalid JSON fields: {body_text}"
|
||||
);
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_unknown_phone_without_registration() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -2462,7 +2465,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() {
|
||||
async fn wechat_miniprogram_login_returns_system_token_and_marks_session_label() {
|
||||
let config = AppConfig {
|
||||
wechat_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
@@ -2484,7 +2487,8 @@ mod tests {
|
||||
.header("x-mini-program-env", "develop")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"code": "wx-mini-code-001"
|
||||
"code": "wx-mini-code-001",
|
||||
"displayName": "微信旅人"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -2517,10 +2521,19 @@ mod tests {
|
||||
login_payload["bindingStatus"],
|
||||
Value::String("pending_bind_phone".to_string())
|
||||
);
|
||||
assert_eq!(login_payload["created"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
login_payload["user"]["loginMethod"],
|
||||
Value::String("wechat".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
login_payload["user"]["wechatDisplayName"],
|
||||
Value::String("微信旅人".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
login_payload["user"]["wechatAccount"],
|
||||
Value::String("wx-mini-code-001".to_string())
|
||||
);
|
||||
assert!(refresh_cookie.contains("genarrative_refresh_session="));
|
||||
|
||||
let sessions_response = app
|
||||
@@ -2545,16 +2558,23 @@ mod tests {
|
||||
let sessions_payload: Value =
|
||||
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
|
||||
assert_eq!(
|
||||
sessions_payload["sessions"][0]["clientType"],
|
||||
Value::String("mini_program".to_string())
|
||||
sessions_payload["sessions"][0]["clientLabel"],
|
||||
Value::String("微信小程序 / iPhone".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sessions_payload["sessions"][0]["clientRuntime"],
|
||||
Value::String("wechat_mini_program".to_string())
|
||||
sessions_payload["sessions"][0]["sessionCount"],
|
||||
Value::Number(1.into())
|
||||
);
|
||||
assert_eq!(
|
||||
sessions_payload["sessions"][0]["miniProgramAppId"],
|
||||
Value::String("wx-mini-test".to_string())
|
||||
sessions_payload["sessions"][0]["isCurrent"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert_eq!(
|
||||
sessions_payload["sessions"][0]["sessionIds"]
|
||||
.as_array()
|
||||
.expect("session ids should exist")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2581,7 +2601,8 @@ mod tests {
|
||||
.header("x-mini-program-env", "develop")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"code": "wx-mini-code-bind-001"
|
||||
"code": "wx-mini-code-bind-001",
|
||||
"displayName": "微信旅人"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -2607,6 +2628,7 @@ mod tests {
|
||||
login_payload["bindingStatus"],
|
||||
Value::String("pending_bind_phone".to_string())
|
||||
);
|
||||
assert_eq!(login_payload["created"], Value::Bool(true));
|
||||
|
||||
let bind_response = app
|
||||
.oneshot(
|
||||
@@ -2623,7 +2645,8 @@ mod tests {
|
||||
.header("x-mini-program-env", "develop")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"wechatPhoneCode": "13800138000"
|
||||
"wechatPhoneCode": "13800138000",
|
||||
"displayName": "微信旅人"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -2658,6 +2681,18 @@ mod tests {
|
||||
bind_payload["user"]["phoneNumberMasked"],
|
||||
Value::String("138****8000".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
bind_payload["user"]["phoneNumber"],
|
||||
Value::String("+8613800138000".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
bind_payload["user"]["wechatAccount"],
|
||||
Value::String("wx-mini-code-bind-001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
bind_payload["user"]["wechatDisplayName"],
|
||||
Value::String("微信旅人".to_string())
|
||||
);
|
||||
assert!(
|
||||
bind_payload["token"]
|
||||
.as_str()
|
||||
@@ -2862,7 +2897,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_sessions_returns_multi_device_session_fields() {
|
||||
async fn auth_sessions_returns_multi_device_session_summaries() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
@@ -2962,23 +2997,19 @@ mod tests {
|
||||
|
||||
assert_eq!(sessions.len(), 2);
|
||||
assert!(sessions.iter().any(|session| {
|
||||
session["clientType"] == Value::String("web_browser".to_string())
|
||||
&& session["clientRuntime"] == Value::String("chrome".to_string())
|
||||
&& session["clientPlatform"] == Value::String("windows".to_string())
|
||||
session["clientLabel"] == Value::String("Windows / Chrome".to_string())
|
||||
&& session["sessionCount"] == Value::Number(1.into())
|
||||
&& session["sessionIds"]
|
||||
.as_array()
|
||||
.is_some_and(|ids| ids.len() == 1)
|
||||
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
|
||||
&& session["isCurrent"] == Value::Bool(true)
|
||||
}));
|
||||
assert!(sessions.iter().any(|session| {
|
||||
session["clientType"] == Value::String("mini_program".to_string())
|
||||
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
|
||||
session["clientLabel"] == Value::String("微信小程序 / Android".to_string())
|
||||
&& session["sessionCount"] == Value::Number(1.into())
|
||||
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
|
||||
&& session["miniProgramEnv"] == Value::String("release".to_string())
|
||||
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
|
||||
&& session["sessionIds"]
|
||||
.as_array()
|
||||
.is_some_and(|ids| ids.len() == 1)
|
||||
&& session["isCurrent"] == Value::Bool(false)
|
||||
}));
|
||||
}
|
||||
@@ -3345,6 +3376,10 @@ mod tests {
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
|
||||
assert_eq!(
|
||||
payload["user"]["phoneNumber"],
|
||||
Value::String("+8613800138016".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["availableLoginMethods"],
|
||||
serde_json::json!(["phone", "password", "wechat"])
|
||||
@@ -3690,6 +3725,111 @@ mod tests {
|
||||
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_current_device_keeps_other_device_session_alive() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138031", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let first_login_response = password_login_request_with_client(
|
||||
app.clone(),
|
||||
"13800138031",
|
||||
TEST_PASSWORD,
|
||||
"logout-current-device",
|
||||
"203.0.113.41",
|
||||
)
|
||||
.await;
|
||||
let first_refresh_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first refresh cookie should exist")
|
||||
.to_string();
|
||||
let first_login_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let first_access_token = read_access_token(&first_login_body);
|
||||
|
||||
let second_login_response = password_login_request_with_client(
|
||||
app.clone(),
|
||||
"13800138031",
|
||||
TEST_PASSWORD,
|
||||
"logout-other-device",
|
||||
"203.0.113.42",
|
||||
)
|
||||
.await;
|
||||
let second_refresh_cookie = second_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("second refresh cookie should exist")
|
||||
.to_string();
|
||||
let second_login_body = second_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("second login body should collect")
|
||||
.to_bytes();
|
||||
let second_access_token = read_access_token(&second_login_body);
|
||||
|
||||
let logout_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/logout")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.header("cookie", first_refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("logout request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("logout request should succeed");
|
||||
assert_eq!(logout_response.status(), StatusCode::OK);
|
||||
|
||||
let first_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("first me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first me request should succeed");
|
||||
assert_eq!(first_me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let second_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {second_access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("second me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second me request should succeed");
|
||||
assert_eq!(second_me_response.status(), StatusCode::OK);
|
||||
|
||||
let second_refresh_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", second_refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("second refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second refresh request should succeed");
|
||||
assert_eq!(second_refresh_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -4125,6 +4265,62 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// 中文注释:验证后台作品互动配置保存后回到同一份入口配置响应。
|
||||
#[tokio::test]
|
||||
async fn admin_public_work_interactions_route_saves_form_payload() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/interactions")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"publicWorkInteractions": [
|
||||
{
|
||||
"sourceType": "puzzle",
|
||||
"likeEnabled": false,
|
||||
"remixEnabled": true,
|
||||
"likeDisabledMessage": "拼图点赞维护中。",
|
||||
"remixDisabledMessage": "拼图作品改造暂不可用。"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("interactions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("interactions request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("interactions body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("interactions payload should be json");
|
||||
let puzzle = payload["publicWorkInteractions"]
|
||||
.as_array()
|
||||
.expect("interactions should be array")
|
||||
.iter()
|
||||
.find(|item| item["sourceType"] == "puzzle")
|
||||
.expect("puzzle interaction should exist");
|
||||
|
||||
assert_eq!(puzzle["likeEnabled"], false);
|
||||
assert_eq!(puzzle["remixEnabled"], true);
|
||||
assert_eq!(puzzle["likeDisabledMessage"], "拼图点赞维护中。");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -71,6 +71,10 @@ async fn consume_asset_operation_points(
|
||||
asset_id: &str,
|
||||
points_cost: u64,
|
||||
) -> Result<bool, AppError> {
|
||||
if points_cost == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let ledger_id = format!(
|
||||
"asset_operation_consume:{}:{}:{}",
|
||||
owner_user_id, asset_kind, asset_id
|
||||
|
||||
@@ -208,6 +208,7 @@ fn direct_upload_ticket_form_fields_from_oss(
|
||||
signature: value.signature,
|
||||
success_action_status: value.success_action_status,
|
||||
content_type: value.content_type,
|
||||
cache_control: value.cache_control,
|
||||
metadata: value.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,10 @@ pub async fn require_bearer_auth(
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(authenticated) = authenticate_request(&state, &request)? else {
|
||||
let path = request.uri().path().to_string();
|
||||
let headers = request.headers().clone();
|
||||
let request_id = request_id_from_request(&request);
|
||||
let Some(authenticated) = authenticate_request(&state, path, headers, request_id).await? else {
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||
};
|
||||
request.extensions_mut().insert(authenticated.clone());
|
||||
@@ -151,7 +154,11 @@ pub async fn require_runtime_principal_auth(
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(principal) = authenticate_runtime_principal(&state, &request)? else {
|
||||
let path = request.uri().path().to_string();
|
||||
let headers = request.headers().clone();
|
||||
let request_id = request_id_from_request(&request);
|
||||
let Some(principal) = authenticate_runtime_principal(&state, path, headers, request_id).await?
|
||||
else {
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||
};
|
||||
request.extensions_mut().insert(principal.clone());
|
||||
@@ -162,24 +169,21 @@ pub async fn require_runtime_principal_auth(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn authenticate_runtime_principal(
|
||||
async fn authenticate_runtime_principal(
|
||||
state: &AppState,
|
||||
request: &Request,
|
||||
path: String,
|
||||
headers: HeaderMap,
|
||||
request_id: String,
|
||||
) -> Result<Option<RuntimePrincipal>, AppError> {
|
||||
if !request.headers().contains_key(AUTHORIZATION) {
|
||||
if !headers.contains_key(AUTHORIZATION) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match authenticate_request(state, request) {
|
||||
match authenticate_request(state, path, headers.clone(), request_id.clone()).await {
|
||||
Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(_) => {
|
||||
let bearer_token = extract_bearer_token(request.headers())?;
|
||||
let request_id = request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let bearer_token = extract_bearer_token(&headers)?;
|
||||
let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config())
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
@@ -202,26 +206,23 @@ fn authenticate_runtime_principal(
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate_request(
|
||||
async fn authenticate_request(
|
||||
state: &AppState,
|
||||
request: &Request,
|
||||
path: String,
|
||||
headers: HeaderMap,
|
||||
request_id: String,
|
||||
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
|
||||
if allows_internal_forwarded_auth(request.uri().path()) {
|
||||
if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) {
|
||||
if allows_internal_forwarded_auth(&path) {
|
||||
if let Some(claims) = try_build_internal_forwarded_claims(state, &headers) {
|
||||
return Ok(Some(AuthenticatedAccessToken::new(claims)));
|
||||
}
|
||||
}
|
||||
|
||||
if !request.headers().contains_key(AUTHORIZATION) {
|
||||
if !headers.contains_key(AUTHORIZATION) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let bearer_token = extract_bearer_token(request.headers())?;
|
||||
let request_id = request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let bearer_token = extract_bearer_token(&headers)?;
|
||||
let claims = verify_access_token(&bearer_token, state.auth_jwt_config()).map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
@@ -230,7 +231,7 @@ fn authenticate_request(
|
||||
);
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
let current_user = state
|
||||
let mut current_user = state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(claims.user_id())
|
||||
.map_err(|error| {
|
||||
@@ -240,15 +241,52 @@ fn authenticate_request(
|
||||
"Bearer JWT 用户快照读取失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
"Bearer JWT 对应用户不存在"
|
||||
);
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
if current_user.is_none() {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
"Bearer JWT 对应用户不存在,准备刷新认证工作集后复查"
|
||||
);
|
||||
if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await {
|
||||
current_user = state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(claims.user_id())
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
error = %error,
|
||||
"Bearer JWT 用户快照刷新后读取失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let Some(mut current_user) = current_user else {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
"Bearer JWT 对应用户不存在"
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||
};
|
||||
if current_user.token_version != claims.token_version() {
|
||||
if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await
|
||||
&& let Some(refreshed_user) = state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(claims.user_id())
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
error = %error,
|
||||
"Bearer JWT 用户版本刷新后读取失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})?
|
||||
{
|
||||
current_user = refreshed_user;
|
||||
}
|
||||
}
|
||||
if current_user.token_version != claims.token_version() {
|
||||
warn!(
|
||||
%request_id,
|
||||
@@ -261,7 +299,7 @@ fn authenticate_request(
|
||||
.with_message("当前登录态已失效,请重新登录"));
|
||||
}
|
||||
|
||||
let session_is_active = state
|
||||
let mut session_is_active = state
|
||||
.refresh_session_service()
|
||||
.is_session_active_for_user(
|
||||
claims.user_id(),
|
||||
@@ -278,6 +316,27 @@ fn authenticate_request(
|
||||
);
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})?;
|
||||
if !session_is_active
|
||||
&& refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await
|
||||
{
|
||||
session_is_active = state
|
||||
.refresh_session_service()
|
||||
.is_session_active_for_user(
|
||||
claims.user_id(),
|
||||
claims.session_id(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
session_id = %claims.session_id(),
|
||||
error = %error,
|
||||
"Bearer JWT refresh session 刷新后状态读取失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})?;
|
||||
}
|
||||
if !session_is_active {
|
||||
warn!(
|
||||
%request_id,
|
||||
@@ -292,6 +351,33 @@ fn authenticate_request(
|
||||
Ok(Some(AuthenticatedAccessToken::new(claims)))
|
||||
}
|
||||
|
||||
fn request_id_from_request(request: &Request) -> String {
|
||||
request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
async fn refresh_auth_store_for_stale_bearer(
|
||||
state: &AppState,
|
||||
request_id: &str,
|
||||
user_id: &str,
|
||||
) -> bool {
|
||||
match state.refresh_auth_store_from_spacetime().await {
|
||||
Ok(refreshed) => refreshed,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %user_id,
|
||||
error = %error,
|
||||
"刷新认证工作集失败,继续按本进程现有状态处理"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn inspect_auth_claims(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
|
||||
@@ -7,10 +7,13 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
|
||||
public_user_code: user.public_user_code,
|
||||
display_name: user.display_name,
|
||||
avatar_url: user.avatar_url,
|
||||
phone_number: user.phone_number,
|
||||
phone_number_masked: user.phone_number_masked,
|
||||
login_method: user.login_method.as_str().to_string(),
|
||||
binding_status: user.binding_status.as_str().to_string(),
|
||||
wechat_bound: user.wechat_bound,
|
||||
wechat_display_name: user.wechat_display_name,
|
||||
wechat_account: user.wechat_account,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ fn reject_overloaded_request(request: &Request<Body>) -> Response {
|
||||
}
|
||||
|
||||
fn should_bypass_backpressure(request: &Request<Body>) -> bool {
|
||||
request.uri().path() == "/healthz"
|
||||
matches!(request.uri().path(), "/healthz" | "/readyz")
|
||||
}
|
||||
|
||||
fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind {
|
||||
@@ -200,6 +200,7 @@ mod tests {
|
||||
.route("/held", get(held_request))
|
||||
.route("/fast", get(fast_request))
|
||||
.route("/healthz", get(fast_request))
|
||||
.route("/readyz", get(fast_request))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
backpressure_state,
|
||||
limit_concurrent_requests,
|
||||
@@ -297,6 +298,13 @@ mod tests {
|
||||
.expect("healthz request should complete");
|
||||
assert_eq!(health_response.status(), StatusCode::OK);
|
||||
|
||||
let ready_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/readyz"))
|
||||
.await
|
||||
.expect("readyz request should complete");
|
||||
assert_eq!(ready_response.status(), StatusCode::OK);
|
||||
|
||||
gate.release.notify_one();
|
||||
let completed_response = held_response
|
||||
.await
|
||||
|
||||
@@ -30,14 +30,14 @@ use shared_kernel::{
|
||||
use spacetime_client::{
|
||||
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
|
||||
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
|
||||
BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
||||
BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
asset_billing::execute_billable_asset_operation_with_cost,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||||
@@ -62,6 +62,8 @@ const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
|
||||
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
|
||||
const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-";
|
||||
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
|
||||
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE: &str = "initial_draft_generation";
|
||||
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT: u64 = 3;
|
||||
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
|
||||
const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024";
|
||||
const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792";
|
||||
@@ -303,11 +305,13 @@ pub async fn generate_bark_battle_image_asset(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
let result = execute_billable_asset_operation(
|
||||
let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await;
|
||||
let result = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
bark_battle_slot_asset_kind(&slot),
|
||||
asset_id.as_str(),
|
||||
points_cost,
|
||||
async {
|
||||
generate_and_persist_bark_battle_image_asset(
|
||||
&state,
|
||||
@@ -328,6 +332,40 @@ pub async fn generate_bark_battle_image_asset(
|
||||
Ok(json_success_body(Some(&request_context), result))
|
||||
}
|
||||
|
||||
async fn resolve_bark_battle_image_asset_points_cost(
|
||||
state: &AppState,
|
||||
payload: &BarkBattleImageAssetGenerateRequest,
|
||||
) -> u64 {
|
||||
if payload.billing_purpose.as_deref()
|
||||
!= Some(BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE)
|
||||
{
|
||||
return crate::asset_billing::ASSET_OPERATION_POINTS_COST;
|
||||
}
|
||||
|
||||
let total_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
BARK_BATTLE_PLAY_TYPE_ID,
|
||||
BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT
|
||||
* crate::asset_billing::ASSET_OPERATION_POINTS_COST,
|
||||
)
|
||||
.await;
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(&payload.slot, total_cost)
|
||||
}
|
||||
|
||||
fn resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
slot: &BarkBattleAssetSlot,
|
||||
total_cost: u64,
|
||||
) -> u64 {
|
||||
let base_cost = total_cost / BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
|
||||
let remainder = total_cost % BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
|
||||
let slot_index = match slot {
|
||||
BarkBattleAssetSlot::PlayerCharacter => 0,
|
||||
BarkBattleAssetSlot::OpponentCharacter => 1,
|
||||
BarkBattleAssetSlot::UiBackground => 2,
|
||||
};
|
||||
base_cost + u64::from(slot_index < remainder)
|
||||
}
|
||||
|
||||
pub async fn publish_bark_battle_work(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -406,6 +444,38 @@ pub async fn list_bark_battle_works(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_bark_battle_work(
|
||||
State(state): State<AppState>,
|
||||
Path(work_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.delete_bark_battle_work(BarkBattleWorkDeleteRecordInput {
|
||||
work_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let author_display_name =
|
||||
resolve_bark_battle_author_display_name_for_record(&state, &item);
|
||||
map_work_summary_record(item, &request_context, author_display_name)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
BarkBattleWorksResponse { items },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_bark_battle_gallery(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -436,13 +506,13 @@ pub async fn get_bark_battle_runtime_config(
|
||||
State(state): State<AppState>,
|
||||
Path(work_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||
|
||||
let config = state
|
||||
.spacetime_client()
|
||||
.get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string()))
|
||||
.get_bark_battle_runtime_config(work_id, Some(principal.subject().to_string()))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
@@ -456,7 +526,7 @@ pub async fn start_bark_battle_run(
|
||||
State(state): State<AppState>,
|
||||
Path(work_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||||
@@ -473,7 +543,7 @@ pub async fn start_bark_battle_run(
|
||||
};
|
||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let owner_user_id = principal.subject().to_string();
|
||||
let runtime_config = state
|
||||
.spacetime_client()
|
||||
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
|
||||
@@ -523,12 +593,13 @@ pub async fn start_bark_battle_run(
|
||||
record_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
WorkPlayTrackingDraft::runtime_principal(
|
||||
BARK_BATTLE_PLAY_TYPE_ID,
|
||||
work_id.clone(),
|
||||
&authenticated,
|
||||
&principal,
|
||||
"/api/runtime/bark-battle/...",
|
||||
)
|
||||
.owner_user_id(owner_user_id.clone())
|
||||
.extra(json!({
|
||||
"runId": run_snapshot.run_id,
|
||||
"workId": work_id,
|
||||
@@ -537,6 +608,7 @@ pub async fn start_bark_battle_run(
|
||||
"difficultyPreset": runtime_config.difficulty_preset,
|
||||
"sourceRoute": request.source_route,
|
||||
"clientRuntimeVersion": request.client_runtime_version,
|
||||
"principalKind": principal.kind().as_str(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -568,12 +640,12 @@ pub async fn get_bark_battle_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_bark_battle_run(run_id, authenticated.claims().user_id().to_string())
|
||||
.get_bark_battle_run(run_id, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
@@ -587,7 +659,7 @@ pub async fn finish_bark_battle_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||
@@ -628,7 +700,7 @@ pub async fn finish_bark_battle_run(
|
||||
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
|
||||
run_id,
|
||||
run_token: payload.run_token,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
work_id: payload.work_id.clone(),
|
||||
config_version: u64::from(payload.config_version),
|
||||
ruleset_version: payload.ruleset_version.clone(),
|
||||
@@ -1020,6 +1092,7 @@ fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str
|
||||
resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn normalize_author_display_name(display_name: Option<String>) -> String {
|
||||
display_name
|
||||
.map(|value| value.trim().to_string())
|
||||
@@ -1628,6 +1701,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_generation_slot_cost_splits_creation_entry_total_cost() {
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::PlayerCharacter,
|
||||
1,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::OpponentCharacter,
|
||||
1,
|
||||
),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::UiBackground,
|
||||
1,
|
||||
),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::PlayerCharacter,
|
||||
2,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::OpponentCharacter,
|
||||
2,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::UiBackground,
|
||||
2,
|
||||
),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::PlayerCharacter,
|
||||
6,
|
||||
),
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::OpponentCharacter,
|
||||
6,
|
||||
),
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::UiBackground,
|
||||
6,
|
||||
),
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::PlayerCharacter,
|
||||
8,
|
||||
),
|
||||
3,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::OpponentCharacter,
|
||||
8,
|
||||
),
|
||||
3,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bark_battle_initial_generation_slot_points_cost(
|
||||
&BarkBattleAssetSlot::UiBackground,
|
||||
8,
|
||||
),
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_config_mapping_includes_stable_work_identity() {
|
||||
let request_context = RequestContext::new(
|
||||
|
||||
@@ -63,7 +63,7 @@ use crate::{
|
||||
},
|
||||
api_response::json_success_body,
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
character_visual_assets::try_apply_background_alpha_to_png,
|
||||
http_error::AppError,
|
||||
platform_errors::map_oss_error,
|
||||
@@ -224,7 +224,7 @@ pub async fn record_big_fish_play(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<RecordBigFishPlayRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
@@ -242,7 +242,7 @@ pub async fn record_big_fish_play(
|
||||
.spacetime_client()
|
||||
.record_big_fish_play(BigFishPlayReportRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
user_id: principal.subject().to_string(),
|
||||
elapsed_ms: payload.elapsed_ms.unwrap_or(0),
|
||||
reported_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -254,13 +254,14 @@ pub async fn record_big_fish_play(
|
||||
record_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
WorkPlayTrackingDraft::runtime_principal(
|
||||
"big-fish",
|
||||
session_id.clone(),
|
||||
&authenticated,
|
||||
&principal,
|
||||
"/api/runtime/big-fish/sessions/{session_id}/play",
|
||||
)
|
||||
.run_id(session_id.clone()),
|
||||
.run_id(session_id.clone())
|
||||
.owner_user_id(principal.subject().to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -279,7 +280,7 @@ pub async fn start_big_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||
|
||||
@@ -288,7 +289,7 @@ pub async fn start_big_fish_run(
|
||||
.start_big_fish_run(BigFishRunStartRecordInput {
|
||||
run_id: build_prefixed_uuid_id("big-fish-run-"),
|
||||
session_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
started_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
@@ -339,13 +340,13 @@ pub async fn get_big_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
|
||||
.get_big_fish_run(run_id, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
@@ -363,7 +364,7 @@ pub async fn submit_big_fish_input(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<SubmitBigFishInputRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
@@ -384,7 +385,7 @@ pub async fn submit_big_fish_input(
|
||||
.spacetime_client()
|
||||
.submit_big_fish_input(BigFishInputSubmitRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
x: payload.x,
|
||||
y: payload.y,
|
||||
submitted_at_micros: current_utc_micros(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
|
||||
use platform_llm::{
|
||||
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
@@ -12,6 +12,7 @@ use platform_speech::{
|
||||
|
||||
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
|
||||
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
|
||||
const DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS: u64 = 2;
|
||||
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
|
||||
|
||||
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
|
||||
@@ -25,6 +26,7 @@ pub struct AppConfig {
|
||||
pub gallery_max_concurrent_requests: Option<usize>,
|
||||
pub detail_max_concurrent_requests: Option<usize>,
|
||||
pub admin_max_concurrent_requests: Option<usize>,
|
||||
pub shutdown_outbox_flush_timeout: Duration,
|
||||
pub tracking_outbox_enabled: bool,
|
||||
pub tracking_outbox_dir: PathBuf,
|
||||
pub tracking_outbox_batch_size: usize,
|
||||
@@ -99,6 +101,10 @@ pub struct AppConfig {
|
||||
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
|
||||
pub wechat_mini_program_message_token: Option<String>,
|
||||
pub wechat_mini_program_message_encoding_aes_key: Option<String>,
|
||||
pub wechat_mini_program_subscribe_message_enabled: bool,
|
||||
pub wechat_mini_program_generation_result_template_id: Option<String>,
|
||||
pub wechat_mini_program_subscribe_message_endpoint: String,
|
||||
pub wechat_mini_program_subscribe_message_state: String,
|
||||
pub wechat_mini_program_virtual_payment_env: u8,
|
||||
pub oss_bucket: Option<String>,
|
||||
pub oss_endpoint: Option<String>,
|
||||
@@ -113,6 +119,7 @@ pub struct AppConfig {
|
||||
pub spacetime_token: Option<String>,
|
||||
pub spacetime_pool_size: u32,
|
||||
pub spacetime_procedure_timeout: Duration,
|
||||
pub spacetime_health_check_timeout: Duration,
|
||||
pub llm_provider: LlmProvider,
|
||||
pub llm_base_url: String,
|
||||
pub llm_api_key: Option<String>,
|
||||
@@ -128,9 +135,10 @@ pub struct AppConfig {
|
||||
pub dashscope_reference_image_model: String,
|
||||
pub dashscope_cover_image_model: String,
|
||||
pub dashscope_image_request_timeout_ms: u64,
|
||||
pub apimart_base_url: String,
|
||||
pub apimart_api_key: Option<String>,
|
||||
pub apimart_image_request_timeout_ms: u64,
|
||||
// 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine(同时支持 Chat Completions / Responses 协议)。
|
||||
// pub apimart_base_url: String,
|
||||
// pub apimart_api_key: Option<String>,
|
||||
// pub apimart_image_request_timeout_ms: u64,
|
||||
pub vector_engine_base_url: String,
|
||||
pub vector_engine_api_key: Option<String>,
|
||||
pub vector_engine_image_request_timeout_ms: u64,
|
||||
@@ -169,6 +177,7 @@ impl Default for AppConfig {
|
||||
gallery_max_concurrent_requests: None,
|
||||
detail_max_concurrent_requests: None,
|
||||
admin_max_concurrent_requests: None,
|
||||
shutdown_outbox_flush_timeout: Duration::from_millis(5_000),
|
||||
tracking_outbox_enabled: true,
|
||||
tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"),
|
||||
tracking_outbox_batch_size: 500,
|
||||
@@ -248,6 +257,13 @@ impl Default for AppConfig {
|
||||
wechat_mini_program_virtual_payment_sandbox_app_key: None,
|
||||
wechat_mini_program_message_token: None,
|
||||
wechat_mini_program_message_encoding_aes_key: None,
|
||||
wechat_mini_program_subscribe_message_enabled: true,
|
||||
wechat_mini_program_generation_result_template_id: Some(
|
||||
"m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU".to_string(),
|
||||
),
|
||||
wechat_mini_program_subscribe_message_endpoint:
|
||||
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send".to_string(),
|
||||
wechat_mini_program_subscribe_message_state: "formal".to_string(),
|
||||
wechat_mini_program_virtual_payment_env: 0,
|
||||
oss_bucket: None,
|
||||
oss_endpoint: None,
|
||||
@@ -262,6 +278,9 @@ impl Default for AppConfig {
|
||||
spacetime_token: None,
|
||||
spacetime_pool_size: 4,
|
||||
spacetime_procedure_timeout: Duration::from_secs(30),
|
||||
spacetime_health_check_timeout: Duration::from_secs(
|
||||
DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS,
|
||||
),
|
||||
llm_provider: LlmProvider::Ark,
|
||||
llm_base_url: String::new(),
|
||||
llm_api_key: None,
|
||||
@@ -280,9 +299,9 @@ impl Default for AppConfig {
|
||||
dashscope_reference_image_model: String::new(),
|
||||
dashscope_cover_image_model: String::new(),
|
||||
dashscope_image_request_timeout_ms: 150_000,
|
||||
apimart_base_url: String::new(),
|
||||
apimart_api_key: None,
|
||||
apimart_image_request_timeout_ms: 180_000,
|
||||
// apimart_base_url: String::new(),
|
||||
// apimart_api_key: None,
|
||||
// apimart_image_request_timeout_ms: 180_000,
|
||||
vector_engine_base_url: String::new(),
|
||||
vector_engine_api_key: None,
|
||||
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||||
@@ -365,6 +384,11 @@ impl AppConfig {
|
||||
{
|
||||
config.admin_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(timeout_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"])
|
||||
{
|
||||
config.shutdown_outbox_flush_timeout = Duration::from_millis(timeout_ms);
|
||||
}
|
||||
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) {
|
||||
config.tracking_outbox_enabled = enabled;
|
||||
}
|
||||
@@ -606,6 +630,26 @@ impl AppConfig {
|
||||
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]);
|
||||
config.wechat_mini_program_message_encoding_aes_key =
|
||||
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]);
|
||||
if let Some(enabled) =
|
||||
read_first_bool_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"])
|
||||
{
|
||||
config.wechat_mini_program_subscribe_message_enabled = enabled;
|
||||
}
|
||||
if let Some(template_id) =
|
||||
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"])
|
||||
{
|
||||
config.wechat_mini_program_generation_result_template_id = Some(template_id);
|
||||
}
|
||||
if let Some(endpoint) =
|
||||
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENDPOINT"])
|
||||
{
|
||||
config.wechat_mini_program_subscribe_message_endpoint = endpoint;
|
||||
}
|
||||
if let Some(state) =
|
||||
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"])
|
||||
{
|
||||
config.wechat_mini_program_subscribe_message_state = state;
|
||||
}
|
||||
if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
|
||||
&& env <= 1
|
||||
{
|
||||
@@ -665,6 +709,12 @@ impl AppConfig {
|
||||
config.spacetime_procedure_timeout =
|
||||
Duration::from_secs(spacetime_procedure_timeout_seconds);
|
||||
}
|
||||
if let Some(spacetime_health_check_timeout_seconds) =
|
||||
read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS"])
|
||||
{
|
||||
config.spacetime_health_check_timeout =
|
||||
Duration::from_secs(spacetime_health_check_timeout_seconds);
|
||||
}
|
||||
|
||||
if let Some(llm_provider) =
|
||||
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
|
||||
@@ -753,17 +803,17 @@ impl AppConfig {
|
||||
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
|
||||
}
|
||||
|
||||
if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) {
|
||||
config.apimart_base_url = apimart_base_url;
|
||||
}
|
||||
|
||||
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
|
||||
|
||||
if let Some(apimart_image_request_timeout_ms) =
|
||||
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
|
||||
{
|
||||
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
|
||||
}
|
||||
// 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。
|
||||
// 保留以下历史加载代码,后续删除:
|
||||
// if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) {
|
||||
// config.apimart_base_url = apimart_base_url;
|
||||
// }
|
||||
// config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
|
||||
// if let Some(apimart_image_request_timeout_ms) =
|
||||
// read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
|
||||
// {
|
||||
// config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
|
||||
// }
|
||||
|
||||
if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"])
|
||||
{
|
||||
@@ -1151,7 +1201,7 @@ mod tests {
|
||||
|
||||
assert!(config.llm_model.is_empty());
|
||||
assert!(config.llm_base_url.is_empty());
|
||||
assert!(config.apimart_base_url.is_empty());
|
||||
// assert!(config.apimart_base_url.is_empty());
|
||||
assert!(config.vector_engine_base_url.is_empty());
|
||||
assert!(config.ark_character_video_base_url.is_empty());
|
||||
assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2");
|
||||
@@ -1247,11 +1297,11 @@ mod tests {
|
||||
assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible);
|
||||
assert_eq!(config.llm_base_url, "https://llm.internal.example/v1");
|
||||
assert_eq!(config.llm_model, "internal-text-model");
|
||||
assert_eq!(
|
||||
config.apimart_base_url,
|
||||
"https://responses.internal.example/v1"
|
||||
);
|
||||
assert_eq!(config.apimart_image_request_timeout_ms, 190_000);
|
||||
// assert_eq!(
|
||||
// config.apimart_base_url,
|
||||
// "https://responses.internal.example/v1"
|
||||
// );
|
||||
// assert_eq!(config.apimart_image_request_timeout_ms, 190_000);
|
||||
assert_eq!(
|
||||
config.vector_engine_base_url,
|
||||
"https://vector.internal.example"
|
||||
@@ -1324,6 +1374,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
@@ -1336,6 +1387,7 @@ mod tests {
|
||||
std::env::set_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
|
||||
std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
|
||||
std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
|
||||
std::env::set_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_TRACKING_OUTBOX_DIR",
|
||||
@@ -1354,6 +1406,10 @@ mod tests {
|
||||
assert_eq!(config.gallery_max_concurrent_requests, Some(64));
|
||||
assert_eq!(config.detail_max_concurrent_requests, Some(32));
|
||||
assert_eq!(config.admin_max_concurrent_requests, Some(16));
|
||||
assert_eq!(
|
||||
config.shutdown_outbox_flush_timeout,
|
||||
std::time::Duration::from_millis(3_000)
|
||||
);
|
||||
assert!(!config.tracking_outbox_enabled);
|
||||
assert_eq!(
|
||||
config.tracking_outbox_dir,
|
||||
@@ -1374,6 +1430,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
@@ -1405,6 +1462,9 @@ mod tests {
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||
std::env::set_var("WECHAT_PAY_ENABLED", "true");
|
||||
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
|
||||
@@ -1432,6 +1492,12 @@ mod tests {
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
||||
);
|
||||
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED", "true");
|
||||
std::env::set_var(
|
||||
"WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID",
|
||||
"tmpl-generation-result",
|
||||
);
|
||||
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE", "trial");
|
||||
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
|
||||
}
|
||||
|
||||
@@ -1483,6 +1549,14 @@ mod tests {
|
||||
.as_deref(),
|
||||
Some("sandbox-app-key-001")
|
||||
);
|
||||
assert!(config.wechat_mini_program_subscribe_message_enabled);
|
||||
assert_eq!(
|
||||
config
|
||||
.wechat_mini_program_generation_result_template_id
|
||||
.as_deref(),
|
||||
Some("tmpl-generation-result")
|
||||
);
|
||||
assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial");
|
||||
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
|
||||
|
||||
unsafe {
|
||||
@@ -1500,6 +1574,9 @@ mod tests {
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||
}
|
||||
}
|
||||
@@ -1544,6 +1621,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_spacetime_health_check_timeout() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS");
|
||||
std::env::set_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS", "3");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert_eq!(config.spacetime_health_check_timeout.as_secs(), 3);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keeps_structured_llm_web_search_disabled() {
|
||||
let config = AppConfig::default();
|
||||
|
||||
@@ -11,11 +11,27 @@ use serde_json::{Value, json};
|
||||
use module_runtime::build_creation_entry_config_response;
|
||||
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
|
||||
pub use crate::modules::play_flow::resolve_creation_entry_route_id;
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PublicWorkInteractionAction {
|
||||
Like,
|
||||
Remix,
|
||||
}
|
||||
|
||||
impl PublicWorkInteractionAction {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Like => "like",
|
||||
Self::Remix => "remix",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文注释:入口配置由 SpacetimeDB 表提供;api-server 只负责读取同一份配置并熔断运行态路由。
|
||||
pub async fn get_creation_entry_config_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -70,62 +86,106 @@ pub async fn require_creation_entry_route_enabled(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
let normalized = path.trim_end_matches('/');
|
||||
if normalized == "/api/runtime/puzzle/agent/sessions"
|
||||
|| normalized == "/api/runtime/puzzle/onboarding/generate"
|
||||
{
|
||||
return Some("puzzle");
|
||||
/// 中文注释:公开作品互动配置只拦点赞 / 改造动作,不影响作品详情读取和正式游玩。
|
||||
pub async fn require_public_work_interaction_enabled(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path();
|
||||
if let Some((source_type, action)) = resolve_public_work_interaction_route(path) {
|
||||
match state
|
||||
.is_public_work_interaction_enabled(source_type, action)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("该作品互动暂不可用")
|
||||
.with_details(json!({
|
||||
"reason": "public_work_interaction_disabled",
|
||||
"sourceType": source_type,
|
||||
"action": action.as_str(),
|
||||
}))
|
||||
.into();
|
||||
}
|
||||
Err(error) => {
|
||||
return AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("读取作品互动配置失败")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") {
|
||||
return Some("puzzle");
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_public_work_interaction_route(
|
||||
path: &str,
|
||||
) -> Option<(&'static str, PublicWorkInteractionAction)> {
|
||||
let action = if path.ends_with("/like") {
|
||||
PublicWorkInteractionAction::Like
|
||||
} else if path.ends_with("/remix") {
|
||||
PublicWorkInteractionAction::Remix
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if path.starts_with("/api/runtime/custom-world-gallery/") {
|
||||
return Some(("custom-world", action));
|
||||
}
|
||||
if normalized == "/api/runtime/big-fish/agent/sessions" {
|
||||
return Some("big-fish");
|
||||
if path.starts_with("/api/runtime/big-fish/gallery/") {
|
||||
return Some(("big-fish", action));
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") {
|
||||
return Some("big-fish");
|
||||
}
|
||||
if normalized == "/api/runtime/custom-world/agent/sessions"
|
||||
|| normalized == "/api/runtime/custom-world/profile"
|
||||
{
|
||||
return Some("rpg");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/custom-world-gallery/")
|
||||
&& normalized.ends_with("/remix")
|
||||
{
|
||||
return Some("rpg");
|
||||
}
|
||||
if normalized == "/api/creation/match3d/sessions" {
|
||||
return Some("match3d");
|
||||
}
|
||||
if normalized == "/api/creation/square-hole/sessions" {
|
||||
return Some("square-hole");
|
||||
}
|
||||
if normalized == "/api/creation/bark-battle/drafts" {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized == "/api/creation/wooden-fish/sessions" {
|
||||
return Some("wooden-fish");
|
||||
}
|
||||
if normalized == "/api/creation/jump-hop/sessions" {
|
||||
return Some("jump-hop");
|
||||
}
|
||||
if normalized == "/api/creation/puzzle-clear/sessions" {
|
||||
return Some("puzzle-clear");
|
||||
}
|
||||
if normalized == "/api/creation/visual-novel/sessions" {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
if normalized == "/api/creation/edutainment/baby-object-match/assets" {
|
||||
return Some("baby-object-match");
|
||||
}
|
||||
if normalized == "/api/creation/edutainment/baby-love-drawing/magic" {
|
||||
return Some("baby-love-drawing");
|
||||
if path.starts_with("/api/runtime/puzzle/gallery/") {
|
||||
return Some(("puzzle", action));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
|
||||
config: &CreationEntryConfigResponse,
|
||||
creation_type_id: &str,
|
||||
fallback_cost: u64,
|
||||
) -> u64 {
|
||||
config
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == creation_type_id)
|
||||
.and_then(|item| item.unified_creation_spec.as_ref())
|
||||
.map(|spec| u64::from(spec.mud_point_cost))
|
||||
.filter(|cost| *cost > 0)
|
||||
.unwrap_or(fallback_cost)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_creation_entry_mud_point_cost(
|
||||
state: &AppState,
|
||||
creation_type_id: &str,
|
||||
fallback_cost: u64,
|
||||
) -> u64 {
|
||||
match state.get_creation_entry_config().await {
|
||||
Ok(config) => resolve_creation_entry_mud_point_cost_from_config(
|
||||
&config,
|
||||
creation_type_id,
|
||||
fallback_cost,
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
creation_type_id,
|
||||
fallback_cost,
|
||||
error = %error,
|
||||
"读取创作入口泥点成本失败,回退到代码默认值"
|
||||
);
|
||||
fallback_cost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
@@ -159,6 +219,9 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
|
||||
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
|
||||
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
|
||||
updated_at_micros: 0,
|
||||
public_work_interactions_json: Some(
|
||||
module_runtime::default_public_work_interaction_config_json(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,6 +233,7 @@ pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigRespon
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
|
||||
|
||||
#[test]
|
||||
fn resolves_new_creation_paths_to_creation_type_ids() {
|
||||
@@ -258,6 +322,72 @@ mod tests {
|
||||
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_public_work_interaction_routes() {
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1/like"),
|
||||
Some(("puzzle", PublicWorkInteractionAction::Like)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route(
|
||||
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
|
||||
),
|
||||
Some(("custom-world", PublicWorkInteractionAction::Remix)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_public_work_interaction_route("/api/runtime/wooden-fish/runs/run-1"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_mud_point_cost_from_unified_creation_spec() {
|
||||
let mut config = test_creation_entry_config_response();
|
||||
let puzzle = config
|
||||
.creation_types
|
||||
.iter_mut()
|
||||
.find(|item| item.id == "puzzle")
|
||||
.expect("puzzle config should exist");
|
||||
let spec = puzzle
|
||||
.unified_creation_spec
|
||||
.as_mut()
|
||||
.expect("puzzle unified spec should exist");
|
||||
spec.mud_point_cost = 8;
|
||||
|
||||
assert_eq!(
|
||||
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
|
||||
8,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_mud_point_cost_with_fallback_for_legacy_config() {
|
||||
let mut config = test_creation_entry_config_response();
|
||||
let puzzle = config
|
||||
.creation_types
|
||||
.iter_mut()
|
||||
.find(|item| item.id == "puzzle")
|
||||
.expect("puzzle config should exist");
|
||||
puzzle.unified_creation_spec = None;
|
||||
|
||||
assert_eq!(
|
||||
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_mud_point_cost_from_config(
|
||||
&config,
|
||||
"missing-play",
|
||||
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||||
),
|
||||
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation_entry_config_response_opens_bark_battle() {
|
||||
let config = test_creation_entry_config_response();
|
||||
@@ -277,6 +407,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation_entry_config_response_updates_jump_hop_metadata() {
|
||||
let config = test_creation_entry_config_response();
|
||||
let jump_hop = config
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == "jump-hop")
|
||||
.expect("test creation entry config should include jump-hop");
|
||||
|
||||
assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
|
||||
assert!(jump_hop.visible);
|
||||
assert!(jump_hop.open);
|
||||
assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
|
||||
assert_eq!(
|
||||
jump_hop.subtitle,
|
||||
"\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
|
||||
);
|
||||
assert_eq!(
|
||||
jump_hop.image_src,
|
||||
"/creation-type-references/jump-hop.webp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
|
||||
let config = test_creation_entry_config_response();
|
||||
|
||||
@@ -37,11 +37,11 @@ use spacetime_client::{
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput,
|
||||
CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput,
|
||||
CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord,
|
||||
CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
CustomWorldLibraryEntryRecord, CustomWorldProfileLikeReportRecordInput,
|
||||
CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
@@ -114,41 +114,6 @@ pub(super) fn build_custom_world_library_list_profile_payload(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_custom_world_gallery_card_response(
|
||||
state: &AppState,
|
||||
entry: CustomWorldGalleryEntryRecord,
|
||||
) -> CustomWorldGalleryCardResponse {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&entry.owner_user_id,
|
||||
Some(&entry.author_display_name),
|
||||
Some(&entry.author_public_user_code),
|
||||
);
|
||||
CustomWorldGalleryCardResponse {
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
public_work_code: entry.public_work_code,
|
||||
author_public_user_code: author
|
||||
.public_user_code
|
||||
.unwrap_or(entry.author_public_user_code),
|
||||
visibility: entry.visibility,
|
||||
published_at: entry.published_at,
|
||||
updated_at: entry.updated_at,
|
||||
author_display_name: author.display_name,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle,
|
||||
summary_text: entry.summary_text,
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: entry.theme_mode,
|
||||
playable_npc_count: entry.playable_npc_count,
|
||||
landmark_count: entry.landmark_count,
|
||||
play_count: entry.play_count,
|
||||
remix_count: entry.remix_count,
|
||||
like_count: entry.like_count,
|
||||
recent_play_count_7d: entry.recent_play_count_7d,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_public_work_custom_world_gallery_card_response(
|
||||
state: &AppState,
|
||||
entry: spacetime_client::PublicWorkGalleryEntryRecord,
|
||||
|
||||
@@ -10,9 +10,9 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{
|
||||
DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use image::ImageFormat;
|
||||
use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[cfg(test)]
|
||||
use axum::http::StatusCode;
|
||||
use module_runtime::RuntimeTrackingScopeKind;
|
||||
use platform_image::PlatformImageFailureAudit;
|
||||
@@ -157,6 +158,7 @@ pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
|
||||
}
|
||||
|
||||
/// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
|
||||
status_class(Some(status_code.as_u16()))
|
||||
}
|
||||
@@ -304,6 +306,7 @@ fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Val
|
||||
metadata
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_retryable_external_api_failure(
|
||||
status_code: Option<u16>,
|
||||
timeout: bool,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::StatusCode;
|
||||
use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
|
||||
|
||||
use crate::{
|
||||
@@ -8,27 +8,14 @@ use crate::{
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use generated_asset_sheets_impl::{
|
||||
GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
|
||||
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
|
||||
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
|
||||
crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||||
};
|
||||
|
||||
pub(crate) fn build_generated_asset_sheet_prompt(
|
||||
input: &GeneratedAssetSheetPromptInput<'_>,
|
||||
) -> Result<String, AppError> {
|
||||
generated_asset_sheets_impl::build_generated_asset_sheet_prompt(input)
|
||||
.map_err(map_generated_asset_sheet_error)
|
||||
}
|
||||
|
||||
pub(crate) fn slice_generated_asset_sheet(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
grid_size: usize,
|
||||
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, AppError> {
|
||||
generated_asset_sheets_impl::slice_generated_asset_sheet(image, item_names, grid_size)
|
||||
.map_err(map_generated_asset_sheet_error)
|
||||
}
|
||||
|
||||
pub(crate) fn slice_generated_asset_sheet_two_items_per_row(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
|
||||
@@ -6,15 +6,8 @@ pub mod helpers {
|
||||
pub use platform_image::generated_assets::helpers::*;
|
||||
}
|
||||
|
||||
pub(crate) use adapter::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary,
|
||||
GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput,
|
||||
GeneratedImageAssetPreparedPut,
|
||||
};
|
||||
pub(crate) use adapter::GeneratedImageAssetAdapter;
|
||||
pub(crate) use helpers::{
|
||||
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
|
||||
GeneratedImageAssetMetadataInput, GeneratedImageAssetStoragePaths,
|
||||
build_generated_image_asset_metadata, build_generated_image_asset_storage_paths,
|
||||
decode_generated_image_asset_data_url, merge_generated_image_asset_metadata,
|
||||
GeneratedImageAssetDataUrl, decode_generated_image_asset_data_url,
|
||||
normalize_generated_image_asset_mime,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
use axum::{Json, extract::Extension};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{api_response::json_success_body, request_context::RequestContext};
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientHealthSnapshot;
|
||||
|
||||
pub async fn health_check(Extension(request_context): Extension<RequestContext>) -> Json<Value> {
|
||||
json_success_body(
|
||||
@@ -12,3 +21,54 @@ pub async fn health_check(Extension(request_context): Extension<RequestContext>)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn readiness_check(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Response {
|
||||
if !state.is_ready() {
|
||||
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("api-server 正在退出,不再接收新流量")
|
||||
.with_details(json!({
|
||||
"reason": "api_server_draining",
|
||||
"ready": false,
|
||||
}))
|
||||
.into_response_with_context(Some(&request_context));
|
||||
}
|
||||
|
||||
let spacetime_health = state.spacetime_health_check().await;
|
||||
if spacetime_health.ok {
|
||||
return json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"ok": true,
|
||||
"ready": true,
|
||||
"service": "genarrative-api-server",
|
||||
"spacetime": spacetime_health_to_json(&spacetime_health),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("SpacetimeDB 连接健康检查失败,api-server 暂不接收新流量")
|
||||
.with_details(json!({
|
||||
"reason": "spacetime_unhealthy",
|
||||
"ready": false,
|
||||
"spacetime": spacetime_health_to_json(&spacetime_health),
|
||||
}))
|
||||
.into_response_with_context(Some(&request_context))
|
||||
}
|
||||
|
||||
fn spacetime_health_to_json(snapshot: &SpacetimeClientHealthSnapshot) -> Value {
|
||||
json!({
|
||||
"ok": snapshot.ok,
|
||||
"stage": snapshot.stage.as_str(),
|
||||
"checkedAtMicros": snapshot.checked_at_micros,
|
||||
"elapsedMs": snapshot.elapsed_ms,
|
||||
"timeoutMs": snapshot.timeout_ms,
|
||||
"error": snapshot.error,
|
||||
"lastSuccessAtMicros": snapshot.last_success_at_micros,
|
||||
"lastError": snapshot.last_error,
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
};
|
||||
|
||||
@@ -89,9 +89,7 @@ mod tracking_outbox;
|
||||
mod vector_engine_audio_generation;
|
||||
mod visual_novel;
|
||||
mod volcengine_speech;
|
||||
mod wechat_auth;
|
||||
mod wechat_pay;
|
||||
mod wechat_provider;
|
||||
mod wechat;
|
||||
mod wooden_fish;
|
||||
mod work_author;
|
||||
mod work_play_tracking;
|
||||
@@ -100,24 +98,35 @@ use shared_logging::{OtelConfig, init_tracing};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
env, fs, io,
|
||||
env, fs, future, io,
|
||||
net::{SocketAddr, TcpListener as StdTcpListener},
|
||||
panic, thread,
|
||||
panic,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
app::{build_router, build_spacetime_unavailable_router},
|
||||
config::AppConfig,
|
||||
state::{AppState, AppStateInitError},
|
||||
tracking_outbox::TrackingOutbox,
|
||||
};
|
||||
|
||||
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
const AUTH_STORE_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ShutdownContext {
|
||||
app_state: Option<AppState>,
|
||||
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
||||
outbox_flush_timeout: Duration,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), io::Error> {
|
||||
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
|
||||
@@ -159,19 +168,33 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
let listen_backlog = config.listen_backlog;
|
||||
let worker_threads = config.worker_threads;
|
||||
let otel_enabled = config.otel_enabled;
|
||||
let outbox_flush_timeout = config.shutdown_outbox_flush_timeout;
|
||||
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||
|
||||
let router = match restore_app_state_for_startup(config).await {
|
||||
let (router, shutdown_context) = match restore_app_state_for_startup(config).await {
|
||||
Ok(state) => {
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
let tracking_outbox = state.tracking_outbox();
|
||||
if let Some(outbox) = tracking_outbox.clone() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
build_router(state)
|
||||
}
|
||||
Err(AppStateInitError::DependencyUnavailable(message)) => {
|
||||
build_spacetime_unavailable_router(message)
|
||||
(
|
||||
build_router(state.clone()),
|
||||
ShutdownContext {
|
||||
app_state: Some(state),
|
||||
tracking_outbox,
|
||||
outbox_flush_timeout,
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(AppStateInitError::DependencyUnavailable(message)) => (
|
||||
build_spacetime_unavailable_router(message),
|
||||
ShutdownContext {
|
||||
app_state: None,
|
||||
tracking_outbox: None,
|
||||
outbox_flush_timeout,
|
||||
},
|
||||
),
|
||||
Err(error) => {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"初始化应用状态失败:{error}"
|
||||
@@ -187,7 +210,98 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
"api-server 已完成 tracing 初始化并开始监听"
|
||||
);
|
||||
|
||||
axum::serve(listener, router).await
|
||||
let result = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()))
|
||||
.await;
|
||||
finalize_shutdown(shutdown_context).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn shutdown_signal(context: ShutdownContext) {
|
||||
let signal = wait_for_shutdown_signal().await;
|
||||
if let Some(state) = context.app_state.as_ref() {
|
||||
state.mark_not_ready();
|
||||
}
|
||||
info!(
|
||||
signal,
|
||||
"api-server 收到退出信号,已标记 readiness 不可用并开始排空 HTTP 请求"
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown_signal() -> &'static str {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
tokio::select! {
|
||||
signal = wait_for_ctrl_c_signal() => signal,
|
||||
signal = wait_for_sigterm_signal() => signal,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
wait_for_ctrl_c_signal().await
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_ctrl_c_signal() -> &'static str {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
error!(error = %error, "监听 SIGINT 失败,无法通过 Ctrl-C 触发优雅退出");
|
||||
future::pending::<()>().await;
|
||||
}
|
||||
"sigint"
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn wait_for_sigterm_signal() -> &'static str {
|
||||
let mut signal = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
{
|
||||
Ok(signal) => signal,
|
||||
Err(error) => {
|
||||
error!(error = %error, "监听 SIGTERM 失败,无法通过 systemd terminate 触发优雅退出");
|
||||
future::pending::<()>().await;
|
||||
unreachable!("pending future never returns");
|
||||
}
|
||||
};
|
||||
signal.recv().await;
|
||||
"sigterm"
|
||||
}
|
||||
|
||||
async fn finalize_shutdown(context: ShutdownContext) {
|
||||
if let Some(state) = context.app_state.as_ref() {
|
||||
state.mark_not_ready();
|
||||
}
|
||||
|
||||
let Some(outbox) = context.tracking_outbox else {
|
||||
return;
|
||||
};
|
||||
|
||||
if context.outbox_flush_timeout.is_zero() {
|
||||
warn!("api-server 退出时 tracking outbox flush timeout 为 0,跳过主动 flush");
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout_ms = context
|
||||
.outbox_flush_timeout
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64;
|
||||
info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox");
|
||||
match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await {
|
||||
Ok(Ok(())) => {
|
||||
info!("api-server 退出前 tracking outbox flush 完成");
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
warn!(
|
||||
error = %error,
|
||||
"api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
timeout_ms,
|
||||
"api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tcp_listener(
|
||||
@@ -205,6 +319,25 @@ fn build_tcp_listener(
|
||||
|
||||
async fn restore_app_state_for_startup(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
loop {
|
||||
match try_restore_app_state_for_startup(config.clone()).await {
|
||||
Ok(state) => return Ok(state),
|
||||
Err(state::AppStateInitError::DependencyUnavailable(message)) => {
|
||||
warn!(
|
||||
retry_after_seconds = AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(),
|
||||
error = %message,
|
||||
"启动恢复 SpacetimeDB 认证快照暂不可用,api-server 将继续重试"
|
||||
);
|
||||
tokio::time::sleep(AUTH_STORE_STARTUP_RETRY_INTERVAL).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_restore_app_state_for_startup(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
match timeout(
|
||||
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
|
||||
@@ -216,7 +349,7 @@ async fn restore_app_state_for_startup(
|
||||
Err(_) => {
|
||||
error!(
|
||||
timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(),
|
||||
"启动等待 SpacetimeDB 恢复认证快照超时,api-server 将进入依赖不可用模式"
|
||||
"启动等待 SpacetimeDB 恢复认证快照超时"
|
||||
);
|
||||
Err(state::AppStateInitError::DependencyUnavailable(
|
||||
"SpacetimeDB 启动恢复认证快照超时".to_string(),
|
||||
@@ -299,7 +432,10 @@ fn is_valid_env_key(key: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_valid_env_key, protected_env_keys_from, strip_env_value};
|
||||
use super::{
|
||||
AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from,
|
||||
strip_env_value,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn strip_env_value_removes_wrapping_quotes() {
|
||||
@@ -340,4 +476,9 @@ mod tests {
|
||||
assert!(!protected.contains("ALIYUN_OSS_ENDPOINT"));
|
||||
assert!(protected.contains("ALIYUN_OSS_ACCESS_KEY_ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_dependency_retry_interval_is_short_enough_for_service_recovery() {
|
||||
assert_eq!(AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
convert::Infallible,
|
||||
future::Future,
|
||||
@@ -69,15 +69,14 @@ use crate::{
|
||||
execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error,
|
||||
should_skip_asset_operation_billing_for_connectivity,
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
config::AppConfig,
|
||||
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage,
|
||||
build_openai_image_http_client, create_openai_image_edit,
|
||||
create_openai_image_edit_with_references, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
|
||||
create_openai_image_edit, create_openai_image_edit_with_references,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
@@ -85,9 +84,12 @@ use crate::{
|
||||
vector_engine_audio_generation::{
|
||||
GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation,
|
||||
},
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent";
|
||||
const MATCH3D_WORKS_PROVIDER: &str = "match3d-works";
|
||||
const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime";
|
||||
@@ -101,7 +103,9 @@ const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20;
|
||||
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
|
||||
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10;
|
||||
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20;
|
||||
#[cfg(test)]
|
||||
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview";
|
||||
#[cfg(test)]
|
||||
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1";
|
||||
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
|
||||
const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
|
||||
@@ -509,7 +513,9 @@ use self::runtime::*;
|
||||
mod item_assets;
|
||||
use self::item_assets::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod vector_engine_gemini;
|
||||
#[cfg(test)]
|
||||
use self::vector_engine_gemini::*;
|
||||
|
||||
fn ensure_non_empty(
|
||||
@@ -528,6 +534,16 @@ fn ensure_non_empty(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
fn match3d_json<T>(
|
||||
payload: Result<Json<T>, JsonRejection>,
|
||||
request_context: &RequestContext,
|
||||
|
||||
@@ -163,6 +163,12 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||
let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
"match3d",
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
)
|
||||
.await;
|
||||
let compile_session_id = session_id.clone();
|
||||
let compile_owner_user_id = owner_user_id.clone();
|
||||
let compile_profile_id = profile_id.clone();
|
||||
@@ -175,6 +181,7 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
billing_asset_id.as_str(),
|
||||
points_cost,
|
||||
async {
|
||||
let mut session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
@@ -316,27 +323,56 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(response) = result.as_ref()
|
||||
&& response.status().is_server_error()
|
||||
{
|
||||
let failure_message = match3d_response_failure_message(response);
|
||||
persist_failed_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
compile_session_id,
|
||||
compile_owner_user_id,
|
||||
compile_profile_id,
|
||||
compile_initial_game_name,
|
||||
compile_requested_summary,
|
||||
compile_initial_tags,
|
||||
compile_requested_cover_image_src,
|
||||
failure_message,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok((session, generated_item_assets)) => {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: compile_owner_user_id.clone(),
|
||||
task_name: Some("抓大鹅".to_string()),
|
||||
work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: points_cost,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Ok((session, generated_item_assets))
|
||||
}
|
||||
Err(response) if response.status().is_server_error() => {
|
||||
let failure_message = match3d_response_failure_message(&response);
|
||||
persist_failed_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
compile_session_id,
|
||||
compile_owner_user_id.clone(),
|
||||
compile_profile_id,
|
||||
compile_initial_game_name.clone(),
|
||||
compile_requested_summary,
|
||||
compile_initial_tags,
|
||||
compile_requested_cover_image_src,
|
||||
failure_message,
|
||||
)
|
||||
.await;
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: compile_owner_user_id,
|
||||
task_name: Some("抓大鹅".to_string()),
|
||||
work_name: Some(compile_initial_game_name),
|
||||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Err(response)
|
||||
}
|
||||
Err(response) => Err(response),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -418,12 +454,13 @@ fn match3d_response_failure_message(response: &Response) -> String {
|
||||
.unwrap_or_else(|| format!("抓大鹅草稿生成失败,HTTP {}", response.status()))
|
||||
}
|
||||
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按后台入口配置的泥点成本幂等预扣。
|
||||
async fn execute_billable_match3d_draft_generation<T, Fut>(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
points_cost: u64,
|
||||
operation: Fut,
|
||||
) -> Result<T, Response>
|
||||
where
|
||||
@@ -434,6 +471,7 @@ where
|
||||
request_context,
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
points_cost,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -441,8 +479,13 @@ where
|
||||
Ok(value) => Ok(value),
|
||||
Err(response) => {
|
||||
if points_consumed {
|
||||
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
|
||||
.await;
|
||||
refund_match3d_draft_generation_points(
|
||||
state,
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
points_cost,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(response)
|
||||
}
|
||||
@@ -454,6 +497,7 @@ async fn consume_match3d_draft_generation_points(
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
points_cost: u64,
|
||||
) -> Result<bool, Response> {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_consume:{}:match3d_draft_generation:{}",
|
||||
@@ -463,7 +507,7 @@ async fn consume_match3d_draft_generation_points(
|
||||
.spacetime_client()
|
||||
.consume_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
points_cost,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
@@ -491,6 +535,7 @@ async fn refund_match3d_draft_generation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
points_cost: u64,
|
||||
) {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_refund:{}:match3d_draft_generation:{}",
|
||||
@@ -500,7 +545,7 @@ async fn refund_match3d_draft_generation_points(
|
||||
.spacetime_client()
|
||||
.refund_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
points_cost,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
|
||||
@@ -1171,7 +1171,7 @@ pub async fn start_match3d_run(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<StartMatch3DRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||||
@@ -1191,7 +1191,7 @@ pub async fn start_match3d_run(
|
||||
.spacetime_client()
|
||||
.start_match3d_run(Match3DRunStartRecordInput {
|
||||
run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
profile_id: profile_id.clone(),
|
||||
started_at_ms: current_utc_ms(),
|
||||
item_type_count_override: maybe_payload
|
||||
@@ -1211,15 +1211,17 @@ pub async fn start_match3d_run(
|
||||
record_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
WorkPlayTrackingDraft::runtime_principal(
|
||||
"match3d",
|
||||
profile_id.clone(),
|
||||
&authenticated,
|
||||
&principal,
|
||||
"/api/runtime/match3d/...",
|
||||
)
|
||||
.profile_id(profile_id.clone())
|
||||
.owner_user_id(principal.subject().to_string())
|
||||
.extra(json!({
|
||||
"runId": run.run_id,
|
||||
"principalKind": principal.kind().as_str(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -1236,13 +1238,13 @@ pub async fn get_match3d_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
|
||||
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_match3d_run(run_id, authenticated.claims().user_id().to_string())
|
||||
.get_match3d_run(run_id, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
@@ -1264,7 +1266,7 @@ pub async fn click_match3d_item(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<ClickMatch3DItemRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?;
|
||||
@@ -1286,7 +1288,7 @@ pub async fn click_match3d_item(
|
||||
.spacetime_client()
|
||||
.click_match3d_item(Match3DRunClickRecordInput {
|
||||
run_id: payload.run_id.unwrap_or(run_id),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
item_instance_id: payload.item_instance_id,
|
||||
client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32,
|
||||
client_event_id: payload.client_event_id,
|
||||
@@ -1313,7 +1315,7 @@ pub async fn stop_match3d_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<StopMatch3DRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let _ = payload.ok();
|
||||
@@ -1323,7 +1325,7 @@ pub async fn stop_match3d_run(
|
||||
.spacetime_client()
|
||||
.stop_match3d_run(Match3DRunStopRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
stopped_at_ms: current_utc_ms(),
|
||||
})
|
||||
.await
|
||||
@@ -1347,7 +1349,7 @@ pub async fn restart_match3d_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
|
||||
|
||||
@@ -1356,7 +1358,7 @@ pub async fn restart_match3d_run(
|
||||
.restart_match3d_run(Match3DRunRestartRecordInput {
|
||||
source_run_id: run_id,
|
||||
next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
restarted_at_ms: current_utc_ms(),
|
||||
})
|
||||
.await
|
||||
@@ -1380,7 +1382,7 @@ pub async fn finish_match3d_time_up(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
|
||||
|
||||
@@ -1388,7 +1390,7 @@ pub async fn finish_match3d_time_up(
|
||||
.spacetime_client()
|
||||
.finish_match3d_time_up(Match3DRunTimeUpRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
finished_at_ms: current_utc_ms(),
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -735,10 +735,9 @@ pub(super) struct Match3DMaterialSheet {
|
||||
pub(super) image: DownloadedOpenAiImage,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct Match3DVectorEngineGeminiImageSettings {
|
||||
pub(super) base_url: String,
|
||||
pub(super) api_key: String,
|
||||
pub(super) request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1482,6 +1481,7 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou
|
||||
.is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn build_match3d_material_sheet_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset {
|
||||
Match3DGeneratedItemAsset {
|
||||
item_id: format!("match3d-item-{index}"),
|
||||
@@ -149,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::new(width, height);
|
||||
for row in 0..5 {
|
||||
for col in 0..5 {
|
||||
for row in 0..10 {
|
||||
for col in 0..10 {
|
||||
let color = image::Rgba([
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
24 + row as u8 * 16,
|
||||
30 + col as u8 * 14,
|
||||
210 - row as u8 * 10,
|
||||
255,
|
||||
]);
|
||||
for y in row * 100..(row + 1) * 100 {
|
||||
@@ -182,22 +180,24 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
|
||||
|
||||
assert_eq!(slices.len(), 3);
|
||||
for (row, views) in slices.iter().enumerate() {
|
||||
for (item_index, views) in slices.iter().enumerate() {
|
||||
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
|
||||
for (col, view) in views.iter().enumerate() {
|
||||
for (view_index, view) in views.iter().enumerate() {
|
||||
let decoded = image::load_from_memory(view.bytes.as_slice())
|
||||
.expect("view should decode")
|
||||
.to_rgba8();
|
||||
let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2);
|
||||
let source_row = item_index / 2;
|
||||
let source_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT + view_index;
|
||||
assert_eq!(
|
||||
pixel.0,
|
||||
[
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
24 + source_row as u8 * 16,
|
||||
30 + source_col as u8 * 14,
|
||||
210 - source_row as u8 * 10,
|
||||
255,
|
||||
],
|
||||
"row {row} col {col} should be cut from the fixed 5*5 grid row"
|
||||
"item {item_index} view {view_index} should be cut from the fixed 10*10 grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
|
||||
for y in 1..5 {
|
||||
@@ -689,35 +689,35 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_spritesheet_batch() {
|
||||
let plan = parse_match3d_draft_plan(
|
||||
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
|
||||
&config("水果", 12, 4),
|
||||
)
|
||||
.expect("draft plan should parse");
|
||||
|
||||
assert_eq!(plan.items.len(), 10);
|
||||
assert_eq!(plan.items.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE);
|
||||
assert_eq!(plan.items[8].name, "蓝莓");
|
||||
assert_ne!(plan.items[9].name, "蓝莓");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_generated_item_count_rounds_up_to_five_multiples() {
|
||||
fn match3d_generated_item_count_uses_full_spritesheet_batch() {
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
|
||||
5
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
|
||||
10
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 16, 6)),
|
||||
15
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
|
||||
25
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -733,12 +733,12 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_item_asset_points_cost_counts_five_item_batches() {
|
||||
fn match3d_item_asset_points_cost_counts_spritesheet_batches() {
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(40), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -777,7 +777,10 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
|
||||
);
|
||||
|
||||
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(plan.padded_item_names.len(), 5);
|
||||
assert_eq!(
|
||||
plan.padded_item_names.len(),
|
||||
MATCH3D_MATERIAL_ITEM_BATCH_SIZE
|
||||
);
|
||||
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(
|
||||
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
|
||||
@@ -900,28 +903,27 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
|
||||
fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_layout() {
|
||||
let prompt = build_match3d_material_sheet_prompt(
|
||||
&config("水果", 12, 4),
|
||||
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
|
||||
);
|
||||
|
||||
assert!(prompt.contains("5行*5列"));
|
||||
assert!(prompt.contains("严格5*5均匀排布"));
|
||||
assert!(prompt.contains("10行*10列"));
|
||||
assert!(prompt.contains("素材间距严格均匀分布"));
|
||||
assert!(prompt.contains("每一行包含两种物品"));
|
||||
assert!(prompt.contains("每种物品的五个不同形态"));
|
||||
assert!(prompt.contains("绿幕背景"));
|
||||
assert!(prompt.contains("#00FF00"));
|
||||
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
|
||||
assert!(prompt.contains("约25%单格宽度"));
|
||||
assert!(prompt.contains("禁止主体跨格"));
|
||||
assert!(prompt.contains("贴边或越界"));
|
||||
assert!(prompt.contains("严禁出现两种高相似度的物品"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
|
||||
fn match3d_pixel_retro_style_prompt_hardens_asset_style_and_negative_prompt() {
|
||||
let mut config = config("水果", 12, 4);
|
||||
config.asset_style_id = Some("pixel-retro".to_string());
|
||||
config.asset_style_label = Some("像素复古".to_string());
|
||||
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
|
||||
let prompt = resolve_match3d_asset_style_prompt(&config).expect("style prompt should exist");
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
|
||||
|
||||
assert!(prompt.contains("64x64"));
|
||||
@@ -1004,13 +1006,9 @@ fn match3d_extracts_vector_engine_gemini_inline_image_data() {
|
||||
fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() {
|
||||
let root_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
let v1_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,165 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||||
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||||
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_str(),
|
||||
"抓大鹅素材图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
prompt,
|
||||
image_src: None,
|
||||
image_object_key: None,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_match3d_vector_engine_gemini_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||||
);
|
||||
let response = http_client
|
||||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings,
|
||||
))
|
||||
.query(&[("key", settings.api_key.as_str())])
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_match3d_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||||
"vector-engine-gemini",
|
||||
)?;
|
||||
let image_urls = extract_match3d_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
return download_match3d_images_from_urls(
|
||||
http_client,
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
image_urls,
|
||||
1,
|
||||
"vector-engine-gemini",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let b64_images = extract_match3d_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(match3d_images_from_base64(
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
@@ -201,125 +41,6 @@ fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
async fn download_match3d_images_from_urls(
|
||||
http_client: &reqwest::Client,
|
||||
task_id: String,
|
||||
image_urls: Vec<String>,
|
||||
candidate_count: u32,
|
||||
provider: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
{
|
||||
images
|
||||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
}
|
||||
Ok(OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
provider: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "下载抓大鹅生成图片失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||||
Ok(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_images_from_base64(
|
||||
task_id: String,
|
||||
b64_images: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> OpenAiGeneratedImages {
|
||||
let images = b64_images
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||||
.collect();
|
||||
OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||||
Some(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_match3d_json_payload(
|
||||
raw_text: &str,
|
||||
failure_context: &str,
|
||||
provider: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("{failure_context}:{error}"),
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||||
@@ -365,12 +86,6 @@ fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>)
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
@@ -408,79 +123,3 @@ fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &m
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> AppError {
|
||||
let message = parse_match3d_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
|
||||
tracing::warn!(
|
||||
provider = "vector-engine-gemini",
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
|
||||
for key in ["message", "code"] {
|
||||
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||||
return if key == "message" {
|
||||
value
|
||||
} else {
|
||||
format!("{fallback_message}({value})")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||
raw_text.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png");
|
||||
match mime_type {
|
||||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "image/png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,54 +189,6 @@ pub(super) fn resolve_author_display_name(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "玩家".to_string())
|
||||
}
|
||||
pub(super) async fn ensure_match3d_background_asset(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
background_prompt: &str,
|
||||
mut assets: Vec<Match3DGeneratedItemAsset>,
|
||||
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
|
||||
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
|
||||
let resolved_prompt = if normalized_prompt.is_empty() {
|
||||
build_fallback_match3d_background_prompt(config)
|
||||
} else {
|
||||
normalized_prompt
|
||||
};
|
||||
if let Some(existing_background) = find_match3d_generated_background_asset(&assets) {
|
||||
if is_match3d_background_asset_ready(&existing_background) {
|
||||
return Ok(assets);
|
||||
}
|
||||
}
|
||||
|
||||
let generated_background = generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
config,
|
||||
&resolved_prompt,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
attach_match3d_background_asset_to_assets(&mut assets, generated_background);
|
||||
persist_match3d_generated_item_assets_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
&assets,
|
||||
)
|
||||
.await?;
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_or_generate_match3d_level_asset_bundle(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
@@ -769,6 +721,7 @@ pub(super) fn build_match3d_background_from_scene_prompt() -> String {
|
||||
"移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn build_match3d_background_generation_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
prompt: &str,
|
||||
|
||||
@@ -10,10 +10,12 @@ pub mod internal;
|
||||
pub mod jump_hop;
|
||||
pub mod match3d;
|
||||
pub mod platform;
|
||||
pub mod play_flow;
|
||||
pub mod profile;
|
||||
pub mod public_work;
|
||||
pub mod puzzle;
|
||||
pub mod puzzle_clear;
|
||||
pub mod square_hole;
|
||||
pub mod story;
|
||||
pub mod visual_novel;
|
||||
pub mod wooden_fish;
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
|
||||
admin_login, admin_me, admin_overview, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
|
||||
require_admin_auth,
|
||||
admin_upsert_public_work_interaction_config, require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -81,6 +81,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/creation-entry/config/interactions",
|
||||
post(admin_upsert_public_work_interaction_config).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/works/visibility",
|
||||
get(admin_list_work_visibility)
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
use crate::{
|
||||
assets::{
|
||||
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
|
||||
create_sts_upload_credentials, get_asset_history, get_asset_read_bytes, get_asset_read_url,
|
||||
create_sts_upload_credentials, get_asset_read_bytes, get_asset_read_url,
|
||||
},
|
||||
auth::require_bearer_auth,
|
||||
state::AppState,
|
||||
@@ -44,11 +44,4 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route("/api/assets/read-bytes", get(get_asset_read_bytes))
|
||||
.route(
|
||||
"/api/assets/history",
|
||||
get(get_asset_history).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
phone_auth::{phone_login, send_phone_code},
|
||||
refresh_session::refresh_session,
|
||||
state::AppState,
|
||||
wechat_auth::{
|
||||
wechat::auth::{
|
||||
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
bark_battle::{
|
||||
create_bark_battle_draft, finish_bark_battle_run, generate_bark_battle_image_asset,
|
||||
get_bark_battle_run, get_bark_battle_runtime_config, list_bark_battle_gallery,
|
||||
list_bark_battle_works, publish_bark_battle_work, start_bark_battle_run,
|
||||
update_bark_battle_draft_config,
|
||||
create_bark_battle_draft, delete_bark_battle_work, finish_bark_battle_run,
|
||||
generate_bark_battle_image_asset, get_bark_battle_run, get_bark_battle_runtime_config,
|
||||
list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
|
||||
start_bark_battle_run, update_bark_battle_draft_config,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -51,6 +51,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}",
|
||||
delete(delete_bark_battle_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/gallery",
|
||||
get(list_bark_battle_gallery),
|
||||
@@ -59,26 +66,28 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/bark-battle/works/{work_id}/config",
|
||||
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}/runs",
|
||||
post(start_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}",
|
||||
get(get_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}/finish",
|
||||
post(finish_bark_battle_run)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
post(finish_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
big_fish::{
|
||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
||||
@@ -85,35 +85,35 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/big-fish/sessions/{session_id}/play",
|
||||
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/big-fish/works/{session_id}/play",
|
||||
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/big-fish/sessions/{session_id}/runs",
|
||||
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/big-fish/runs/{run_id}",
|
||||
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/big-fish/runs/{run_id}/input",
|
||||
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use crate::{health::health_check, state::AppState};
|
||||
use crate::{
|
||||
health::{health_check, readiness_check},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router(_state: AppState) -> Router<AppState> {
|
||||
Router::new().route("/healthz", get(health_check))
|
||||
Router::new()
|
||||
.route("/healthz", get(health_check))
|
||||
.route("/readyz", get(readiness_check))
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ use axum::{
|
||||
use crate::{
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
jump_hop::{
|
||||
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
||||
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
|
||||
create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
|
||||
get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
|
||||
get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, list_jump_hop_gallery,
|
||||
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
},
|
||||
state::AppState,
|
||||
@@ -43,6 +44,15 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}",
|
||||
get(get_jump_hop_work_detail)
|
||||
.delete(delete_jump_hop_work)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}/publish",
|
||||
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
||||
@@ -54,6 +64,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/jump-hop/works/{profile_id}",
|
||||
get(get_jump_hop_runtime_work),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/works/{profile_id}/leaderboard",
|
||||
get(get_jump_hop_leaderboard).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/runs",
|
||||
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
match3d::{
|
||||
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
|
||||
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
|
||||
@@ -139,42 +139,42 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/match3d/works/{profile_id}/runs",
|
||||
post(start_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/match3d/runs/{run_id}",
|
||||
get(get_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/match3d/runs/{run_id}/click",
|
||||
post(click_match3d_item).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/match3d/runs/{run_id}/stop",
|
||||
post(stop_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/match3d/runs/{run_id}/restart",
|
||||
post(restart_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/match3d/runs/{run_id}/time-up",
|
||||
post(finish_match3d_time_up).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai_tasks::{
|
||||
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
|
||||
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
|
||||
},
|
||||
auth::require_bearer_auth,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
|
||||
save_character_workflow_cache,
|
||||
},
|
||||
character_visual_assets::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
},
|
||||
creation_agent_document_input::parse_creation_agent_document_input,
|
||||
creation_entry_config::get_creation_entry_config_handler,
|
||||
hyper3d_generation::{
|
||||
get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model,
|
||||
submit_hyper3d_text_to_model,
|
||||
},
|
||||
llm::proxy_llm_chat_completions,
|
||||
runtime_chat::stream_runtime_npc_chat_turn,
|
||||
runtime_chat_plain::{
|
||||
generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary,
|
||||
stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue,
|
||||
stream_runtime_npc_recruit_dialogue,
|
||||
},
|
||||
runtime_save::{delete_runtime_snapshot, get_runtime_snapshot, put_runtime_snapshot},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
state::AppState,
|
||||
volcengine_speech::{
|
||||
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
|
||||
@@ -42,8 +13,6 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -81,213 +50,4 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/character/suggestions",
|
||||
post(generate_runtime_character_chat_suggestions).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/character/summary",
|
||||
post(generate_runtime_character_chat_summary).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/character/reply/stream",
|
||||
post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/npc/dialogue/stream",
|
||||
post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/npc/turn/stream",
|
||||
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/npc/recruit/stream",
|
||||
post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creation-agent/document-inputs/parse",
|
||||
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks",
|
||||
post(create_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/start",
|
||||
post(start_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
|
||||
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/chunks",
|
||||
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
|
||||
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/references",
|
||||
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/complete",
|
||||
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/fail",
|
||||
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/cancel",
|
||||
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/generate",
|
||||
post(generate_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/jobs/{task_id}",
|
||||
get(get_character_visual_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/publish",
|
||||
post(publish_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/generate",
|
||||
post(generate_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/publish",
|
||||
post(publish_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/import-video",
|
||||
post(import_character_animation_video),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/templates",
|
||||
get(list_character_animation_templates),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache",
|
||||
post(save_character_workflow_cache),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache/{character_id}",
|
||||
get(get_character_workflow_cache),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/asset-studio/role/{character_id}/workflow",
|
||||
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/hyper3d/text-to-model",
|
||||
post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/hyper3d/image-to-model",
|
||||
post(submit_hyper3d_image_to_model)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/hyper3d/status",
|
||||
post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/hyper3d/download",
|
||||
post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation-entry/config",
|
||||
get(get_creation_entry_config_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
.put(put_runtime_settings)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/save/snapshot",
|
||||
get(get_runtime_snapshot)
|
||||
.put(put_runtime_snapshot)
|
||||
.delete(delete_runtime_snapshot)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
1028
server-rs/crates/api-server/src/modules/play_flow.rs
Normal file
1028
server-rs/crates/api-server/src/modules/play_flow.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,18 +6,13 @@ use axum::{
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
profile_identity::update_profile_identity,
|
||||
runtime_browse_history::{
|
||||
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
|
||||
},
|
||||
runtime_profile::{
|
||||
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
|
||||
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
|
||||
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
|
||||
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
|
||||
redeem_profile_reward_code, stream_wechat_profile_recharge_order_events,
|
||||
submit_profile_feedback,
|
||||
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center,
|
||||
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
|
||||
stream_wechat_profile_recharge_order_events, submit_profile_feedback,
|
||||
},
|
||||
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -30,16 +25,6 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/browse-history",
|
||||
get(get_runtime_browse_history)
|
||||
.post(post_runtime_browse_history)
|
||||
.delete(delete_runtime_browse_history)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/dashboard",
|
||||
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
|
||||
@@ -131,25 +116,4 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/save-archives",
|
||||
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/save-archives/{world_key}",
|
||||
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/play-stats",
|
||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
square_hole::{
|
||||
compile_square_hole_agent_draft, create_square_hole_agent_session, delete_square_hole_work,
|
||||
drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up,
|
||||
@@ -101,42 +101,42 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/square-hole/works/{profile_id}/runs",
|
||||
post(start_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/square-hole/runs/{run_id}",
|
||||
get(get_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/square-hole/runs/{run_id}/drop",
|
||||
post(drop_square_hole_shape).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/square-hole/runs/{run_id}/stop",
|
||||
post(stop_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/square-hole/runs/{run_id}/restart",
|
||||
post(restart_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/square-hole/runs/{run_id}/time-up",
|
||||
post(finish_square_hole_time_up).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
185
server-rs/crates/api-server/src/modules/visual_novel.rs
Normal file
185
server-rs/crates/api-server/src/modules/visual_novel.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
state::AppState,
|
||||
vector_engine_audio_generation::{
|
||||
create_background_music_task, create_sound_effect_task,
|
||||
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
|
||||
publish_background_music_asset, publish_sound_effect_asset,
|
||||
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
|
||||
},
|
||||
visual_novel::{
|
||||
compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work,
|
||||
execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session,
|
||||
get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history,
|
||||
list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run,
|
||||
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
|
||||
submit_visual_novel_message, update_visual_novel_work,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions",
|
||||
post(create_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}",
|
||||
get(get_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/messages",
|
||||
post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/messages/stream",
|
||||
post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/actions",
|
||||
post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/compile",
|
||||
post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works",
|
||||
get(list_visual_novel_works).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works/{profile_id}",
|
||||
get(get_visual_novel_work)
|
||||
.put(update_visual_novel_work)
|
||||
.patch(update_visual_novel_work)
|
||||
.delete(delete_visual_novel_work)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works/{profile_id}/publish",
|
||||
post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/background-music",
|
||||
post(create_visual_novel_background_music_task).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/background-music/{task_id}/asset",
|
||||
post(publish_visual_novel_background_music_asset).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/sound-effect",
|
||||
post(create_visual_novel_sound_effect_task).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/audio/sound-effect/{task_id}/asset",
|
||||
post(publish_visual_novel_sound_effect_asset).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/background-music",
|
||||
post(create_background_music_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/background-music/{task_id}/asset",
|
||||
post(publish_background_music_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/sound-effect",
|
||||
post(create_sound_effect_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/sound-effect/{task_id}/asset",
|
||||
post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/gallery",
|
||||
get(list_visual_novel_gallery),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/works/{profile_id}/runs",
|
||||
post(start_visual_novel_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}",
|
||||
get(get_visual_novel_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/actions/stream",
|
||||
post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/history",
|
||||
get(list_visual_novel_history).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/regenerate",
|
||||
post(regenerate_visual_novel_run).route_layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
@@ -14,14 +16,19 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const WOODEN_FISH_CREATION_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/wooden-fish/sessions",
|
||||
post(create_wooden_fish_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(create_wooden_fish_session)
|
||||
// 中文注释:兼容旧小程序把参考图或录音 Data URL 放进创作 JSON 的请求;新前端音频会先直传 OSS。
|
||||
.layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/sessions/{session_id}",
|
||||
@@ -32,10 +39,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/sessions/{session_id}/actions",
|
||||
post(execute_wooden_fish_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(execute_wooden_fish_action)
|
||||
// 中文注释:compile/regenerate 会携带参考图旧兼容输入,避免 Axum 默认 2MB 先于 handler 拦截。
|
||||
.layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works",
|
||||
|
||||
@@ -2,9 +2,12 @@ use axum::http::StatusCode;
|
||||
use platform_image::{
|
||||
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
|
||||
build_vector_engine_image_request_body, create_vector_engine_image_edit,
|
||||
create_vector_engine_image_edit_with_references, create_vector_engine_image_generation,
|
||||
download_remote_image as download_platform_image_remote_image, vector_engine_images_edit_url,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_generation,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use platform_image::{
|
||||
build_vector_engine_image_request_body, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -233,15 +236,7 @@ pub(crate) async fn create_openai_image_edit_with_references(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
download_platform_image_remote_image(http_client, image_url)
|
||||
.await
|
||||
.map_err(map_platform_image_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_openai_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
@@ -430,10 +425,12 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
AppError::from_status(status).with_details(details)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String {
|
||||
vector_engine_images_generation_url(&settings.provider_settings())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String {
|
||||
vector_engine_images_edit_url(&settings.provider_settings())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
|
||||
use platform_llm::{LlmError, LlmErrorKind};
|
||||
use platform_oss::{OssError, OssErrorKind};
|
||||
use platform_wechat::{WechatError, WechatErrorKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::http_error::AppError;
|
||||
@@ -68,6 +69,17 @@ pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
|
||||
AppError::from_status(status).with_message(error.to_string())
|
||||
}
|
||||
|
||||
pub fn map_wechat_error(error: WechatError) -> AppError {
|
||||
let status = match error.kind() {
|
||||
WechatErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
|
||||
WechatErrorKind::RequestFailed
|
||||
| WechatErrorKind::DeserializeFailed
|
||||
| WechatErrorKind::Upstream => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_message(error.to_string())
|
||||
}
|
||||
|
||||
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
|
||||
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
|
||||
Ok(value) => error.with_header("retry-after", value),
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RuntimeStoryTextPromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub character: Value,
|
||||
pub monsters: Value,
|
||||
pub history: Value,
|
||||
pub choice: Value,
|
||||
pub context: Value,
|
||||
pub available_options: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
@@ -25,42 +14,6 @@ pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
|
||||
pub available_options: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RuntimeReasonedStoryPromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub character: &'a Value,
|
||||
pub monsters: Vec<Value>,
|
||||
pub history: Vec<Value>,
|
||||
pub context: Value,
|
||||
pub choice: &'a str,
|
||||
pub result_summary: &'a str,
|
||||
pub requested_option: Value,
|
||||
pub available_options: Vec<Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_story_director_system_prompt(initial: bool) -> &'static str {
|
||||
if initial {
|
||||
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
|
||||
} else {
|
||||
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_story_director_user_prompt(
|
||||
params: RuntimeStoryTextPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"character": params.character,
|
||||
"monsters": params.monsters,
|
||||
"history": params.history,
|
||||
"choice": params.choice,
|
||||
"context": params.context,
|
||||
"availableOptions": params.available_options,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
|
||||
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
|
||||
}
|
||||
@@ -200,31 +153,6 @@ pub(crate) fn build_npc_recruit_dialogue_user_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
|
||||
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态,不要发明额外奖励。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_reasoned_story_user_prompt(
|
||||
params: RuntimeReasonedStoryPromptParams<'_>,
|
||||
) -> String {
|
||||
let state_prompt = json!({
|
||||
"worldType": params.world_type,
|
||||
"character": params.character,
|
||||
"monsters": params.monsters,
|
||||
"history": params.history,
|
||||
"context": params.context,
|
||||
"choice": params.choice,
|
||||
"resultSummary": params.result_summary,
|
||||
"requestedOption": params.requested_option,
|
||||
"availableOptions": params.available_options,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
format!(
|
||||
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
|
||||
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -22,7 +23,7 @@ use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::{
|
||||
creation_audio::CreationAudioAsset,
|
||||
puzzle_agent::{
|
||||
@@ -57,16 +58,15 @@ use spacetime_client::{
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
|
||||
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
|
||||
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
|
||||
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
|
||||
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
@@ -105,6 +105,10 @@ use crate::{
|
||||
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
||||
request_context::RequestContext,
|
||||
state::{AppState, PuzzleApiState},
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
work_author::resolve_puzzle_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
|
||||
};
|
||||
@@ -130,6 +134,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
||||
|
||||
static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn puzzle_background_compile_tasks() -> &'static Mutex<HashSet<String>> {
|
||||
PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn try_register_puzzle_background_compile_task(session_id: &str) -> bool {
|
||||
match puzzle_background_compile_tasks().lock() {
|
||||
Ok(mut tasks) => tasks.insert(session_id.to_string()),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
error = %error,
|
||||
"拼图后台生成任务注册表锁已损坏,允许本次任务继续"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unregister_puzzle_background_compile_task(session_id: &str) {
|
||||
match puzzle_background_compile_tasks().lock() {
|
||||
Ok(mut tasks) => {
|
||||
tasks.remove(session_id);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
error = %error,
|
||||
"拼图后台生成任务注册表解锁失败,忽略清理"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_puzzle_cover_image_src(value: &Option<String>) -> bool {
|
||||
value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn mark_puzzle_initial_generation_started_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
session.stage = "image_refining".to_string();
|
||||
session.progress_percent = session.progress_percent.max(88);
|
||||
if let Some(draft) = session.draft.as_mut() {
|
||||
let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src);
|
||||
if let Some(primary_level) = draft.levels.first_mut() {
|
||||
if !has_puzzle_cover_image_src(&primary_level.cover_image_src) {
|
||||
primary_level.generation_status = "generating".to_string();
|
||||
}
|
||||
draft.generation_status = primary_level.generation_status.clone();
|
||||
draft.candidates = primary_level.candidates.clone();
|
||||
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
|
||||
draft.cover_image_src = primary_level.cover_image_src.clone();
|
||||
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
||||
} else if draft_needs_cover {
|
||||
draft.generation_status = "generating".to_string();
|
||||
}
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
||||
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
||||
}
|
||||
|
||||
@@ -307,13 +307,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload(
|
||||
levels,
|
||||
form_draft: None,
|
||||
};
|
||||
let stage = if is_puzzle_session_snapshot_publish_ready(&draft) {
|
||||
"ready_to_publish"
|
||||
} else {
|
||||
"image_refining"
|
||||
};
|
||||
|
||||
Ok(PuzzleAgentSessionRecord {
|
||||
session_id: session_id.to_string(),
|
||||
seed_text: String::new(),
|
||||
current_turn: 0,
|
||||
progress_percent: 94,
|
||||
stage: "ready_to_publish".to_string(),
|
||||
stage: stage.to_string(),
|
||||
anchor_pack,
|
||||
draft: Some(draft),
|
||||
messages: Vec::new(),
|
||||
@@ -977,6 +982,7 @@ pub(crate) fn attach_selected_puzzle_candidate_to_levels(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn resolve_puzzle_initial_ui_background_prompt(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
@@ -1042,6 +1048,7 @@ pub(crate) fn build_puzzle_ui_background_generation_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn attach_puzzle_level_ui_background(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
@@ -1083,27 +1090,6 @@ pub(crate) fn attach_puzzle_level_asset_bundle(
|
||||
level.ui_background_image_object_key = Some(generated.level_background.object_key);
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
target_level.level_name.as_str(),
|
||||
prompt.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok((prompt, generated))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
@@ -1177,21 +1163,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
.or_else(|| levels.first())
|
||||
}
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: String,
|
||||
compiled_session: PuzzleAgentSessionRecord,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
reference_image_src: Option<&str>,
|
||||
image_model: Option<&str>,
|
||||
now: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error)?;
|
||||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -1419,7 +1400,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
match state
|
||||
.spacetime_client()
|
||||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||||
session_id,
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id,
|
||||
level_id: Some(target_level.level_id),
|
||||
candidate_id: selected_candidate_id,
|
||||
@@ -1788,7 +1769,11 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
session.progress_percent = session.progress_percent.max(94);
|
||||
session.stage = "ready_to_publish".to_string();
|
||||
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
|
||||
"ready_to_publish".to_string()
|
||||
} else {
|
||||
"image_refining".to_string()
|
||||
};
|
||||
session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
|
||||
@@ -589,6 +589,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
let now = current_utc_micros();
|
||||
let action = payload.action.trim().to_string();
|
||||
let billing_asset_id = format!("{session_id}:{now}");
|
||||
let mut operation_consumed_points = 0;
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session_id,
|
||||
@@ -617,13 +618,14 @@ pub async fn execute_puzzle_agent_action(
|
||||
let log_session_id = session_id.clone();
|
||||
let log_owner_user_id = owner_user_id.clone();
|
||||
async move {
|
||||
let failed_at_micros = current_utc_micros();
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
error_message,
|
||||
failed_at_micros: now,
|
||||
failed_at_micros,
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
@@ -634,6 +636,20 @@ pub async fn execute_puzzle_agent_action(
|
||||
message = %error,
|
||||
"拼图草稿失败态回写失败,继续返回原始错误"
|
||||
);
|
||||
} else {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state.root_state(),
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id,
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: None,
|
||||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: failed_at_micros,
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -641,6 +657,17 @@ pub async fn execute_puzzle_agent_action(
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let puzzle_draft_generation_points_cost = if ai_redraw {
|
||||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state.root_state(),
|
||||
"puzzle",
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
0
|
||||
};
|
||||
operation_consumed_points = puzzle_draft_generation_points_cost;
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
@@ -668,27 +695,157 @@ pub async fn execute_puzzle_agent_action(
|
||||
Err(response) => return Err(response),
|
||||
};
|
||||
let session = if ai_redraw {
|
||||
execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
&request_context,
|
||||
if !try_register_puzzle_background_compile_task(&compile_session_id) {
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compile_session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
"拼图首图后台生成任务已存在,本次 action 直接返回生成中会话"
|
||||
);
|
||||
state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map(mark_puzzle_initial_generation_started_snapshot)
|
||||
.map_err(map_puzzle_client_error)
|
||||
} else {
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
primary_reference_image_src,
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error);
|
||||
match compiled_session {
|
||||
Ok(compiled_session) => {
|
||||
let response_session = mark_puzzle_initial_generation_started_snapshot(
|
||||
compiled_session.clone(),
|
||||
);
|
||||
let background_state = state.clone();
|
||||
let background_request_context = request_context.clone();
|
||||
let background_session_id = compile_session_id.clone();
|
||||
let background_owner_user_id = owner_user_id.clone();
|
||||
let background_prompt_text = prompt_text.map(str::to_string);
|
||||
let background_reference_image_src =
|
||||
primary_reference_image_src.map(str::to_string);
|
||||
let background_image_model = payload.image_model.clone();
|
||||
let background_points_cost = puzzle_draft_generation_points_cost;
|
||||
let background_work_name = compiled_session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.work_title.clone());
|
||||
let background_billing_asset_id =
|
||||
format!("{background_session_id}:compile_puzzle_draft");
|
||||
tokio::spawn(async move {
|
||||
let operation_owner_user_id = background_owner_user_id.clone();
|
||||
let background_root_state = background_state.root_state().clone();
|
||||
let operation_state = background_state.clone();
|
||||
let result = execute_billable_asset_operation_with_cost(
|
||||
&background_root_state,
|
||||
&background_owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&background_billing_asset_id,
|
||||
background_points_cost,
|
||||
async move {
|
||||
generate_puzzle_initial_cover_from_compiled_session(
|
||||
&operation_state,
|
||||
&background_request_context,
|
||||
compiled_session,
|
||||
operation_owner_user_id,
|
||||
background_prompt_text.as_deref(),
|
||||
background_reference_image_src.as_deref(),
|
||||
background_image_model.as_deref(),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(session) => {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
&background_root_state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: background_owner_user_id.clone(),
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.work_title.clone()),
|
||||
status:
|
||||
GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: background_points_cost,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session.session_id,
|
||||
owner_user_id = %background_owner_user_id,
|
||||
"拼图首图后台生成任务完成"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let error_message = error.body_text();
|
||||
let failed_at_micros = current_utc_micros();
|
||||
let failure_result = background_state
|
||||
.spacetime_client()
|
||||
.mark_puzzle_draft_generation_failed(
|
||||
PuzzleDraftCompileFailureRecordInput {
|
||||
session_id: background_session_id.clone(),
|
||||
owner_user_id: background_owner_user_id.clone(),
|
||||
error_message: error_message.clone(),
|
||||
failed_at_micros,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Err(mark_error) = failure_result {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %background_session_id,
|
||||
owner_user_id = %background_owner_user_id,
|
||||
message = %mark_error,
|
||||
"拼图首图后台生成失败态回写失败"
|
||||
);
|
||||
} else {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
&background_root_state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: background_owner_user_id.clone(),
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: background_work_name.clone(),
|
||||
status:
|
||||
GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: failed_at_micros,
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %background_session_id,
|
||||
owner_user_id = %background_owner_user_id,
|
||||
message = %error_message,
|
||||
"拼图首图后台生成任务失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
unregister_puzzle_background_compile_task(&background_session_id);
|
||||
});
|
||||
Ok(response_session)
|
||||
}
|
||||
Err(error) => {
|
||||
unregister_puzzle_background_compile_task(&compile_session_id);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
compile_puzzle_draft_with_uploaded_cover(
|
||||
&state,
|
||||
@@ -716,7 +873,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
"compile_puzzle_draft",
|
||||
"首关拼图草稿",
|
||||
if ai_redraw {
|
||||
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
|
||||
"已编译首关草稿,并启动首关画面和 UI 资产后台生成。"
|
||||
} else {
|
||||
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
|
||||
},
|
||||
@@ -1327,6 +1484,26 @@ pub async fn execute_puzzle_agent_action(
|
||||
};
|
||||
|
||||
let session = session?;
|
||||
if operation_type == "compile_puzzle_draft"
|
||||
&& session
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.generation_status == "ready")
|
||||
{
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state.root_state(),
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: operation_consumed_points,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
|
||||
@@ -396,49 +396,6 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_gallery_card_response(
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleGalleryCardRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
None,
|
||||
);
|
||||
PuzzleWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
source_session_id: item.source_session_id,
|
||||
author_display_name: author.display_name,
|
||||
work_title: item.work_title,
|
||||
work_description: item.work_description,
|
||||
level_name: item.level_name,
|
||||
summary: item.summary,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
cover_asset_id: item.cover_asset_id,
|
||||
publication_status: item.publication_status,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||
point_incentive_claimable_points: item
|
||||
.point_incentive_total_half_points
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status: item.generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_public_work_puzzle_gallery_card_response(
|
||||
state: &PuzzleApiState,
|
||||
item: spacetime_client::PublicWorkGalleryEntryRecord,
|
||||
|
||||
@@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> bool {
|
||||
image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
|| object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
|
||||
!draft.work_title.trim().is_empty()
|
||||
&& !draft.work_description.trim().is_empty()
|
||||
@@ -261,6 +272,18 @@ pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraft
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& has_required_puzzle_asset_ref(
|
||||
&level.level_scene_image_src,
|
||||
&level.level_scene_image_object_key,
|
||||
)
|
||||
&& has_required_puzzle_asset_ref(
|
||||
&level.ui_spritesheet_image_src,
|
||||
&level.ui_spritesheet_image_object_key,
|
||||
)
|
||||
&& has_required_puzzle_asset_ref(
|
||||
&level.level_background_image_src,
|
||||
&level.level_background_image_object_key,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ fn puzzle_vector_engine_create_request_never_embeds_reference_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -197,15 +196,11 @@ fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
|
||||
fn puzzle_vector_engine_create_request_never_embeds_reference_payload() {
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 4,
|
||||
bytes: b"test".to_vec(),
|
||||
signed_read_url: Some(
|
||||
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -474,7 +469,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
.expect("fallback session");
|
||||
|
||||
let draft = session.draft.expect("draft");
|
||||
assert_eq!(session.stage, "ready_to_publish");
|
||||
assert_eq!(session.stage, "image_refining");
|
||||
assert_eq!(draft.work_title, "暖灯猫街作品");
|
||||
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
|
||||
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
|
||||
@@ -484,6 +479,62 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() {
|
||||
let levels_json = serde_json::to_string(&vec![json!({
|
||||
"level_id": "puzzle-level-1",
|
||||
"level_name": "雨夜猫街",
|
||||
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||
"candidates": [],
|
||||
"selected_candidate_id": null,
|
||||
"cover_image_src": "/generated/puzzle/cover.png",
|
||||
"cover_asset_id": "asset-cover",
|
||||
"level_scene_image_src": "/generated/puzzle/level-scene.png",
|
||||
"level_scene_image_object_key": "generated/puzzle/level-scene.png",
|
||||
"ui_spritesheet_image_src": "/generated/puzzle/ui-spritesheet.png",
|
||||
"ui_spritesheet_image_object_key": "generated/puzzle/ui-spritesheet.png",
|
||||
"level_background_image_src": "/generated/puzzle/level-background.png",
|
||||
"level_background_image_object_key": "generated/puzzle/level-background.png",
|
||||
"generation_status": "ready",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let payload = ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
should_auto_name_level: None,
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("暖灯猫街作品".to_string()),
|
||||
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
|
||||
picture_description: None,
|
||||
level_name: None,
|
||||
summary: Some("当前关卡画面。".to_string()),
|
||||
theme_tags: Some(vec![
|
||||
"猫咪".to_string(),
|
||||
"雨夜".to_string(),
|
||||
"灯牌".to_string(),
|
||||
]),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
};
|
||||
|
||||
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||
"puzzle-session-1",
|
||||
&payload,
|
||||
Some(levels_json.as_str()),
|
||||
1_713_686_401_234_567,
|
||||
)
|
||||
.expect("fallback session");
|
||||
|
||||
assert_eq!(session.stage, "ready_to_publish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||
assert_eq!(
|
||||
@@ -583,7 +634,6 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 8,
|
||||
bytes: b"pngbytes".to_vec(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
|
||||
@@ -980,6 +1030,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_compile_started_snapshot_marks_primary_level_generating() {
|
||||
let mut session = PuzzleAgentSessionRecord {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
|
||||
current_turn: 1,
|
||||
progress_percent: 88,
|
||||
stage: "draft_ready".to_string(),
|
||||
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||
draft: Some(test_puzzle_draft_record()),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
suggested_actions: Vec::new(),
|
||||
result_preview: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
{
|
||||
let draft = session.draft.as_mut().expect("draft");
|
||||
draft.generation_status = "idle".to_string();
|
||||
draft.levels[0].generation_status = "idle".to_string();
|
||||
draft.levels[0].cover_image_src = None;
|
||||
draft.levels[0].cover_asset_id = None;
|
||||
}
|
||||
|
||||
let session = mark_puzzle_initial_generation_started_snapshot(session);
|
||||
let draft = session.draft.expect("draft");
|
||||
|
||||
assert_eq!(session.stage, "image_refining");
|
||||
assert_eq!(draft.generation_status, "generating");
|
||||
assert_eq!(draft.levels[0].generation_status, "generating");
|
||||
assert!(draft.cover_image_src.is_none());
|
||||
assert!(draft.levels[0].cover_image_src.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
||||
|
||||
@@ -45,7 +45,6 @@ pub(crate) struct PuzzleResolvedReferenceImage {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) bytes_len: usize,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) signed_read_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GeneratedPuzzleImageCandidate {
|
||||
@@ -318,10 +317,10 @@ pub(crate) fn build_puzzle_downloaded_image_reference(
|
||||
mime_type: image.mime_type.clone(),
|
||||
bytes_len: image.bytes.len(),
|
||||
bytes: image.bytes.clone(),
|
||||
signed_read_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
@@ -330,7 +329,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Value {
|
||||
let body = Map::from_iter([
|
||||
let body = serde_json::Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(image_model.request_model_name().to_string()),
|
||||
@@ -415,32 +414,6 @@ pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn collect_legacy_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
@@ -463,6 +436,7 @@ pub(crate) fn should_use_puzzle_reference_image_generation(
|
||||
use_reference_image_generation && has_puzzle_reference_image(reference_image_src)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt.trim();
|
||||
@@ -525,7 +499,6 @@ pub(crate) async fn resolve_puzzle_reference_image(
|
||||
mime_type: parsed.mime_type,
|
||||
bytes_len,
|
||||
bytes: parsed.bytes,
|
||||
signed_read_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -758,7 +731,6 @@ async fn download_signed_puzzle_reference_image(
|
||||
mime_type,
|
||||
bytes_len,
|
||||
bytes: body.to_vec(),
|
||||
signed_read_url: Some(signed_read_url),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1075,47 +1047,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
||||
Some(output)
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_puzzle_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_puzzle_strings_by_key(
|
||||
payload: &Value,
|
||||
target_key: &str,
|
||||
results: &mut Vec<String>,
|
||||
) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_puzzle_strings_by_key(entry, target_key, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if key == target_key {
|
||||
collect_puzzle_string_values(value, results);
|
||||
}
|
||||
collect_puzzle_strings_by_key(value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::String(text) => results.push(text.to_string()),
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_puzzle_string_values(item, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
|
||||
@@ -9,8 +9,10 @@ use shared_contracts::{
|
||||
puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse},
|
||||
puzzle_works::PuzzleWorkSummaryResponse,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use tokio::{
|
||||
sync::{Mutex, MutexGuard, OwnedMutexGuard, RwLock},
|
||||
sync::{Mutex, MutexGuard, RwLock},
|
||||
time,
|
||||
};
|
||||
|
||||
@@ -69,6 +71,7 @@ impl PuzzleGalleryCache {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn read_stale_response(&self) -> Option<PuzzleGalleryCachedResponse> {
|
||||
let guard = self.inner.read().await;
|
||||
let entry = guard.as_ref()?;
|
||||
@@ -77,6 +80,7 @@ impl PuzzleGalleryCache {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn try_acquire_owned_rebuild_guard(&self) -> Option<OwnedMutexGuard<()>> {
|
||||
self.rebuild_lock.clone().try_lock_owned().ok()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use shared_contracts::auth::RefreshSessionResponse;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
@@ -39,16 +40,48 @@ pub async fn refresh_session(
|
||||
let next_refresh_token = platform_auth::create_refresh_session_token();
|
||||
let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token);
|
||||
|
||||
let rotated = state
|
||||
.refresh_session_service()
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash,
|
||||
next_refresh_token_hash,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?;
|
||||
let rotated = match state.refresh_session_service().rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: refresh_token_hash.clone(),
|
||||
next_refresh_token_hash: next_refresh_token_hash.clone(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
) {
|
||||
Ok(rotated) => rotated,
|
||||
Err(RefreshSessionError::SessionNotFound) => {
|
||||
match state.refresh_auth_store_from_spacetime().await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err(map_refresh_error_with_clear_cookie(
|
||||
&state,
|
||||
RefreshSessionError::SessionNotFound,
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
request_id = request_context.request_id(),
|
||||
error = %error,
|
||||
"refresh session 本地未命中后刷新认证工作集失败"
|
||||
);
|
||||
return Err(map_refresh_error_with_clear_cookie(
|
||||
&state,
|
||||
RefreshSessionError::SessionNotFound,
|
||||
));
|
||||
}
|
||||
}
|
||||
state
|
||||
.refresh_session_service()
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash,
|
||||
next_refresh_token_hash,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?
|
||||
}
|
||||
Err(error) => return Err(map_refresh_error_with_clear_cookie(&state, error)),
|
||||
};
|
||||
let access_token = sign_access_token_for_user(
|
||||
&state,
|
||||
&rotated.user,
|
||||
|
||||
@@ -270,8 +270,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_rejects_blank_required_fields() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -316,8 +316,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway()
|
||||
{
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -361,23 +361,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
async fn seed_authenticated_state() -> (AppState, String) {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
let user_id = state
|
||||
.seed_test_phone_user_with_password("13800138102", "secret123")
|
||||
.await
|
||||
.id;
|
||||
state
|
||||
(state, user_id)
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
fn issue_access_token(state: &AppState, user_id: &str) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_browse_history",
|
||||
),
|
||||
user_id: user_id.to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id(user_id, "sess_runtime_browse_history"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -26,6 +26,7 @@ use module_runtime::{
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeTrackingScopeKind,
|
||||
};
|
||||
use platform_wechat::pay::WechatPayNotifyOrder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Sha256;
|
||||
@@ -81,9 +82,9 @@ use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_pay::{
|
||||
WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request,
|
||||
current_unix_micros, map_wechat_pay_error,
|
||||
wechat::pay::{
|
||||
build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros,
|
||||
map_wechat_pay_error,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3056,11 +3057,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let user_id = test_authenticated_user_id(state);
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
user_id: user_id.clone(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
|
||||
.seed_test_refresh_session_for_user_id(&user_id, "sess_runtime_profile"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
@@ -3081,11 +3083,11 @@ mod tests {
|
||||
client_platform: &str,
|
||||
session_id: &str,
|
||||
) -> String {
|
||||
let user_id = test_authenticated_user_id(state);
|
||||
let claims = AccessTokenClaims::from_input_with_device(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", session_id),
|
||||
user_id: user_id.clone(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
|
||||
provider: AuthProvider::Wechat,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
@@ -3105,4 +3107,13 @@ mod tests {
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
|
||||
fn test_authenticated_user_id(state: &AppState) -> String {
|
||||
state
|
||||
.auth_user_service()
|
||||
.get_user_by_public_user_code("SY-00000001")
|
||||
.expect("test user lookup should succeed")
|
||||
.expect("seeded test user should exist")
|
||||
.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,8 +347,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_rejects_legacy_full_snapshot_upload() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -379,8 +379,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_requires_existing_server_snapshot() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -407,9 +407,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_rejects_session_mismatch() {
|
||||
let state = seed_authenticated_state().await;
|
||||
seed_runtime_snapshot(&state, "runtime-server", "adventure").await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
seed_runtime_snapshot(&state, user_id.as_str(), "runtime-server", "adventure").await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -436,9 +436,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_uses_persisted_server_snapshot() {
|
||||
let state = seed_authenticated_state().await;
|
||||
seed_runtime_snapshot(&state, "runtime-main", "adventure").await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
seed_runtime_snapshot(&state, user_id.as_str(), "runtime-main", "adventure").await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -509,8 +509,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_profile_save_archive_rejects_blank_world_key() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -529,21 +529,26 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
async fn seed_authenticated_state() -> (AppState, String) {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
let user_id = state
|
||||
.seed_test_phone_user_with_password("13800138105", "secret123")
|
||||
.await
|
||||
.id;
|
||||
state
|
||||
(state, user_id)
|
||||
}
|
||||
|
||||
async fn seed_runtime_snapshot(state: &AppState, session_id: &str, bottom_tab: &str) {
|
||||
async fn seed_runtime_snapshot(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
bottom_tab: &str,
|
||||
) {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let now_micros = shared_kernel::offset_datetime_to_unix_micros(now);
|
||||
state
|
||||
.put_runtime_snapshot_record(
|
||||
"user_00000001".to_string(),
|
||||
user_id.to_string(),
|
||||
now_micros - 2_000_000,
|
||||
bottom_tab.to_string(),
|
||||
json!({
|
||||
@@ -571,12 +576,12 @@ mod tests {
|
||||
.expect("runtime snapshot should seed");
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
fn issue_access_token(state: &AppState, user_id: &str) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"),
|
||||
.seed_test_refresh_session_for_user_id(user_id, "sess_runtime_save"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -184,8 +184,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -221,8 +221,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_rejects_invalid_theme_with_envelope() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -266,8 +266,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module;验证 PUT/GET settings 主链"]
|
||||
async fn runtime_settings_round_trip_against_local_spacetimedb() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, user_id) = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state, user_id.as_str());
|
||||
let app = build_router(state);
|
||||
|
||||
let put_response = app
|
||||
@@ -337,23 +337,21 @@ mod tests {
|
||||
assert_eq!(get_payload["data"]["musicVolume"], json!(1.0));
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
async fn seed_authenticated_state() -> (AppState, String) {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
let user_id = state
|
||||
.seed_test_phone_user_with_password("13800138106", "secret123")
|
||||
.await
|
||||
.id;
|
||||
state
|
||||
(state, user_id)
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
fn issue_access_token(state: &AppState, user_id: &str) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_settings",
|
||||
),
|
||||
user_id: user_id.to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id(user_id, "sess_runtime_settings"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -69,7 +69,7 @@ use crate::generated_image_assets::{
|
||||
use crate::{
|
||||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||
@@ -81,12 +81,18 @@ use crate::{
|
||||
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
|
||||
},
|
||||
state::AppState,
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent";
|
||||
const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works";
|
||||
const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime";
|
||||
const SQUARE_HOLE_TEMPLATE_ID: &str = "square-hole";
|
||||
const SQUARE_HOLE_TEMPLATE_NAME: &str = "方洞";
|
||||
const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱";
|
||||
const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能";
|
||||
const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12;
|
||||
@@ -733,7 +739,7 @@ pub async fn start_square_hole_run(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<StartSquareHoleRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||||
@@ -752,7 +758,7 @@ pub async fn start_square_hole_run(
|
||||
.spacetime_client()
|
||||
.start_square_hole_run(SquareHoleRunStartRecordInput {
|
||||
run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
profile_id: profile_id.clone(),
|
||||
started_at_ms: current_utc_ms(),
|
||||
})
|
||||
@@ -768,15 +774,17 @@ pub async fn start_square_hole_run(
|
||||
record_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
WorkPlayTrackingDraft::runtime_principal(
|
||||
"square-hole",
|
||||
profile_id.clone(),
|
||||
&authenticated,
|
||||
&principal,
|
||||
"/api/runtime/square-hole/...",
|
||||
)
|
||||
.profile_id(profile_id.clone())
|
||||
.owner_user_id(principal.subject().to_string())
|
||||
.extra(json!({
|
||||
"runId": run.run_id,
|
||||
"principalKind": principal.kind().as_str(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -793,7 +801,7 @@ pub async fn get_square_hole_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(
|
||||
&request_context,
|
||||
@@ -804,7 +812,7 @@ pub async fn get_square_hole_run(
|
||||
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_square_hole_run(run_id, authenticated.claims().user_id().to_string())
|
||||
.get_square_hole_run(run_id, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(
|
||||
@@ -826,7 +834,7 @@ pub async fn drop_square_hole_shape(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<DropSquareHoleShapeRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_RUNTIME_PROVIDER)?;
|
||||
@@ -853,7 +861,7 @@ pub async fn drop_square_hole_shape(
|
||||
.spacetime_client()
|
||||
.drop_square_hole_shape(SquareHoleRunDropRecordInput {
|
||||
run_id: payload.run_id.unwrap_or(run_id),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
hole_id: payload.hole_id,
|
||||
client_snapshot_version: payload.client_snapshot_version,
|
||||
client_event_id: payload.client_event_id,
|
||||
@@ -881,7 +889,7 @@ pub async fn stop_square_hole_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<StopSquareHoleRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let _ = payload.ok();
|
||||
@@ -896,7 +904,7 @@ pub async fn stop_square_hole_run(
|
||||
.spacetime_client()
|
||||
.stop_square_hole_run(SquareHoleRunStopRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
stopped_at_ms: current_utc_ms(),
|
||||
})
|
||||
.await
|
||||
@@ -920,7 +928,7 @@ pub async fn restart_square_hole_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(
|
||||
&request_context,
|
||||
@@ -934,7 +942,7 @@ pub async fn restart_square_hole_run(
|
||||
.restart_square_hole_run(SquareHoleRunRestartRecordInput {
|
||||
source_run_id: run_id,
|
||||
next_run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
restarted_at_ms: current_utc_ms(),
|
||||
})
|
||||
.await
|
||||
@@ -958,7 +966,7 @@ pub async fn finish_square_hole_time_up(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(
|
||||
&request_context,
|
||||
@@ -971,7 +979,7 @@ pub async fn finish_square_hole_time_up(
|
||||
.spacetime_client()
|
||||
.finish_square_hole_time_up(SquareHoleRunTimeUpRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
finished_at_ms: current_utc_ms(),
|
||||
})
|
||||
.await
|
||||
@@ -1112,14 +1120,24 @@ async fn compile_square_hole_draft_for_session(
|
||||
.as_ref()
|
||||
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
|
||||
|
||||
state
|
||||
let resolved_game_name = game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text)));
|
||||
let generation_points_cost =
|
||||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
SQUARE_HOLE_TEMPLATE_ID,
|
||||
u64::from(
|
||||
shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.compile_square_hole_draft(SquareHoleCompileDraftRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX),
|
||||
author_display_name: resolve_author_display_name(state, authenticated),
|
||||
game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))),
|
||||
game_name: resolved_game_name.clone(),
|
||||
summary_text: summary,
|
||||
tags_json,
|
||||
cover_image_src,
|
||||
@@ -1132,7 +1150,43 @@ async fn compile_square_hole_draft_for_session(
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
map_square_hole_client_error(error),
|
||||
)
|
||||
})
|
||||
});
|
||||
match result {
|
||||
Ok(session) => {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id,
|
||||
task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()),
|
||||
work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: generation_points_cost,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Ok(session)
|
||||
}
|
||||
Err(response) => {
|
||||
if response.status().is_server_error() {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id,
|
||||
task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()),
|
||||
work_name: resolved_game_name,
|
||||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod visual_assets;
|
||||
|
||||
@@ -2,11 +2,16 @@ use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
#[cfg(not(test))]
|
||||
use module_auth::RefreshAuthStoreSnapshotResult;
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
@@ -22,10 +27,13 @@ use platform_auth::{
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||
use spacetime_client::{
|
||||
SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::{Semaphore, broadcast};
|
||||
use tracing::{info, warn};
|
||||
@@ -33,8 +41,8 @@ use tracing::{info, warn};
|
||||
use crate::config::AppConfig;
|
||||
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
|
||||
use crate::tracking_outbox::TrackingOutbox;
|
||||
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||
use crate::wechat_provider::build_wechat_provider;
|
||||
use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error};
|
||||
use crate::wechat::provider::build_wechat_provider;
|
||||
use crate::work_author::{
|
||||
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
|
||||
};
|
||||
@@ -229,12 +237,15 @@ pub struct AppStateInner {
|
||||
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
|
||||
#[allow(dead_code)]
|
||||
pub config: AppConfig,
|
||||
ready: AtomicBool,
|
||||
http_request_permit_pools: HttpRequestPermitPools,
|
||||
auth_jwt_config: JwtConfig,
|
||||
admin_runtime: Option<AdminRuntime>,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
#[cfg(test)]
|
||||
test_creation_entry_config: Arc<Mutex<Option<CreationEntryConfigResponse>>>,
|
||||
#[cfg(test)]
|
||||
test_spacetime_health: Arc<Mutex<Option<SpacetimeClientHealthSnapshot>>>,
|
||||
oss_client: Option<OssClient>,
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
auth_store: InMemoryAuthStore,
|
||||
@@ -245,6 +256,7 @@ pub struct AppStateInner {
|
||||
wechat_auth_state_service: WechatAuthStateService,
|
||||
wechat_auth_service: WechatAuthService,
|
||||
wechat_provider: WechatProvider,
|
||||
wechat_client: WechatClient,
|
||||
wechat_pay_client: WechatPayClient,
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
ai_task_service: AiTaskService,
|
||||
@@ -311,6 +323,7 @@ pub enum AppStateInitError {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
#[cfg(test)]
|
||||
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
|
||||
Self::new_with_empty_auth_store(config)
|
||||
}
|
||||
@@ -378,8 +391,9 @@ impl AppState {
|
||||
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
||||
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
|
||||
let wechat_provider = build_wechat_provider(&config);
|
||||
let wechat_pay_client =
|
||||
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
|
||||
let wechat_client = build_wechat_client(&config);
|
||||
let wechat_pay_client = WechatPayClient::from_config(&build_wechat_pay_config(&config))
|
||||
.map_err(map_wechat_pay_init_error)?;
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days);
|
||||
// AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。
|
||||
@@ -399,6 +413,7 @@ impl AppState {
|
||||
|
||||
Ok(Self(Arc::new(AppStateInner {
|
||||
config,
|
||||
ready: AtomicBool::new(true),
|
||||
http_request_permit_pools,
|
||||
auth_jwt_config,
|
||||
admin_runtime,
|
||||
@@ -407,6 +422,10 @@ impl AppState {
|
||||
test_creation_entry_config: Arc::new(Mutex::new(Some(
|
||||
crate::creation_entry_config::test_creation_entry_config_response(),
|
||||
))),
|
||||
#[cfg(test)]
|
||||
test_spacetime_health: Arc::new(Mutex::new(Some(
|
||||
SpacetimeClientHealthSnapshot::healthy_for_test(),
|
||||
))),
|
||||
oss_client,
|
||||
auth_store,
|
||||
password_entry_service,
|
||||
@@ -416,6 +435,7 @@ impl AppState {
|
||||
wechat_auth_state_service,
|
||||
wechat_auth_service,
|
||||
wechat_provider,
|
||||
wechat_client,
|
||||
wechat_pay_client,
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
@@ -447,6 +467,38 @@ impl AppState {
|
||||
self.http_request_permit_pools.clone()
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.ready.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn mark_not_ready(&self) {
|
||||
self.ready.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub async fn spacetime_health_check(&self) -> SpacetimeClientHealthSnapshot {
|
||||
#[cfg(test)]
|
||||
if let Some(snapshot) = self
|
||||
.test_spacetime_health
|
||||
.lock()
|
||||
.expect("test spacetime health should lock")
|
||||
.clone()
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
self.spacetime_client
|
||||
.health_check(self.config.spacetime_health_check_timeout)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_spacetime_health(&self, snapshot: SpacetimeClientHealthSnapshot) {
|
||||
*self
|
||||
.test_spacetime_health
|
||||
.lock()
|
||||
.expect("test spacetime health should lock") = Some(snapshot);
|
||||
}
|
||||
|
||||
pub async fn upsert_creation_entry_type_config(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryTypeAdminUpsertInput,
|
||||
@@ -507,6 +559,44 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 SpacetimeDB 保存公开作品互动配置,并同步测试缓存。
|
||||
pub async fn upsert_public_work_interaction_config(
|
||||
&self,
|
||||
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
let test_interactions_json = input.public_work_interactions_json.clone();
|
||||
match self
|
||||
.spacetime_client
|
||||
.upsert_public_work_interaction_config(input)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => {
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Ok(interactions) =
|
||||
module_runtime::decode_public_work_interaction_config_snapshots(
|
||||
test_interactions_json.as_str(),
|
||||
)
|
||||
{
|
||||
config.public_work_interactions = interactions
|
||||
.into_iter()
|
||||
.map(module_runtime::build_public_work_interaction_config_response)
|
||||
.collect();
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_creation_entry_config(
|
||||
&self,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
@@ -567,6 +657,53 @@ impl AppState {
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
pub async fn is_public_work_interaction_enabled(
|
||||
&self,
|
||||
source_type: &str,
|
||||
action: crate::creation_entry_config::PublicWorkInteractionAction,
|
||||
) -> Result<bool, SpacetimeClientError> {
|
||||
let config = self.get_creation_entry_config().await?;
|
||||
Ok(config
|
||||
.public_work_interactions
|
||||
.iter()
|
||||
.find(|item| item.source_type == source_type)
|
||||
.map(|item| match action {
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
|
||||
item.like_enabled
|
||||
}
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
|
||||
item.remix_enabled
|
||||
}
|
||||
})
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_public_work_interaction_enabled(
|
||||
&self,
|
||||
source_type: impl AsRef<str>,
|
||||
action: crate::creation_entry_config::PublicWorkInteractionAction,
|
||||
enabled: bool,
|
||||
) {
|
||||
let source_type = source_type.as_ref();
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Some(item) = config
|
||||
.public_work_interactions
|
||||
.iter_mut()
|
||||
.find(|item| item.source_type == source_type)
|
||||
{
|
||||
match action {
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
|
||||
item.like_enabled = enabled;
|
||||
}
|
||||
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
|
||||
item.remix_enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cache_test_creation_entry_config(config);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_creation_entry_route_enabled(
|
||||
&self,
|
||||
@@ -647,6 +784,47 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn refresh_auth_store_from_snapshot_json(
|
||||
&self,
|
||||
snapshot_json: &str,
|
||||
) -> Result<RefreshAuthStoreSnapshotResult, SpacetimeClientError> {
|
||||
self.auth_store
|
||||
.refresh_from_snapshot_json(snapshot_json)
|
||||
.map_err(SpacetimeClientError::Runtime)
|
||||
}
|
||||
|
||||
pub async fn refresh_auth_store_from_spacetime(&self) -> Result<bool, SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let snapshot = self
|
||||
.spacetime_client
|
||||
.export_auth_store_snapshot_from_tables()
|
||||
.await?;
|
||||
let Some(snapshot_json) = snapshot
|
||||
.snapshot_json
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let result = self.refresh_auth_store_from_snapshot_json(snapshot_json)?;
|
||||
info!(
|
||||
user_count = result.user_count,
|
||||
session_count = result.session_count,
|
||||
updated_at_micros = snapshot.updated_at_micros,
|
||||
"已按需刷新本进程认证工作集"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn try_restore_auth_store_from_spacetime(
|
||||
config: AppConfig,
|
||||
) -> Result<Self, AppStateInitError> {
|
||||
@@ -719,6 +897,10 @@ impl AppState {
|
||||
&self.wechat_provider
|
||||
}
|
||||
|
||||
pub fn wechat_client(&self) -> &WechatClient {
|
||||
&self.wechat_client
|
||||
}
|
||||
|
||||
pub fn wechat_pay_client(&self) -> &WechatPayClient {
|
||||
&self.wechat_pay_client
|
||||
}
|
||||
@@ -1276,6 +1458,17 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
|
||||
Ok(Some(OssClient::new(oss_config)))
|
||||
}
|
||||
|
||||
fn build_wechat_client(config: &AppConfig) -> WechatClient {
|
||||
WechatClient::new(WechatConfig {
|
||||
app_id: config.wechat_mini_program_app_id.clone(),
|
||||
app_secret: config.wechat_mini_program_app_secret.clone(),
|
||||
stable_access_token_endpoint: config.wechat_stable_access_token_endpoint.clone(),
|
||||
subscribe_message_endpoint: config
|
||||
.wechat_mini_program_subscribe_message_endpoint
|
||||
.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> {
|
||||
let Some(api_key) = config
|
||||
.llm_api_key
|
||||
@@ -1302,8 +1495,9 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
|
||||
fn build_creative_agent_gpt5_client(
|
||||
config: &AppConfig,
|
||||
) -> Result<Option<LlmClient>, AppStateInitError> {
|
||||
// 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。
|
||||
let Some(api_key) = config
|
||||
.apimart_api_key
|
||||
.vector_engine_api_key
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -1311,9 +1505,15 @@ fn build_creative_agent_gpt5_client(
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let base_url = if config.vector_engine_base_url.ends_with("/v1") {
|
||||
config.vector_engine_base_url.clone()
|
||||
} else {
|
||||
format!("{}/v1", config.vector_engine_base_url.trim_end_matches('/'))
|
||||
};
|
||||
|
||||
let llm_config = LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
config.apimart_base_url.clone(),
|
||||
base_url,
|
||||
api_key.to_string(),
|
||||
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
|
||||
config.llm_request_timeout_ms,
|
||||
@@ -1436,11 +1636,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() {
|
||||
fn app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings() {
|
||||
let mut config = AppConfig::default();
|
||||
config.llm_api_key = None;
|
||||
config.apimart_base_url = "https://api.apimart.test/v1".to_string();
|
||||
config.apimart_api_key = Some("apimart-key".to_string());
|
||||
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
|
||||
config.vector_engine_api_key = Some("ve-key".to_string());
|
||||
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let client = state
|
||||
@@ -1453,7 +1653,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
client.config().responses_url(),
|
||||
"https://api.apimart.test/v1/responses"
|
||||
"https://api.vectorengine.test/v1/responses"
|
||||
);
|
||||
assert!(client.config().official_fallback());
|
||||
}
|
||||
|
||||
@@ -97,22 +97,10 @@ pub(crate) fn record_puzzle_gallery_cache_hit() {
|
||||
puzzle_gallery_cache_metrics().hits.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_stale_hit() {
|
||||
puzzle_gallery_cache_metrics().stale_hits.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_miss() {
|
||||
puzzle_gallery_cache_metrics().misses.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_refresh_started() {
|
||||
puzzle_gallery_cache_metrics().refreshes_started.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_refresh_failed() {
|
||||
puzzle_gallery_cache_metrics().refreshes_failed.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_rebuild(
|
||||
duration: std::time::Duration,
|
||||
data_bytes: usize,
|
||||
@@ -208,10 +196,7 @@ struct HttpMetrics {
|
||||
|
||||
struct PuzzleGalleryCacheMetrics {
|
||||
hits: Counter<u64>,
|
||||
stale_hits: Counter<u64>,
|
||||
misses: Counter<u64>,
|
||||
refreshes_started: Counter<u64>,
|
||||
refreshes_failed: Counter<u64>,
|
||||
rebuilds: Counter<u64>,
|
||||
rebuild_duration: opentelemetry::metrics::Histogram<f64>,
|
||||
data_json_bytes: opentelemetry::metrics::Histogram<u64>,
|
||||
@@ -301,22 +286,10 @@ fn puzzle_gallery_cache_metrics() -> &'static PuzzleGalleryCacheMetrics {
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.hits")
|
||||
.with_description("Puzzle gallery response cache hits")
|
||||
.build(),
|
||||
stale_hits: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.stale_hits")
|
||||
.with_description("Puzzle gallery stale response cache hits")
|
||||
.build(),
|
||||
misses: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.misses")
|
||||
.with_description("Puzzle gallery response cache misses")
|
||||
.build(),
|
||||
refreshes_started: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_started")
|
||||
.with_description("Puzzle gallery background refresh start count")
|
||||
.build(),
|
||||
refreshes_failed: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_failed")
|
||||
.with_description("Puzzle gallery background refresh failure count")
|
||||
.build(),
|
||||
rebuilds: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.rebuilds")
|
||||
.with_description("Puzzle gallery response cache rebuild count")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum::http::{Method, StatusCode};
|
||||
#[cfg(not(test))]
|
||||
use module_auth::AuthLoginMethod;
|
||||
use module_runtime::RuntimeTrackingScopeKind;
|
||||
use serde_json::{Value, json};
|
||||
@@ -553,6 +554,7 @@ fn is_dynamic_path_segment(segment: &str) -> bool {
|
||||
|| lower.starts_with("session")
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub async fn record_daily_login_tracking_event_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
|
||||
@@ -159,6 +159,16 @@ impl TrackingOutbox {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn flush_for_shutdown(&self) -> Result<(), TrackingOutboxError> {
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
self.ensure_initialized_locked(&mut inner).await?;
|
||||
self.seal_active_locked(&mut inner, "shutdown").await?;
|
||||
}
|
||||
|
||||
self.flush_sealed_files_once().await
|
||||
}
|
||||
|
||||
async fn seal_active_if_due(&self) -> Result<(), TrackingOutboxError> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
self.ensure_initialized_locked(&mut inner).await?;
|
||||
@@ -176,7 +186,11 @@ impl TrackingOutbox {
|
||||
crate::telemetry::update_tracking_outbox_pending_files(sealed_files.len());
|
||||
for path in sealed_files {
|
||||
let started_at = Instant::now();
|
||||
let metadata = fs::metadata(&path).await?;
|
||||
let metadata = match fs::metadata(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let file_bytes = metadata.len();
|
||||
let events = match read_outbox_events(&path).await {
|
||||
Ok(events) => events,
|
||||
@@ -203,7 +217,11 @@ impl TrackingOutbox {
|
||||
|
||||
match self.spacetime_client.record_tracking_events(events).await {
|
||||
Ok(accepted_count) => {
|
||||
fs::remove_file(&path).await?;
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
self.subtract_total_bytes(file_bytes).await;
|
||||
crate::telemetry::record_tracking_outbox_flush(
|
||||
started_at.elapsed(),
|
||||
@@ -596,6 +614,34 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_flush_seals_active_file_for_later_retry() {
|
||||
let dir = test_dir("shutdown");
|
||||
let outbox = test_outbox(dir.clone(), 500, 1024 * 1024);
|
||||
|
||||
outbox.enqueue(sample_event("event-1")).await.unwrap();
|
||||
let result = outbox.flush_for_shutdown().await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(TrackingOutboxError::Spacetime(_))),
|
||||
"missing test SpacetimeDB should keep sealed file for retry"
|
||||
);
|
||||
assert!(!dir.join(ACTIVE_FILE_NAME).exists());
|
||||
let sealed_count = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(sealed_count, 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_size_excludes_quarantined_corrupt_files() {
|
||||
let dir = test_dir("directory-size");
|
||||
|
||||
@@ -18,7 +18,5 @@ pub use handlers::{
|
||||
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
|
||||
};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use generation::generate_background_music_asset_for_creation;
|
||||
pub(crate) use generation::generate_sound_effect_asset_for_creation;
|
||||
pub(crate) use types::GeneratedCreationAudioTarget;
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{
|
||||
clock::{current_utc_iso_text, current_utc_micros},
|
||||
errors::{map_platform_audio_error, vector_engine_bad_gateway},
|
||||
publish::wait_for_generated_audio_asset,
|
||||
tasks::{create_background_music_task_response, create_sound_effect_task_response},
|
||||
tasks::create_sound_effect_task_response,
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget},
|
||||
};
|
||||
|
||||
@@ -86,92 +86,6 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
prompt: String,
|
||||
title: String,
|
||||
tags: Option<String>,
|
||||
model: Option<String>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
|
||||
&prompt,
|
||||
"prompt",
|
||||
platform_audio::SUNO_PROMPT_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let normalized_title = platform_audio::normalize_limited_text(
|
||||
&title,
|
||||
"title",
|
||||
platform_audio::SUNO_TITLE_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let request_payload = json!({
|
||||
"kind": "background_music",
|
||||
"promptChars": normalized_prompt.chars().count(),
|
||||
"titleChars": normalized_title.chars().count(),
|
||||
"hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()),
|
||||
"model": model,
|
||||
"targetEntityKind": target.entity_kind,
|
||||
"targetEntityId": target.entity_id,
|
||||
"targetSlot": target.slot,
|
||||
"targetAssetKind": target.asset_kind,
|
||||
});
|
||||
let outcome = async {
|
||||
let task = create_background_music_task_response(
|
||||
state,
|
||||
normalized_prompt.clone(),
|
||||
normalized_title.clone(),
|
||||
tags,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok::<_, AppError>(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: Some(normalized_title),
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
record_creation_audio_generation_run(
|
||||
state,
|
||||
"background_music",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
&outcome,
|
||||
)
|
||||
.await;
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn record_creation_audio_generation_run(
|
||||
state: &AppState,
|
||||
operation: &'static str,
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest};
|
||||
use platform_audio::SoundEffectTaskRequest;
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings};
|
||||
|
||||
pub(super) async fn create_background_music_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
title: String,
|
||||
tags: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = platform_audio::submit_background_music_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
BackgroundMusicTaskRequest {
|
||||
prompt,
|
||||
title,
|
||||
tags,
|
||||
model,
|
||||
instrumental: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
task_id: task.task_id,
|
||||
provider: task.provider,
|
||||
status: task.status,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn create_sound_effect_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
|
||||
@@ -30,11 +30,15 @@ use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
http_error::AppError,
|
||||
prompt::visual_novel as vn_prompt,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
work_author::resolve_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
@@ -430,7 +434,7 @@ pub async fn start_visual_novel_run(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<contract::VisualNovelStartRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
@@ -449,7 +453,7 @@ pub async fn start_visual_novel_run(
|
||||
.spacetime_client()
|
||||
.start_visual_novel_run(VisualNovelRunStartRecordInput {
|
||||
run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
owner_user_id: principal.subject().to_string(),
|
||||
profile_id: profile_id.clone(),
|
||||
mode: run_mode_to_wire(&payload.mode).to_string(),
|
||||
snapshot_json: None,
|
||||
@@ -463,16 +467,18 @@ pub async fn start_visual_novel_run(
|
||||
record_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
WorkPlayTrackingDraft::runtime_principal(
|
||||
"visual-novel",
|
||||
profile_id.clone(),
|
||||
&authenticated,
|
||||
&principal,
|
||||
"/api/runtime/visual-novel/...",
|
||||
)
|
||||
.profile_id(profile_id.clone())
|
||||
.owner_user_id(principal.subject().to_string())
|
||||
.extra(json!({
|
||||
"mode": run_mode_to_wire(&payload.mode),
|
||||
"runId": run.run_id,
|
||||
"principalKind": principal.kind().as_str(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -489,12 +495,12 @@ pub async fn get_visual_novel_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&run_id, "runId")?;
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_visual_novel_run(run_id, authenticated.claims().user_id().to_string())
|
||||
.get_visual_novel_run(run_id, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
visual_novel_error_response(&request_context, map_spacetime_error(error))
|
||||
@@ -512,13 +518,13 @@ pub async fn stream_visual_novel_action(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<contract::VisualNovelRuntimeActionRequest>, JsonRejection>,
|
||||
) -> Result<Response, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
ensure_non_empty(&run_id, "runId")?;
|
||||
ensure_non_empty(&payload.client_event_id, "clientEventId")?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let owner_user_id = principal.subject().to_string();
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_visual_novel_run(run_id.clone(), owner_user_id.clone())
|
||||
@@ -565,12 +571,12 @@ pub async fn list_visual_novel_history(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&run_id, "runId")?;
|
||||
let history = state
|
||||
.spacetime_client()
|
||||
.list_visual_novel_runtime_history(run_id, authenticated.claims().user_id().to_string())
|
||||
.list_visual_novel_runtime_history(run_id, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
visual_novel_error_response(&request_context, map_spacetime_error(error))
|
||||
@@ -591,13 +597,13 @@ pub async fn regenerate_visual_novel_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<contract::VisualNovelRegenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
ensure_non_empty(&run_id, "runId")?;
|
||||
ensure_non_empty(&payload.history_entry_id, "historyEntryId")?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let owner_user_id = principal.subject().to_string();
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_visual_novel_run(run_id.clone(), owner_user_id.clone())
|
||||
@@ -1743,8 +1749,18 @@ async fn compile_visual_novel_session_inner(
|
||||
current_utc_iso().as_str(),
|
||||
);
|
||||
let projection = project_draft_for_work(&draft, &profile_id)?;
|
||||
let notification_work_name = projection.work_title.clone();
|
||||
let generation_points_cost =
|
||||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
VISUAL_NOVEL_RUNTIME_KIND,
|
||||
u64::from(
|
||||
shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None);
|
||||
let compiled_session = state
|
||||
let compile_result = state
|
||||
.spacetime_client()
|
||||
.compile_visual_novel_work_profile(VisualNovelWorkCompileRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
@@ -1759,9 +1775,43 @@ async fn compile_visual_novel_session_inner(
|
||||
compiled_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
visual_novel_error_response(request_context, map_spacetime_error(error))
|
||||
})?;
|
||||
.map_err(|error| visual_novel_error_response(request_context, map_spacetime_error(error)));
|
||||
let compiled_session = match compile_result {
|
||||
Ok(session) => {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
task_name: Some("视觉小说".to_string()),
|
||||
work_name: Some(notification_work_name.clone()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: generation_points_cost,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
session
|
||||
}
|
||||
Err(response) => {
|
||||
if response.status().is_server_error() {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id,
|
||||
task_name: Some("视觉小说".to_string()),
|
||||
work_name: Some(notification_work_name),
|
||||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(response);
|
||||
}
|
||||
};
|
||||
let work = state
|
||||
.spacetime_client()
|
||||
.get_visual_novel_work_detail(profile_id, owner_user_id)
|
||||
|
||||
4
server-rs/crates/api-server/src/wechat.rs
Normal file
4
server-rs/crates/api-server/src/wechat.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod auth;
|
||||
pub(crate) mod pay;
|
||||
pub(crate) mod provider;
|
||||
pub(crate) mod subscribe_message;
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
@@ -14,6 +14,7 @@ use shared_contracts::auth::{
|
||||
WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery,
|
||||
WechatStartResponse,
|
||||
};
|
||||
use shared_kernel::normalize_optional_string;
|
||||
use time::OffsetDateTime;
|
||||
use url::Url;
|
||||
|
||||
@@ -208,6 +209,7 @@ pub async fn bind_wechat_phone(
|
||||
.bind_wechat_verified_phone(BindWechatVerifiedPhoneInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
phone_number: phone_profile.phone_number,
|
||||
wechat_display_name: payload.display_name.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_wechat_bind_phone_error)?
|
||||
@@ -235,6 +237,7 @@ pub async fn bind_wechat_phone(
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
phone_number: phone.to_string(),
|
||||
verify_code: code.to_string(),
|
||||
wechat_display_name: payload.display_name.clone(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
@@ -313,7 +316,7 @@ pub async fn login_wechat_mini_program(
|
||||
let result = state
|
||||
.wechat_auth_service()
|
||||
.resolve_login(module_auth::ResolveWechatLoginInput {
|
||||
profile: map_wechat_profile_to_domain(profile),
|
||||
profile: map_wechat_profile_to_domain_with_display_name(profile, payload.display_name),
|
||||
})
|
||||
.await
|
||||
.map_err(map_wechat_auth_error)?;
|
||||
@@ -346,6 +349,7 @@ pub async fn login_wechat_mini_program(
|
||||
token: signed_session.access_token,
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
user: map_auth_user_payload(result.user),
|
||||
created: result.created,
|
||||
},
|
||||
),
|
||||
))
|
||||
@@ -389,6 +393,17 @@ fn map_wechat_profile_to_domain(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_wechat_profile_to_domain_with_display_name(
|
||||
profile: platform_auth::WechatIdentityProfile,
|
||||
display_name: Option<String>,
|
||||
) -> module_auth::WechatIdentityProfile {
|
||||
let mut profile = map_wechat_profile_to_domain(profile);
|
||||
if let Some(display_name) = normalize_optional_string(display_name) {
|
||||
profile.display_name = Some(display_name);
|
||||
}
|
||||
profile
|
||||
}
|
||||
|
||||
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
|
||||
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return fallback.to_string();
|
||||
423
server-rs/crates/api-server/src/wechat/pay.rs
Normal file
423
server-rs/crates/api-server/src/wechat/pay.rs
Normal file
@@ -0,0 +1,423 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use platform_wechat::pay::{
|
||||
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig,
|
||||
WechatPayError, WechatWebOrderRequest, decrypt_wechat_message_push_ciphertext,
|
||||
parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload,
|
||||
resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{config::AppConfig, http_error::AppError, state::AppState};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum VirtualPaymentNotifyResponseFormat {
|
||||
Json,
|
||||
Xml,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ApiWechatVirtualPaymentNotifyResponse {
|
||||
#[serde(rename = "ErrCode")]
|
||||
err_code: i32,
|
||||
#[serde(rename = "ErrMsg")]
|
||||
err_msg: String,
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_pay_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let notify = state
|
||||
.wechat_pay_client()
|
||||
.parse_notify(&headers, &body)
|
||||
.map_err(map_wechat_pay_notify_error)?;
|
||||
if notify.trade_state != "SUCCESS" {
|
||||
info!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
trade_state = notify.trade_state.as_str(),
|
||||
"收到非成功微信支付通知"
|
||||
);
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify
|
||||
.success_time
|
||||
.as_deref()
|
||||
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.unwrap_or_else(current_unix_micros);
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
notify.out_trade_no.clone(),
|
||||
paid_at_micros,
|
||||
notify.transaction_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("确认微信支付订单失败:{error}"))
|
||||
})?;
|
||||
info!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"微信支付通知已确认订单入账"
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_message_push_verify(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
) -> Response {
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
match resolve_wechat_message_push_verify_response(
|
||||
token,
|
||||
aes_key,
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
&query,
|
||||
) {
|
||||
Ok(plaintext) => (StatusCode::OK, plaintext).into_response(),
|
||||
Err(error) => build_wechat_message_push_verify_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let response_format = detect_virtual_payment_notify_response_format(&headers, &body);
|
||||
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let signature = query
|
||||
.msg_signature
|
||||
.as_deref()
|
||||
.or(query.signature.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("");
|
||||
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||||
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||||
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
if !verify_wechat_message_push_signature(
|
||||
token,
|
||||
timestamp,
|
||||
nonce,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
signature,
|
||||
) {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
let notify_body = match decrypt_wechat_message_push_ciphertext(
|
||||
aes_key,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
) {
|
||||
Ok(body) => body,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
|
||||
Ok(notify) => notify,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" {
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"收到非订单入账虚拟支付推送"
|
||||
);
|
||||
return build_virtual_payment_notify_success_response(response_format);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros);
|
||||
if state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
notify.out_trade_no.clone(),
|
||||
paid_at_micros,
|
||||
notify.transaction_id.clone(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"确认微信虚拟支付订单失败"
|
||||
);
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
|
||||
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
|
||||
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"微信虚拟支付推送已确认订单入账"
|
||||
);
|
||||
|
||||
build_virtual_payment_notify_success_response(response_format)
|
||||
}
|
||||
|
||||
pub fn build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig {
|
||||
WechatPayConfig {
|
||||
enabled: config.wechat_pay_enabled,
|
||||
provider: config.wechat_pay_provider.clone(),
|
||||
app_id: config
|
||||
.wechat_mini_program_app_id
|
||||
.clone()
|
||||
.or_else(|| config.wechat_app_id.clone()),
|
||||
mch_id: config.wechat_pay_mch_id.clone(),
|
||||
merchant_serial_no: config.wechat_pay_merchant_serial_no.clone(),
|
||||
private_key_pem: config.wechat_pay_private_key_pem.clone(),
|
||||
private_key_path: config.wechat_pay_private_key_path.clone(),
|
||||
platform_public_key_pem: config.wechat_pay_platform_public_key_pem.clone(),
|
||||
platform_public_key_path: config.wechat_pay_platform_public_key_path.clone(),
|
||||
platform_serial_no: config.wechat_pay_platform_serial_no.clone(),
|
||||
api_v3_key: config.wechat_pay_api_v3_key.clone(),
|
||||
notify_url: config.wechat_pay_notify_url.clone(),
|
||||
jsapi_endpoint: config.wechat_pay_jsapi_endpoint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
match error {
|
||||
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("微信支付暂未启用")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidConfig(message) => {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" }))
|
||||
}
|
||||
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature(message) => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
|
||||
crate::state::AppStateInitError::WechatPay(error.to_string())
|
||||
}
|
||||
|
||||
pub fn build_wechat_payment_request(
|
||||
order_id: String,
|
||||
product_title: String,
|
||||
amount_cents: u64,
|
||||
payer_openid: String,
|
||||
) -> WechatMiniProgramOrderRequest {
|
||||
WechatMiniProgramOrderRequest {
|
||||
order_id,
|
||||
description: format!("陶泥儿 - {product_title}"),
|
||||
amount_cents,
|
||||
payer_openid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_wechat_web_payment_request(
|
||||
order_id: String,
|
||||
product_title: String,
|
||||
amount_cents: u64,
|
||||
payer_client_ip: String,
|
||||
) -> WechatWebOrderRequest {
|
||||
WechatWebOrderRequest {
|
||||
order_id,
|
||||
description: format!("陶泥儿 - {product_title}"),
|
||||
amount_cents,
|
||||
payer_client_ip,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_unix_micros() -> i64 {
|
||||
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
i64::try_from(value).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
warn!(error = %error, "微信支付通知处理失败");
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn read_wechat_message_push_config<'a>(
|
||||
value: Option<&'a str>,
|
||||
key: &str,
|
||||
) -> Result<&'a str, WechatPayError> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
|
||||
}
|
||||
|
||||
fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response {
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
(StatusCode::BAD_REQUEST, message).into_response()
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_error_response(
|
||||
error: WechatPayError,
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
warn!(error = %error, "微信虚拟支付通知处理失败");
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
build_virtual_payment_notify_response(response_format, 1, message)
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_success_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
build_virtual_payment_notify_response(response_format, 0, "success")
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> Response {
|
||||
let err_msg = err_msg.into();
|
||||
match response_format {
|
||||
VirtualPaymentNotifyResponseFormat::Json => Json(
|
||||
build_wechat_virtual_payment_notify_response(err_code, err_msg),
|
||||
)
|
||||
.into_response(),
|
||||
VirtualPaymentNotifyResponseFormat::Xml => {
|
||||
let body = format!(
|
||||
"<xml><ErrCode>{err_code}</ErrCode><ErrMsg><![CDATA[{err_msg}]]></ErrMsg></xml>"
|
||||
);
|
||||
let mut response = (StatusCode::OK, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/xml; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wechat_virtual_payment_notify_response(
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> ApiWechatVirtualPaymentNotifyResponse {
|
||||
ApiWechatVirtualPaymentNotifyResponse {
|
||||
err_code,
|
||||
err_msg: err_msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_virtual_payment_notify_response_format(
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> VirtualPaymentNotifyResponseFormat {
|
||||
let content_type = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if content_type.contains("xml") {
|
||||
return VirtualPaymentNotifyResponseFormat::Xml;
|
||||
}
|
||||
let body_trimmed = body
|
||||
.iter()
|
||||
.copied()
|
||||
.skip_while(|byte| byte.is_ascii_whitespace())
|
||||
.next();
|
||||
match body_trimmed {
|
||||
Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml,
|
||||
_ => VirtualPaymentNotifyResponseFormat::Json,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use platform_auth::{
|
||||
use platform_auth::{
|
||||
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
|
||||
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT,
|
||||
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT,
|
||||
246
server-rs/crates/api-server/src/wechat/subscribe_message.rs
Normal file
246
server-rs/crates/api-server/src/wechat/subscribe_message.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use platform_wechat::WechatSubscribeMessageRequest;
|
||||
use time::{OffsetDateTime, UtcOffset};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{http_error::AppError, platform_errors::map_wechat_error, state::AppState};
|
||||
|
||||
const GENERATION_RESULT_TASK_NAME: &str = "AI创作生成";
|
||||
const DEFAULT_WORK_NAME: &str = "AI创作作品";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GenerationResultSubscribeMessageStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerationResultSubscribeMessage {
|
||||
pub owner_user_id: String,
|
||||
pub task_name: Option<String>,
|
||||
pub work_name: Option<String>,
|
||||
pub status: GenerationResultSubscribeMessageStatus,
|
||||
pub consumed_points: u64,
|
||||
pub completed_at_micros: i64,
|
||||
pub page: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn send_generation_result_subscribe_message_after_completion(
|
||||
state: &AppState,
|
||||
message: GenerationResultSubscribeMessage,
|
||||
) {
|
||||
if let Err(error) = send_generation_result_subscribe_message(state, message).await {
|
||||
warn!(
|
||||
error = %error,
|
||||
"微信小程序生成结果订阅消息发送失败,已忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_generation_result_subscribe_message(
|
||||
state: &AppState,
|
||||
message: GenerationResultSubscribeMessage,
|
||||
) -> Result<(), AppError> {
|
||||
if !state.config.wechat_mini_program_subscribe_message_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let template_id = state
|
||||
.config
|
||||
.wechat_mini_program_generation_result_template_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("微信订阅消息模板 ID 未配置")
|
||||
})?;
|
||||
let user = state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(&message.owner_user_id)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("读取微信订阅消息用户失败:{error}"))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("微信订阅消息用户不存在")
|
||||
})?;
|
||||
let openid = user
|
||||
.wechat_account
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("用户未绑定微信小程序 openid")
|
||||
})?;
|
||||
|
||||
state
|
||||
.wechat_client()
|
||||
.send_subscribe_message(WechatSubscribeMessageRequest {
|
||||
touser: openid.to_string(),
|
||||
template_id: template_id.to_string(),
|
||||
page: message
|
||||
.page
|
||||
.clone()
|
||||
.or_else(|| Some("/pages/web-view/index".to_string())),
|
||||
miniprogram_state: Some(
|
||||
normalize_miniprogram_state(
|
||||
&state.config.wechat_mini_program_subscribe_message_state,
|
||||
)
|
||||
.to_string(),
|
||||
),
|
||||
lang: Some("zh_CN".to_string()),
|
||||
data: build_generation_result_template_data(&message),
|
||||
})
|
||||
.await
|
||||
.map_err(map_wechat_error)?;
|
||||
|
||||
info!(
|
||||
owner_user_id = %message.owner_user_id,
|
||||
template_id,
|
||||
"微信小程序生成结果订阅消息已发送"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_generation_result_template_data(
|
||||
message: &GenerationResultSubscribeMessage,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"thing1".to_string(),
|
||||
truncate_template_value(
|
||||
message
|
||||
.task_name
|
||||
.as_deref()
|
||||
.unwrap_or(GENERATION_RESULT_TASK_NAME),
|
||||
20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"phrase2".to_string(),
|
||||
truncate_template_value(message.status.template_status_label(), 5),
|
||||
),
|
||||
(
|
||||
"time4".to_string(),
|
||||
truncate_template_value(
|
||||
&format_generation_completed_time(message.completed_at_micros),
|
||||
20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"thing5".to_string(),
|
||||
truncate_template_value(
|
||||
message.work_name.as_deref().unwrap_or(DEFAULT_WORK_NAME),
|
||||
20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"number6".to_string(),
|
||||
truncate_template_value(&message.consumed_points.to_string(), 32),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
impl GenerationResultSubscribeMessageStatus {
|
||||
fn template_status_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Succeeded => "已完成",
|
||||
Self::Failed => "生成失败",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_template_value(value: &str, max_chars: usize) -> String {
|
||||
let trimmed = value.trim();
|
||||
let mut result = String::new();
|
||||
for character in trimmed.chars().take(max_chars) {
|
||||
result.push(character);
|
||||
}
|
||||
if result.is_empty() {
|
||||
DEFAULT_WORK_NAME.to_string()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn format_generation_completed_time(completed_at_micros: i64) -> String {
|
||||
let seconds = completed_at_micros.div_euclid(1_000_000);
|
||||
let Ok(utc_time) = OffsetDateTime::from_unix_timestamp(seconds) else {
|
||||
return "1970-01-01 08:00".to_string();
|
||||
};
|
||||
let beijing_offset = UtcOffset::from_hms(8, 0, 0).unwrap_or(UtcOffset::UTC);
|
||||
let local_time = utc_time.to_offset(beijing_offset);
|
||||
format!(
|
||||
"{:04}-{:02}-{:02} {:02}:{:02}",
|
||||
local_time.year(),
|
||||
u8::from(local_time.month()),
|
||||
local_time.day(),
|
||||
local_time.hour(),
|
||||
local_time.minute()
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_miniprogram_state(value: &str) -> &'static str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"developer" | "develop" | "dev" => "developer",
|
||||
"trial" => "trial",
|
||||
_ => "formal",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_generation_result_template_uses_failed_status_and_zero_points() {
|
||||
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
|
||||
owner_user_id: "user-1".to_string(),
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: Some("首关拼图".to_string()),
|
||||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: 1_762_000_000_000_000,
|
||||
page: None,
|
||||
});
|
||||
|
||||
assert_eq!(data.get("phrase2").map(String::as_str), Some("生成失败"));
|
||||
assert_eq!(data.get("number6").map(String::as_str), Some("0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_result_template_time_uses_wechat_time_format() {
|
||||
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
|
||||
owner_user_id: "user-1".to_string(),
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: Some("首关拼图".to_string()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: 15,
|
||||
completed_at_micros: 0,
|
||||
page: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
data.get("time4").map(String::as_str),
|
||||
Some("1970-01-01 08:00")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_result_template_uses_task_template_name() {
|
||||
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
|
||||
owner_user_id: "user-1".to_string(),
|
||||
task_name: Some("敲木鱼".to_string()),
|
||||
work_name: Some("功德木鱼".to_string()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: 10,
|
||||
completed_at_micros: 0,
|
||||
page: None,
|
||||
});
|
||||
|
||||
assert_eq!(data.get("thing1").map(String::as_str), Some("敲木鱼"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
use std::{
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
@@ -43,6 +43,10 @@ use crate::{
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
};
|
||||
|
||||
const WOODEN_FISH_PROVIDER: &str = "wooden-fish";
|
||||
@@ -147,6 +151,15 @@ pub async fn execute_wooden_fish_action(
|
||||
wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let is_compile_draft = matches!(
|
||||
payload.action_type,
|
||||
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
|
||||
);
|
||||
let generation_points_cost = if is_compile_draft {
|
||||
resolve_wooden_fish_generation_points_cost(&state).await
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let result = execute_wooden_fish_action_with_generated_assets(
|
||||
&state,
|
||||
&request_context,
|
||||
@@ -160,21 +173,55 @@ pub async fn execute_wooden_fish_action(
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|response| response.status().is_server_error())
|
||||
&& matches!(
|
||||
payload.action_type,
|
||||
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
|
||||
)
|
||||
&& is_compile_draft
|
||||
{
|
||||
mark_wooden_fish_generation_failed(
|
||||
let failed_at_micros = current_utc_micros();
|
||||
let work_name =
|
||||
resolve_wooden_fish_notification_work_name(&state, &session_id, &owner_user_id).await;
|
||||
if mark_wooden_fish_generation_failed(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
owner_user_id.as_str(),
|
||||
author_display_name.as_str(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
&state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()),
|
||||
work_name,
|
||||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: failed_at_micros,
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let response = result?;
|
||||
if is_compile_draft && response.session.status == WoodenFishGenerationStatus::Ready {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
&state,
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id,
|
||||
task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()),
|
||||
work_name: response
|
||||
.session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.work_title.clone()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: generation_points_cost,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
@@ -588,13 +635,37 @@ async fn execute_wooden_fish_action_with_generated_assets(
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 {
|
||||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
WOODEN_FISH_TEMPLATE_ID,
|
||||
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_wooden_fish_notification_work_name(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
) -> Option<String> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|session| session.draft)
|
||||
.map(|draft| draft.work_title)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
async fn mark_wooden_fish_generation_failed(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
author_display_name: &str,
|
||||
) {
|
||||
) -> bool {
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.mark_wooden_fish_generation_failed(
|
||||
@@ -612,7 +683,9 @@ async fn mark_wooden_fish_generation_failed(
|
||||
error = %error,
|
||||
"敲木鱼草稿生成失败后的状态回写失败"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {
|
||||
|
||||
@@ -87,6 +87,7 @@ fn orphan_work_author_summary() -> WorkAuthorSummary {
|
||||
}
|
||||
|
||||
/// 中文注释:运维回填只处理空作者或认证仓储不可再解析的历史 owner_user_id,避免把有效作品误转给占位账号。
|
||||
#[cfg(test)]
|
||||
pub fn should_rebind_orphan_work_owner(
|
||||
auth_user_service: &module_auth::AuthUserService,
|
||||
owner_user_id: &str,
|
||||
|
||||
Reference in New Issue
Block a user