diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 003bc244..f8799da4 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/deploy/systemd/genarrative-database-backup.service b/deploy/systemd/genarrative-database-backup.service index cde294e2..a19481b6 100644 --- a/deploy/systemd/genarrative-database-backup.service +++ b/deploy/systemd/genarrative-database-backup.service @@ -9,10 +9,9 @@ User=root Group=root WorkingDirectory=/opt/genarrative/current EnvironmentFile=/etc/genarrative/api-server.env -ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service +ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service # 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。 PrivateTmp=true ProtectSystem=full ReadWritePaths=/stdb /var/lib/genarrative - diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ed2a1676..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 可见状态。 @@ -254,7 +266,7 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreSnapshot` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 `auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 255026f0..cefd3279 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -214,10 +214,10 @@ UI 相关修改要重点验证: 数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份: ```bash -npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service +npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service ``` -脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive ` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 +脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service` 依赖 `spacetimedb.service`,生产定时冷备份还必须传入 `--restart-service-after genarrative-api.service`,确保备份后 API 随数据库一起恢复。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive ` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 生产环境变量模板在 `deploy/env/api-server.env.example`: @@ -248,6 +248,8 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 `Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256` 和 `release-manifest.json` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物,再执行 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 +`Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json` 和 `scripts/database-backup-to-oss.mjs`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`Genarrative-Api-Deploy` 会优先从上游 API 构建产物复制该脚本到新的 API release 目录;如果 API 发布后 current release 中缺少该脚本,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//scripts/database-backup-to-oss.mjs`,不要只在部署机工作区手工补文件。 + `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。其它构建 / 发布流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。 @@ -257,7 +259,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 `Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。 -`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。 +`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash;Stdb module 的 `CARGO_HOME`、`CARGO_TARGET_DIR` 和 `SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/stdb-module` 下,可用 `GENARRATIVE_STDB_CACHE_ROOT` 覆盖,避免 `WORKSPACE@tmp` 被清理后无改动也触发近似冷构建。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。 `Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务,目标 dev / release agent 非 dry-run 时都必须具备 root 权限。 @@ -414,7 +416,7 @@ systemctl restart genarrative-api.service journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' ``` -`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会等待启动恢复,超时后继续监听但进入依赖不可用模式,所有请求统一返回 `503 SERVICE_UNAVAILABLE`,错误详情包含 `reason=spacetime_startup_unavailable`,以避免用空本地状态或旧快照覆盖认证表。 +`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 常用检查思路: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 7dba486f..308b4178 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/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 609866ca..757f6823 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -104,7 +104,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/database-backup-to-oss.mjs", fingerprint: true } } diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index dcb824b6..52fa61fd 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -112,7 +112,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json", + filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/scripts/database-backup-to-oss.mjs", target: '.', fingerprintArtifacts: true ) diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 6b6a964a..aa4c2d6f 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -12,6 +12,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' + GENARRATIVE_STDB_CACHE_ROOT = 'caches/genarrative-jenkins/stdb-module' CARGO_INCREMENTAL = '0' RUSTC_WRAPPER = 'sccache' SCCACHE_CACHE_SIZE = '30G' @@ -81,12 +82,15 @@ pipeline { sh ''' bash -lc ' set -euo pipefail - workspace_tmp="${WORKSPACE_TMP:-${WORKSPACE}@tmp}" - export CARGO_HOME="${workspace_tmp}/cargo-home" - export CARGO_TARGET_DIR="${workspace_tmp}/cargo-target/prod-release" + stdb_cache_root="${GENARRATIVE_STDB_CACHE_ROOT:-caches/genarrative-jenkins/stdb-module}" + if [[ "${stdb_cache_root}" != /* ]]; then + stdb_cache_root="${HOME:?HOME 不能为空}/${stdb_cache_root}" + fi + export CARGO_HOME="${stdb_cache_root}/cargo-home" + export CARGO_TARGET_DIR="${stdb_cache_root}/cargo-target/prod-release" export CARGO_INCREMENTAL=0 export RUSTC_WRAPPER=sccache - export SCCACHE_DIR="${workspace_tmp}/sccache-stdb-module" + export SCCACHE_DIR="${stdb_cache_root}/sccache" export SCCACHE_CACHE_SIZE=30G mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" chmod +x scripts/jenkins-prepare-cargo-env.sh diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 5eac405b..b01cf557 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -20,7 +20,7 @@ const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'; function usage() { console.log(`用法: npm run database:backup:oss -- [--data-dir ] [--work-dir ] [--bucket ] [--object-prefix ] [--keep-local] - node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--defer-upload] + node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--restart-service-after genarrative-api.service] [--defer-upload] node scripts/database-backup-to-oss.mjs --upload-archive 说明: @@ -100,6 +100,7 @@ function parseArgs(argv) { envFiles: [], keepLocal: false, stopService: '', + restartServicesAfter: [], database: '', dryRun: false, deferUpload: false, @@ -159,6 +160,9 @@ function parseArgs(argv) { case '--stop-service': options.stopService = readValue(); break; + case '--restart-service-after': + options.restartServicesAfter.push(readValue()); + break; case '--keep-local': options.keepLocal = true; break; @@ -266,6 +270,16 @@ function startServiceIfNeeded(serviceName, wasStopped) { runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'}); } +function restartServicesAfterBackup(serviceNames) { + for (const serviceName of serviceNames) { + if (!serviceName) { + continue; + } + console.log(`[database-backup] 冷备份后重启依赖服务: ${serviceName}`); + runCommand('systemctl', ['restart', serviceName], {stdio: 'inherit'}); + } +} + function createArchive({dataDir, workDir, fileName}) { if (!existsSync(dataDir)) { throw new Error(`数据库数据目录不存在: ${dataDir}`); @@ -510,6 +524,13 @@ async function main() { } finally { startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped); } + restartServicesAfterBackup([ + ...String(env.GENARRATIVE_DATABASE_BACKUP_RESTART_SERVICE_AFTER ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + ...args.restartServicesAfter, + ]); const manifestPath = `${archivePath}.manifest.json`; writeManifest({ diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 0f861923..6fd2c121 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -332,14 +332,20 @@ mkdir -p "${RELEASE_DIR}" cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server" chmod +x "${RELEASE_DIR}/api-server" -SCRIPT_SOURCE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts" +BACKUP_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" +WORKSPACE_BACKUP_SCRIPT_SOURCE="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts/database-backup-to-oss.mjs" mkdir -p "${RELEASE_DIR}/scripts" -if [[ -f "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" ]]; then - cp "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" - chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" -else - echo "[production-api-deploy] 未找到数据库备份脚本,release 目录不会包含 scripts/database-backup-to-oss.mjs" >&2 +if [[ ! -f "${BACKUP_SCRIPT_SOURCE}" ]]; then + if [[ -f "${WORKSPACE_BACKUP_SCRIPT_SOURCE}" ]]; then + echo "[production-api-deploy] 发布产物缺少 scripts/database-backup-to-oss.mjs,回退使用部署工作区脚本;请重新触发包含该脚本的 API 构建。" >&2 + BACKUP_SCRIPT_SOURCE="${WORKSPACE_BACKUP_SCRIPT_SOURCE}" + else + echo "[production-api-deploy] 缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2 + exit 1 + fi fi +cp "${BACKUP_SCRIPT_SOURCE}" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" +chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.api-server.json" diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index 64f4e9dd..cdd60d41 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -179,6 +179,7 @@ prepare_async_backup() { --data-dir "${SPACETIME_ROOT_DIR}" \ --database "${DATABASE}" \ --stop-service spacetimedb.service \ + --restart-service-after genarrative-api.service \ --defer-upload \ --result-file "${ASYNC_BACKUP_STATUS_FILE}" } @@ -257,7 +258,8 @@ case "${BACKUP_MODE}" in --env-file /etc/genarrative/api-server.env \ --data-dir "${SPACETIME_ROOT_DIR}" \ --database "${DATABASE}" \ - --stop-service spacetimedb.service + --stop-service spacetimedb.service \ + --restart-service-after genarrative-api.service ;; skip) echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份" 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/main.rs b/server-rs/crates/api-server/src/main.rs index 480d88db..1867c754 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -119,6 +119,7 @@ use crate::{ const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8); +const AUTH_STORE_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(5); #[derive(Clone)] struct ShutdownContext { @@ -318,6 +319,25 @@ fn build_tcp_listener( async fn restore_app_state_for_startup( config: AppConfig, +) -> Result { + loop { + match try_restore_app_state_for_startup(config.clone()).await { + Ok(state) => return Ok(state), + Err(state::AppStateInitError::DependencyUnavailable(message)) => { + warn!( + retry_after_seconds = AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), + error = %message, + "启动恢复 SpacetimeDB 认证快照暂不可用,api-server 将继续重试" + ); + tokio::time::sleep(AUTH_STORE_STARTUP_RETRY_INTERVAL).await; + } + Err(error) => return Err(error), + } + } +} + +async fn try_restore_app_state_for_startup( + config: AppConfig, ) -> Result { match timeout( AUTH_STORE_STARTUP_RESTORE_TIMEOUT, @@ -329,7 +349,7 @@ async fn restore_app_state_for_startup( Err(_) => { error!( timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(), - "启动等待 SpacetimeDB 恢复认证快照超时,api-server 将进入依赖不可用模式" + "启动等待 SpacetimeDB 恢复认证快照超时" ); Err(state::AppStateInitError::DependencyUnavailable( "SpacetimeDB 启动恢复认证快照超时".to_string(), @@ -412,7 +432,10 @@ fn is_valid_env_key(key: &str) -> bool { #[cfg(test)] mod tests { - use super::{is_valid_env_key, protected_env_keys_from, strip_env_value}; + use super::{ + AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from, + strip_env_value, + }; #[test] fn strip_env_value_removes_wrapping_quotes() { @@ -453,4 +476,9 @@ mod tests { assert!(!protected.contains("ALIYUN_OSS_ENDPOINT")); assert!(protected.contains("ALIYUN_OSS_ACCESS_KEY_ID")); } + + #[test] + fn startup_dependency_retry_interval_is_short_enough_for_service_recovery() { + assert_eq!(AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), 5); + } } 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,