合并 master 并保留外部生成 worker 模式

合入 master 的生产健康巡检、JumpHop 和 SpacetimeDB 更新
保留外部生成 worker、队列/内联模式与 lease guard 口径
合并 Server-Provision 工具复用、health patrol 和外部生成 worker systemd 配置
补齐 SpacetimeDB 生成绑定并通过本地检查
This commit is contained in:
2026-06-10 21:26:53 +08:00
93 changed files with 7872 additions and 2244 deletions

View File

@@ -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 {

View File

@@ -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,

View File

@@ -15,32 +15,19 @@ 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::{
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"));
@@ -918,6 +762,45 @@ mod tests {
);
}
#[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();
@@ -4382,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();

View File

@@ -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 的启动配置,避免入口层直接散落环境变量解析逻辑。
@@ -124,6 +125,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>,
@@ -139,9 +141,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,
@@ -331,6 +334,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,
@@ -349,9 +355,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,
@@ -788,6 +794,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"])
@@ -876,17 +888,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"])
{
@@ -1331,7 +1343,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");
@@ -1498,11 +1510,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"
@@ -1822,6 +1834,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();

View File

@@ -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,59 +86,65 @@ 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
}
@@ -197,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(),
),
})
}
@@ -297,6 +322,28 @@ 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();

View File

@@ -10,6 +10,7 @@ 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(
@@ -25,23 +26,49 @@ pub async fn readiness_check(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Response {
if state.is_ready() {
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("api-server 正在退出,不再接收新流量")
.with_message("SpacetimeDB 连接健康检查失败,api-server 暂不接收新流量")
.with_details(json!({
"reason": "api_server_draining",
"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

View File

@@ -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;

View File

@@ -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)

View File

@@ -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,
)),
)
}

View File

@@ -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,
)),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,
)),
)
}

View File

@@ -0,0 +1,183 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_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_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)),
)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -31,7 +31,9 @@ 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};
@@ -242,6 +244,8 @@ pub struct AppStateInner {
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,
@@ -418,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,
@@ -467,6 +475,30 @@ impl AppState {
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,
@@ -527,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> {
@@ -587,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,
@@ -1378,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())
@@ -1387,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,
@@ -1512,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
@@ -1529,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());
}