合并 master 并保留外部生成 worker 模式
合入 master 的生产健康巡检、JumpHop 和 SpacetimeDB 更新 保留外部生成 worker、队列/内联模式与 lease guard 口径 合并 Server-Provision 工具复用、health patrol 和外部生成 worker systemd 配置 补齐 SpacetimeDB 生成绑定并通过本地检查
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
183
server-rs/crates/api-server/src/modules/visual_novel.rs
Normal file
183
server-rs/crates/api-server/src/modules/visual_novel.rs
Normal 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)),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user