feat: checkpoint m5 and bootstrap m6 asset flow

This commit is contained in:
2026-04-22 14:46:43 +08:00
parent 0773a0d0ca
commit 91fb8edee7
22 changed files with 5096 additions and 445 deletions

View File

@@ -113,6 +113,7 @@
3. 部署
4. 观测
5. 灰度切流
6. 收口 `spacetime-module` 主工程结构,拆分过大的 `src/lib.rs`
详见:

View File

@@ -68,14 +68,91 @@
40. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml``cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`,当前 battle/story 新链路在编译层已恢复通过。
41. 已新增 `docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md`,冻结旧 `POST /api/runtime/story/state/resolve` 的首版兼容桥边界,明确当前先做 DTO 与状态桥,不提前误宣称 `actions/resolve` 已可迁移。
42. 已在 `server-rs/crates/shared-contracts` 中新增 `runtime_story` 模块,冻结 `RuntimeStoryStateResolveRequest``RuntimeStoryActionResponse` 以及 `viewModel / presentation / patches / snapshot` 的首版 camelCase DTO与当前前端消费口径对齐。
43. 已恢复并重建 `server-rs/crates/api-server/src/runtime_story.rs`,把上一轮误删留下的中间态收口回可编译实现。
44. 已在 Rust `api-server` 侧挂出旧 runtime story 兼容接口:
- `POST /api/runtime/story/state/resolve`
- `GET /api/runtime/story/state/:sessionId`
- `POST /api/runtime/story/actions/resolve`
- `POST /api/runtime/story/initial`
- `POST /api/runtime/story/continue`
45. `state/resolve``actions/resolve` 已统一复用 `runtime_save` 的 SpacetimeDB 快照持久化链:
- 请求带 `snapshot` 时先写入 `runtime_snapshot`
- 请求不带 `snapshot` 时从持久化 `runtime_snapshot` 读取
- 无可用快照时返回 `409`
46. `actions/resolve` 已补齐当前前端主链需要的确定性兼容动作闭环,覆盖:
- `story_continue_adventure`
- `story_opening_camp_dialogue`
- `camp_travel_home_scene`
- `idle_call_out`
- `idle_explore_forward`
- `idle_observe_signs`
- `idle_rest_focus`
- `idle_travel_next_scene`
- `npc_preview_talk`
- `npc_chat`
- `npc_help`
- `npc_leave`
- `npc_fight`
- `npc_spar`
- `npc_recruit`
- `battle_attack_basic`
- `battle_use_skill`
- `battle_all_in_crush`
- `battle_escape_breakout`
- `battle_feint_step`
- `battle_finisher_window`
- `battle_guard_break`
- `battle_probe_pressure`
- `battle_recover_breath`
- `treasure_secure`
- `treasure_inspect`
- `treasure_leave`
47. `actions/resolve` 已补 `clientVersion``gameState.runtimeActionVersion` 的冲突校验、动作后版本自增、`storyHistory` 追加和 snapshot 回写。
48. `initial` / `continue` 已先落稳定 `RuntimeStoryAiResponse`
- 优先透传 `requestOptions.availableOptions / optionCatalog`
- 未配置 LLM 时走确定性 fallback 文本
- 已配置 `platform-llm` 时可做文本增强,但不阻塞接口可用性
49. 已执行 `cargo test -p shared-contracts``cargo check -p api-server``cargo test -p api-server runtime_story` 并通过,当前 runtime story 兼容链在 Rust 侧已恢复到可编译、可测试状态。
50. 已补 Rust 侧 route boundary 回归:
- `runtime_story_routes_resolve_through_rust_route_boundary`
- `runtime_story_action_resolve_rejects_client_version_conflict`
- `runtime_story_npc_help_is_one_shot_and_restores_resources`
- `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full`
51. 已把兼容桥里的关键 NPC 行为继续对齐到 Node 旧主链:
- `npc_chat` 好感增长改为 `max(2, 6 - chattedCount)`,首聊可从 `46 -> 52`
- `npc_help` 改为一次性援手,成功时恢复 `10 HP / 8 Mana` 且关系 `+4`
- `npc_recruit` 改为要求 `affinity >= 60`,队伍满员时必须透传 `releaseNpcId`
52. 已补测试环境专用的 runtime snapshot 内存兜底,仅在 `#[cfg(test)]` 下生效,用于在未启动本地 SpacetimeDB 时稳定回归 `PUT /api/runtime/save/snapshot -> GET /api/runtime/story/state -> POST /api/runtime/story/actions/resolve` 这条 Rust 边界链。
53. 已把 quest compat 主循环补到 Rust `runtime story` 兼容桥:
- `npc_chat_quest_offer_view`
- `npc_chat_quest_offer_replace`
- `npc_chat_quest_offer_abandon`
- `npc_quest_accept`
- `npc_quest_turn_in`
54. 已把 quest offer 对话态的 `currentStory.npcChatState.pendingQuestOffer` 与前端面板依赖的 `runtimePayload.npcChatQuestOfferAction` 一并回填到 Rust compat 回包,保证现有 quest 面板入口不回退。
55. 已把 `npc_quest_turn_in` 的最小奖励闭环补回 Rust compat handler
- quest 状态改为保留在 `gameState.quests` 中的 `turned_in`
- 同步写回 `playerCurrency`
- 同步写回 `playerInventory`
- 同步写回 `playerProgression.totalXp / level / xpToNextLevel / lastGrantedSource`
- 同步写回 NPC `affinity`
56. 已新增 quest compat Rust 回归:
- `runtime_story_quest_offer_replace_updates_pending_offer_and_payload`
- `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options`
- `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story`
- `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity`
57. 已再次执行 `cargo test -p api-server runtime_story``cargo check -p api-server``node scripts/check-encoding.mjs` 并通过,当前 quest compat 已恢复到可编译、可回归状态。
当前验证边界补充:
1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时很长,已有多轮回归尝试,但还没有在单次时窗内收敛到最终断言结果
2. `npm run check:encoding` 已启动到 `node scripts/check-encoding.mjs`,但当前尚未在单次时窗内跑完,不能标记为已完成
3. 因此,当前可以确认的是 `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通;测试与编码检查仍应继续追。
1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时仍然较长,还没有把更大范围的 story/battle 回归全部收拢到单次时窗内
2. `node scripts/check-encoding.mjs` 已再次执行并通过,当前本轮涉及的中文文件编码未被写坏
3. 当前可以确认的是
- `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通
- Rust `runtime story` compat route boundary 与关键 NPC 主循环规则已有回归覆盖
- 真正的 `resolve_story_action / sync_runtime_snapshot_projection` 真相链仍未完成
当前这轮仍未扩到 `resolve_story_action``sync_runtime_snapshot_projection`、旧 `/api/runtime/story/*` 兼容接口和前端实际 runtime story API 切换,这些继续保留在后续 `M4` 工作项中
当前这轮仍未扩到真正的 SpacetimeDB `resolve_story_action` / `sync_runtime_snapshot_projection` 真相 reducer也还没有完成前端默认切流到 Rust `api-server`。当前已完成的是“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧的快照桥与确定性动作闭环”,后续 `M4` 继续推进真相态替换与前端切换
## 1. SpacetimeDB gameplay 表
@@ -106,7 +183,7 @@
- [ ] 迁移 `rpg-entry` 配套后端入口能力
- [ ] 迁移 `rpg-profile` 资料域
- [ ] 迁移 `rpg-runtime-story`
- [x] 迁移 `rpg-runtime-story`
- [x] 迁移 `combat`
- [ ] 迁移 `inventory`
- [ ] 迁移 `npc`
@@ -117,37 +194,67 @@
## 4. 兼容接口
- [ ] 兼容 `POST /api/runtime/story/actions/resolve`
- [ ] 兼容 `GET /api/runtime/story/state/:sessionId`
- [ ] 兼容 `POST /api/runtime/story/state/resolve`
- [ ] 兼容 `POST /api/runtime/story/initial`
- [ ] 兼容 `POST /api/runtime/story/continue`
- [x] 兼容 `POST /api/runtime/story/actions/resolve`
- [x] 兼容 `GET /api/runtime/story/state/:sessionId`
- [x] 兼容 `POST /api/runtime/story/state/resolve`
- [x] 兼容 `POST /api/runtime/story/initial`
- [x] 兼容 `POST /api/runtime/story/continue`
补充说明:
1. 当前已落地的是新的 Rust facade
1. 当前已落地的是两类 Rust facade
- 新真相态接口:
- `POST /api/story/sessions`
- `POST /api/story/sessions/continue`
- `GET /api/story/sessions/:storySessionId/state`
- `GET /api/story/battles/:battleStateId`
- `POST /api/story/npc/battle`
2. 其中前 3 个接口是 `story session` 真相链路,后 2 个接口是 battle / NPC 开战真相链路,都不等价于旧 Node 的 LLM `runtime/story/*` 兼容接口
3. 当前新增的 `story state` 查询只返回 `storySession + storyEvents`,还没有兼容旧 `RuntimeStoryActionResponse``currentStory``availableOptions`
4. 当前新增的 `battle state` 查询只返回单个 `battleState`,还没有拼回旧 runtime story state 视图。
5.`resolve_story_action / story state` contract 未冻结前,不应误勾选旧兼容接口。
-runtime story 兼容接口
- `POST /api/runtime/story/state/resolve`
- `GET /api/runtime/story/state/:sessionId`
- `POST /api/runtime/story/actions/resolve`
- `POST /api/runtime/story/initial`
- `POST /api/runtime/story/continue`
2. 其中新真相态接口仍是 `story session / battle / NPC 开战` 的底层切片;旧 `runtime/story/*` 则是复用 `runtime_snapshot` 的兼容桥,不等价于最终真相态实现。
3. 当前 `runtime/story/*` 已能返回旧前端需要的 `RuntimeStoryActionResponse / AIResponse` 形状,但内部动作仍以确定性兼容编排为主,不代表 `resolve_story_action` 真相 reducer 已完成。
4. 当前新增的 `battle state` 查询仍只返回单个 `battleState` 真相切片,不等价于 runtime story 全量视图。
5. 后续 `M4` 仍需把兼容桥逐步替换成真正的 story action / snapshot projection 真相链。
## 5. ViewModel 兼容
- [ ] 兼容当前 `RuntimeStoryActionResponse`
- [ ] 兼容当前 `RuntimeStoryOptionView`
- [ ] 兼容当前 `interaction` 元数据
- [ ] 兼容当前 battle / toast / patch 响应结构
- [ ] 兼容当前 `currentStory` 回填逻辑
- [x] 兼容当前 `RuntimeStoryActionResponse`
- [x] 兼容当前 `RuntimeStoryOptionView`
- [x] 兼容当前 `interaction` 元数据
- [x] 兼容当前 battle / toast / patch 响应结构
- [x] 兼容当前 `currentStory` 回填逻辑
## 6. 阶段验收
- [ ] 当前前端 story 选项点击后可走新后端闭环
- [x] 当前前端 story 选项点击后可走新后端闭环
- [ ] NPC / quest / treasure / combat 主循环行为不回退
- [ ] `story state` 恢复链可用
- [x] `story state` 恢复链可用
- [ ] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致
- [ ] 旧 Node 版 story route 回归用例完成平移
- [x] 旧 Node 版 story route 回归用例完成平移
阶段验收补充说明:
1. `当前前端 story 选项点击后可走新后端闭环` 当前按 Rust `api-server` 的真实边界回归判定已满足:
- `PUT /api/runtime/save/snapshot`
- `GET /api/runtime/story/state/runtime-main`
- `POST /api/runtime/story/actions/resolve`
但这不等于“生产默认流量已经切到 Rust”。
2. `story state 恢复链可用` 当前指:
- 请求带 `snapshot` 时可先写后读
- 请求不带 `snapshot` 时可从已持久化 `runtime_snapshot` 恢复
3. `旧 Node 版 story route 回归用例完成平移` 当前指:
- 已平移 Node 的 `rpg runtime story routes resolve through the new route boundary`
- 已补 `clientVersion` 冲突回归
- 已把 `npc_chat``46 -> 52` Node 旧语义对齐进 Rust compat handler
4. `NPC / quest / treasure / combat 主循环行为不回退` 仍不能勾选:
- 虽然 `npc_chat / npc_help / npc_recruit / npc_chat_quest_offer_* / npc_quest_accept / npc_quest_turn_in / npc_fight / npc_spar / treasure_* / battle_*` 已有确定性兼容闭环
- 且 quest 交付奖励、quest offer 对话态与 quest follow-up 选项已补到 Rust compat handler
- 但 treasure / combat 的更大范围 Node 回归还没全部平移
- 真相态 reducer 仍未替换 compat bridge
5. `后端边界与当前 rpgEntry -> ...` 仍不能勾选:
- 前端真实调用链已对齐 `/api/runtime/story/*`
- 但“默认走 Rust server”的联调证据仍未冻结

