feat: checkpoint m5 and bootstrap m6 asset flow
This commit is contained in:
@@ -113,6 +113,7 @@
|
||||
3. 部署
|
||||
4. 观测
|
||||
5. 灰度切流
|
||||
6. 收口 `spacetime-module` 主工程结构,拆分过大的 `src/lib.rs`
|
||||
|
||||
详见:
|
||||
|
||||
|
||||
@@ -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”的联调证据仍未冻结
|
||||
|
||||
@@ -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 精灵与其余历史渲染入口一并收口后再整体勾选。
|
||||
|
||||
@@ -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 通过
|
||||
- [ ] 主流程回归通过
|
||||
|
||||
@@ -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”。
|
||||
|
||||
@@ -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)
|
||||
@@ -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 后端的实施方案与验收口径。
|
||||
|
||||
@@ -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 的同步伪流式语义。
|
||||
@@ -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. 验收标准
|
||||
|
||||
重写完成至少要满足:
|
||||
|
||||
@@ -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 级改动范围
|
||||
|
||||
|
||||
@@ -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` 真相链已完成。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('>', ">")
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
130
server-rs/crates/api-server/src/sse.rs
Normal file
130
server-rs/crates/api-server/src/sse.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 不能为空")
|
||||
|
||||
@@ -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
|
||||
|
||||
当前仍刻意未做:
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user