diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 0cbdb334..a94bc34d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:执行 `cargo check --manifest-path server-rs/Cargo.toml -p platform-wechat`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server`、微信相关定向测试和编码检查;新增微信协议细节优先落到 `platform-wechat`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 +## 2026-06-08 后端创作 / 游玩流程先统一主干再领域分发 + +- 背景:前端平台入口、作品架、公开详情和推荐运行态已经持续收口,但 `api-server` 仍在 `app.rs` 逐玩法合并创作 / 运行态路由,入口开关路径判断也独立维护,新增玩法容易复制出平行链路。 +- 决策:后端所有创作 / 游玩相关 HTTP 路由先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 统一主干;主干注册 `playId`、领域模块 key、创作路由前缀、运行态路由前缀和新建创作入口开关匹配规则,并在进入领域 handler 前统一挂载 `PlayFlowRequestContext`,再在最后一步分发到各玩法领域 HTTP Adapter。创作入口配置、AI task、runtime chat、运行态设置 / 存档、运行态库存、游玩历史、存档归档、游玩统计、历史素材、角色资产工坊、角色图像 / 动画生成和 Hyper3D 代理也作为创作 / 游玩支撑能力从 `play_flow` 进入;`modules/platform.rs` 只保留通用 LLM / 语音代理。`app.rs` 只合并 `modules::play_flow::router(state)`,不再逐玩法 merge;`creation_entry_config.rs` 复用 `play_flow` 的入口开关解析,不维护第二份路径表。 +- 影响范围:`api-server` 路由组织、入口开关、玩法接入 SOP、后端契约文档、后续新增 / 迁移玩法。 +- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation//*`、历史 `/api/runtime//agent/*` 与公开 runtime 路由外部契约不变。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐 - 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 2be25228..39e9c314 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -56,10 +56,11 @@ npm run check:server-rs-ddd - 健康检查:`GET /healthz`。 - 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品可见性、兑换码、邀请码、任务配置和充值商品配置。 - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 -- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请、兑换、存档、历史浏览和游玩统计。 -- LLM 与语音:`/api/llm/*`、`/api/speech/volcengine/*`。 -- 资产:`/api/assets/*`,包括直传票据、STS、对象确认、实体绑定、读签名、读 bytes、历史资产、角色图像/动画和 Hyper3D 代理。 -- 创作入口配置:`/api/creation-entry/config`,后台 `/admin/api/creation-entry/config` 和 `/admin/api/creation-entry/config/banners`。 +- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 +- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 +- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。 +- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`。 +- 后台入口配置:`/admin/api/creation-entry/config` 和 `/admin/api/creation-entry/config/banners`。 - 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。 - 拼图:`/api/runtime/puzzle/*`。 - 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。 @@ -70,9 +71,20 @@ npm run check:server-rs-ddd - 跳一跳:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`。 - 汪汪声浪:`/api/runtime/bark-battle/*`。 - 儿童向创作:`/api/creation/edutainment/*`。 -- AI task:`/api/ai/tasks*`。 -需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。 +需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。涉及创作、生成、作品、公开详情、试玩、正式运行态、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、AI task、角色资产工坊或玩法生成支撑资产的路由,不再直接在 `app.rs` 逐玩法 `.merge(...)`,也不挂到 `modules/platform.rs`;必须先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 的统一玩法流程主干,再由主干注册表分发到各领域 HTTP Adapter 或支撑能力 handler。 + +### 创作 / 游玩统一流程主干 + +`modules/play_flow.rs` 是后端创作与游玩流程的统一入口。现有外部 URL、DTO、错误 envelope、鉴权方式、入口开关语义和 SpacetimeDB schema 默认不变,但路由组织必须遵循: + +1. `app.rs` 只合并 `modules::play_flow::router(state)`,不直接合并 RPG、拼图、抓大鹅、跳一跳、敲木鱼、拼消消、汪汪声浪、视觉小说或儿童向创作等逐玩法模块。 +2. `play_flow` 统一注册每个玩法的 `playId`、领域模块 key、创作路由前缀和运行态路由前缀;后续新增玩法或迁移旧玩法时,先补这个注册表,再挂具体领域模块路由。 +3. 新建创作、首次生成和 Remix 成草稿等会产生新创作的入口开关匹配规则同样归 `play_flow` 管理;`creation_entry_config.rs` 只复用该规则执行 `open=false` 熔断,不再维护第二份路径判断。 +4. `play_flow` 在进入领域 handler 前先解析并挂载 `PlayFlowRequestContext`,统一标记请求处于 `Creation`、`Runtime`、`CreationEntryConfig`、`CreationSupport`、`RuntimeSupport`、`AiTask`、`PublicReadModel` 或 `RuntimeInventory` 阶段,并记录目标 `playId` / 领域模块 key;领域 handler 可以读取该上下文做后续收口,但不能绕过主干自建平行流程。 +5. `play_flow` 只做平台共性编排和领域 Adapter 组合,不下沉玩法规则;最后一步的草稿编译、资产生成、发布、运行态 start/action/finish、计分和排行榜仍交给对应 `module-*`、`spacetime-module` procedure 和玩法 HTTP handler 处理。 +6. 公开作品聚合、作品详情、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、历史素材、AI task、runtime chat、文档解析、角色资产工坊、角色图像 / 动画生成和 Hyper3D 代理属于跨玩法或玩法支撑流程,也从 `play_flow` 主干挂入;`modules/platform.rs` 只保留通用 LLM / 语音代理,不再承接创作 / 游玩支撑路由。 +7. 如果某个旧玩法仍使用历史 `/api/runtime//agent/*` 作为创作命名空间,只保留外部兼容路径;新增实现和文档仍按“统一主干 -> 领域 Adapter”的语义描述,不把历史路径当新架构模板。 ### 认证态用户与会话摘要下发口径 @@ -87,8 +99,8 @@ npm run check:server-rs-ddd 路由模块化规则: -1. 每个能力 Module 只暴露 `router(state) -> Router`,由 `app.rs` 统一 `.merge(...)`。 -2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。 +1. 每个能力 Module 只暴露 `router(state) -> Router`;平台创作 / 游玩相关 Module 和支撑能力由 `modules/play_flow.rs` 统一 `.merge(...)` 或在支撑 router 内挂载,其它账号、资产基础、后台和平台基础能力再由 `app.rs` 直接合并。 +2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue;不得重新恢复逐玩法 creation/runtime merge 列表。 3. 能力 Module 可在路由内部用 `FromRef` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。 4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。 5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 31ea9b3d..e41f6771 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -56,6 +56,8 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 创作入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 ``` +后端链路也按同一条平台主干组织:所有创作、生成、作品回读、发布、试玩、正式 runtime、公开详情、作品架、运行态设置 / 存档、游玩历史、存档归档、游玩统计、历史素材、AI task、runtime chat、文档解析、角色资产工坊和玩法生成支撑资产相关 HTTP 路由,先注册到 `server-rs/crates/api-server/src/modules/play_flow.rs`,由主干在进入领域 handler 前统一解析 `PlayFlowRequestContext`,再在最后一步分发给对应领域模块或支撑能力 handler 处理。`app.rs` 不再逐玩法挂载创作 / 运行态路由,`modules/platform.rs` 只保留通用 LLM / 语音代理;新增玩法、补齐旧玩法或迁移旧路径时,必须先补 `play_flow` 的 `playId`、领域模块 key、创作路由前缀、运行态路由前缀和入口开关匹配规则,再补具体 handler。领域规则、胜负裁决、计分、发布状态、资产完整性和排行榜仍留在各自 `module-*` 与 SpacetimeDB procedure 中,不把平台主干写成某个玩法的新业务真相。 + 默认工作台只提交结构化表单、图片槽位和配置 payload,不默认增加聊天输入区、流式消息区或轻输入 Agent。确需偏离该模式时,必须先在 PRD 和本文档写明例外原因、影响范围和回退方式,再进入编码。 单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图、主图预览和删除确认;新玩法页面不得重复手写这些交互。主图已有图片时,默认点击图片打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方只能通过 `canUploadMainImage`、`canUseImageHistory` 等受控参数开关上传和历史入口,不得用复制组件或样式遮挡改行为。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。 diff --git a/server-rs/crates/api-server/src/ai_tasks.rs b/server-rs/crates/api-server/src/ai_tasks.rs index 42a446df..d2c176d0 100644 --- a/server-rs/crates/api-server/src/ai_tasks.rs +++ b/server-rs/crates/api-server/src/ai_tasks.rs @@ -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, diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 759fa842..a68f6db5 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -15,7 +15,7 @@ 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, error_middleware::normalize_error_response, @@ -23,24 +23,9 @@ use crate::{ 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 +42,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,13 +52,6 @@ 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(), @@ -290,166 +256,6 @@ async fn record_api_tracking_after_success( response } -fn visual_novel_router(state: AppState) -> Router { - 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::{ diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 9804bf83..36639108 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -11,6 +11,7 @@ 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, @@ -70,62 +71,6 @@ 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"); - } - if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") { - return Some("puzzle"); - } - if normalized == "/api/runtime/big-fish/agent/sessions" { - return Some("big-fish"); - } - 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"); - } - None -} - pub(crate) fn resolve_creation_entry_mud_point_cost_from_config( config: &CreationEntryConfigResponse, creation_type_id: &str, diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 1cac08d9..88caf30d 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules/assets.rs b/server-rs/crates/api-server/src/modules/assets.rs index a03e8372..73da2d27 100644 --- a/server-rs/crates/api-server/src/modules/assets.rs +++ b/server-rs/crates/api-server/src/modules/assets.rs @@ -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 { ) .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, - )), - ) } diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs index 12efa9f0..ce399eec 100644 --- a/server-rs/crates/api-server/src/modules/platform.rs +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -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 { Router::new() .route( @@ -81,213 +50,4 @@ pub fn router(state: AppState) -> Router { 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, - )), - ) } diff --git a/server-rs/crates/api-server/src/modules/play_flow.rs b/server-rs/crates/api-server/src/modules/play_flow.rs new file mode 100644 index 00000000..be85f260 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -0,0 +1,1028 @@ +use axum::{ + Router, + body::Body, + extract::DefaultBodyLimit, + http::Request, + middleware::{self, Next}, + response::Response, + 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, + }, + assets::get_asset_history, + 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, + }, + runtime_browse_history::{ + delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, + }, + 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_inventory::get_runtime_inventory_state, + runtime_profile::get_profile_play_stats, + runtime_save::{delete_runtime_snapshot, get_runtime_snapshot, put_runtime_snapshot}, + runtime_save::{list_profile_save_archives, resume_profile_save_archive}, + runtime_settings::{get_runtime_settings, put_runtime_settings}, + state::AppState, +}; + +const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PlayFlowDomainAdapter { + pub play_id: &'static str, + pub module_key: &'static str, + pub creation_route_prefixes: &'static [&'static str], + pub runtime_route_prefixes: &'static [&'static str], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PlayFlowStage { + Creation, + Runtime, + CreationEntryConfig, + CreationSupport, + RuntimeSupport, + AiTask, + PublicReadModel, + RuntimeInventory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PlayFlowRequestContext { + pub stage: PlayFlowStage, + pub play_id: Option<&'static str>, + pub module_key: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreationEntryRouteMatcher { + Exact(&'static str), + PrefixAndSuffix { + prefix: &'static str, + suffix: &'static str, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CreationEntryRouteRule { + play_id: &'static str, + matcher: CreationEntryRouteMatcher, +} + +impl CreationEntryRouteMatcher { + fn matches(self, normalized_path: &str) -> bool { + match self { + Self::Exact(path) => normalized_path == path, + Self::PrefixAndSuffix { prefix, suffix } => { + normalized_path.starts_with(prefix) && normalized_path.ends_with(suffix) + } + } + } +} + +impl PlayFlowDomainAdapter { + fn creation_matches(self, normalized_path: &str) -> bool { + self.creation_route_prefixes + .iter() + .any(|prefix| normalized_path.starts_with(prefix)) + } + + fn runtime_matches(self, normalized_path: &str) -> bool { + self.runtime_route_prefixes + .iter() + .any(|prefix| normalized_path.starts_with(prefix)) + } +} + +// 中文注释:平台玩法流程先在这里统一注册;HTTP handler 仍在最后一步分发到各领域模块执行。 +pub(crate) const PLAY_FLOW_DOMAIN_ADAPTERS: &[PlayFlowDomainAdapter] = &[ + PlayFlowDomainAdapter { + play_id: "rpg", + module_key: "custom_world", + creation_route_prefixes: &[ + "/api/runtime/custom-world/agent", + "/api/runtime/custom-world/profile", + "/api/runtime/custom-world-gallery/", + ], + runtime_route_prefixes: &[ + "/api/runtime/custom-world-library", + "/api/runtime/custom-world-gallery", + "/api/runtime/custom-world/", + ], + }, + PlayFlowDomainAdapter { + play_id: "creative-agent", + module_key: "story", + creation_route_prefixes: &["/api/runtime/creative-agent"], + runtime_route_prefixes: &["/api/story"], + }, + PlayFlowDomainAdapter { + play_id: "big-fish", + module_key: "big_fish", + creation_route_prefixes: &["/api/runtime/big-fish/agent"], + runtime_route_prefixes: &["/api/runtime/big-fish"], + }, + PlayFlowDomainAdapter { + play_id: "puzzle", + module_key: "puzzle", + creation_route_prefixes: &[ + "/api/runtime/puzzle/agent", + "/api/runtime/puzzle/onboarding", + "/api/runtime/puzzle/gallery/", + ], + runtime_route_prefixes: &["/api/runtime/puzzle"], + }, + PlayFlowDomainAdapter { + play_id: "match3d", + module_key: "match3d", + creation_route_prefixes: &["/api/creation/match3d"], + runtime_route_prefixes: &["/api/runtime/match3d"], + }, + PlayFlowDomainAdapter { + play_id: "square-hole", + module_key: "square_hole", + creation_route_prefixes: &["/api/creation/square-hole"], + runtime_route_prefixes: &["/api/runtime/square-hole"], + }, + PlayFlowDomainAdapter { + play_id: "jump-hop", + module_key: "jump_hop", + creation_route_prefixes: &["/api/creation/jump-hop"], + runtime_route_prefixes: &["/api/runtime/jump-hop"], + }, + PlayFlowDomainAdapter { + play_id: "wooden-fish", + module_key: "wooden_fish", + creation_route_prefixes: &["/api/creation/wooden-fish"], + runtime_route_prefixes: &["/api/runtime/wooden-fish"], + }, + PlayFlowDomainAdapter { + play_id: "puzzle-clear", + module_key: "puzzle_clear", + creation_route_prefixes: &["/api/creation/puzzle-clear"], + runtime_route_prefixes: &["/api/runtime/puzzle-clear"], + }, + PlayFlowDomainAdapter { + play_id: "bark-battle", + module_key: "bark_battle", + creation_route_prefixes: &["/api/creation/bark-battle"], + runtime_route_prefixes: &["/api/runtime/bark-battle"], + }, + PlayFlowDomainAdapter { + play_id: "visual-novel", + module_key: "visual_novel", + creation_route_prefixes: &["/api/creation/visual-novel", "/api/creation/audio"], + runtime_route_prefixes: &["/api/runtime/visual-novel"], + }, + PlayFlowDomainAdapter { + play_id: "baby-object-match", + module_key: "edutainment", + creation_route_prefixes: &["/api/creation/edutainment/baby-object-match"], + runtime_route_prefixes: &[], + }, + PlayFlowDomainAdapter { + play_id: "baby-love-drawing", + module_key: "edutainment", + creation_route_prefixes: &["/api/creation/edutainment/baby-love-drawing"], + runtime_route_prefixes: &[], + }, +]; + +const NEW_CREATION_ROUTE_RULES: &[CreationEntryRouteRule] = &[ + CreationEntryRouteRule { + play_id: "puzzle", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/puzzle/agent/sessions"), + }, + CreationEntryRouteRule { + play_id: "puzzle", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/puzzle/onboarding/generate"), + }, + CreationEntryRouteRule { + play_id: "puzzle", + matcher: CreationEntryRouteMatcher::PrefixAndSuffix { + prefix: "/api/runtime/puzzle/gallery/", + suffix: "/remix", + }, + }, + CreationEntryRouteRule { + play_id: "big-fish", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/big-fish/agent/sessions"), + }, + CreationEntryRouteRule { + play_id: "big-fish", + matcher: CreationEntryRouteMatcher::PrefixAndSuffix { + prefix: "/api/runtime/big-fish/gallery/", + suffix: "/remix", + }, + }, + CreationEntryRouteRule { + play_id: "rpg", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/custom-world/agent/sessions"), + }, + CreationEntryRouteRule { + play_id: "rpg", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/custom-world/profile"), + }, + CreationEntryRouteRule { + play_id: "rpg", + matcher: CreationEntryRouteMatcher::PrefixAndSuffix { + prefix: "/api/runtime/custom-world-gallery/", + suffix: "/remix", + }, + }, + CreationEntryRouteRule { + play_id: "match3d", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/match3d/sessions"), + }, + CreationEntryRouteRule { + play_id: "square-hole", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/square-hole/sessions"), + }, + CreationEntryRouteRule { + play_id: "bark-battle", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/bark-battle/drafts"), + }, + CreationEntryRouteRule { + play_id: "wooden-fish", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/wooden-fish/sessions"), + }, + CreationEntryRouteRule { + play_id: "jump-hop", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/jump-hop/sessions"), + }, + CreationEntryRouteRule { + play_id: "puzzle-clear", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/puzzle-clear/sessions"), + }, + CreationEntryRouteRule { + play_id: "visual-novel", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/visual-novel/sessions"), + }, + CreationEntryRouteRule { + play_id: "baby-object-match", + matcher: CreationEntryRouteMatcher::Exact( + "/api/creation/edutainment/baby-object-match/assets", + ), + }, + CreationEntryRouteRule { + play_id: "baby-love-drawing", + matcher: CreationEntryRouteMatcher::Exact( + "/api/creation/edutainment/baby-love-drawing/magic", + ), + }, +]; + +pub fn router(state: AppState) -> Router { + assert_play_flow_domain_registry(); + + Router::new() + .merge(play_flow_support_router(state.clone())) + .merge(super::public_work::router(state.clone())) + .merge(super::story::router(state.clone())) + .merge(super::custom_world::router(state.clone())) + .merge(super::big_fish::router(state.clone())) + .merge(super::bark_battle::router(state.clone())) + .merge(super::match3d::router(state.clone())) + .merge(super::square_hole::router(state.clone())) + .merge(super::jump_hop::router(state.clone())) + .merge(super::wooden_fish::router(state.clone())) + .merge(super::puzzle_clear::router(state.clone())) + .merge(super::puzzle::router(state.clone())) + .merge(super::visual_novel::router(state.clone())) + .merge(super::edutainment::router(state.clone())) + .route( + "/api/runtime/sessions/{runtime_session_id}/inventory", + get(get_runtime_inventory_state) + .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), + ) + .layer(middleware::from_fn(attach_play_flow_request_context)) +} + +fn play_flow_support_router(state: AppState) -> Router { + Router::new() + .route( + "/api/creation-entry/config", + get(get_creation_entry_config_handler), + ) + .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/assets/history", + get(get_asset_history).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .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/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, + )), + ) + .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/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, + )), + ) +} + +pub(crate) fn play_flow_domain_adapters() -> &'static [PlayFlowDomainAdapter] { + PLAY_FLOW_DOMAIN_ADAPTERS +} + +fn assert_play_flow_domain_registry() { + debug_assert!( + play_flow_domain_adapters().iter().all(|adapter| { + !adapter.play_id.is_empty() + && !adapter.module_key.is_empty() + && (!adapter.creation_route_prefixes.is_empty() + || !adapter.runtime_route_prefixes.is_empty()) + }), + "play flow domain adapters must declare identity and at least one route prefix" + ); +} + +pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { + let normalized = path.trim_end_matches('/'); + NEW_CREATION_ROUTE_RULES + .iter() + .find(|rule| rule.matcher.matches(normalized)) + .map(|rule| rule.play_id) +} + +pub(crate) fn resolve_play_flow_request_context(path: &str) -> Option { + let normalized = path.trim_end_matches('/'); + if let Some(context) = resolve_play_flow_support_context(normalized) { + return Some(context); + } + if normalized == "/api/public-works" || normalized.starts_with("/api/public-works/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::PublicReadModel, + play_id: None, + module_key: "public_work", + }); + } + if normalized.starts_with("/api/runtime/sessions/") && normalized.ends_with("/inventory") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeInventory, + play_id: None, + module_key: "runtime_inventory", + }); + } + + play_flow_domain_adapters() + .iter() + .find(|adapter| adapter.creation_matches(normalized)) + .map(|adapter| PlayFlowRequestContext { + stage: PlayFlowStage::Creation, + play_id: Some(adapter.play_id), + module_key: adapter.module_key, + }) + .or_else(|| { + play_flow_domain_adapters() + .iter() + .find(|adapter| adapter.runtime_matches(normalized)) + .map(|adapter| PlayFlowRequestContext { + stage: PlayFlowStage::Runtime, + play_id: Some(adapter.play_id), + module_key: adapter.module_key, + }) + }) +} + +fn resolve_play_flow_support_context(normalized_path: &str) -> Option { + if normalized_path == "/api/creation-entry/config" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationEntryConfig, + play_id: None, + module_key: "creation_entry_config", + }); + } + if normalized_path.starts_with("/api/runtime/chat/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: Some("rpg"), + module_key: "runtime_chat", + }); + } + if normalized_path == "/api/runtime/creation-agent/document-inputs/parse" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("creative-agent"), + module_key: "creation_agent_document_input", + }); + } + if normalized_path == "/api/ai/tasks" || normalized_path.starts_with("/api/ai/tasks/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::AiTask, + play_id: None, + module_key: "ai_tasks", + }); + } + if normalized_path.starts_with("/api/assets/character-visual/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_visual_assets", + }); + } + if normalized_path.starts_with("/api/assets/character-animation/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_animation_assets", + }); + } + if normalized_path == "/api/assets/character-workflow-cache" + || normalized_path.starts_with("/api/assets/character-workflow-cache/") + || normalized_path.starts_with("/api/runtime/custom-world/asset-studio/") + { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("rpg"), + module_key: "custom_world_asset_studio", + }); + } + if normalized_path == "/api/assets/history" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "asset_history", + }); + } + if normalized_path.starts_with("/api/assets/hyper3d/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "hyper3d_generation", + }); + } + if normalized_path == "/api/runtime/settings" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_settings", + }); + } + if normalized_path == "/api/runtime/save/snapshot" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save", + }); + } + if normalized_path == "/api/profile/browse-history" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_browse_history", + }); + } + if normalized_path == "/api/profile/save-archives" + || normalized_path.starts_with("/api/profile/save-archives/") + { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save_archives", + }); + } + if normalized_path == "/api/profile/play-stats" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "profile_play_stats", + }); + } + + None +} + +async fn attach_play_flow_request_context(mut request: Request, next: Next) -> Response { + if let Some(context) = resolve_play_flow_request_context(request.uri().path()) { + request.extensions_mut().insert(context); + } + + next.run(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn play_flow_registry_keeps_current_domain_adapters_together() { + let adapters = play_flow_domain_adapters(); + for play_id in [ + "rpg", + "creative-agent", + "big-fish", + "puzzle", + "match3d", + "square-hole", + "jump-hop", + "wooden-fish", + "puzzle-clear", + "bark-battle", + "visual-novel", + "baby-object-match", + "baby-love-drawing", + ] { + assert!( + adapters.iter().any(|adapter| adapter.play_id == play_id), + "{play_id} should be registered in the unified play flow" + ); + } + } + + #[test] + fn resolves_new_creation_paths_before_domain_fanout() { + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"), + Some("puzzle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/puzzle-clear/sessions"), + Some("puzzle-clear"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"), + Some("puzzle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/match3d/sessions"), + Some("match3d"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/square-hole/sessions"), + Some("square-hole"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), + Some("visual-novel"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"), + Some("big-fish"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id( + "/api/runtime/custom-world-gallery/user-1/profile-1/remix" + ), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/story/sessions/runtime"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"), + Some("bark-battle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"), + Some("wooden-fish"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"), + Some("baby-object-match"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"), + Some("baby-love-drawing"), + ); + assert_eq!(resolve_creation_entry_route_id("/healthz"), None); + } + + #[test] + fn resolves_unified_play_flow_context_before_domain_handlers() { + assert_eq!( + resolve_play_flow_request_context("/api/creation/match3d/sessions"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::Creation, + play_id: Some("match3d"), + module_key: "match3d", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/jump-hop/runs"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::Runtime, + play_id: Some("jump-hop"), + module_key: "jump_hop", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/public-works/JH-12345678"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::PublicReadModel, + play_id: None, + module_key: "public_work", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/sessions/runtime-1/inventory"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeInventory, + play_id: None, + module_key: "runtime_inventory", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/creation-entry/config"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationEntryConfig, + play_id: None, + module_key: "creation_entry_config", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/chat/npc/turn/stream"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: Some("rpg"), + module_key: "runtime_chat", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/creation-agent/document-inputs/parse"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("creative-agent"), + module_key: "creation_agent_document_input", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/ai/tasks/aitask_001/complete"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::AiTask, + play_id: None, + module_key: "ai_tasks", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/character-workflow-cache/role-1"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("rpg"), + module_key: "custom_world_asset_studio", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/character-visual/generate"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_visual_assets", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/history"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "asset_history", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/character-animation/jobs/task-1"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_animation_assets", + }), + ); + assert_eq!( + resolve_play_flow_request_context( + "/api/runtime/custom-world/asset-studio/role/role-1/workflow" + ), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("rpg"), + module_key: "custom_world_asset_studio", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/hyper3d/text-to-model"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "hyper3d_generation", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/settings"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_settings", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/save/snapshot"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/profile/browse-history"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_browse_history", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/profile/save-archives/world-1"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save_archives", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/profile/play-stats"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "profile_play_stats", + }), + ); + assert_eq!(resolve_play_flow_request_context("/api/profile/me"), None); + } +} diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index 8875caf2..54b17f8b 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -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 { 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 { 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, - )), - ) } diff --git a/server-rs/crates/api-server/src/modules/visual_novel.rs b/server-rs/crates/api-server/src/modules/visual_novel.rs new file mode 100644 index 00000000..521122ca --- /dev/null +++ b/server-rs/crates/api-server/src/modules/visual_novel.rs @@ -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 { + 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)), + ) +} diff --git a/server-rs/crates/api-server/src/runtime_browse_history.rs b/server-rs/crates/api-server/src/runtime_browse_history.rs index 7981ad82..17a499af 100644 --- a/server-rs/crates/api-server/src/runtime_browse_history.rs +++ b/server-rs/crates/api-server/src/runtime_browse_history.rs @@ -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, diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index 3b02de8f..9a05ca8a 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -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, diff --git a/server-rs/crates/api-server/src/runtime_settings.rs b/server-rs/crates/api-server/src/runtime_settings.rs index 8535f692..26df7b03 100644 --- a/server-rs/crates/api-server/src/runtime_settings.rs +++ b/server-rs/crates/api-server/src/runtime_settings.rs @@ -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,