View File

@@ -77,6 +77,13 @@
- [ ] 迁移 Qwen 保存
- [ ] 迁移场景图生成
- [ ] 迁移封面图上传
- [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract不再写仓库 `public/`
补充说明:
1. 本次收口只解决 custom world 兼容图片入口的正式资产真相链,不代表 DashScope 图片生成、任务状态、封面裁剪压缩能力已全量迁完。
2. 详细边界见:
- [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
## 5. 路径兼容
@@ -108,3 +115,8 @@
- [ ] 前端仍能通过旧路径习惯访问资源
- [ ] 资产任务状态可查询
- [x] 已确认对象可绑定到业务实体槽位
补充说明:
1. custom world 的 `scene-image / cover-image / cover-upload` 已在本轮切到正式 OSS 对象与绑定主链。
2. `所有新生成资产都写入 OSS``前端仍能通过旧路径习惯访问资源` 仍需继续把角色主形象、动画、Qwen 精灵与其余历史渲染入口一并收口后再整体勾选。

View File

@@ -38,7 +38,17 @@
- [ ] 准备前端切换开关
- [ ] 准备回退开关
## 5. 阶段验收
## 5. 主工程结构收口
- [ ] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime``gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}``custom_world``asset_metadata``ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口
执行约束:
1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。
2. 拆分后的模块边界必须与 `M0` 已冻结的模块迁移归属一致,避免 `spacetime-module` 再回退成单大包。
3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。
## 6. 阶段验收
- [ ] 全链路 smoke 通过
- [ ] 主流程回归通过

View File

@@ -4,16 +4,16 @@
## 0. 文档目标
本文件冻结 `M4` 当前下一条最小可落地兼容桥
本文件冻结 `M4` 当前 runtime story compat bridge 的实际已落地边界
**先把 Rust `api-server` 旧 `runtime story state` 兼容返回所需的 DTO 与状态桥边界冻结清楚,再进入 Axum handler 与状态编译迁移**
**Rust `api-server` 已承接旧 `runtime/story/*` 兼容接口,但当前仍属于“快照桥 + 确定性兼容动作”阶段,不等价于最终 SpacetimeDB 真相 story reducer**
当前仓库已经有两条并行现实:
1. `server-node` 侧旧兼容接口 `POST /api/runtime/story/state/resolve` 仍然在真实服务前端。
2. `server-rs` 侧已经有 `story_session / battle_state / npc battle / inventory state` 等真相态接口,但还没有编译成旧前端消费的 `RuntimeStoryActionResponse`
因此,本轮不直接宣称“runtime story 已迁完”,而是先把兼容桥 contract 冻结为下一段可编码的工程基线
因此,本文档既记录当前兼容桥为什么存在,也明确它的已完成能力和仍未替换掉的真相态缺口
---
@@ -74,19 +74,19 @@
## 2. 本轮冻结范围
本轮冻结以下兼容桥边界:
本轮实际已落地并冻结以下兼容桥边界:
1. Rust `shared-contracts` 新增旧 `runtime story` 兼容响应 DTO
2. Rust `shared-contracts` 新增 `POST /api/runtime/story/state/resolve` 的最小请求 DTO
3. 明确 Rust 侧第一段只先承接“状态查询兼容桥”
4. 明确 `actions/resolve``initial``continue` 继续后置
2. Rust `shared-contracts` 新增 `POST /api/runtime/story/state/resolve` / `POST /api/runtime/story/actions/resolve` / `POST /api/runtime/story/initial` / `POST /api/runtime/story/continue` 所需请求 DTO
3. Rust `api-server` 已挂出全部旧 runtime story 兼容接口
4. 明确当前实现仍以 `runtime_snapshot` 为状态真相来源,而不是新的 `resolve_story_action` reducer
本轮明确做:
本轮明确仍未做:
1. 不在 `server-rs` 里直接落完整 `resolve_story_action`
2. 不迁移 Node 侧全部 story 行为决策
3. 不把 `runtime snapshot` 正式持久化真相一次性迁到 Rust
4. 不在本轮让前端切到 Rust `api-server`
3. 不把 `runtime snapshot projection` 一次性改成全量新真相模型
4. 不在本文里宣称前端默认流量已经切到 Rust `api-server`
---
@@ -183,6 +183,81 @@
这与当前 Node `getRuntimeStoryState(...)` 的行为一致,不需要在状态查询时伪造 patch。
### 4.2.2 `actions/resolve` 首版策略
当前 Rust compat handler 已按“确定性兼容动作 + snapshot 回写”落地,目标是先覆盖前端实际点击主链,而不是一步到位复刻 Node 全部 story domain。
当前已覆盖动作:
1. `story_continue_adventure`
2. `story_opening_camp_dialogue`
3. `camp_travel_home_scene`
4. `idle_call_out`
5. `idle_explore_forward`
6. `idle_observe_signs`
7. `idle_rest_focus`
8. `idle_travel_next_scene`
9. `npc_preview_talk`
10. `npc_chat`
11. `npc_help`
12. `npc_leave`
13. `npc_fight`
14. `npc_spar`
15. `npc_recruit`
16. `battle_attack_basic`
17. `battle_use_skill`
18. `battle_all_in_crush`
19. `battle_escape_breakout`
20. `battle_feint_step`
21. `battle_finisher_window`
22. `battle_guard_break`
23. `battle_probe_pressure`
24. `battle_recover_breath`
25. `treasure_secure`
26. `treasure_inspect`
27. `treasure_leave`
统一规则:
1. 请求带 `snapshot` 时先写入 `runtime_snapshot`
2. 请求不带 `snapshot` 时回退读取持久化 `runtime_snapshot`
3. `clientVersion``gameState.runtimeActionVersion` 不一致时返回 `409`
4. 动作成功后递增 `runtimeActionVersion`
5. 追加 `storyHistory`,并把新的 `currentStory` / `viewModel` / `presentation` / `patches` 回写到 snapshot
当前已额外对齐的 Node 旧主链细节:
1. `npc_chat`
- 已从最初的固定 `+1 affinity` 修正为 Node 旧规则 `max(2, 6 - chattedCount)`
- 例如 `chattedCount = 0` 时首聊会从 `46 -> 52`
2. `npc_help`
- 已改为一次性援手
- 成功时恢复 `10 HP / 8 Mana`
- 同时关系 `+4`
- 二次调用返回错误
3. `npc_recruit`
- 已要求 `affinity >= 60`
- 当前队伍满员时必须提交 `releaseNpcId`
- 当前 compat bridge 也会把换队结果写回 `companions`
4. quest compat 主循环
- 已补 `npc_chat_quest_offer_view / replace / abandon`
- 已补 `npc_quest_accept / npc_quest_turn_in`
- `pendingQuestOffer.quest` 会继续写回 `currentStory.npcChatState`
- quest offer 选项会继续携带前端面板依赖的 `runtimePayload.npcChatQuestOfferAction`
5. `npc_quest_turn_in`
- quest 不再被直接从快照中移除,而是保留为 `status = turned_in`
- 当前最小奖励闭环已写回 `playerCurrency / playerInventory / playerProgression / npc affinity`
- `playerProgression` 当前仍走 compat 侧确定性经验累计,不等价于最终 SpacetimeDB 真相成长链
### 4.2.3 `initial` / `continue` 首版策略
当前 Rust compat handler 已提供稳定 `RuntimeStoryAiResponse`
1. 优先复用 `requestOptions.availableOptions / optionCatalog`
2. 未配置 `platform-llm` 时返回确定性 fallback `storyText`
3. 已配置 `platform-llm` 时,允许基于同一请求载荷生成增强版文本
4. 当前 `encounter` 仍返回 `null`
---
## 5. DTO 分层
@@ -223,15 +298,29 @@
---
## 6. 第一段工程落地顺序
## 6. 当前已落地工程顺序
建议直接按下面顺序编码
本轮实际完成顺序
1. `shared-contracts` 新增 `runtime_story.rs`
2.`RuntimeStoryStateResolveRequest / RuntimeStoryActionResponse` 补 camelCase 序列化测试
3. `docs/technical/README.md``shared-contracts/README.md` 更新索引
4. `backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md` 追加当前冻结进展
5. 下一轮再进入 `api-server``state/resolve` handler 与兼容 compiler
3. 恢复并重建 `api-server/src/runtime_story.rs`
4. 接入 `state/resolve``GET state``actions/resolve``initial``continue`
5. 复用 `runtime_save`SpacetimeDB snapshot 持久化链
6. 执行 `cargo test -p shared-contracts`
7. 执行 `cargo check -p api-server`
8. 执行 `cargo test -p api-server runtime_story`
9. 继续把 Node 旧 route boundary 回归平移到 Rust
- `runtime_story_routes_resolve_through_rust_route_boundary`
- `runtime_story_action_resolve_rejects_client_version_conflict`
10. 继续补关键 NPC compat 行为回归:
- `runtime_story_npc_help_is_one_shot_and_restores_resources`
- `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full`
11. 继续补 quest compat 回归:
- `runtime_story_quest_offer_replace_updates_pending_offer_and_payload`
- `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options`
- `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story`
- `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity`
---
@@ -239,13 +328,14 @@
以下内容继续明确后置:
1. `POST /api/runtime/story/actions/resolve` 的请求 DTO 是否直接复用旧 TS contract 全量字段
2. `resolve_story_action` 是否拆成:
1. `resolve_story_action` 是否拆成:
- `resolve_story_action`
- `resolve_story_combat_action`
- `resolve_story_interaction_action`
3. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory`
4. `LLM` 文本续写是在 Rust bridge 内继续调用,还是继续通过 Node 兼容层兜底
2. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory`
3. 当前确定性 compat action 何时被真正的 SpacetimeDB story reducer 替换
4. `battle / npc / quest / inventory` patch 是否继续细化成与 Node 完全逐字段一致
5. `npc_quest_turn_in` 的经验、物品、情报、章节推进何时切换到真正的 SpacetimeDB progression / inventory / quest 真相链,而不是 compat 侧快照写回
这些边界在状态桥稳定前都不应提前拍死。
@@ -258,7 +348,20 @@
1. 已有独立技术文档冻结 `state/resolve` 兼容桥边界
2. `shared-contracts` 已拥有旧 `runtime story` 兼容 DTO
3. DTO 字段名与当前前端消费口径保持一致
4. `cargo test -p shared-contracts` 通过
5. `npm run check:encoding` 通过,确保新增中文文档与 Rust 源文件编码未损坏
4. `api-server` 已挂出:
- `POST /api/runtime/story/state/resolve`
- `GET /api/runtime/story/state/:sessionId`
- `POST /api/runtime/story/actions/resolve`
- `POST /api/runtime/story/initial`
- `POST /api/runtime/story/continue`
5. `cargo test -p shared-contracts` 通过
6. `cargo check -p api-server` 通过
7. `cargo test -p api-server runtime_story` 通过
8. `node scripts/check-encoding.mjs` 通过
达到以上条件后,下一轮即可直接进入 Rust `state bridge compiler` 与 Axum handler 落地。
补充边界:
1. 当前测试里为 `runtime_snapshot` 加了 `#[cfg(test)]` 下的内存兜底,只用于在未启动本地 SpacetimeDB 时稳定验证 Rust route boundary。
2. 该测试兜底不进入生产链路,不改变真实 `runtime_save -> spacetime-client -> SpacetimeDB procedure` 的运行时实现。
达到以上条件后,兼容桥这一段已不再停留在 DTO / 空壳状态下一轮重点转向“compat bridge 替换成真相态 reducer / projection”。

View File

@@ -0,0 +1,158 @@
# M6 custom world 资产接入 OSS 第一批设计
日期:`2026-04-22`
## 1. 文档目的
这份文档用于冻结 `M6` 第一批 custom world 资产链的真实落地口径。
本批只解决一个明确问题:
1. `POST /api/custom-world/scene-image`
2. `POST /api/custom-world/cover-image`
3. `POST /api/custom-world/cover-upload`
不再把图片产物写入仓库 `public/` 本地文件,而是统一接到:
1. `platform-oss::put_object`
2. `asset_object`
3. `asset_entity_binding`
形成正式 `OSS + SpacetimeDB 元数据` 真相链。
## 2. 当前前提
当前仓库已经具备以下基础能力:
1. `POST /api/assets/direct-upload-tickets`
2. `GET /api/assets/read-url`
3. `POST /api/assets/objects/confirm`
4. `POST /api/assets/objects/bind`
5. `platform-oss::OssClient::put_object`
6. `spacetime-module` 中的 `asset_object / asset_entity_binding`
因此本批不重新设计新资产系统,只复用既有 `assets` 主链。
## 3. 本批范围
### 3.1 要完成的内容
1. custom world 场景图生成结果写入 OSS
2. custom world 封面图生成结果写入 OSS
3. custom world 封面上传结果写入 OSS
4. 每个写入对象都执行一次正式对象确认
5. 每个正式对象都绑定到 custom world 业务实体槽位
6. 路由响应继续返回旧前端可消费的 `imageSrc`
### 3.2 本批不解决的内容
1. 不补 DashScope 图片模型的完整 Rust 编排
2. 不补 `cover-upload` 的裁剪、压缩、16:9 强校验全量能力
3. 不新增 `scene_image_asset / character_visual_asset` 强业务表
4. 不在本批落 `custom_world_asset_link`
5. 不把旧前端响应 contract 改成直接返回 OSS URL
## 4. 业务实体与槽位约定
本批统一复用通用 `asset_entity_binding`
### 4.1 场景图
| 字段 | 取值 |
| --- | --- |
| `entity_kind` | `custom_world_landmark` |
| `entity_id` | 优先 `landmarkId`,否则回退 `landmarkName` |
| `slot` | `scene_image` |
| `asset_kind` | `scene_image` |
### 4.2 封面图
| 字段 | 取值 |
| --- | --- |
| `entity_kind` | `custom_world_profile` |
| `entity_id` | 优先 `profileId`,否则回退世界 `id/name` |
| `slot` | `cover` |
| `asset_kind` | `custom_world_cover` |
补充口径:
1. 绑定幂等键仍是 `entity_kind + entity_id + slot`
2. 同一 profile 重复生成/上传封面时,允许覆盖到最新对象
3. 同一 landmark 重复生成场景图时,允许覆盖到最新对象
## 5. OSS 对象键与返回 contract
### 5.1 对象键
场景图固定写入:
`generated-custom-world-scenes/{profileSegment}/{landmarkSegment}/{assetId}/scene.{ext}`
封面图固定写入:
`generated-custom-world-covers/{profileSegment}/{assetId}/cover.{ext}`
### 5.2 返回 contract
路由响应继续沿用旧前端使用的字段:
1. `imageSrc`
2. `assetId`
3. `sourceType`
4. `model`
5. `size`
6. `taskId`
7. `prompt`
8. `actualPrompt`
其中:
1. `imageSrc` 固定返回 `legacyPublicPath`,也就是旧 `/generated-*` 路径
2. 前端若要真正读取私有 OSS 对象,仍必须通过 `GET /api/assets/read-url` 换签名读 URL
3. 不直接把 `signedUrl` 塞进 custom world 业务返回,避免把短期读签名误存成长期业务字段
## 6. 服务端执行顺序
每次 custom world 图片产出固定执行以下顺序:
1. 生成或接收图片字节
2.`platform-oss::put_object`
3. 通过 `HEAD Object` 真值确认对象
4. 写入 `asset_object`
5. 写入 `asset_entity_binding`
6. 返回 `legacyPublicPath`
注意:
1. `put_object` 成功不代表已完成正式落库
2. `asset_object` 仍必须经过确认链路写入
3. 业务引用真相以 `asset_entity_binding` 为准,不以 OSS 上是否存在 key 为准
## 7. 与 M5 的衔接
`M5` 为保证前端不断链,曾允许 `scene-image / cover-image / cover-upload` 先写本地 `public/`
从本批开始,这个临时口径失效,统一改为:
1. 二进制对象只进 OSS
2. 元数据只进 `asset_object`
3. 业务槽位只进 `asset_entity_binding`
这样 `Stage9` 的兼容路由就不会继续偏离 `M6` 正式资产主链。
## 8. 完成定义
当以下条件满足时,本批视为完成:
1. custom world 三条图片兼容路由不再写本地 `public/`
2. 路由成功返回的 `imageSrc` 全部来自 `OSS legacyPublicPath`
3. 每次成功写图后都能在 SpacetimeDB 中形成 `asset_object`
4. 每次成功写图后都能形成对应 `asset_entity_binding`
5. 旧前端仍可继续使用返回的 `/generated-*` 路径配合读签名服务显示图片
## 9. 关联文档
1. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
2. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
3. [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
4. [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md)

View File

@@ -42,8 +42,10 @@
- [SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md):冻结 `M5` Agent session create / snapshot 的最小 SpacetimeDB 与 Axum facade 闭环,明确本轮不迁移 LLM、SSE、卡片更新和完整 action registry。
- [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md):冻结 `M5` Agent `message submit / operation query` 的 deterministic 最小闭环,明确同步写入 user/assistant 消息、`process_message` operation 与 session 进度推进规则。
- [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md):冻结 `M5` Agent `/messages/stream` 的最小兼容 SSE facade明确复用 Stage 7 的同步写表逻辑,只输出当前前端真实消费的 `reply_delta / session / done / error` 事件。
- [RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md](./RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md):冻结 `server-rs/crates/api-server` 内部 SSE 基础设施抽取口径,统一 Rust 侧 `text/event-stream` 响应头、事件编码与缓冲式 SSE 输出 helper。
- [SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md):补齐 `M5` Stage 5 遗漏的 owner-only `GET /api/runtime/custom-world-library/:profileId` 设计,冻结单条 profile detail 的 SpacetimeDB procedure、client facade、404 语义与 Axum 路由扩展方式。
- [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md):冻结 `M5` 剩余主链的 works、card detail、publish gate、supportedActions、action registry 与 AI/OSS 兼容路由边界,作为 Stage 9 到收口阶段的统一落地依据。
- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批 custom world 场景图、封面图、封面上传从本地 `public/` 临时落地切到 `OSS + asset_object + asset_entity_binding` 正式真相链的边界与槽位约定。
- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 第二批 `browse history` 纵向切片的 `user_browse_history` 表、双路径 facade、宽松归一化、去重排序规则与测试策略。
- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。
- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md)把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。

View File

@@ -0,0 +1,118 @@
# Rust `api-server` SSE 基础设施设计2026-04-22
日期:`2026-04-22`
## 1. 文档目的
这份文档用于冻结 `server-rs/crates/api-server` 内部的 SSE 基础设施抽取口径。
本轮目标只有一个:
1. 把当前散落在业务 handler 中的 `text/event-stream` 响应头与事件文本编码逻辑,收口为 `api-server` 可复用的 Rust 基础设施。
本轮不做:
1. 不改前端消费协议
2. 不把 custom world message stream 改成真实逐段 token streaming
3. 不引入跨 crate 的共享 `shared-contracts` SSE runtime helper
4. 不同时重构 story / runtime / txt mode 的未来流式接口
## 2. 当前问题
当前 Rust 侧 SSE 能力只在一个地方手写:
1. `server-rs/crates/api-server/src/custom_world.rs`
当前实现存在以下问题:
1. `append_sse_event(...)``build_event_stream_response(...)` 直接写在业务文件里
2. SSE 响应头、事件编码规则没有统一入口
3. 后续如果再新增第二条 Rust SSE 路由,极容易复制一份近似实现
4. 业务层和传输层耦合在一起,不利于测试
## 3. 抽取边界
本轮只抽以下基础能力:
1. 标准 SSE 响应头构造
2. 单条事件编码
3. 缓冲式 SSE body builder
4. 一次性返回完整 SSE 文本的响应构造
本轮明确不抽:
1. `reply_delta / session / done / error` 这些业务事件名
2. 事件发送顺序
3. custom world session 的查询与回复文本推导
4. 业务错误到 SSE `error` 事件的映射策略
原因固定如下:
1. 这些内容属于业务协议,而不是通用传输设施
2. 当前不同链路未来很可能有不同事件集合
3. 先把传输层抽干净,后续真实流式能力才能稳定复用
## 4. 基础设施 API 口径
本轮在 `server-rs/crates/api-server/src/sse.rs` 提供:
1. `SseEventBuffer`
- 面向当前最小兼容场景
- 内部持有 `String`
- 提供 `push_json(event, payload)``into_response()`
2. `build_sse_response(body)`
- 统一写入标准 SSE 响应头
3. `encode_sse_event(body, event, payload)`
- 只负责把事件编码为:
```text
event: xxx
data: {...}
```
## 5. 标准响应头
所有通过本基础设施输出的 SSE 响应,统一包含:
1. `Content-Type: text/event-stream; charset=utf-8`
2. `Cache-Control: no-cache`
3. `X-Accel-Buffering: no`
当前不默认加入:
1. `Connection: keep-alive`
原因:
1. 当前 Rust `axum` 一次性 body 返回场景不依赖显式设置该头
2. 保持最小必要头集合,避免提前固化未来长连接策略
## 6. 与 custom world message stream 的关系
`POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` 仍然保持 Stage 8 文档冻结的最小语义:
1. 业务层先完成 deterministic 写表
2. 读取最新 session snapshot
3. 组装 `reply_delta`
4. 组装 `session`
5. 组装 `done`
6. 一次性返回完整 SSE 文本
本轮变化只在于:
1. 事件编码和响应头不再手写在 `custom_world.rs`
2. 改由 `sse.rs` 基础设施承接
## 7. 验收标准
当以下条件满足时,本轮视为完成:
1. `api-server/src/sse.rs` 已提供可复用 SSE helper
2. `custom_world.rs` 不再内联维护 SSE 编码与响应头细节
3. `cargo fmt -p api-server` 通过
4. `cargo check -p api-server` 通过
5. `npm run check:encoding` 通过
## 8. 一句话结论
本轮把 Rust `api-server` 里的 SSE 能力收口为“最小传输层基础设施”,统一事件编码与响应头,但不改业务事件协议和当前 custom world 的同步伪流式语义。

View File

@@ -832,6 +832,21 @@ workflow-cache/{workflow_type}/{workflow_id}.json
1. `editor` 已于 `2026-04-21` 被确认为遗留无用模块,退出本轮 Rust 后端重写范围。
2. Phase 5 只覆盖资产与 OSS 主链,不再包含 editor 迁移。
## Phase 6联调、回归、部署与切流收口
交付:
1. 联调与回归测试体系
2. 灰度环境、切流开关、回退方案
3. tracing / request id / 关键链路观测
4. 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 结构重组为 `runtime``gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}``custom_world``asset_metadata``ai` 等聚合子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口
阶段执行补充:
1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。
2. 拆分后的目录与模块边界必须对齐 `M0` 已冻结的模块迁移归属,避免 `spacetime-module` 回退成“单大文件 + 单大包”结构。
3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。
## 14. 验收标准
重写完成至少要满足:

View File

@@ -331,15 +331,24 @@ session snapshot 中的 `resultPreview` 固定输出:
#### `scene-image / cover-image`
1. 当前不直接生成真实图片
2. 返回明确 `NOT_IMPLEMENTED` 或最小占位错误会导致前端主链中断
3. 因此前端兼容需要的最小可用策略是:创建上传票据或返回可继续上传的对象位置信息
1. `M5` 验收时允许先用本地占位产物保证前端主链不断
2. `2026-04-22``M6` 第一批开始,正式口径改为:
- `platform-oss::put_object`
- `asset_object`
- `asset_entity_binding`
3. 兼容响应仍返回旧 `/generated-*` 路径,不直接返回裸 OSS URL
4. 详细边界见:
- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
#### `cover-upload`
1. 复用 `/api/assets/direct-upload-tickets`
2. 生成 OSS 上传票据
3. 返回兼容旧前端所需的上传字段
1. `M5` 阶段允许先走最小本地上传兼容
2. `2026-04-22``M6` 第一批开始,正式口径与 `cover-image` 一致:
- 服务器接收 Data URL
- 服务器上传 OSS
- 确认 `asset_object`
- 绑定 `asset_entity_binding`
3. 返回值仍保持旧前端所需的 `imageSrc / assetId / sourceType`
## 8. crate 级改动范围

View File

@@ -44,6 +44,12 @@
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
23. 接入 `custom-world-library``custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
24. 接入 custom world agent `session create / session snapshot` Axum facade
25. 接入旧 `runtime story` 兼容接口:
- `POST /api/runtime/story/state/resolve`
- `GET /api/runtime/story/state/{session_id}`
- `POST /api/runtime/story/actions/resolve`
- `POST /api/runtime/story/initial`
- `POST /api/runtime/story/continue`
后续与本 crate 直接相关的任务包括:
@@ -68,6 +74,7 @@
19. [x] 接入 `/api/assets/sts-upload-credentials`
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
21. [x] 接入 `custom world agent session create / snapshot` facade
22. [x] 接入旧 `runtime story` compat facade
当前 tracing 约定:
@@ -136,3 +143,4 @@
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB而是统一换成系统签发的 JWT。
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
14. 当前 `/api/runtime/custom-world/agent/sessions``/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。

View File

@@ -26,11 +26,9 @@ use crate::{
auth_sessions::auth_sessions,
custom_world::{
create_custom_world_agent_session, execute_custom_world_agent_action,
get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_works,
get_custom_world_gallery_detail, get_custom_world_library,
get_custom_world_library_detail, list_custom_world_gallery,
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
get_custom_world_agent_session, get_custom_world_gallery_detail, get_custom_world_library,
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
publish_custom_world_library_profile, put_custom_world_library_profile,
stream_custom_world_agent_message, submit_custom_world_agent_message,
unpublish_custom_world_library_profile,
@@ -62,8 +60,8 @@ use crate::{
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::{
generate_runtime_story_continue, generate_runtime_story_initial,
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
resolve_runtime_story_action, resolve_runtime_story_state,
},
state::AppState,
story_battles::{
@@ -242,9 +240,9 @@ pub fn build_router(state: AppState) -> Router {
get(get_runtime_settings)
.put(put_runtime_settings)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/save/snapshot",
@@ -316,9 +314,10 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
get(get_custom_world_agent_card_detail).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
@@ -364,45 +363,52 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-image",
post(generate_custom_world_scene_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/browse-history",

View File

@@ -1,8 +1,8 @@
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
http::StatusCode,
response::Response,
};
use module_custom_world::{
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
@@ -10,35 +10,34 @@ use module_custom_world::{
};
use serde_json::{Map, Value, json};
use shared_contracts::runtime::{
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse,
CustomWorldAgentCardDetailResponse,
CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse,
CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse,
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse,
CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse,
ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest,
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
SendCustomWorldAgentMessageRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
CustomWorldAgentActionExecuteRecordInput,
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord,
CustomWorldSupportedActionRecord, SpacetimeClientError,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
request_context::RequestContext, sse::SseEventBuffer, state::AppState,
};
pub async fn get_custom_world_library(
@@ -84,10 +83,7 @@ pub async fn get_custom_world_library_detail(
let detail = state
.spacetime_client()
.get_custom_world_library_detail(
authenticated.claims().user_id().to_string(),
profile_id,
)
.get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
@@ -582,19 +578,15 @@ pub async fn stream_custom_world_agent_message(
// 这里先用“一次性构造完整 SSE 文本”的最小兼容方案,
// 复用 Stage 7 的同步 deterministic 写表逻辑,保证前端当前的 reader 协议可直接消费。
let mut sse_body = String::new();
append_sse_event(&mut sse_body, "reply_delta", &json!({ "text": reply_text }))
let mut sse = SseEventBuffer::new();
sse.push_json("reply_delta", &json!({ "text": reply_text }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
append_sse_event(
&mut sse_body,
"session",
&json!({ "session": session_response }),
)
.map_err(|error| custom_world_error_response(&request_context, error))?;
append_sse_event(&mut sse_body, "done", &json!({ "ok": true }))
sse.push_json("session", &json!({ "session": session_response }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
sse.push_json("done", &json!({ "ok": true }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
Ok(build_event_stream_response(sse_body))
Ok(sse.into_response())
}
pub async fn get_custom_world_agent_operation(
@@ -815,7 +807,9 @@ fn map_custom_world_agent_session_response(
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response),
publish_gate: session
.publish_gate
.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
@@ -958,40 +952,11 @@ fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse)
.unwrap_or_default()
}
fn append_sse_event(body: &mut String, event: &str, payload: &Value) -> Result<(), AppError> {
let payload_text = serde_json::to_string(payload).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-agent",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
// 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。
(HeaderName::from_static("x-accel-buffering"), "no"),
],
body,
)
.into_response()
}
fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Procedure(message) if message.contains("custom_world_profile 不存在") => {
SpacetimeClientError::Procedure(message)
if message.contains("custom_world_profile 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)

View File

@@ -1,7 +1,4 @@
use std::{
fs,
path::{Path, PathBuf},
};
use std::collections::BTreeMap;
use axum::{
Json,
@@ -9,9 +6,15 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -87,6 +90,20 @@ struct GeneratedAssetResponse {
actual_prompt: Option<String>,
}
struct PreparedAssetUpload {
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
asset_kind: &'static str,
entity_kind: &'static str,
entity_id: String,
profile_id: Option<String>,
slot: &'static str,
source_job_id: Option<String>,
}
pub async fn generate_custom_world_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -160,8 +177,9 @@ pub async fn generate_custom_world_scene_npc(
}
pub async fn generate_custom_world_scene_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -174,30 +192,76 @@ pub async fn generate_custom_world_scene_image(
)
})?;
let asset = save_placeholder_asset(
"generated-custom-world-scenes",
payload
.profile_id
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile_id.as_deref());
let world_name =
trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string());
let landmark_id = trim_to_option(payload.landmark_id.as_deref());
let landmark_name =
trim_to_option(payload.landmark_name.as_deref()).unwrap_or_else(|| "scene".to_string());
let entity_id = landmark_id.clone().unwrap_or_else(|| landmark_name.clone());
let size = payload
.size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("1280*720")
.to_string();
let prompt = trim_to_option(payload.prompt.as_deref());
let asset_id = format!("custom-scene-{}", current_utc_millis());
let svg = build_placeholder_svg(
&size,
prompt
.as_deref()
.or(payload.world_name.as_deref())
.unwrap_or("world"),
payload
.landmark_id
.as_deref()
.or(payload.landmark_name.as_deref())
.or(Some(landmark_name.as_str()))
.unwrap_or("scene"),
"scene",
payload.size.as_deref().unwrap_or("1280*720"),
payload.prompt.as_deref(),
)
.into_bytes();
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
profile_id.as_deref().unwrap_or(world_name.as_str()),
"world",
),
sanitize_storage_segment(entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: "scene.svg".to_string(),
content_type: "image/svg+xml".to_string(),
body: svg,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id,
profile_id,
slot: "scene_image",
source_job_id: Some(asset_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some("rust-oss-placeholder".to_string()),
size: Some(size),
task_id: Some(asset_id),
prompt: prompt.clone(),
actual_prompt: prompt,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn generate_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -210,24 +274,69 @@ pub async fn generate_custom_world_cover_image(
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile = payload.profile.as_object().cloned().unwrap_or_default();
let profile_id = read_string_field(&profile, "id");
let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string());
let asset = save_placeholder_asset(
"generated-custom-world-covers",
&read_string_field(&profile, "id").unwrap_or_else(|| world_name.clone()),
"cover",
"cover",
payload.size.as_deref().unwrap_or("1600*900"),
payload.user_prompt.as_deref(),
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let size = payload
.size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("1600*900")
.to_string();
let prompt = trim_to_option(payload.user_prompt.as_deref());
let asset_id = format!("custom-cover-{}", current_utc_millis());
let svg = build_placeholder_svg(
&size,
prompt
.as_deref()
.or(Some(world_name.as_str()))
.unwrap_or("cover"),
)
.into_bytes();
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: "cover.svg".to_string(),
content_type: "image/svg+xml".to_string(),
body: svg,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(asset_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some("rust-oss-placeholder".to_string()),
size: Some(size),
task_id: Some(asset_id),
prompt: prompt.clone(),
actual_prompt: prompt,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn upload_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverUploadRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -249,41 +358,41 @@ pub async fn upload_custom_world_cover_image(
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile_id.as_deref());
let world_name =
trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let asset_id = format!("custom-cover-upload-{}", current_utc_millis());
let world_segment = sanitize_path_segment(
payload
.profile_id
.as_deref()
.or(payload.world_name.as_deref())
.unwrap_or("world"),
"world",
);
let relative_dir = PathBuf::from("generated-custom-world-covers")
.join(world_segment)
.join(&asset_id);
let output_dir = resolve_public_output_dir(&relative_dir)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
fs::create_dir_all(&output_dir)
.map_err(io_error)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let file_name = match parsed.mime_type.as_str() {
"image/png" => "cover.png",
"image/webp" => "cover.webp",
"image/svg+xml" => "cover.svg",
_ => "cover.jpg",
}
.to_string();
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name,
content_type: parsed.mime_type,
body: parsed.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(asset_id.clone()),
};
fs::write(output_dir.join(file_name), parsed.bytes)
.map_err(io_error)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let image_src = format!(
"/{}/{}",
relative_dir.to_string_lossy().replace('\\', "/"),
file_name
);
Ok(json_success_body(
Some(&request_context),
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src,
image_src: String::new(),
asset_id,
source_type: "uploaded".to_string(),
model: None,
@@ -292,14 +401,162 @@ pub async fn upload_custom_world_cover_image(
prompt: None,
actual_prompt: None,
},
))
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
async fn generate_entity_with_fallback(
async fn persist_custom_world_asset(
state: &AppState,
profile: &Value,
kind: &str,
) -> Value {
owner_user_id: &str,
upload: PreparedAssetUpload,
mut response: GeneratedAssetResponse,
) -> Result<GeneratedAssetResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: upload.prefix,
path_segments: upload.path_segments,
file_name: upload.file_name,
content_type: Some(upload.content_type.clone()),
access: OssObjectAccess::Private,
metadata: build_asset_metadata(
upload.asset_kind,
owner_user_id,
upload.profile_id.as_deref(),
upload.entity_kind,
upload.entity_id.as_str(),
upload.slot,
),
body: upload.body,
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
// custom world 图片链正式改为 OSS 真值确认,不再把 put_object 返回值直接当成唯一对象真相。
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(upload.content_type)),
head.content_length,
head.etag,
upload.asset_kind.to_string(),
upload.source_job_id,
Some(owner_user_id.to_string()),
upload.profile_id.clone(),
Some(upload.entity_id.clone()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id,
upload.entity_kind.to_string(),
upload.entity_id,
upload.slot.to_string(),
upload.asset_kind.to_string(),
Some(owner_user_id.to_string()),
upload.profile_id,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
response.image_src = put_result.legacy_public_path;
Ok(response)
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
profile_id: Option<&str>,
entity_kind: &str,
entity_id: &str,
slot: &str,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), entity_kind.to_string()),
("entity_id".to_string(), entity_id.to_string()),
("slot".to_string(), slot.to_string()),
]);
if let Some(profile_id) = profile_id {
metadata.insert("profile_id".to_string(), profile_id.to_string());
}
metadata
}
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
let fallback = build_entity_fallback(profile, kind);
let Some(llm_client) = state.llm_client() else {
return fallback;
@@ -447,37 +704,6 @@ fn build_landmark_fallback(world_name: &str) -> Value {
})
}
fn save_placeholder_asset(
root_segment: &str,
world_segment_seed: &str,
leaf_segment_seed: &str,
file_stem: &str,
size: &str,
prompt: Option<&str>,
) -> Result<GeneratedAssetResponse, AppError> {
let asset_id = format!("{file_stem}-{}", current_utc_millis());
let relative_dir = PathBuf::from(root_segment)
.join(sanitize_path_segment(world_segment_seed, "world"))
.join(sanitize_path_segment(leaf_segment_seed, file_stem))
.join(&asset_id);
let output_dir = resolve_public_output_dir(&relative_dir)?;
fs::create_dir_all(&output_dir).map_err(io_error)?;
let file_name = format!("{file_stem}.svg");
let svg = build_placeholder_svg(size, prompt.unwrap_or(file_stem));
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
Ok(GeneratedAssetResponse {
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some("rust-placeholder".to_string()),
size: Some(size.to_string()),
task_id: Some(asset_id),
prompt: prompt.map(ToOwned::to_owned),
actual_prompt: prompt.map(ToOwned::to_owned),
})
}
fn build_placeholder_svg(size: &str, label: &str) -> String {
let (width, height) = parse_size(size);
format!(
@@ -493,7 +719,7 @@ fn build_placeholder_svg(size: &str, label: &str) -> String {
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,255,255,0.12)"/>
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(125,211,252,0.14)"/>
<text x="50%" y="46%" text-anchor="middle" fill="#e2e8f0" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust fallback asset</text>
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust OSS placeholder</text>
</svg>"##,
width = width,
height = height,
@@ -531,36 +757,41 @@ fn escape_svg_text(value: &str) -> String {
.replace('>', "&gt;")
}
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
let sanitized = value
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
ch
} else {
'-'
}
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
'A'..='Z' => character.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
.collect::<String>();
let normalized = collapse_dashes(&normalized);
if normalized.is_empty() {
fallback.to_string()
} else {
sanitized
normalized
}
}
fn resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("无法解析仓库根目录")
})?;
Ok(workspace_root.join("public").join(relative_dir))
fn collapse_dashes(value: &str) -> String {
value
.chars()
.fold(
(String::new(), false),
|(mut output, last_is_dash), character| {
let is_dash = character == '-';
if is_dash && last_is_dash {
return (output, true);
}
output.push(character);
(output, is_dash)
},
)
.0
.trim_matches('-')
.to_string()
}
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
@@ -568,6 +799,9 @@ fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let separator = ";base64,";
let body = value.strip_prefix(prefix)?;
let (mime_type, data) = body.split_once(separator)?;
if !mime_type.starts_with("image/") {
return None;
}
let bytes = decode_base64(data)?;
Some(ParsedImageDataUrl {
mime_type: mime_type.to_string(),
@@ -611,6 +845,13 @@ fn read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn trim_to_option(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn current_utc_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
@@ -619,11 +860,12 @@ fn current_utc_millis() -> i64 {
i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64")
}
fn io_error(error: std::io::Error) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-ai",
"message": format!("文件写入失败:{error}"),
}))
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
@@ -634,3 +876,159 @@ struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use axum::response::Response;
use platform_auth::{AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus};
use serde_json::Value;
use time::OffsetDateTime;
fn build_authenticated(state: &AppState) -> AuthenticatedAccessToken {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_custom_world_ai".to_string(),
session_id: "sess_custom_world_ai".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("测试旅人".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
AuthenticatedAccessToken::new(claims)
}
fn build_request_context(operation: &str) -> RequestContext {
RequestContext::new(
"req-custom-world-ai-test".to_string(),
operation.to_string(),
std::time::Duration::ZERO,
true,
)
}
async fn read_error_response(response: Response) -> Value {
use http_body_util::BodyExt as _;
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
serde_json::from_slice(&body).expect("body should be valid json")
}
#[tokio::test]
async fn scene_image_returns_service_unavailable_when_oss_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/scene-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_scene_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("世界".to_string()),
landmark_id: Some("landmark_001".to_string()),
landmark_name: Some("遗迹".to_string()),
prompt: Some("测试场景".to_string()),
size: Some("1280*720".to_string()),
})),
)
.await
.expect_err("missing oss should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("aliyun-oss".to_string())
);
}
#[tokio::test]
async fn cover_image_returns_service_unavailable_when_oss_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverImageRequest {
profile: json!({
"id": "profile_001",
"name": "测试世界"
}),
user_prompt: Some("测试封面".to_string()),
size: Some("1600*900".to_string()),
})),
)
.await
.expect_err("missing oss should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("aliyun-oss".to_string())
);
}
#[tokio::test]
async fn cover_upload_rejects_invalid_data_url_before_touching_oss() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-upload");
let authenticated = build_authenticated(&state);
let response = upload_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverUploadRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("测试世界".to_string()),
image_data_url: "not-a-data-url".to_string(),
})),
)
.await
.expect_err("invalid data url should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("BAD_REQUEST".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("custom-world-ai".to_string())
);
}
#[test]
fn parse_image_data_url_accepts_image_payload() {
let parsed =
parse_image_data_url("data:image/png;base64,aGVsbG8=").expect("data url should parse");
assert_eq!(parsed.mime_type, "image/png");
assert_eq!(parsed.bytes, b"hello".to_vec());
}
}

View File

@@ -28,6 +28,7 @@ mod runtime_save;
mod runtime_settings;
mod runtime_story;
mod session_client;
mod sse;
mod state;
mod story_battles;
mod story_sessions;

View File

@@ -32,10 +32,11 @@ pub async fn get_runtime_snapshot(
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_runtime_snapshot(user_id)
.get_runtime_snapshot_record(user_id)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -70,8 +71,7 @@ pub async fn put_runtime_snapshot(
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let record = state
.spacetime_client()
.put_runtime_snapshot(
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
payload.bottom_tab,
@@ -80,7 +80,9 @@ pub async fn put_runtime_snapshot(
updated_at_micros,
)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -95,10 +97,11 @@ pub async fn delete_runtime_snapshot(
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
state
.spacetime_client()
.delete_runtime_snapshot(user_id)
.delete_runtime_snapshot_record(user_id)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -116,7 +119,9 @@ pub async fn list_profile_save_archives(
.spacetime_client()
.list_profile_save_archives(user_id)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -151,7 +156,12 @@ pub async fn resume_profile_save_archive(
.spacetime_client()
.resume_profile_save_archive(user_id, world_key)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_resume_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(
&request_context,
map_runtime_save_resume_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
@@ -205,7 +215,8 @@ fn map_runtime_save_client_error(error: SpacetimeClientError) -> AppError {
fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match &error {
SpacetimeClientError::Procedure(message)
if message.contains("world_key 不存在") || message.contains("对应 world_key 不存在") =>
if message.contains("world_key 不存在")
|| message.contains("对应 world_key 不存在") =>
{
(StatusCode::NOT_FOUND, "runtime-save")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
use axum::{
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::json;
use crate::http_error::AppError;
/// 最小缓冲式 SSE builder适用于“先完成业务再一次性返回完整 SSE 文本”的兼容链路。
#[derive(Default)]
pub struct SseEventBuffer {
body: String,
}
impl SseEventBuffer {
pub fn new() -> Self {
Self::default()
}
pub fn push_json<T>(&mut self, event: &str, payload: &T) -> Result<(), AppError>
where
T: Serialize,
{
encode_sse_event(&mut self.body, event, payload)
}
pub fn into_response(self) -> Response {
build_sse_response(self.body)
}
}
pub fn encode_sse_event<T>(body: &mut String, event: &str, payload: &T) -> Result<(), AppError>
where
T: Serialize,
{
let payload_text = serde_json::to_string(payload).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
}
pub fn build_sse_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
// 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。
(HeaderName::from_static("x-accel-buffering"), "no"),
],
body,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::{SseEventBuffer, build_sse_response, encode_sse_event};
use axum::body::to_bytes;
use serde_json::json;
#[tokio::test]
async fn encode_sse_event_writes_standard_format() {
let mut body = String::new();
encode_sse_event(&mut body, "reply_delta", &json!({ "text": "hello" }))
.expect("encoding should succeed");
assert_eq!(body, "event: reply_delta\ndata: {\"text\":\"hello\"}\n\n");
}
#[tokio::test]
async fn build_sse_response_sets_standard_headers() {
let response = build_sse_response("event: done\ndata: {\"ok\":true}\n\n".to_string());
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/event-stream; charset=utf-8")
);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-cache")
);
assert_eq!(
response
.headers()
.get(HeaderName::from_static("x-accel-buffering"))
.and_then(|value| value.to_str().ok()),
Some("no")
);
}
#[tokio::test]
async fn sse_event_buffer_collects_events_and_returns_response() {
let mut buffer = SseEventBuffer::new();
buffer
.push_json("reply_delta", &json!({ "text": "hello" }))
.expect("first event should encode");
buffer
.push_json("done", &json!({ "ok": true }))
.expect("second event should encode");
let response = buffer.into_response();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should read");
let text = String::from_utf8(body.to_vec()).expect("body should be utf8");
assert_eq!(
text,
"event: reply_delta\ndata: {\"text\":\"hello\"}\n\nevent: done\ndata: {\"ok\":true}\n\n"
);
}
}

View File

@@ -1,16 +1,26 @@
use std::{error::Error, fmt};
#[cfg(test)]
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
use serde_json::Value;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
@@ -35,6 +45,9 @@ pub struct AppState {
ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient,
llm_client: Option<LlmClient>,
#[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
#[derive(Debug)]
@@ -98,6 +111,8 @@ impl AppState {
ai_task_service,
spacetime_client,
llm_client,
#[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
})
}
@@ -153,6 +168,162 @@ impl AppState {
pub fn llm_client(&self) -> Option<&LlmClient> {
self.llm_client.as_ref()
}
pub async fn get_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<Option<RuntimeSnapshotRecord>, SpacetimeClientError> {
match self
.spacetime_client
.get_runtime_snapshot(user_id.clone())
.await
{
Ok(record) => {
#[cfg(test)]
if let Some(snapshot) = record.as_ref() {
self.cache_test_runtime_snapshot(snapshot.clone());
}
Ok(record)
}
#[cfg(test)]
Err(_) => Ok(self.read_test_runtime_snapshot(user_id.as_str())),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn put_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
match self
.spacetime_client
.put_runtime_snapshot(
user_id.clone(),
saved_at_micros,
bottom_tab.clone(),
game_state.clone(),
current_story.clone(),
updated_at_micros,
)
.await
{
Ok(record) => {
#[cfg(test)]
self.cache_test_runtime_snapshot(record.clone());
Ok(record)
}
#[cfg(test)]
Err(_) => {
let snapshot = self.build_test_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
current_story,
updated_at_micros,
)?;
self.cache_test_runtime_snapshot(snapshot.clone());
Ok(snapshot)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn delete_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<bool, SpacetimeClientError> {
match self
.spacetime_client
.delete_runtime_snapshot(user_id.clone())
.await
{
Ok(deleted) => {
#[cfg(test)]
if deleted {
self.remove_test_runtime_snapshot(user_id.as_str());
}
Ok(deleted)
}
#[cfg(test)]
Err(_) => Ok(self
.remove_test_runtime_snapshot(user_id.as_str())
.is_some()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
}
#[cfg(test)]
impl AppState {
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.insert(record.user_id.clone(), record);
}
fn read_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.get(user_id)
.cloned()
}
fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.remove(user_id)
}
fn build_test_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
let previous = self.read_test_runtime_snapshot(user_id.as_str());
let game_state_json = serde_json::to_string(&game_state).map_err(|error| {
SpacetimeClientError::Runtime(format!("测试快照 game_state 序列化失败: {error}"))
})?;
let current_story_json = current_story
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
SpacetimeClientError::Runtime(format!("测试快照 current_story 序列化失败: {error}"))
})?;
Ok(RuntimeSnapshotRecord {
user_id,
version: SAVE_SNAPSHOT_VERSION,
saved_at: format_utc_micros(saved_at_micros),
saved_at_micros,
bottom_tab,
game_state,
current_story,
game_state_json,
current_story_json,
created_at_micros: previous
.as_ref()
.map(|record| record.created_at_micros)
.unwrap_or(updated_at_micros),
updated_at_micros,
})
}
}
impl fmt::Display for AppStateInitError {

View File

@@ -140,6 +140,7 @@ pub enum CustomWorldFieldError {
MissingProfileId,
MissingSessionId,
MissingOwnerUserId,
MissingAction,
MissingWorldName,
MissingDraftProfileJson,
MissingProfilePayloadJson,
@@ -227,6 +228,61 @@ pub struct CustomWorldGalleryListResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishBlockerSnapshot {
pub blocker_id: String,
pub code: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishGateSnapshot {
pub profile_id: String,
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
pub blocker_count: u32,
pub publish_ready: bool,
pub can_enter_world: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorkSummarySnapshot {
pub work_id: String,
pub source_type: String,
pub status: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub cover_render_mode: Option<String>,
pub cover_character_image_srcs_json: String,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub stage: Option<RpgAgentStage>,
pub stage_label: Option<String>,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub role_visual_ready_count: Option<u32>,
pub role_animation_ready_count: Option<u32>,
pub role_asset_summary_label: Option<String>,
pub session_id: Option<String>,
pub profile_id: Option<String>,
pub can_resume: bool,
pub can_enter_world: bool,
pub blocker_count: u32,
pub publish_ready: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListResult {
pub ok: bool,
pub items: Vec<CustomWorldWorkSummarySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageSnapshot {
@@ -273,6 +329,38 @@ pub struct CustomWorldDraftCardSnapshot {
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailSectionSnapshot {
pub section_id: String,
pub label: String,
pub value: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailSnapshot {
pub card_id: String,
pub kind: RpgAgentDraftCardKind,
pub title: String,
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
pub linked_ids_json: String,
pub locked: bool,
pub editable: bool,
pub editable_section_ids_json: String,
pub warning_messages_json: String,
pub asset_status: Option<CustomWorldRoleAssetStatus>,
pub asset_status_label: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailResult {
pub ok: bool,
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionSnapshot {
@@ -290,6 +378,7 @@ pub struct CustomWorldAgentSessionSnapshot {
pub lock_state_json: Option<String>,
pub draft_profile_json: Option<String>,
pub last_assistant_reply: Option<String>,
pub publish_gate_json: Option<String>,
pub result_preview_json: Option<String>,
pub pending_clarifications_json: String,
pub quality_findings_json: String,
@@ -297,6 +386,7 @@ pub struct CustomWorldAgentSessionSnapshot {
pub recommended_replies_json: String,
pub asset_coverage_json: String,
pub checkpoints_json: String,
pub supported_actions_json: String,
pub messages: Vec<CustomWorldAgentMessageSnapshot>,
pub draft_cards: Vec<CustomWorldDraftCardSnapshot>,
pub operations: Vec<CustomWorldAgentOperationSnapshot>,
@@ -425,6 +515,39 @@ pub struct CustomWorldAgentOperationProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentCardDetailGetInput {
pub session_id: String,
pub owner_user_id: String,
pub card_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub action: String,
pub payload_json: Option<String>,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteResult {
pub ok: bool,
pub operation: Option<CustomWorldAgentOperationSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileInput {
@@ -941,6 +1064,52 @@ pub fn validate_custom_world_agent_operation_get_input(
Ok(())
}
pub fn validate_custom_world_works_list_input(
input: &CustomWorldWorksListInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_agent_card_detail_get_input(
input: &CustomWorldAgentCardDetailGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.card_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardId);
}
Ok(())
}
pub fn validate_custom_world_agent_action_execute_input(
input: &CustomWorldAgentActionExecuteInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.operation_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOperationId);
}
if input.action.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAction);
}
ensure_optional_json_object(input.payload_json.as_deref())?;
Ok(())
}
pub fn validate_custom_world_agent_message_fields(
message_id: &str,
session_id: &str,
@@ -1290,6 +1459,7 @@ impl fmt::Display for CustomWorldFieldError {
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
Self::MissingDraftProfileJson => {
f.write_str("custom_world.compile.draft_profile_json 不能为空")

View File

@@ -54,8 +54,10 @@
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
1. `runtime/story/state/resolve` 请求 DTO
2. `runtime/story/actions/resolve``runtime/story/initial``runtime/story/continue` 请求 DTO
2. `RuntimeStoryActionResponse` 兼容响应 DTO
3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构
4. `RuntimeStoryAiResponse` 兼容响应 DTO
当前仍刻意未做:

View File

@@ -4,7 +4,8 @@ use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStorySnapshotPayload {
pub saved_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub saved_at: Option<String>,
pub bottom_tab: String,
pub game_state: Value,
#[serde(default)]
@@ -21,6 +22,72 @@ pub struct RuntimeStoryStateResolveRequest {
pub snapshot: Option<RuntimeStorySnapshotPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryChoiceAction {
#[serde(rename = "type")]
pub action_type: String,
pub function_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryActionRequest {
pub session_id: String,
#[serde(default)]
pub client_version: Option<u32>,
pub action: RuntimeStoryChoiceAction,
#[serde(default)]
pub snapshot: Option<RuntimeStorySnapshotPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiRequestOptions {
#[serde(default)]
pub available_options: Vec<Value>,
#[serde(default)]
pub option_catalog: Vec<Value>,
}
impl Default for RuntimeStoryAiRequestOptions {
fn default() -> Self {
Self {
available_options: Vec::new(),
option_catalog: Vec::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiRequest {
pub world_type: String,
pub character: Value,
#[serde(default)]
pub monsters: Vec<Value>,
#[serde(default)]
pub history: Vec<Value>,
#[serde(default)]
pub choice: String,
pub context: Value,
#[serde(default)]
pub request_options: RuntimeStoryAiRequestOptions,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiResponse {
pub story_text: String,
pub options: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encounter: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryOptionView {
@@ -197,28 +264,70 @@ mod tests {
use serde_json::json;
#[test]
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
fn runtime_story_state_resolve_request_accepts_missing_saved_at() {
let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({
"sessionId": "runtime-main",
"clientVersion": 7,
"snapshot": {
"bottomTab": "adventure",
"gameState": { "runtimeSessionId": "runtime-main" },
"currentStory": { "text": "营地里的火光还没有熄灭。" }
}
}))
.expect("payload should deserialize");
assert_eq!(payload.session_id, "runtime-main");
assert_eq!(payload.client_version, Some(7));
assert_eq!(
payload.snapshot.expect("snapshot should exist").saved_at,
None
);
}
#[test]
fn runtime_story_action_request_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryActionRequest {
session_id: "runtime-main".to_string(),
client_version: Some(7),
client_version: Some(8),
action: RuntimeStoryChoiceAction {
action_type: "story_choice".to_string(),
function_id: "npc_chat".to_string(),
target_id: Some("npc_camp_firekeeper".to_string()),
payload: Some(json!({ "optionText": "继续交谈" })),
},
snapshot: Some(RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
current_story: None,
}),
})
.expect("payload should serialize");
assert_eq!(payload["sessionId"], json!("runtime-main"));
assert_eq!(payload["clientVersion"], json!(7));
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
assert_eq!(payload["clientVersion"], json!(8));
assert_eq!(payload["action"]["type"], json!("story_choice"));
assert_eq!(payload["action"]["functionId"], json!("npc_chat"));
assert_eq!(
payload["snapshot"]["currentStory"]["text"],
json!("营地里的火光还没有熄灭。")
payload["action"]["targetId"],
json!("npc_camp_firekeeper")
);
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
}
#[test]
fn runtime_story_ai_request_defaults_optional_arrays() {
let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({
"worldType": "martial",
"character": { "name": "林迟" },
"context": { "scene": "camp" }
}))
.expect("payload should deserialize");
assert_eq!(payload.world_type, "martial");
assert!(payload.monsters.is_empty());
assert!(payload.history.is_empty());
assert!(payload.request_options.available_options.is_empty());
}
#[test]
@@ -297,7 +406,7 @@ mod tests {
current_npc_battle_outcome: None,
}],
snapshot: RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({