后端重写提交

This commit is contained in:
2026-04-22 12:34:49 +08:00
parent cf8da3f50f
commit 997a8daada
438 changed files with 53355 additions and 865 deletions

5
.gitignore vendored
View File

@@ -5,12 +5,17 @@ coverage/
.DS_Store
*.log
/.codex-logs/
/.codex-cargo-home-*/
/.codex-cache*/
/.tmp*/
.preview.*
tmp_*
tmp/
npc-editor-*
temp-write-check.txt
temp-build-goal-check/
/server-rs-codex-*/
/server-rs/target-*/
**/__pycache__/
*.py[cod]
/public/generated-custom-world-scenes

View File

@@ -6,6 +6,8 @@
- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md)
- [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
- [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
关联拆分任务:
@@ -70,9 +72,9 @@
重点:
1. 迁移 story action 主循环
2. 迁移 gameplay reducer
3. 兼容当前 story view model 与 state 恢复接口
1. 迁移 RPG runtime story 主循环
2. 迁移 RPG 入口 / session / runtime 对应的后端边界与编译职责
3. 兼容当前 story view model 与 state 恢复接口,并与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory` 口径对齐
详见:
@@ -82,9 +84,9 @@
重点:
1. 迁移传统 custom world 问答流
2. 迁移 custom world library / gallery
3. 迁移 custom world agent 会话、消息、卡片、操作
1. 迁移 RPG 创作主链Agent session、result preview、published profile
2. 迁移 works / library / gallery / publish / enter-world 配套链路
3. `custom-world/sessions` 传统问答流只按历史兼容台账处理,不再作为当前主链扩展目标
详见:

View File

@@ -3,12 +3,12 @@
## 1. SpacetimeDB 运行时主表
- [ ] 设计 `runtime_snapshot`
- [ ] 设计 `runtime_setting`
- [ ] 设计 `profile_dashboard_state`
- [ ] 设计 `profile_wallet_ledger`
- [ ] 设计 `profile_played_world`
- [x] 设计 `runtime_setting`
- [x] 设计 `profile_dashboard_state`
- [x] 设计 `profile_wallet_ledger`
- [x] 设计 `profile_played_world`
- [ ] 设计 `profile_save_archive`
- [ ] 设计 `user_browse_history`
- [x] 设计 `user_browse_history`
## 2. 兼容快照策略
@@ -16,31 +16,31 @@
- [ ] 设计 snapshot projection 刷新机制
- [ ] 迁移当前 snapshot hydration / normalize 规则
- [ ] 迁移当前 save archive 聚合逻辑
- [ ] 迁移当前 browse history 去重与排序逻辑
- [x] 迁移当前 browse history 去重与排序逻辑
## 3. Axum facade
- [ ] 兼容 `GET /api/runtime/save/snapshot`
- [ ] 兼容 `PUT /api/runtime/save/snapshot`
- [ ] 兼容 `DELETE /api/runtime/save/snapshot`
- [ ] 兼容 `GET /api/runtime/settings`
- [ ] 兼容 `PUT /api/runtime/settings`
- [ ] 兼容 `GET /api/runtime/profile/dashboard`
- [ ] 兼容 `GET /api/profile/dashboard`
- [ ] 兼容 `GET /api/runtime/profile/wallet-ledger`
- [ ] 兼容 `GET /api/profile/wallet-ledger`
- [ ] 兼容 `GET /api/runtime/profile/play-stats`
- [ ] 兼容 `GET /api/profile/play-stats`
- [x] 兼容 `GET /api/runtime/settings`
- [x] 兼容 `PUT /api/runtime/settings`
- [x] 兼容 `GET /api/runtime/profile/dashboard`
- [x] 兼容 `GET /api/profile/dashboard`
- [x] 兼容 `GET /api/runtime/profile/wallet-ledger`
- [x] 兼容 `GET /api/profile/wallet-ledger`
- [x] 兼容 `GET /api/runtime/profile/play-stats`
- [x] 兼容 `GET /api/profile/play-stats`
- [ ] 兼容 `GET /api/runtime/profile/save-archives`
- [ ] 兼容 `GET /api/profile/save-archives`
- [ ] 兼容 `POST /api/runtime/profile/save-archives/:worldKey`
- [ ] 兼容 `POST /api/profile/save-archives/:worldKey`
- [ ] 兼容 `GET /api/runtime/profile/browse-history`
- [ ] 兼容 `POST /api/runtime/profile/browse-history`
- [ ] 兼容 `DELETE /api/runtime/profile/browse-history`
- [ ] 兼容 `GET /api/profile/browse-history`
- [ ] 兼容 `POST /api/profile/browse-history`
- [ ] 兼容 `DELETE /api/profile/browse-history`
- [x] 兼容 `GET /api/runtime/profile/browse-history`
- [x] 兼容 `POST /api/runtime/profile/browse-history`
- [x] 兼容 `DELETE /api/runtime/profile/browse-history`
- [x] 兼容 `GET /api/profile/browse-history`
- [x] 兼容 `POST /api/profile/browse-history`
- [x] 兼容 `DELETE /api/profile/browse-history`
## 4. 阶段验收
@@ -48,3 +48,18 @@
- [ ] 兼容路径与主路径返回一致
- [ ] profile dashboard / browse history / save archive 行为一致
- [ ] 前端当前恢复流程可在不改 UI 的前提下跑通
## 5. 本轮进展记录
- `2026-04-21`:已完成 `runtime_setting` 首版设计与 `GET/PUT /api/runtime/settings` 的 Rust 主链迁移。
- 本轮已落地 `module-runtime``spacetime-module``spacetime-client``api-server` 四层串联,并补齐定向测试。
- 详细设计与字段冻结见:
- [../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- `2026-04-22`:已完成 `user_browse_history` 表设计冻结、去重与排序规则迁移,以及 `/api/runtime/profile/browse-history``/api/profile/browse-history` 双路径 facade 落地。
- `2026-04-22`:已补 `browse history` 的 API 入口必填字段校验、批量 shape 兼容与定向测试,详细设计见:
- [../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- `2026-04-22`:已冻结 `profile_dashboard_state``profile_wallet_ledger``profile_played_world` 三张 projection 表,以及 `dashboard / wallet-ledger / play-stats` 的 Axum + SpacetimeDB 读链设计。
- `2026-04-22`:已完成 `api-server``runtime_profile` facade 编译与定向测试收口,`/api/runtime/profile/*``/api/profile/*` 六条只读路由均已接通。
- `2026-04-22`:已通过 `cargo check -p api-server --tests --message-format short``cargo test -p shared-contracts --lib``cargo test -p api-server runtime_profile::tests:: -- --nocapture` 验证本轮 profile projection 读链。
- 详细设计见:
- [../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md)

View File

@@ -1,48 +1,141 @@
# M4story action 与 gameplay reducer 任务清单
## 0. 当前执行基线
本阶段与当前仓库里的 RPG 入口与运行时主链重构直接对应,统一以以下文档为准:
1. [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
2. [../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md)
3. [../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md)
当前任务清单只维护 Axum / SpacetimeDB 重写侧的后端迁移项,不再把旧 `GameShell / runtimeRoutes.ts / storyActionService.ts` 命名视为新架构目标。
### 当前进展(`2026-04-22`
本阶段首轮已先把 `server-rs` 从“只有 `module-story` 占位目录”推进到“SpacetimeDB 侧 story 会话基座真实可编译”:
1. 已新增 `server-rs/crates/module-story` 真实 crate。
2. 已冻结 `story_session / story_event` 的首版领域类型、状态枚举和字段校验 helper。
3. 已在 `server-rs/crates/spacetime-module` 中新增 `story_session``story_event` 两张表。
4. 已新增 `begin_story_session``continue_story` 两个 reducer形成最小会话事件链。
5. 已新增 `begin_story_session_and_return``continue_story_and_return` 两个 procedure形成可同步返回快照的最小 story session contract。
6. 已重新执行 `spacetime generate`,把 `story_session / story_event` Rust bindings 刷入 `spacetime-client/src/module_bindings`
7. 已在 `server-rs/crates/spacetime-client` 中新增 `begin_story_session(...)``continue_story(...)` facade。
8. 已在 `server-rs/crates/api-server` 中新增:
- `POST /api/story/sessions`
- `POST /api/story/sessions/continue`
9. 已执行 `cargo check -p module-story -p spacetime-module -p spacetime-client -p api-server` 并通过。
6. 已新增 `docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `battle_state``resolve_combat_action` 的首版字段与规则口径。
7. 已新增 `server-rs/crates/module-runtime-item` 真实 crate。
8. 已冻结 `treasure_record` 的首版领域类型、完整奖励物品快照和字段校验规则。
9. 已在 `server-rs/crates/spacetime-module` 中新增 `treasure_record` 表。
10. 已新增 `resolve_treasure_interaction` reducer 与 `resolve_treasure_interaction_and_return` procedure并把宝箱奖励同步写入 `inventory_slot`
11. 已新增 `docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `inventory_slot``apply_inventory_mutation` 的首版字段与规则口径。
12. 已新增 `server-rs/crates/module-inventory` 真实 crate。
13. 已在 `server-rs/crates/spacetime-module` 中新增 `inventory_slot` 表。
14. 已新增 `apply_inventory_mutation` reducer形成最小背包主链。
15. 已新增 `docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `npc_state``resolve_npc_social_action``resolve_npc_interaction` 的首版字段与交互口径。
16. 已新增 `server-rs/crates/module-npc` 真实 crate。
17. 已在 `server-rs/crates/spacetime-module` 中新增 `npc_state` 表。
18. 已新增 `upsert_npc_state``resolve_npc_social_action``resolve_npc_interaction` 及对应 procedure。
19. 已新增 `docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md`,冻结 `npc_fight / npc_spar``battle_state` 的最小联合编排口径。
20. 已在 `server-rs/crates/spacetime-module` 中新增 `resolve_npc_battle_interaction_and_return` procedure把 NPC 开战交互与 battle 初始化写入串到同一事务。
15. 已新增 `docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `player_progression / chapter_progression` 的首版字段、成长曲线与章节预算口径。
16. 已新增 `server-rs/crates/module-progression` 真实 crate。
17. 已在 `server-rs/crates/spacetime-module` 中新增 `player_progression``chapter_progression` 两张表。
18. 已新增 `get_player_progression_or_default``grant_player_progression_experience``upsert_chapter_progression``apply_chapter_progression_ledger_entry` 及对应 procedure。
19. 已新增 `docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `quest_record / quest_log / apply_quest_signal` 的首版字段、日志口径与交付状态流转规则。
20. 已新增 `server-rs/crates/module-quest` 真实 crate。
21. 已在 `server-rs/crates/spacetime-module` 中新增 `quest_record``quest_log` 两张表。
22. 已新增 `accept_quest``apply_quest_signal``acknowledge_quest_completion``turn_in_quest` reducer形成最小任务闭环。
23. 已执行 `cargo test -p module-quest``cargo check -p spacetime-module``cargo check -p api-server` 与全量 `cargo check` 并通过。
24. 已新增 `docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md`,冻结任务交付与战斗胜利到成长系统的联动口径。
25. 已把 `turn_in_quest` 接到 `player_progression / chapter_progression` 的最小经验写入。
26. 已把 `resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 的最小经验写入。
27. 已把 `turn_in_quest.reward.items` 接到 `inventory_slot` 发物链,形成任务交付的最小物品奖励闭环。
28. 已新增 `docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `story state` 查询切片,只开放 `storySession + storyEvents` 真相态查询。
29. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/sessions/:storySessionId/state`,通过 `spacetime-client.get_story_session_state(...)` 读取 `SpacetimeDB procedure` 返回的会话快照与事件流。
30. 已新增 `docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md`,冻结 `battle_state.reward_items``resolve_combat_action(Victory)` 发物到 `inventory_slot` 的最小联动口径。
31. 已新增 `docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `battle state` 查询切片,只开放单个 `battleState` 真相态查询。
32. 已在 `server-rs/crates/spacetime-module` 中新增 `get_battle_state` procedure`battle_state_id` 返回当前战斗快照。
33. 已在 `server-rs/crates/spacetime-client` 中新增 `get_battle_state(...)` facade供 Axum 同步读取 battle 真相态。
34. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/battles/:battleStateId`,通过 `spacetime-client.get_battle_state(...)` 返回单战斗快照。
35. 已在 `server-rs/crates/spacetime-client` 中新增 `resolve_npc_battle_interaction(...)` facade`resolve_npc_battle_interaction_and_return` procedure 映射为稳定 Rust record供 Axum 直接消费。
36. 已在 `server-rs/crates/api-server` 中挂出 `POST /api/story/npc/battle`,当前只接受 `npc_fight / npc_spar`,同步返回 `npcInteraction + battleState`
37. 已执行 `cargo check -p spacetime-client -p api-server` 并通过,完成 `module-npc -> spacetime-client -> api-server` 的最小 NPC 开战同步返回链闭环。
38. 已重新执行 `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes`,把 `get_battle_state``battle_state.reward_items``custom_world_agent_session` 相关 bindings 刷入 `spacetime-client/src/module_bindings`
39. 已把 `server-rs/crates/spacetime-client/src/lib.rs` 中原本占位返回错误的 `get_battle_state(...)` 改成真实 procedure 调用,当前 battle query 已不再停留在 facade stub。
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与当前前端消费口径对齐。
当前验证边界补充:
1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时很长,已有多轮回归尝试,但还没有在单次时窗内收敛到最终断言结果。
2. `npm run check:encoding` 已启动到 `node scripts/check-encoding.mjs`,但当前尚未在单次时窗内跑完,不能标记为已完成。
3. 因此,当前可以确认的是 `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通;测试与编码检查仍应继续追。
当前这轮仍未扩到 `resolve_story_action``sync_runtime_snapshot_projection`、旧 `/api/runtime/story/*` 兼容接口和前端实际 runtime story API 切换,这些继续保留在后续 `M4` 工作项中。
## 1. SpacetimeDB gameplay 表
- [ ] 设计 `story_session`
- [ ] 设计 `story_event`
- [ ] 设计 `npc_state`
- [ ] 设计 `quest_record`
- [ ] 设计 `inventory_slot`
- [ ] 设计 `treasure_record`
- [ ] 设计 `battle_state`
- [ ] 设计 `player_progression`
- [ ] 设计 `chapter_progression`
- [x] 设计 `story_session`
- [x] 设计 `story_event`
- [x] 设计 `npc_state`
- [x] 设计 `quest_record`
- [x] 设计 `inventory_slot`
- [x] 设计 `treasure_record`
- [x] 设计 `battle_state`
- [x] 设计 `player_progression`
- [x] 设计 `chapter_progression`
## 2. 核心 reducer
- [ ] 设计 `resolve_story_action`
- [ ] 设计 `continue_story`
- [ ] 设计 `begin_story_session`
- [x] 设计 `continue_story`
- [x] 设计 `begin_story_session`
- [ ] 设计 `sync_runtime_snapshot_projection`
- [ ] 设计 `apply_quest_signal`
- [ ] 设计 `apply_inventory_mutation`
- [ ] 设计 `resolve_npc_interaction`
- [ ] 设计 `resolve_treasure_interaction`
- [ ] 设计 `resolve_combat_action`
- [ ] 设计 `update_progression_state`
- [x] 设计 `apply_quest_signal`
- [x] 设计 `apply_inventory_mutation`
- [x] 设计 `resolve_npc_interaction`
- [x] 设计 `resolve_treasure_interaction`
- [x] 设计 `resolve_combat_action`
- [x] 设计 `update_progression_state`
## 3. 迁移模块规则
## 3. 当前主链模块落位
- [ ] 迁移 `story`
- [ ] 迁移 `combat`
- [ ] 迁移 `rpg-entry` 配套后端入口能力
- [ ] 迁移 `rpg-profile` 资料域
- [ ] 迁移 `rpg-runtime-story`
- [x] 迁移 `combat`
- [ ] 迁移 `inventory`
- [ ] 迁移 `npc`
- [ ] 迁移 `progression`
- [ ] 迁移 `quest`
- [ ] 迁移 `runtime-item`
- [ ] 迁移 `runtime` 的状态归一化规则
- [x] 迁移 `progression`
- [x] 迁移 `quest`
- [x] 迁移 `runtime-item`
- [ ] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则
## 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`
补充说明:
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 未冻结前,不应误勾选旧兼容接口。
## 5. ViewModel 兼容
- [ ] 兼容当前 `RuntimeStoryActionResponse`
@@ -56,4 +149,5 @@
- [ ] 当前前端 story 选项点击后可走新后端闭环
- [ ] NPC / quest / treasure / combat 主循环行为不回退
- [ ] `story state` 恢复链可用
- [ ] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致
- [ ] 旧 Node 版 story route 回归用例完成平移

View File

@@ -1,68 +1,87 @@
# M5custom world / gallery / agent 任务清单
## 0. 当前执行基线
本阶段与当前仓库里的创作链重构直接对应,统一以以下文档为准:
1. [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
2. [../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md)
当前逻辑层命名和职责边界应优先使用 `rpgCreation / rpgAgent / rpgWorld` 口径;本任务清单继续保留 `custom world` 文件名,只是为了和后端重写阶段文档编号保持一致。
本轮首批可编码表设计见:
3. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
4. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
5. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md)
6. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md)
## 1. SpacetimeDB custom world 表
- [ ] 设计 `custom_world_profile`
- [ ] 设计 `custom_world_session`
- [ ] 设计 `custom_world_agent_session`
- [ ] 设计 `custom_world_agent_message`
- [ ] 设计 `custom_world_agent_operation`
- [ ] 设计 `custom_world_draft_card`
- [x] 设计 `custom_world_profile`
- [x] 设计 `custom_world_session`
- [x] 设计 `custom_world_agent_session`
- [x] 设计 `custom_world_agent_message`
- [x] 设计 `custom_world_agent_operation`
- [x] 设计 `custom_world_draft_card`
- [ ] 设计 `custom_world_asset_link`
- [ ] 设计 `custom_world_gallery_entry`
- [x] 设计 `custom_world_gallery_entry`
## 2. 传统 custom world 问答流
## 2. 当前 RPG 创作主链
- [ ] 迁移 `create session`
- [ ] 迁移 `answer question`
- [ ] 迁移 `generate stream`
- [ ] 迁移 profile compile
- [ ] 迁移 library 存储与删除
- [ ] 迁移 publish / unpublish
- [ ] 迁移 gallery 列表与详情
- [ ] 迁移 result preview compiler
- [x] 迁移 published profile compileStage 3 已落地)
- [ ] 迁移 works 聚合读模型
- [x] 迁移 library 存储与删除Stage 2 设计已冻结,待继续接 Axum 兼容)
- [x] 迁移 publish / unpublishStage 2 设计已冻结,待继续接 Agent publish gate
- [x] 迁移 publish_world 串联主链Stage 4 设计已冻结,待继续接 Axum action / publish gate
- [ ] 迁移 publish gate / enter-world gate
- [x] 迁移 gallery 列表与详情Stage 2 设计已冻结,待继续接 Axum 兼容)
## 3. custom world agent 主链
## 3. RPG 创作 Agent 主链
- [ ] 迁移 session create
- [ ] 迁移 session snapshot
- [ ] 迁移 message submit
- [ ] 迁移 message stream
- [ ] 迁移 operation query
- [x] 迁移 session createStage 6 首批 Agent session skeleton
- [x] 迁移 session snapshotStage 6 首批 Agent session skeleton
- [x] 迁移 message submitStage 7 deterministic message / operation 最小闭环)
- [x] 迁移 message streamStage 8 SSE facade 已落地)
- [x] 迁移 operation queryStage 7 deterministic message / operation 最小闭环)
- [ ] 迁移 card detail
- [ ] 迁移 card update
- [ ] 迁移 action registry / supportedActions
- [ ] 迁移 draft foundation
- [ ] 迁移 result preview 生成
- [ ] 迁移 entity generation
- [ ] 迁移 role asset sync
- [ ] 迁移 role / scene asset sync
- [ ] 迁移 checkpoint / blocker / quality findings 主链
## 4. Axum 编排层
- [ ] 接入 LLM 编排
- [ ] 接入世界草稿编译
- [ ] 接入服务端 result preview 编译
- [ ] 接入角色 / 地点 / 场景 NPC 生成
- [ ] 接入封面图生成
- [ ] 接入场景图生成
- [ ] 接入 OSS 对象写入与绑定
- [ ] 接入 SSE 事件分发
## 5. 兼容接口
## 5. 当前正式接口与历史兼容台账
- [ ] 兼容 `/api/runtime/custom-world/sessions`
- [ ] 兼容 `/api/runtime/custom-world/sessions/:sessionId`
- [ ] 兼容 `/api/runtime/custom-world/sessions/:sessionId/answers`
- [ ] 兼容 `/api/runtime/custom-world/sessions/:sessionId/generate/stream`
- [ ] 兼容 `/api/runtime/custom-world-library`
- [ ] 兼容 `/api/runtime/custom-world-library/:profileId`
- [ ] 兼容 `/api/runtime/custom-world-library/:profileId/publish`
- [ ] 兼容 `/api/runtime/custom-world-library/:profileId/unpublish`
- [ ] 兼容 `/api/runtime/custom-world-gallery`
- [ ] 兼容 `/api/runtime/custom-world-gallery/:ownerUserId/:profileId`
### 5.1 当前正式接口
- [x] 兼容 `/api/runtime/custom-world-library`Stage 5 首批 Axum facade
- [x] 兼容 `/api/runtime/custom-world-library/:profileId`owner-only detail 查询已补齐)
- [x] 兼容 `/api/runtime/custom-world-library/:profileId/publish`Stage 5 首批 Axum facade
- [x] 兼容 `/api/runtime/custom-world-library/:profileId/unpublish`Stage 5 首批 Axum facade
- [x] 兼容 `/api/runtime/custom-world-gallery`Stage 5 首批 Axum facade
- [x] 兼容 `/api/runtime/custom-world-gallery/:ownerUserId/:profileId`Stage 5 首批 Axum facade
- [ ] 兼容 `/api/runtime/custom-world/works`
- [ ] 兼容 `/api/runtime/custom-world/agent/sessions`
- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`
- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`
- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`
- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`
- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`
- [x] 兼容 `/api/runtime/custom-world/agent/sessions`Stage 6 首批 Axum facade
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`Stage 6 首批 Axum facade
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`Stage 7 deterministic message submit
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`Stage 8 SSE facade
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`Stage 5 仅支持 `publish_world` 显式 draft payload
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`Stage 7 deterministic operation query
- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId`
- [ ] 兼容 `/api/custom-world/entity`
- [ ] 兼容 `/api/runtime/custom-world/entity`
@@ -72,9 +91,17 @@
- [ ] 兼容 `/api/custom-world/cover-image`
- [ ] 兼容 `/api/custom-world/cover-upload`
### 5.2 历史兼容台账(非当前主链)
- [ ] 评估 `/api/runtime/custom-world/sessions` 是否仍需保留历史兼容映射
- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId` 是否仍需保留历史兼容映射
- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId/answers` 是否仍需保留历史兼容映射
- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId/generate/stream` 是否仍需保留历史兼容映射
## 6. 阶段验收
- [ ] 传统 custom world 主链可用
- [ ] custom world library / gallery 主链可用
- [ ] custom world agent 主链可用
- [ ] RPG 创作主链可用:`agent session -> result preview -> published profile`
- [ ] works / library / gallery / publish / enter-world 主链可用
- [ ] RPG 创作 Agent 主链可用
- [ ] agent 会话、消息、卡片、操作不再依赖单大 JSON 会话体
- [ ]`custom-world/sessions` 问答流不再作为当前主链扩展目标

View File

@@ -29,15 +29,18 @@
- [ ] 每个阶段完成后同步更新设计文档
- [ ] 每个阶段完成后补一份落地记录
- [ ] 完成接口迁移后更新新的模块与 API 索引文档
- [ ] `M4` 结构变更同步对齐 `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
- [ ] `M5` 结构变更同步对齐 `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
## 2. 第一优先级建议执行顺序
1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。
2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。
3. 再做 `M3`优先跑通快照、设置、profile。
4. 再做 `M4`,把 story action 主循环真正迁走
5. 然后`M5`迁 custom world 与 agent
6. 后做 `M6 + M7`,收口 assets、editor、部署与切流
4. 进入 `M4``M5` 前,先用两份 `2026-04-21` 执行方案冻结当前仓库里的 RPG 运行时链与创作链结构口径
5. `M4`把 RPG runtime story 主循环真正迁走
6. 后做 `M5`,迁 RPG 创作主链、works/library/gallery 与 agent
7. 最后做 `M6 + M7`,收口 assets、editor、部署与切流。
## 3. 最终验收清单
@@ -47,5 +50,7 @@
- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界
- [ ] SpacetimeDB 已成为唯一运行时状态真相源
- [ ] 阿里云 OSS 已成为唯一资产对象仓
- [ ] `M4` 已与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory / rpgProfile` 主链口径一致
- [ ] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致
- [ ] 前端主流程在不大改 UI 的前提下可跑通
- [ ] 能完成灰度切流,并保留可回退能力

View File

@@ -23,6 +23,12 @@
- [M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)`M0` 仓库边界决议文档,用于持续冻结 `server-rs/` 落位、迁移期双栈共存、Axum 边界与副作用收口原则。
- [M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)`M0 ~ M7` 阶段验收矩阵,用于固定每阶段的入口条件、核心交付、退出条件与跨阶段回归焦点。
## 当前 M4 / M5 结构基线
- `M4` 当前涉及的前后端脚本结构、命名根、route/service/compiler/repository 落位,统一参照 [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。
- `M5` 当前涉及的创作入口、Agent session、result preview、works/library/gallery、publish 与 enter-world 主链,统一参照 [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。
-`custom-world/sessions` 传统问答链已经退出当前仓库正式主链;后续若在 `M5` 中提及,只按历史兼容台账处理,不再作为当前功能扩展目标。
## 维护规则
1. 总纲与拆分文件都以本目录为唯一维护位置。

View File

@@ -1,6 +1,6 @@
# 当前 Agent 创作流程优化执行规划(大白话版)
更新时间:`2026-04-20`
更新时间:`2026-04-21`
## 先把话说死
@@ -16,6 +16,22 @@
这份规划就是基于 [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](../audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md) 里已经确认的问题,重新收束出来的一版执行方案。
## 0.1 当前正式执行基线
`2026-04-21` 起,当前仓库里和这条创作主链直接对应的文件级拆分、阶段验收、工作包进度,统一以以下文档为准:
1. [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
2. `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_*_PROGRESS_2026-04-21.md`
3. [../technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](../technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md)
本文件继续承担的职责只剩 3 件事:
1. 解释为什么当前版本只收口现有 Agent 创作动线,不再扩一套新流程
2. 冻结这轮明确不做的能力边界
3. 给出高层执行顺序与判断标准
凡是涉及目录落位、命名规范、前后端真相源边界、阶段完成度和工作包状态,统一以后面的技术执行文档为准。
---
## 1. 现在最大的问题,用大白话讲是什么
@@ -328,17 +344,17 @@
**让用户走起来更顺,让系统找回内容更稳定。**
## 第三阶段:再处理旧 pipeline 的降级和冻结
## 第三阶段:清理旧链残留表述与剩余兼容误导
再做:
1.`custom-world/sessions` 链降级
2. 结果页直改 profile 的旧能力收紧
3. 兼容链保留边界写清楚
1. 清理`custom-world/sessions` 已删除链路在文档、索引、任务清单里的残留表述
2. 结果页直改 profile 的旧能力继续收紧
3. 把仍保留的兼容 façade / 兼容字段边界写清楚
这一阶段的目标是:
**减少系统自己和自己打架**
**减少旧链文档误导和兼容边界漂移**
## 第四阶段:最后做文档清理
@@ -382,8 +398,8 @@
应该看到:
1. 重复 pipeline 明显减少
2. 旧链不再继续吞主流程职责
1. 已删除旧链不再继续出现在当前执行清单里
2. 剩余兼容层的保留边界更清楚
3. 后续开发不会再不知道该往哪条链上接
## 阶段四做完

View File

@@ -15,6 +15,15 @@
**现在的优先级不是“继续扩玩法宽度”,而是“先把底层规则、主流程边界和工程可维护性补齐,再扩玩法深度”。**
补充更新(`2026-04-21`
当前与“主流程边界补齐”直接对应的执行基线,已经从泛化的 `GameShell / useStoryGeneration / customWorld` 热点讨论,收口成两条正式技术方案:
1. [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):负责创作入口 -> Agent session -> result preview -> published profile 主链。
2. [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):负责平台入口 -> 继续游戏/开始游戏 -> RPG runtime -> runtime story 主链。
因此本文里的 `P0-2`,当前应按这两条主线落地,而不是继续围绕旧 `GameShell` / `PreGameSelectionFlow` / `runtimeRoutes.ts` 命名做泛化式重构。
---
## 优先级清单
@@ -57,9 +66,9 @@
### 本阶段要做什么
- `useStoryGeneration` 收敛为 orchestration 层,不再直接吞下 NPC、任务、背包、锻造、聊天、奖励等全部细节
- `useCombatFlow` 进一步拆成“战斗结算”与“播放/演出同步”
- `GameShell` 回到流程壳层职责,把 selection flow、overlay、scene transition 继续下沉
- 按 [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) 收口创作链,统一 `Agent session -> result preview -> published profile`
- 按 [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) 收口 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile`
- `GameShell / PreGameSelectionFlow / runtimeRoutes.ts / storyActionService.ts` 只作为历史热区名或兼容 façade不再作为当前新任务默认落点
### 做到什么算完成
@@ -231,7 +240,7 @@
- 继续横向扩 NPC 交互种类,但不补统一规则底座
- 继续堆宝藏、掉落、锻造分支,但不先做统一物品导演层
- 继续增加任务模板数量,但不升级任务 contract
- 继续往 `useStoryGeneration` / `useCombatFlow` / `GameShell` 里直接塞新逻辑
- 继续往 `useStoryGeneration` / `useCombatFlow` / `GameShell` / `PreGameSelectionFlow` / `runtimeRoutes.ts` / `storyActionService.ts` 里直接塞新逻辑
原因很简单:
@@ -244,7 +253,8 @@
### 第一阶段:先稳住工程与主流程
1. 绿色基线与门禁收紧
2. 运行时主链拆分
2. 创作链按 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口
3. RPG 进入游戏与运行时链按 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口
### 第二阶段:先补统一语义底座

View File

@@ -4,7 +4,9 @@
- [ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md](./ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md):面向无用历史代码、隐形多数据链路和半成品实现的一轮工程大清洗执行计划,强调先建台账、再删重收口、最后恢复主工程可读性。
- [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。
- [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md)在不新增前端创作流程的前提下,围绕当前 Agent 创作动线做收口、删重、补通和文档收束的大白话执行规划
- [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)当前创作入口、Agent session、结果页自动保存、作品库与进入世界主链的正式文件级重构基线涉及目录落位、命名规范、阶段验收与工作包拆分时优先看这一份
- [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)当前平台入口、继续游戏、角色选择、RPG runtime 与 runtime story 主链的正式文件级重构基线涉及入口壳层、session、runtime、story、route/service/repository 拆分时优先看这一份。
- [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):创作链高层目标、冻结边界与执行顺序说明;文件级拆分与阶段验收以创作链重构执行方案为准。
- [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。
- [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。
- [BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md](./BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md):北京市方向 13 / 21 / 24 的统一判断、共用材料框架和准备顺序。
@@ -15,4 +17,5 @@
## 使用建议
- 需要排期、拆阶段、判断先修基线还是先加功能时,先看这份。
- 当前如果要推进创作链或 RPG 运行时主链重构,先看上面的两份 `2026-04-21` 执行方案,再回来看高层优先级和冻结边界。
- 这份文档大量引用了经验文档、工程审查和 PRD适合作为跨文档导航页使用。

View File

@@ -0,0 +1,97 @@
# `api-server` 接入 `platform-llm` 最小代理设计2026-04-21
## 1. 目标
`platform-llm` 已落成真实 Rust crate 后,`api-server` 需要尽快拥有一条可正式消费的平台接线面,避免平台层只停留在“可编译但未接入”状态。
本次目标只做最小闭环:
1.`api-server` 配置层补齐 LLM 文本网关环境变量
2.`AppState` 注入 `platform-llm::LlmClient`
3. 提供 `/api/llm/chat/completions` 非流式兼容代理
4. 保持与旧 Node 路由的鉴权位置和基本请求形态一致
## 2. 本次范围
### 2.1 本次实现
1. `AppConfig` 新增 LLM provider / base url / api key / model / timeout / retry 配置
2. `AppState` 初始化 `LlmClient`
3. 新增 `shared-contracts::llm`
4. 新增 `api-server/src/llm.rs`
5. 路由挂载到 `/api/llm/chat/completions`
### 2.2 本次不实现
1. 不实现 SSE 流式透传
2. 不实现通用原样 body 转发
3. 不实现媒体模型路由
4. 不把 `module-ai` 编排接进来
## 3. 兼容口径
保持与旧 Node `POST /api/llm/chat/completions` 一致的基本语义:
1. 需要登录态
2. 接收 `model? + stream + messages[]`
3. 当前 `stream=true` 明确返回 `501`,避免伪装支持
4. 非流式返回统一后的文本结果,而不是原样上游 JSON
## 4. 返回结构
Rust 首版返回:
1. `id`
2. `model`
3. `content`
4. `finishReason`
原因:
1. 当前 Rust 平台层已经把上游 `choices[0].message.content` 归一完成
2. `api-server` 首版先保持稳定、可消费的文本结果接口
3. 真正需要 OpenAI 完全兼容响应体时,再单独补“原样代理模式”
## 5. 验收
1. `api-server` 能在配置合法时成功构建 `AppState`
2. `/api/llm/chat/completions` 能通过测试打到 mock 上游
3. `stream=true` 返回明确错误
4. crate 级 `check/test` 通过
## 6. 环境变量与默认值
`api-server` 首版按以下优先级解析 LLM 配置,保证兼容仓库现有 `.env` 口径:
1. provider`GENARRATIVE_LLM_PROVIDER` -> `LLM_PROVIDER`
2. base url`GENARRATIVE_LLM_BASE_URL` -> `LLM_BASE_URL`
3. api key`GENARRATIVE_LLM_API_KEY` -> `LLM_API_KEY` -> `ARK_API_KEY`
4. model`GENARRATIVE_LLM_MODEL` -> `LLM_MODEL` -> `VITE_LLM_MODEL`
5. timeout`GENARRATIVE_LLM_REQUEST_TIMEOUT_MS` -> `LLM_REQUEST_TIMEOUT_MS`
6. max retries`GENARRATIVE_LLM_MAX_RETRIES` -> `LLM_MAX_RETRIES`
7. retry backoff`GENARRATIVE_LLM_RETRY_BACKOFF_MS` -> `LLM_RETRY_BACKOFF_MS`
默认值统一对齐 `platform-llm`
1. provider`ark`
2. base url`https://ark.cn-beijing.volces.com/api/v3`
3. model`doubao-1-5-pro-32k-character-250715`
4. request timeout`30000`
5. max retries`1`
6. retry backoff`500`
补充约束:
1. 如果 `api key` 未配置,`api-server` 允许继续启动,但 `/api/llm/chat/completions` 返回 `503`
2. 如果 provider 字符串非法,回退到默认 `ark`,避免因为环境变量拼写问题阻断开发态服务
## 7. 错误映射
`platform-llm` 到 HTTP 的错误映射固定如下:
1. `InvalidRequest` -> `400 BAD_REQUEST`
2. `InvalidConfig` -> `503 SERVICE_UNAVAILABLE`
3. `Timeout` / `Connectivity` / `Transport` / `Deserialize` / `EmptyResponse` / `StreamUnavailable` -> `502 BAD_GATEWAY`
4. `Upstream(status=429)` -> `429 TOO_MANY_REQUESTS`
5. 其他 `Upstream` -> `502 BAD_GATEWAY`
6. `stream=true` 首版直接返回 `501 NOT_IMPLEMENTED`

View File

@@ -0,0 +1,70 @@
# 编码检查与临时工作区噪音收口方案2026-04-22
日期:`2026-04-22`
## 1. 背景
当前仓库根目录存在多份本地临时工作区与 Cargo cache 目录,例如:
1. `.codex-cargo-home-stage4*`
2. `server-rs-codex-stage4-*`
3. `server-rs/target-*`
这些目录属于本地验证产物,不属于主工程源码、文档或正式资源,但 `npm run check:encoding` 仍会通过 `git ls-files --cached --others --exclude-standard` 把其中大量未跟踪文本文件纳入扫描,导致:
1. 编码检查耗时被临时目录放大
2. 检查结果容易被本地 cache / verify copy 噪音污染
3. 仓库级 UTF-8 检查无法稳定反映真实工程文件状态
同时,当前脚本没有把 `.rs` 纳入文本扩展名集合这与仓库约束“Rust / 工程代码中的中文注释也必须保证 UTF-8 正常”不一致。
## 2. 本次冻结规则
本轮对编码检查口径做以下冻结:
1. `scripts/check-encoding.mjs` 只检查主工程真实文本文件,不扫描临时 Cargo cache、临时 verify copy 和 `server-rs/target-*` 目录。
2. `.rs` 必须纳入 UTF-8 编码检查,避免 Rust 文件中的中文注释或中文错误文案被写坏后漏检。
3. `.encoding-check-ignore` 继续只承载少量已知历史坏文本白名单,不用于掩盖大目录级临时产物。
4. 对临时目录的处理优先通过 `.gitignore` 与脚本排除规则完成,不要求物理删除本地 cache。
## 3. 具体落地点
### 3.1 `.gitignore`
新增忽略规则:
1. `/.codex-cargo-home-*/`
2. `/server-rs-codex-*/`
3. `/server-rs/target-*/`
目的:
1.`git ls-files --others --exclude-standard` 不再把这些临时目录当作待检查仓库文件。
2. 与既有噪音清理基线保持一致,继续把本地检查产物留在仓库视野之外。
### 3.2 `scripts/check-encoding.mjs`
脚本同步收紧两点:
1. 增加对上述临时前缀目录的显式排除,避免脚本在显式传参或忽略规则未生效时仍误扫临时目录。
2.`.rs` 加入文本扩展名集合,确保 Rust 源文件进入 UTF-8 校验面。
## 4. 完成定义
当以下条件满足时,本次修复视为完成:
1. `npm run check:encoding` 不再被临时 Cargo / verify 目录拖慢或污染结果。
2. 真实工程中的 Rust 文件会参与 UTF-8 检查。
3. 不需要清理用户本地 cache 目录,也不会对现有并行工作区造成破坏。
## 5. 不在本轮范围
本轮不处理:
1. `.encoding-check-ignore` 中历史坏文本的逐条修复
2. 各类本地 cache / verify 目录的物理删除
3. 与 UTF-8 检查无关的 lint / typecheck / cargo 输出目录清理策略
## 6. 相关文档
1. [./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md)

View File

@@ -0,0 +1,299 @@
# M3browse history Axum + SpacetimeDB 落地设计
日期:`2026-04-21`
关联任务:
- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md)
- [./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
关联现状:
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
- `packages/shared/src/contracts/runtime.ts`
## 1. 文档目的
`02_M3_RUNTIME_PROFILE.md` 已经把 `user_browse_history``browse history` facade 列入 M3但还没有冻结到可直接编码的字段、去重规则、路由兼容方式和错误语义。
本文只解决 `browse history` 这一个最小闭环切片:
1. `user_browse_history` 真相表
2. `GET /api/runtime/profile/browse-history`
3. `POST /api/runtime/profile/browse-history`
4. `DELETE /api/runtime/profile/browse-history`
5. `/api/profile/browse-history` 兼容路径
本文不新建 checklist不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足本轮编码所需的冻结口径。
## 2. 旧实现冻结口径
当前 Node 侧行为来自:
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
冻结行为如下。
### 2.1 路由
主路径与兼容路径都必须保留:
1. `GET /api/runtime/profile/browse-history`
2. `POST /api/runtime/profile/browse-history`
3. `DELETE /api/runtime/profile/browse-history`
4. `GET /api/profile/browse-history`
5. `POST /api/profile/browse-history`
6. `DELETE /api/profile/browse-history`
所有路径都要求 Bearer JWT。
### 2.2 数据字段
单条浏览记录字段与 `packages/shared/src/contracts/runtime.ts` 保持一致:
1. `ownerUserId`
2. `profileId`
3. `worldName`
4. `subtitle`
5. `summaryText`
6. `coverImageSrc`
7. `themeMode`
8. `authorDisplayName`
9. `visitedAt`
### 2.3 POST 请求体兼容
`POST` 同时支持两种形态:
1. 单条对象
2. `{ entries: [...] }` 批量对象
批量最多 `100` 条。
### 2.4 归一化规则
旧 Node 仓储不是严格校验,而是宽松归一化:
1. `ownerUserId``profileId``worldName` 去首尾空白后必须非空,否则该条忽略。
2. `subtitle``summaryText``coverImageSrc` 去首尾空白,空串按空值处理。
3. `themeMode` 不做严格枚举校验,未知值统一回落到 `mythic`
4. `authorDisplayName` 空值回落到 `玩家`
5. `visitedAt` 缺失时回落到当前时间。
### 2.5 去重与排序规则
旧 Node 仓储的关键行为必须保持:
1. 去重键:`ownerUserId + profileId`
2. 同一批写入时,先按 `visitedAt DESC` 排序,再去重,只保留最新一条
3. 表内最终查询结果按 `visitedAt DESC` 返回
### 2.6 清空行为
`DELETE` 清空当前用户的全部浏览历史,并返回:
```json
{
"entries": []
}
```
## 3. Rust 落位决议
### 3.1 crate 分工
本切片固定按以下边界落位:
1. `crates/module-runtime`
- 定义 `browse history` DTO、字段校验、去重排序与宽松归一化规则。
2. `crates/spacetime-module`
- 定义 `user_browse_history` 表。
- 提供 `list / upsert / clear` 三个 procedure。
3. `crates/spacetime-client`
- 提供 `list_platform_browse_history`
- 提供 `upsert_platform_browse_history_entries`
- 提供 `clear_platform_browse_history`
4. `crates/shared-contracts`
- 冻结 Axum facade 的请求/响应 DTO。
5. `crates/api-server`
- 提供双路径兼容 facade。
- 保持 envelope / 错误格式与当前 `runtime settings` 一致。
### 3.2 身份边界
本轮仍沿用 Axum Bearer JWT 作为唯一鉴权边界:
1. `require_bearer_auth` 校验 token。
2. 从 claims 中提取 `user_id`
3. `user_id` 作为 procedure 入参传入 SpacetimeDB。
当前阶段不把浏览历史直接暴露给前端直连订阅。
## 4. SpacetimeDB 表设计
### 4.1 表名
`user_browse_history`
### 4.2 字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `browse_history_id` | `String` | 主键,格式为 `user_id:owner_user_id:profile_id` |
| `user_id` | `String` | 当前登录用户 |
| `owner_user_id` | `String` | 被浏览世界所属用户 |
| `profile_id` | `String` | 被浏览世界 profile |
| `world_name` | `String` | 世界名 |
| `subtitle` | `String` | 副标题 |
| `summary_text` | `String` | 摘要 |
| `cover_image_src` | `Option<String>` | 封面图 |
| `theme_mode` | `RuntimeBrowseHistoryThemeMode` | 主题枚举 |
| `author_display_name` | `String` | 作者显示名 |
| `visited_at` | `Timestamp` | 最近访问时间 |
| `created_at` | `Timestamp` | 首次写入时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
### 4.3 索引
至少保留以下访问路径:
1. 主键 `browse_history_id`
2. `(user_id, visited_at)` 用于当前用户按时间倒序列出
3. `(user_id, owner_user_id, profile_id)` 用于幂等 upsert
### 4.4 设计决议
1. 不额外引入自增 ID直接把幂等键收口成主键。
2. `visited_at` 单独持久化成 `Timestamp`,避免把时间排序退回字符串比较。
3. `theme_mode` 在表内固定为枚举,但 Axum 输入仍宽松接受字符串。
## 5. Procedure 设计
### 5.1 procedure 列表
1. `list_platform_browse_history`
2. `upsert_platform_browse_history_and_return`
3. `clear_platform_browse_history_and_return`
统一返回:
```text
RuntimeBrowseHistoryProcedureResult {
ok
entries
error_message
}
```
### 5.2 行为约束
`list_platform_browse_history`
1. 校验 `user_id`
2. 读取当前用户所有记录
3.`visited_at DESC` 返回
`upsert_platform_browse_history_and_return`
1. 校验 `user_id`
2. 接受单批最多 `100`
3. 先按旧 Node 规则宽松归一化
4. 先按 `visitedAt DESC` 排序,再按 `ownerUserId + profileId` 去重
5.`browse_history_id` 幂等 upsert
6. 返回当前用户完整倒序列表
`clear_platform_browse_history_and_return`
1. 校验 `user_id`
2. 删除当前用户全部记录
3. 返回空列表
## 6. Axum facade 设计
### 6.1 双路径兼容
两组路径必须共用同一组 handler
1. `/api/runtime/profile/browse-history`
2. `/api/profile/browse-history`
只允许路由名不同,不允许行为分叉。
### 6.2 GET
行为:
1. Bearer JWT 校验
2. 读取 claims 中的 `user_id`
3.`spacetime_client.list_platform_browse_history`
4. 返回 `PlatformBrowseHistoryResponse`
### 6.3 POST
行为:
1. Bearer JWT 校验
2. 通过 `serde(untagged)` 同时接单条和批量 shape
3. 不对 `themeMode` 做严格 400 拒绝
4.`ownerUserId``profileId``worldName` 的缺失或空串按旧 Node 路由规则直接返回 `400`
5. 写入成功后返回最新完整列表
### 6.4 DELETE
行为:
1. Bearer JWT 校验
2. 清空当前用户全部记录
3. 返回 `entries: []`
### 6.5 错误映射
1. JSON 解析失败:`400 BAD_REQUEST`
2. DTO 构建失败:`400 BAD_REQUEST`
3. SpacetimeDB 调用失败:`502 BAD_GATEWAY`
4. JWT 缺失或失效:沿用当前 `401 UNAUTHORIZED`
错误 `details.provider` 固定为:
1. `browse-history`
2. `spacetimedb`
## 7. 测试策略
### 7.1 必跑测试
1. `module-runtime`
- 宽松 theme 归一化
- `visitedAt` 默认值
- 去重与倒序逻辑
2. `api-server`
- 未登录返回 `401`
- 兼容路径与主路径一致
- `POST` 同时支持单条和批量
- envelope 打开时错误结构稳定
### 7.2 可选联调测试
保留 `#[ignore]` 的本地 SpacetimeDB 集成测试:
1. `POST -> GET`
2. `DELETE -> GET`
## 8. 本文完成定义
当以下条件成立时,本设计视为完成:
1. `user_browse_history` 表字段、主键和排序规则已冻结。
2. 双路径 facade、请求 shape 和错误契约已冻结。
3. 后续编码不再需要猜测:
- `themeMode` 是否严格校验
- `POST` 是否支持单条/批量双 shape
- 去重时机与排序依据
## 9. 2026-04-22 实际落地进度
1. `module-runtime` 已切换为“API 入口严格校验 + 领域层静默过滤”的旧 Node 对齐模式。
2. `api-server` 已补齐双路径 browse history handler并补 `401``400`、批量 shape、兼容路径一致性测试。
3. 剩余阻塞主要在工作树内其他并行任务带来的 Rust 编译占用与跨模块联调,不属于 browse history 方案本身。

View File

@@ -0,0 +1,410 @@
# M3profile dashboard / wallet ledger / play stats Axum + SpacetimeDB 落地设计
日期:`2026-04-22`
关联任务:
- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md)
- [../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)
关联现状:
- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md)
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
## 1. 文档目的
当前 M3 checklist 已经列出:
1. `profile_dashboard_state`
2. `profile_wallet_ledger`
3. `profile_played_world`
4. `/api/runtime/profile/dashboard`
5. `/api/runtime/profile/wallet-ledger`
6. `/api/runtime/profile/play-stats`
但仓库里还没有把这一组 profile 只读 facade 细化到可以直接编码的程度。本文件补足:
1. 旧 Node 行为冻结
2. SpacetimeDB projection 表字段
3. procedure 返回 contract
4. Axum 双路径 facade 与错误映射
5. 本轮只做读链、不提前承诺 snapshot 写入的边界
本文件不新增新的 M3 checklist只服务于现有 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md) 的后续落地。
## 2. 本轮范围
本轮只覆盖以下 6 条兼容路由:
1. `GET /api/runtime/profile/dashboard`
2. `GET /api/profile/dashboard`
3. `GET /api/runtime/profile/wallet-ledger`
4. `GET /api/profile/wallet-ledger`
5. `GET /api/runtime/profile/play-stats`
6. `GET /api/profile/play-stats`
本轮不做:
1. `runtime_snapshot`
2. `save archive`
3. snapshot -> profile projection 自动刷新
4. profile projection 的写 procedure
这样拆分的原因是:
1. 这组三个 profile 接口本质上都是 projection 读接口。
2. 旧 Node 读语义已经稳定,且空数据时都有明确默认值。
3. 先把读 contract 和表结构固定住,后续 `runtime_snapshot / save archive` 接上 projection writer 时不会再改 facade contract。
## 3. 旧 Node 行为冻结
Node 侧入口位于:
1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
2. `server-node/src/repositories/runtimeRepository.ts`
冻结口径如下。
### 3.1 dashboard
路由:
1. `GET /api/runtime/profile/dashboard`
2. `GET /api/profile/dashboard`
返回:
```json
{
"walletBalance": 0,
"totalPlayTimeMs": 0,
"playedWorldCount": 0,
"updatedAt": null
}
```
语义:
1. `walletBalance``profile_dashboard_state.wallet_balance` 读取。
2. `totalPlayTimeMs``profile_dashboard_state.total_play_time_ms` 读取。
3. `playedWorldCount` 通过 `profile_played_world` 的当前用户记录数计算。
4. `updatedAt` 为空时返回 `null`
5. 当用户尚无任何 projection 时,仍返回默认零值,不返回 `404`
### 3.2 wallet ledger
路由:
1. `GET /api/runtime/profile/wallet-ledger`
2. `GET /api/profile/wallet-ledger`
返回:
```json
{
"entries": [
{
"id": "ledger_001",
"amountDelta": 20,
"balanceAfter": 120,
"sourceType": "snapshot_sync",
"createdAt": "2026-04-22T10:00:00Z"
}
]
}
```
语义:
1. 只返回当前用户的流水。
2.`createdAt DESC` 排序。
3. 最多返回最近 `50` 条。
4. 当前旧 Node 仅冻结 `sourceType = "snapshot_sync"` 一种来源。
5. 没有流水时返回 `{ "entries": [] }`
### 3.3 play stats
路由:
1. `GET /api/runtime/profile/play-stats`
2. `GET /api/profile/play-stats`
返回:
```json
{
"totalPlayTimeMs": 0,
"playedWorks": [],
"updatedAt": null
}
```
其中 `playedWorks` 单项字段冻结为:
```json
{
"worldKey": "builtin:WUXIA",
"ownerUserId": null,
"profileId": null,
"worldType": "WUXIA",
"worldTitle": "武侠世界",
"worldSubtitle": "",
"firstPlayedAt": "2026-04-20T10:00:00Z",
"lastPlayedAt": "2026-04-22T10:00:00Z",
"lastObservedPlayTimeMs": 120000
}
```
语义:
1. `totalPlayTimeMs` 与 dashboard 共用 `profile_dashboard_state.total_play_time_ms`
2. `playedWorks` 来自 `profile_played_world`
3.`lastPlayedAt DESC` 排序。
4. `updatedAt` 与 dashboard 共用 `profile_dashboard_state.updated_at`
5. 没有 projection 时返回空列表和零值,不返回 `404`
## 4. 本轮边界决议
### 4.1 先做 projection 读链
本轮 profile 三接口只做:
1. projection 表 schema
2. procedure 读接口
3. Axum facade
4. shared contract
不做 snapshot 写链,原因:
1. `runtime_snapshot` 仍未冻结最终表结构。
2. save archive 还未把“领域表真相 + 聚合快照”方案完全落到文档。
3. 若现在提前补写逻辑,后续大概率要因为 snapshot 方案调整而返工。
### 4.2 默认值必须前置兼容
虽然 projection 还没有 writer但 facade 仍要先兼容旧 Node 默认值:
1. dashboard 返回零值
2. wallet ledger 返回空数组
3. play stats 返回零值 + 空数组
这样前端不会因为表暂时为空而收到 `404``null` 结构漂移。
## 5. SpacetimeDB 表设计
### 5.1 `profile_dashboard_state`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键,绑定平台用户 |
| `wallet_balance` | `u64` | 当前钱包余额 |
| `total_play_time_ms` | `u64` | 累积游玩时长 |
| `created_at` | `Timestamp` | projection 首次建立时间 |
| `updated_at` | `Timestamp` | projection 最近刷新时间 |
设计决议:
1. 一名用户只保留一行 dashboard 聚合状态。
2. `playedWorldCount` 不单独持久化,读取时直接统计 `profile_played_world`
3. 钱包余额与总游玩时长都固定为非负整数,不保留浮点。
### 5.2 `profile_wallet_ledger`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `wallet_ledger_id` | `String` | 主键,流水 ID |
| `user_id` | `String` | 用户 ID |
| `amount_delta` | `i64` | 本次余额增减 |
| `balance_after` | `u64` | 变动后的余额 |
| `source_type` | `RuntimeProfileWalletLedgerSourceType` | 当前只冻结 `snapshot_sync` |
| `created_at` | `Timestamp` | 流水发生时间 |
设计决议:
1. 钱包流水是 append-only不提供 update。
2. 本轮只冻结 `snapshot_sync` 一种来源,避免前后端散落裸字符串。
3. 读取排序由 procedure 保证,不依赖表天然顺序。
### 5.3 `profile_played_world`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `played_world_id` | `String` | 主键,固定为 `user_id:world_key` |
| `user_id` | `String` | 用户 ID |
| `world_key` | `String` | 世界唯一键,兼容内置世界与自定义世界 |
| `owner_user_id` | `Option<String>` | 自定义世界作者用户 ID |
| `profile_id` | `Option<String>` | 自定义世界 profile ID |
| `world_type` | `Option<String>` | 内置世界类型,例如 `WUXIA` |
| `world_title` | `String` | 世界标题 |
| `world_subtitle` | `String` | 世界副标题 |
| `first_played_at` | `Timestamp` | 首次游玩时间 |
| `last_played_at` | `Timestamp` | 最近游玩时间 |
| `last_observed_play_time_ms` | `u64` | 最近一次观测到的该世界累计游玩时长 |
设计决议:
1. 每个用户每个 `world_key` 只保留一行。
2. `played_world_id = user_id:world_key`,避免额外自增 ID。
3. `lastObservedPlayTimeMs` 保留在表中,为后续 snapshot sync 计算增量时长服务。
## 6. module-runtime DTO 设计
本轮在 `module-runtime` 新增以下类型族:
1. `RuntimeProfileDashboardSnapshot`
2. `RuntimeProfileDashboardProcedureResult`
3. `RuntimeProfileDashboardGetInput`
4. `RuntimeProfileWalletLedgerEntrySnapshot`
5. `RuntimeProfileWalletLedgerProcedureResult`
6. `RuntimeProfileWalletLedgerListInput`
7. `RuntimeProfilePlayedWorldSnapshot`
8. `RuntimeProfilePlayStatsSnapshot`
9. `RuntimeProfilePlayStatsProcedureResult`
10. `RuntimeProfilePlayStatsGetInput`
同时新增 record 层 DTO`spacetime-client` 返回给 Axum
1. `RuntimeProfileDashboardRecord`
2. `RuntimeProfileWalletLedgerEntryRecord`
3. `RuntimeProfilePlayedWorldRecord`
4. `RuntimeProfilePlayStatsRecord`
字段规则:
1. 所有时间在 snapshot 内部统一保存为 `*_micros`
2. record 层统一格式化成 RFC3339 字符串。
3. `updated_at_micros` 使用 `Option<i64>`,避免继续沿用 `0` 这种弱语义占位值。
## 7. Procedure 设计
本轮只新增 3 个只读 procedure
1. `get_profile_dashboard`
2. `list_profile_wallet_ledger`
3. `get_profile_play_stats`
行为要求:
### 7.1 `get_profile_dashboard`
1. 校验 `user_id` 非空。
2. 读取 `profile_dashboard_state`
3. 统计当前用户 `profile_played_world` 数量。
4. 如果 dashboard 状态不存在,返回零值快照。
### 7.2 `list_profile_wallet_ledger`
1. 校验 `user_id` 非空。
2. 读取当前用户全部流水。
3.`created_at DESC` 排序。
4. 截断到最近 `50` 条。
### 7.3 `get_profile_play_stats`
1. 校验 `user_id` 非空。
2.`profile_dashboard_state` 读取 `total_play_time_ms``updated_at`
3. 读取当前用户 `profile_played_world`
4.`last_played_at DESC` 排序。
5. 如果 dashboard 状态不存在,仍返回零值与空数组。
## 8. spacetime-client 设计
新增 3 个调用封装:
1. `get_profile_dashboard(user_id)`
2. `list_profile_wallet_ledger(user_id)`
3. `get_profile_play_stats(user_id)`
错误映射保持当前链路习惯:
1. 本地 DTO 构建失败 -> `SpacetimeClientError::Runtime`
2. procedure 执行失败 -> `SpacetimeClientError::Procedure`
不在 client 层做默认值兜底;默认值由 `spacetime-module` procedure 保证,避免多个调用方重复实现。
## 9. Axum facade 设计
### 9.1 路由
本轮 Rust facade 固定暴露 6 条路由:
1. `/api/runtime/profile/dashboard`
2. `/api/profile/dashboard`
3. `/api/runtime/profile/wallet-ledger`
4. `/api/profile/wallet-ledger`
5. `/api/runtime/profile/play-stats`
6. `/api/profile/play-stats`
全部要求 Bearer JWT。
### 9.2 响应结构
1. dashboard 直接返回 `ProfileDashboardSummaryResponse`
2. wallet-ledger 返回 `ProfileWalletLedgerResponse`
3. play-stats 返回 `ProfilePlayStatsResponse`
字段名保持 camelCase与旧 Node contract 对齐。
### 9.3 错误映射
1. JWT 缺失或失效:沿用现有 `401`
2. 本地 DTO 准备失败:`400`
3. SpacetimeDB 调用失败:`502`
`details.provider` 规则:
1. 本地 DTO 错误使用当前接口自己的 provider
2. 下游 SpacetimeDB 错误统一使用 `spacetimedb`
## 10. 本轮暂不处理的事项
以下事项在本设计中显式延后:
1. `runtime_snapshot` 写入时如何刷新三张 profile projection 表
2. `profile_wallet_ledger` 的更多 `source_type`
3. `profile_played_world` 的世界标题修复、补字段或回填历史迁移
4. `save archive``play stats` 之间的联动
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。
## 11. 测试策略
### 11.1 必跑
1. `module-runtime`
- `user_id` 非空校验
- record 层时间格式化
- wallet ledger source type 字符串格式化
2. `shared-contracts`
- dashboard / wallet-ledger / play-stats 的 camelCase 序列化
3. `api-server`
- 未登录返回 `401`
- 6 条 facade 都已挂接
- SpacetimeDB 未发布时返回 `502`
- 主路径与兼容路径错误 envelope 一致
### 11.2 本轮不强制
1. 不强制本地 SpacetimeDB 联调测试
2. 不强制 projection 写入集成测试
原因是这两类测试都依赖后续 `runtime_snapshot` 写链补齐。
## 12. 本文完成定义
当以下条件满足时,本设计文档视为完成:
1. `profile_dashboard_state / profile_wallet_ledger / profile_played_world` 字段与 ID 规则已冻结。
2. `dashboard / wallet-ledger / play-stats` 的 procedure 名、返回结构、排序与默认值已冻结。
3. `api/runtime/*` 与兼容 `/api/profile/*` 双路径已冻结。
4. 可以据此直接开始 `module-runtime``shared-contracts``spacetime-module``spacetime-client``api-server` 编码。

View File

@@ -0,0 +1,276 @@
# M3runtime settings Axum + SpacetimeDB 落地设计
日期:`2026-04-21`
关联任务:
- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md)
- [../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)
关联现状:
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
- [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md)
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
## 1. 文档目的
`02_M3_RUNTIME_PROFILE.md` 已经冻结了 M3 的任务范围但还没有把首批可编码切片细化到表字段、procedure、Axum facade、兼容错误格式和测试策略。
本文件只解决 M3 第一批最小纵向切片:
1. `GET /api/runtime/settings`
2. `PUT /api/runtime/settings`
以及其在 Rust 重写中的完整落位:
1. `module-runtime` 的字段约束与 DTO
2. `crates/spacetime-module``runtime_setting` 表与 procedure
3. `crates/spacetime-client` 的 procedure 调用封装
4. `crates/api-server` 的兼容 facade 与响应 contract
本文件不新增 checklist不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足可以直接编码的技术口径。
## 2. 为什么先做 runtime settings
在 M3 范围内,`runtime settings` 是当前最适合先迁移的纵向切片:
1. 读写模型最小,只依赖 `user_id + music_volume + platform_theme`
2. 旧 Node 逻辑没有跨表聚合、副作用和复杂 projection。
3. 前端 contract 清晰,兼容路径只有一条,不涉及 `/api/profile/*` 双路径。
4. 可以先把 `Axum -> JWT -> SpacetimeDB procedure -> 标准 envelope` 主链跑通,为后续 `browse history / snapshot / save archive / dashboard` 复用。
## 3. 旧实现冻结口径
当前 Node 侧 `runtime settings` 行为来自:
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
冻结行为如下:
### 3.1 路由
- `GET /api/runtime/settings`
- `PUT /api/runtime/settings`
两条接口都要求 JWT。
### 3.2 请求体
`PUT /api/runtime/settings` 请求体:
```json
{
"musicVolume": 0.42,
"platformTheme": "light"
}
```
校验规则:
1. `musicVolume` 必须在 `0 ~ 1`
2. `platformTheme` 只接受 `light | dark`
### 3.3 默认值
默认值来自 `packages/shared/src/contracts/runtime.ts`
1. `DEFAULT_MUSIC_VOLUME = 0.42`
2. `DEFAULT_PLATFORM_THEME = "light"`
当用户从未写入过设置时,读取接口必须返回默认值,而不是 `404``null`
### 3.4 归一化规则
旧 Node 写入时会做以下归一化:
1. `musicVolume` 强制 clamp 到 `0 ~ 1`
2. `platformTheme` 如果不是 `dark`,统一回退到 `light`
Rust 重写阶段仍保持同样语义,避免前端产生行为漂移。
## 4. Rust 落位决议
### 4.1 crate 分工
本切片固定按以下边界落位:
1. `crates/module-runtime`
- 定义 `RuntimeSettings` 领域 DTO、默认值、字段校验与归一化规则。
2. `crates/spacetime-module`
- 定义 `runtime_setting` 表。
- 提供 `upsert_runtime_setting_and_return` procedure。
3. `crates/spacetime-client`
- 提供 `get_runtime_settings``put_runtime_settings` 调用封装。
4. `crates/api-server`
- 提供 `GET/PUT /api/runtime/settings`
- 保持当前 envelope / 错误格式 / 请求头兼容。
### 4.2 身份边界
当前阶段前端仍只访问 Axum不直连 SpacetimeDB。
因此:
1. 用户身份仍由 Axum 侧 JWT middleware 校验。
2. Axum 从已校验的 access token claims 中取 `user_id`
3. `user_id` 作为 procedure 入参写入 `runtime_setting`
注意:
1. 这不是最终的 SpacetimeDB 原生身份透传形态。
2. 在 M3 首批切片里,先以 Axum 作为唯一鉴权边界,保证与当前前端 contract 一致。
## 5. SpacetimeDB 表设计
### 5.1 表名
`runtime_setting`
### 5.2 字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键,绑定平台用户 |
| `music_volume` | `f32` | 音量,持久化归一化后的值 |
| `platform_theme` | `RuntimePlatformTheme` | 平台主题枚举 |
| `created_at` | `Timestamp` | 首次创建时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
### 5.3 设计决议
1. 每个用户只保留一行设置,不做历史版本表。
2. `user_id` 直接作为主键,避免再引入无业务价值的自增 ID。
3. `platform_theme` 固定为枚举,不把 `light/dark` 继续散落成字符串字面量。
4. 首批阶段不把设置拆成多行 KV 表,避免简单需求被过度抽象。
## 6. Procedure 设计
### 6.1 不单独暴露 reducer 给 Axum
本切片优先提供 procedure而不是让 Axum 直接调 reducer + 再查询表。
原因:
1. 当前 `spacetime-client` 已经以 procedure 返回结果的模式承接资产链。
2. 设置接口需要同步返回最终写入结果procedure 可减少一次额外查询。
3. 当前 `runtime_setting` 不需要客户端订阅private table + procedure 更直接。
### 6.2 Procedure 列表
1. `get_runtime_setting_or_default`
2. `upsert_runtime_setting_and_return`
返回 DTO 固定为:
```text
RuntimeSettingSnapshot {
user_id
music_volume
platform_theme
created_at_micros
updated_at_micros
}
```
如果用户还没有设置记录:
1. `get_runtime_setting_or_default` 返回默认值快照。
2. 但不强制立即插入表,避免纯读取请求制造无意义写入。
## 7. Axum facade 设计
### 7.1 GET /api/runtime/settings
行为:
1.`require_bearer_auth`
2.`claims.user_id` 取用户 ID。
3.`spacetime_client.get_runtime_settings(user_id)`
4. 返回:
```json
{
"musicVolume": 0.42,
"platformTheme": "light"
}
```
### 7.2 PUT /api/runtime/settings
行为:
1.`require_bearer_auth`
2. 使用 Axum `Json` + `serde` 解析请求。
3.`module-runtime` 内做归一化。
4.`spacetime_client.put_runtime_settings(user_id, payload)`
5. 返回归一化后的最终值。
### 7.3 错误映射
1. 请求体解析失败:`400 BAD_REQUEST`
2. 字段校验失败:`400 BAD_REQUEST`
3. SpacetimeDB 调用失败:`502 BAD_GATEWAY`
4. JWT 缺失或失效:沿用现有 `401 UNAUTHORIZED`
错误 `details.provider` 固定为:
1. `runtime-settings`:本地字段归一化或 DTO 构建失败
2. `spacetimedb`procedure 调用失败
## 8. 首批测试策略
本切片测试分两层:
### 8.1 必跑测试
1. `module-runtime`
- 默认值
- clamp 规则
- theme 归一化
2. `api-server`
- 未登录返回 `401`
- 请求 envelope 打开时返回标准 `ok/data/error/meta`
- JSON 结构与字段名兼容
### 8.2 可选联调测试
补一条 `#[ignore]` 的集成测试:
1. 需要本地 SpacetimeDB 已启动
2. 需要当前 `spacetime-module` 已发布
3. 验证 `PUT -> GET` 能往返一致
原因:
1. 当前仓库已有资产链的 `#[ignore]` 集成测试模式。
2. 在未稳定建立测试 harness 前,不强制把 SpacetimeDB 作为默认单测前置条件。
## 9. 后续扩展顺序
`runtime settings` 完成后M3 后续能力按以下顺序推进:
1. `user_browse_history`
2. `runtime_snapshot`
3. `profile_save_archive`
4. `profile_dashboard_state + profile_wallet_ledger + profile_played_world`
顺序原因:
1. `browse_history` 仍是单表为主,只带去重与排序规则。
2. `snapshot``save_archive` 依赖兼容聚合策略,复杂度更高。
3. `dashboard / play-stats / wallet-ledger` 依赖 projection更适合放在 snapshot 规则固定后收口。
## 10. 本文完成定义
当以下条件成立时,本设计文档视为完成:
1. `runtime settings` 的字段、默认值、归一化规则、procedure 与 Axum facade 已书面冻结。
2. 后续编码无需再猜测:
- 表字段名
- 主键策略
- 默认值来源
- Axum 与 SpacetimeDB 的职责边界
3. 可以直接据此开始 `module-runtime``spacetime-module``spacetime-client``api-server` 编码。

View File

@@ -0,0 +1,147 @@
# M4 Combat Reward Inventory Integration2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把 `resolve_combat_action(Victory)` 从“只发经验”推进到“经验与战利品可在同一事务内结算”的最小主链口径。**
本轮不回收完整 runtime item 导演层,也不在 `module-combat` 内直接做 AI 语义生成;只承接已经编译好的 reward item 快照。
---
## 1. 本轮落地范围
本轮只落实下面 4 件事:
1.`module-combat` 中为 `battle_state` 补充 `reward_items` 字段。
2. 允许 `BattleStateInput` 在初始化时携带已经编译好的战利品快照。
3.`spacetime-module::resolve_combat_action` 中,当结果为 `Victory` 时同步把 `reward_items` 写入 `inventory_slot`
4. 保持 `module-combat` 仍然是纯规则 crate不直接依赖 `module-inventory`
---
## 2. 当前冻结的战利品口径
### 2.1 `battle_state.reward_items`
首版字段固定复用 `module-runtime-item::RuntimeItemRewardItemSnapshot`
原因:
1. 宝箱链已经用这套 reward item contract 打通到 `inventory_slot`
2. 任务奖励当前仍有独立 `QuestRewardItem`,但战斗奖励更接近 runtime item 导演层。
3. 先复用现有 reward item 快照,避免本轮再发明第三套 combat 专属掉落结构。
### 2.2 battle 初始化来源
当前 `battle_state.reward_items` 不在战斗 reducer 内生成,只允许由上游在创建 battle 时传入:
1. `resolve_npc_battle_interaction_and_return`
2. 后续 Axum façade / runtime story orchestration
3. 其它明确的 battle create 聚合入口
也就是说:
1. `module-combat` 只消费已确定的 reward item 快照
2. 不在 reducer 内做随机、提示词、外部世界图谱推导
当前已接通:
1. `resolve_npc_battle_interaction_and_return`
2. `POST /api/story/npc/battle`
3. `POST /api/story/battles`
这几条入口都只负责透传已编译奖励,不负责现场生成掉落。
---
## 3. Victory 发物规则
`resolve_combat_action` 结算结果满足:
1. `outcome == Victory`
`spacetime-module` 需要继续执行:
1. `experience_reward > 0` 时写 `player_progression / chapter_progression`
2. `reward_items.len() > 0` 时写 `inventory_slot`
### 3.1 发物方式
当前固定规则:
1. 每个 reward item 显式映射成一条 `InventoryMutation::GrantItem`
2. `source_kind = CombatDrop`
3. `source_reference_id = battle_state_id`
4. 同一 `battle_state_id` 只允许发放一次
### 3.2 幂等约束
本轮先采用与 quest / treasure 一致的“按来源引用查重”思路:
1. 若当前 actor 的 `inventory_slot` 中已经存在 `source_reference_id = battle_state_id`
2. 视为该 battle reward 已发放
3. Victory 再次重放时跳过发物,但不影响 battle_state 已收束结果
---
## 4. 与既有链路的边界
### 4.1 与 `module-combat`
`module-combat` 继续只负责:
1. `battle_state` 结构
2. `resolve_combat_action` 状态推进
3. 胜负结果收束
不负责:
1. inventory 写表
2. progression 写表
3. runtime item 生成
### 4.2 与 `module-runtime-item`
本轮不把战斗奖励映射 helper 上提到 `module-runtime-item`
原因:
1. 当前 `RuntimeItemRewardItemSnapshot -> InventoryItemSnapshot` 的 helper 语义固定为 `TreasureReward`
2. 若直接复用,会把 `source_kind` 写错成 `TreasureReward`
3. 本轮先在 `spacetime-module` 里补一个 combat 专用映射,后续再统一抽象
---
## 5. 当前刻意未做
本轮明确不做下面这些扩张:
1. 不把 Node 版 `monster_drop` AI 导演层整体迁到 Rust
2. 不在 `resolve_npc_battle_interaction_and_return` 里现场计算掉落
3. 不处理 battle reward 的货币、好感、情报
4. 不处理战斗内 `inventory_use`
5. 不把掉落展示或 Battle Reward 面板接到前端
---
## 6. 验证要求
本轮完成后至少执行:
1. `npm run check:encoding`
2. `cargo test -p module-combat --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
3. `cargo check -p module-combat -p spacetime-module --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
---
## 7. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 把 Node 侧 `monster_drop` runtime item 编译逻辑迁到 Rust 聚合层。
2. 视复用收益决定是否把 battle / treasure 的 reward item 归一化 helper 上提到 `module-runtime-item`
3. 最后再把 battle reward 展示、story patch 和前端接口切到新后端。

View File

@@ -0,0 +1,306 @@
# M4 module-ai Axum facade 设计2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把已经在 `spacetime-module` 落地的 `module-ai` 任务真相表与最小 procedure / reducer继续向上接到 `shared-contracts`、`spacetime-client` 与 `api-server`,形成可由 HTTP 直接调用的 AI task mutation facade。**
本轮只做最小同步 mutation 链,不扩到 SSE、真实模型供应商请求或前端订阅。
---
## 1. 本轮要解决的问题
当前仓库已经具备:
1. `module-ai`
- 统一 `AiTaskKind / AiTaskStageKind / AiResultReferenceKind`
- 统一任务、阶段、文本片段、结果引用领域模型
2. `spacetime-module`
- `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference`
- `create / append / complete / attach / fail / cancel` 最小 procedure
- `start_ai_task / start_ai_task_stage` reducer
3. `spacetime-client`
- 已生成 AI 相关 Rust bindings
但当前仍缺三层:
1. `shared-contracts` 还没有 AI task HTTP DTO
2. `spacetime-client` 还没有 AI facade 方法与 record 映射
3. `api-server` 还没有 `/api/ai/tasks*` 路由
因此本轮只补下面三层:
1. `shared-contracts` AI DTO
2. `spacetime-client` AI facade
3. `api-server` AI tasks HTTP route
---
## 2. 当前明确不做的事
本轮明确不做:
1. 不接入真实 `platform-llm` 流式回调
2. 不提供 SSE 增量推送接口
3. 不增加 AI task 查询 / 订阅 projection
4. 不把 story / npc / quest / custom-world 旧入口自动迁到这组新接口
5. 不修改 `spacetime-client/src/module_bindings/*` 生成文件
原因很直接:
1. 当前先把 AI task mutation 的最小 HTTP contract 固定下来
2. SSE 与查询态必须等待后续订阅策略或 query procedure 冻结
3. 业务编排入口切换应该在上层模块各自评估,不在本轮提前硬迁
---
## 3. 路由冻结
本轮首版新增以下路由:
1. `POST /api/ai/tasks`
2. `POST /api/ai/tasks/{taskId}/start`
3. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/start`
4. `POST /api/ai/tasks/{taskId}/chunks`
5. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete`
6. `POST /api/ai/tasks/{taskId}/references`
7. `POST /api/ai/tasks/{taskId}/complete`
8. `POST /api/ai/tasks/{taskId}/fail`
9. `POST /api/ai/tasks/{taskId}/cancel`
### 3.1 同步返回路由
当前下列路由走 `procedure`,成功时同步返回 `aiTask` 快照:
1. `POST /api/ai/tasks`
2. `POST /api/ai/tasks/{taskId}/chunks`
3. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete`
4. `POST /api/ai/tasks/{taskId}/references`
5. `POST /api/ai/tasks/{taskId}/complete`
6. `POST /api/ai/tasks/{taskId}/fail`
7. `POST /api/ai/tasks/{taskId}/cancel`
其中:
1. `chunks` 额外返回 `aiTextChunk`
2. 其他 mutation 当前只返回 `aiTask`
### 3.2 Accepted 路由
当前下列路由只接 `reducer`,不会同步返回快照:
1. `POST /api/ai/tasks/{taskId}/start`
2. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/start`
因此本轮明确冻结为:
1. HTTP 成功状态码返回 `202 Accepted`
2. body 只返回:
- `accepted`
- `taskId`
- `action`
- `stageKind`(仅 stage start
3. 不伪装成“已经拿到最新任务快照”
后续如果要让这两条路由也同步返回快照,应先在 `spacetime-module` 增加对应 procedure。
---
## 4. 请求与响应 DTO 冻结
### 4.1 创建任务请求
`POST /api/ai/tasks` 请求体冻结为:
1. `taskKind`
2. `requestLabel`
3. `sourceModule`
4. `sourceEntityId`
5. `requestPayloadJson`
6. `stageKinds`
其中:
1. `taskId` 不接受外部写入,由 Axum 使用 `generate_ai_task_id(nowMicros)` 生成
2. `ownerUserId` 不接受外部写入,必须取自 Bearer token
3. `stageKinds` 为空时,由 `module-ai` 根据 `taskKind.default_stage_blueprints()` 自动补齐默认阶段蓝图
### 4.2 追加文本片段请求
`POST /api/ai/tasks/{taskId}/chunks` 请求体冻结为:
1. `stageKind`
2. `sequence`
3. `deltaText`
### 4.3 完成阶段请求
`POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete` 请求体冻结为:
1. `textOutput`
2. `structuredPayloadJson`
3. `warningMessages`
### 4.4 绑定结果引用请求
`POST /api/ai/tasks/{taskId}/references` 请求体冻结为:
1. `referenceKind`
2. `referenceId`
3. `label`
### 4.5 失败请求
`POST /api/ai/tasks/{taskId}/fail` 请求体冻结为:
1. `failureMessage`
### 4.6 成功响应
本轮统一返回以下 payload
1. `AiTaskPayload`
2. `AiTaskStagePayload`
3. `AiResultReferencePayload`
4. `AiTextChunkPayload`
5. `AiTaskMutationResponse`
6. `AiTaskAcceptedResponse`
时间字段继续统一为 RFC3339 字符串。
---
## 5. `spacetime-client` 冻结口径
本轮新增以下 facade
1. `create_ai_task`
2. `start_ai_task`
3. `start_ai_task_stage`
4. `append_ai_text_chunk`
5. `complete_ai_stage`
6. `attach_ai_result_reference`
7. `complete_ai_task`
8. `fail_ai_task`
9. `cancel_ai_task`
### 5.1 输入边界
1. procedure 输入直接复用 `module-ai` 领域输入结构
2. `start_ai_task``start_ai_task_stage` 直接复用 reducer 输入结构
3. 不让 `api-server` 直接依赖 generated binding 类型
### 5.2 输出边界
`spacetime-client` 新增下列 record`api-server` 直接消费:
1. `AiTaskRecord`
2. `AiTaskStageRecord`
3. `AiTextChunkRecord`
4. `AiResultReferenceRecord`
5. `AiTaskMutationRecord`
字符串字段规范:
1. `taskKind` 使用:
- `story_generation`
- `character_chat`
- `npc_chat`
- `custom_world_generation`
- `quest_intent`
- `runtime_item_intent`
2. `stageKind` 使用 `module-ai::AiTaskStageKind::as_str()`
3. `status` 使用 snake_case
4. `referenceKind` 使用 snake_case
### 5.3 错误映射
AI facade 在 `spacetime-client` 内部按以下规则区分:
1. procedure / reducer 返回的业务拒绝
- 映射为 `SpacetimeClientError::Runtime`
2. SDK 调用、连接、超时、意外缺字段
- 映射为 `Build / Procedure / ConnectDropped / Timeout`
这样 `api-server` 才能稳定把业务错误映射成 `400`
---
## 6. `api-server` 冻结口径
### 6.1 鉴权与身份
所有 `/api/ai/tasks*` 路由继续统一挂 Bearer 鉴权。
其中:
1. `ownerUserId` 必须来自 `AuthenticatedAccessToken.claims().user_id()`
2. 不接受前端自行写入任务所有者
### 6.2 时间与 ID
以下字段不接受外部写入:
1. `taskId`
2. `createdAtMicros`
3. `startedAtMicros`
4. `completedAtMicros`
统一由 Axum 在请求进入时生成。
### 6.3 字段解析
`api-server` 负责把 HTTP 字符串解析为领域枚举:
1. `taskKind`
2. `stageKind`
3. `referenceKind`
解析失败统一返回 `400``details.provider` 分别写:
1. `ai-task`
2. `ai-task-stage`
3. `ai-task-reference`
---
## 7. 错误映射
本轮 AI facade 的错误策略冻结如下:
1. 请求 JSON 非法、路径字段非法、枚举解析失败:`400`
2. `SpacetimeClientError::Runtime(_)``400`
3. 其他 `SpacetimeClientError``502`
`details.provider` 统一写:
1. 路由入参准备错误:`ai-task`
2. SpacetimeDB 上游错误:`spacetimedb`
---
## 8. 本轮验收口径
满足以下条件,视为本轮 facade 基线完成:
1. `shared-contracts` 已新增 `ai.rs`
2. `spacetime-client` 已新增 AI facade 方法与 record 映射
3. `api-server` 已新增 `ai_tasks.rs`
4. `/api/ai/tasks*` 路由已注册并挂 Bearer 鉴权
5. `cargo fmt -p shared-contracts -p spacetime-client -p api-server` 通过
6. `cargo check -p shared-contracts -p spacetime-client -p api-server` 通过
---
## 9. 下一步建议
本轮完成后,后续最稳的顺序是:
1.`start_ai_task / start_ai_task_stage` 增加同步 procedure
2. 增加 AI task 查询态或订阅 projection
3. 再把 `platform-llm` 流式回调真正接到 `append_ai_text_chunk / complete_ai_stage / fail_ai_task`
4. 最后再把 story / npc / custom-world / quest / runtime-item 的 AI 编排主链逐步切到这组新接口

View File

@@ -0,0 +1,233 @@
# `module-ai` 首版基座设计
日期:`2026-04-21`
## 1. 文档目标
本文只冻结一件事:
**为 `server-rs/crates/module-ai` 建立一套可以直接编码落地的首版领域模型与最小服务边界。**
本轮不做以下内容:
1. 不直接接入真实供应商 SDK。
2. 不在 `SpacetimeDB` 里提前写完整 `ai_task` 表。
3. 不提前改造 `api-server` 的 story/chat/custom world 路由。
本轮只解决两个问题:
1. `module-ai` 不能再停留在“目录占位 + README 口号”状态。
2. 后续 `api-server``platform-llm``spacetime-module` 接线时,需要先有稳定的任务、阶段、流式片段、结果引用领域模型可复用。
## 2. 依据
本文以以下现有文档和代码为准:
1. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
2. [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md)
3. [EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md](./EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md)
4. `server-node/src/modules/ai/storyOrchestrator.ts`
5. `server-node/src/modules/ai/chatOrchestrator.ts`
6. `server-node/src/modules/ai/customWorldOrchestrator.ts`
## 3. 现状问题
当前 `server-rs/crates/module-ai` 只有 README占位描述虽然说明了“AI 编排模块”的方向,但还缺失编码级约束:
1. 没有任务主键、阶段主键、结果引用 ID 的统一前缀。
2. 没有 story/chat/custom world/quest/runtime-item 六类任务的共用枚举。
3. 没有“排队中/运行中/已完成/失败/已取消”的状态模型。
4. 没有“流式片段如何暂存与聚合”的领域对象。
5. 没有“结果引用”与“最终文本/结构化结果”的最小抽象。
6. 没有可供 `api-server` 直接依赖的最小内存服务。
如果继续在没有这层基座的前提下直接接 `platform-llm``api-server`,后续很容易再次把:
1. 阶段枚举散落在 handler 里,
2. 流式文本拼接散落在路由里,
3. 结果引用结构散落在 story/custom-world/quest 各模块里。
## 4. 首版职责冻结
`module-ai` 首版只负责以下职责:
1. 定义统一 AI 任务类型、任务状态、阶段状态、任务快照。
2. 定义统一流式片段、阶段输出、结果引用、最终结果快照。
3. 提供最小编排服务,支持:
- 创建任务
- 启动任务
- 记录阶段开始/完成
- 追加流式文本片段
- 绑定结果引用
- 成功完成 / 失败 / 取消
4. 提供一套内存态 store作为 `api-server` 首轮联调和测试 fallback。
`module-ai` 首版明确不负责:
1. 真实 HTTP 请求、重试、超时和供应商切换。
2. SSE 协议写回。
3. 数据库存储与表结构。
4. 业务模块自己的 prompt 组装细节。
## 5. 任务类型范围
首版统一冻结以下任务类型:
1. `StoryGeneration`
2. `CharacterChat`
3. `NpcChat`
4. `CustomWorldGeneration`
5. `QuestIntent`
6. `RuntimeItemIntent`
说明:
1. 这 6 类直接来自当前 Node 后端已经存在的正式运行时 AI 主链。
2. 不提前引入媒体类资产生成任务,因为资产生成后续归 `module-assets + platform-oss` 主导。
3. 如果后续要增加 `NarrativeRepair``ProfileRepair` 这类内部子任务,应作为新枚举值追加,不复用现有值的语义。
## 6. 阶段模型
首版阶段固定支持以下通用阶段语义:
1. `PreparePrompt`
2. `RequestModel`
3. `RepairResponse`
4. `NormalizeResult`
5. `PersistResult`
说明:
1. 不是每种任务都必须走满 5 个阶段。
2. 任务创建时应携带自己的阶段蓝图,按需要裁剪。
3. 阶段蓝图是显示给上层 orchestration 的稳定数据,不把“阶段名字符串”重新散落到 handler。
首版建议默认蓝图:
| 任务类型 | 默认阶段 |
| --- | --- |
| `StoryGeneration` | `PreparePrompt` -> `RequestModel` -> `RepairResponse` -> `NormalizeResult` |
| `CharacterChat` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
| `NpcChat` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
| `CustomWorldGeneration` | `PreparePrompt` -> `RequestModel` -> `RepairResponse` -> `NormalizeResult` -> `PersistResult` |
| `QuestIntent` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
| `RuntimeItemIntent` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
## 7. 结果模型
首版结果拆成三层:
### 7.1 流式片段
用于记录模型增量输出:
1. `chunk_id`
2. `task_id`
3. `stage_kind`
4. `sequence`
5. `delta_text`
6. `created_at_micros`
这层只负责“增量片段”,不直接宣称是最终结果。
### 7.2 阶段输出
用于记录阶段收口后的聚合内容:
1. `stage_kind`
2. `text_output`
3. `structured_payload_json`
4. `warning_messages`
### 7.3 结果引用
用于让 AI 编排结果和其他模块的记录形成稳定绑定:
1. `result_ref_id`
2. `task_id`
3. `reference_kind`
4. `reference_id`
5. `label`
首版 `reference_kind` 冻结为:
1. `StorySession`
2. `StoryEvent`
3. `CustomWorldProfile`
4. `QuestRecord`
5. `RuntimeItemRecord`
6. `AssetObject`
## 8. 服务边界
首版 `AiTaskService` 只暴露纯领域操作,不直接暴露供应商能力:
1. `create_task`
2. `start_task`
3. `start_stage`
4. `append_text_chunk`
5. `complete_stage`
6. `attach_result_reference`
7. `complete_task`
8. `fail_task`
9. `cancel_task`
10. `get_task`
这层返回的都是领域快照,不返回 HTTP DTO。
## 9. 与其他 crate 的边界
### 9.1 与 `platform-llm`
`platform-llm` 负责:
1. 真实模型请求
2. 流式回调
3. 超时 / 重试 / 供应商错误
`module-ai` 负责:
1. 把这些外部回调映射为任务快照与阶段快照
2. 把供应商响应组织成稳定的模块领域状态
### 9.2 与 `api-server`
`api-server` 负责:
1. HTTP 入参校验
2. SSE 输出
3. Bearer/Cookie 鉴权
4. response envelope
`module-ai` 不负责 HTTP。
### 9.3 与 `spacetime-module`
后续 `spacetime-module` 负责:
1. 任务真相表
2. 阶段表 / 事件表 / 结果引用表
3. reducer / procedure
本轮 `module-ai` 只提供后续可映射到 SpacetimeDB 的稳定领域结构。
## 10. 首版编码要求
首版 crate 必须满足:
1. 提供 `Cargo.toml`
2. 提供 `src/lib.rs`
3. 默认不依赖 `platform-llm`
4. 默认不依赖 `SpacetimeDB`
5. 可选提供 `spacetime-types` feature便于后续映射表结构
6. 提供完整中文注释与基础测试
## 11. 本轮验收口径
本轮完成后,以下条件同时满足才算 `module-ai` 首版落地:
1. `server-rs/Cargo.toml` 已把 `module-ai` 纳入 workspace。
2. `module-ai` 不再只有 README而是有真实可编译源码。
3. 任务/阶段/结果引用/流式片段领域模型已存在。
4. 有最小内存服务可供后续 `api-server` 直接复用。
5. 至少有任务创建、流式片段聚合、阶段完成、结果引用绑定、任务失败/取消等测试。

View File

@@ -0,0 +1,266 @@
# M4 module-ai SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-ai` 从“只有领域模型和内存态服务”推进到“SpacetimeDB 侧已有最小 AI 任务真相表与 procedure 骨架”的真实落地结果。**
本轮只做最小可编译基座不扩到真实模型请求、SSE 输出或前端订阅联调。
---
## 1. 本轮落地范围
本轮只落实下面 5 件事:
1.`server-rs/crates/module-ai/` 中补齐面向 `SpacetimeDB` 接线的输入类型。
2.`server-rs/crates/spacetime-module/` 中新增 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 四张 private 表。
3.`spacetime-module` 中新增 AI 任务的最小 reducer / procedure。
4.`module-ai` 的领域快照与 `SpacetimeDB` 行结构之间的转换 helper 固定下来。
5. 补充 crate README 与技术索引,明确当前 AI 真相源边界。
---
## 2. 新增的真实工程落点
### 2.1 `module-ai`
1. `server-rs/crates/module-ai/src/lib.rs`
- 补充 `AiTaskStartInput`
- 补充 `AiTaskStageStartInput`
- 补充 `AiTextChunkAppendInput`
- 补充 `AiResultReferenceInput`
- 补充 `AiTaskFinishInput`
- 补充 `AiTaskCancelInput`
- 补充 `AiTaskFailureInput`
- 补充 `AI_TASK_STAGE_ID_PREFIX`
- 补充 `AiTaskStageKind::as_str()`
- 补充 `generate_ai_task_stage_id()`
### 2.2 `spacetime-module`
1. `server-rs/crates/spacetime-module/src/lib.rs`
- 新增 `ai_task`
- 新增 `ai_task_stage`
- 新增 `ai_text_chunk`
- 新增 `ai_result_reference`
- 新增 `create_ai_task`
- 新增 `create_ai_task_and_return`
- 新增 `start_ai_task`
- 新增 `start_ai_task_stage`
- 新增 `append_ai_text_chunk_and_return`
- 新增 `complete_ai_stage_and_return`
- 新增 `attach_ai_result_reference_and_return`
- 新增 `complete_ai_task_and_return`
- 新增 `fail_ai_task_and_return`
- 新增 `cancel_ai_task_and_return`
---
## 3. 当前冻结的数据口径
### 3.1 `ai_task`
当前首版字段冻结为:
1. `task_id`
2. `task_kind`
3. `owner_user_id`
4. `request_label`
5. `source_module`
6. `source_entity_id`
7. `request_payload_json`
8. `status`
9. `failure_message`
10. `latest_text_output`
11. `latest_structured_payload_json`
12. `version`
13. `created_at`
14. `started_at`
15. `completed_at`
16. `updated_at`
当前策略:
1. `ai_task` 只保留任务级聚合字段,不在单行内嵌套 `Vec<stage>`
2. 阶段、增量文本、结果引用全部拆到独立表,避免后续更新整行大对象。
3. `version` 继续沿用 `module-ai` 的任务快照版本语义。
### 3.2 `ai_task_stage`
当前首版字段冻结为:
1. `task_stage_id`
2. `task_id`
3. `stage_kind`
4. `label`
5. `detail`
6. `order`
7. `status`
8. `text_output`
9. `structured_payload_json`
10. `warning_messages`
11. `started_at`
12. `completed_at`
当前策略:
1. 一条 stage 一行。
2. `task_stage_id` 使用 `generate_ai_task_stage_id(task_id, stage_kind)`,保持同任务内幂等。
3. 当前不单独存“阶段版本”,统一归任务版本递增。
### 3.3 `ai_text_chunk`
当前首版字段冻结为:
1. `text_chunk_row_id`
2. `chunk_id`
3. `task_id`
4. `stage_kind`
5. `sequence`
6. `delta_text`
7. `created_at`
当前策略:
1. `chunk_id` 保留领域侧 ID 语义。
2. 表级主键使用 `text_chunk_row_id`,避免 `generate_ai_text_chunk_id(seed, sequence)` 在不同任务之间碰撞。
3. 流式文本聚合结果仍写回 `ai_task_stage.text_output``ai_task.latest_text_output`
### 3.4 `ai_result_reference`
当前首版字段冻结为:
1. `result_reference_row_id`
2. `result_ref_id`
3. `task_id`
4. `reference_kind`
5. `reference_id`
6. `label`
7. `created_at`
当前策略:
1. `result_ref_id` 保留领域侧 ID 语义。
2. 表级主键使用 `result_reference_row_id`,避免只按时间种子生成的领域 ID 在并发情况下直接作为主键带来碰撞风险。
---
## 4. 当前 reducer / procedure 口径
### 4.1 `create_ai_task`
当前负责:
1. 校验 `AiTaskCreateInput`
2. 拒绝重复 `task_id`
3. 写入 `ai_task`
4. 按蓝图写入 `ai_task_stage`
### 4.2 `start_ai_task`
当前负责:
1. 校验目标任务存在
2.`ai_task.status``Pending` 推进到 `Running`
3. 填充 `started_at`
### 4.3 `start_ai_task_stage`
当前负责:
1. 校验目标任务与目标阶段存在
2. 推进任务为 `Running`
3. 推进对应 stage 为 `Running`
### 4.4 `append_ai_text_chunk_and_return`
当前负责:
1. 校验任务与阶段存在
2. 追加 `ai_text_chunk`
3.`task_id + stage_kind + sequence` 聚合文本
4. 回写 `ai_task_stage.text_output`
5. 回写 `ai_task.latest_text_output`
### 4.5 `complete_ai_stage_and_return`
当前负责:
1. 更新 stage 状态、阶段输出、warning 列表
2. 回写 `ai_task.latest_text_output`
3. 回写 `ai_task.latest_structured_payload_json`
4. 递增任务版本
### 4.6 `attach_ai_result_reference_and_return`
当前负责:
1. 追加 `ai_result_reference`
2. 更新任务 `updated_at`
3. 递增任务版本
### 4.7 `complete_ai_task_and_return`
当前负责:
1. 推进任务为 `Completed`
2. 填充 `completed_at`
### 4.8 `fail_ai_task_and_return`
当前负责:
1. 推进任务为 `Failed`
2. 写入 `failure_message`
3. 填充 `completed_at`
### 4.9 `cancel_ai_task_and_return`
当前负责:
1. 推进任务为 `Cancelled`
2. 填充 `completed_at`
---
## 5. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有做 AI 任务公开订阅表。
2. 还没有做 `api-server` 的 AI facade 路由。
3. 还没有做 `platform-llm` 真实流式回调接线。
4. 还没有做 story / custom-world / quest / runtime-item 对 AI 任务的自动建链。
5. 还没有做清理旧任务、旧 chunk 的 schedule reducer。
也就是说,本轮只是把 AI 任务真相表和最小写入口立起来,不宣称已经完成 AI runtime 主链迁移。
---
## 6. 当前边界判断
当前仍保持以下职责划分:
1. `module-ai`
- 负责领域模型、校验、快照结构与最小内存服务。
2. `spacetime-module`
- 负责任务真相表、事务性持久化与 procedure 聚合返回。
3. `platform-llm`
- 后续负责真实模型调用、超时、重试、供应商错误。
4. `api-server`
- 后续负责 HTTP / SSE / 鉴权与外部 contract。
---
## 7. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 先把 `platform-llm` 的文本网关正式接到 `append_ai_text_chunk_and_return / complete_ai_stage_and_return`
2. 再给 `api-server` 增加 AI 任务 facade把 HTTP/SSE 对外 contract 冻结下来。
3. 再把 story、custom-world、quest、runtime-item 各自的 AI 编排入口切到 `module-ai + spacetime-module`
4. 最后再根据订阅需求评估是否补 public projection 表或事件表。

View File

@@ -0,0 +1,251 @@
# M4 module-combat Axum facade 设计2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只冻结一件事:
**把已经完成 reducer 化的 `module-combat` 再向上接一层最小同步返回链,让 `api-server` 可以显式创建战斗、推进单次战斗动作,并立即拿到 battle 快照结果。**
这份文档不是完整 `runtime story actions/resolve` 兼容方案,也不替代后续的 `resolve_story_action` 编排设计。
---
## 1. 本轮要解决的问题
当前 `module-combat` 已具备:
1. `battle_state` 真相表
2. `create_battle_state` reducer
3. `resolve_combat_action` reducer
4. `fight / spar` 两种模式下的纯规则推进
但当前仍缺一层明确能力:
1. Axum 还不能同步拿到 battle 快照
2. `spacetime-client` 还没有 battle procedure 调用封装
3. `api-server` 还没有独立的战斗 facade
因此本轮只补下面三层:
1. `spacetime-module` battle procedure
2. `spacetime-client` battle procedure 调用与返回值映射
3. `api-server` 最小战斗 HTTP facade
---
## 2. 当前明确不做的事
本轮刻意不做:
1. 不兼容旧 `POST /api/runtime/story/actions/resolve`
2. 不兼容旧 `GET /api/runtime/story/state/:sessionId`
3. 不把 `inventory_use` 提前接回战斗主链
4. 不把 `quest / progression / npc / story_event` 自动联动写回
5. 不把 battle 直接拼进 `RuntimeStoryActionResponse`
原因很直接:
1. 这些属于更高层的 runtime story 编排问题
2. 当前 battle 子域应该先把“独立可调用、同步可返回”这一层固定下来
3. 先补 procedure + facade后续 `resolve_story_action` 才有稳定下游可调入口
---
## 3. `spacetime-module` 的新增口径
### 3.1 reducer 继续保留
已有 reducer 继续保留:
1. `create_battle_state`
2. `resolve_combat_action`
职责不变:
1. reducer 仍然只负责 battle 真相写入
2. reducer 不直接向调用方返回业务快照
### 3.2 新增 procedure
本轮新增两个 procedure
1. `create_battle_state_and_return`
2. `resolve_combat_action_and_return`
职责冻结如下:
1. procedure 只包一层 `try_with_tx`
2. procedure 内部复用 reducer 共享的写入 helper
3. procedure 负责把最终 `battle_state``resolve result` 同步返回给 Axum
### 3.3 返回类型
本轮冻结两种返回 DTO
1. `BattleStateProcedureResult`
2. `ResolveCombatActionProcedureResult`
字段口径统一为:
1. `ok`
2. `snapshot``result`
3. `error_message`
这样能与现有 `story / treasure / npc` procedure 返回风格保持一致。
---
## 4. `spacetime-client` 的新增口径
`spacetime-client` 本轮新增两条最小调用链:
1. `create_battle_state`
2. `resolve_combat_action`
调用策略继续沿用当前已验证模式:
1. 先建立 `DbConnection`
2. 等待 `on_connect`
3. 再调用对应 procedure
4. 统一经 `oneshot + timeout` 收口结果
当前不做:
1. battle 订阅
2. battle cache 读模型
3. battle 长连接复用策略
---
## 5. `api-server` 的新增 facade 口径
### 5.1 路由
本轮新增两条最小路由:
1. `POST /api/story/battles`
2. `POST /api/story/battles/resolve`
这两条路由的定位不是旧 runtime 兼容层,而是:
1. 面向新 Rust 后端内部联调
2. 面向后续 `resolve_story_action` 编排层调用
### 5.2 `POST /api/story/battles`
请求体只提交 battle 建立所需的业务字段:
1. `storySessionId`
2. `runtimeSessionId`
3. `targetNpcId`
4. `targetName`
5. `battleMode`
6. `playerHp`
7. `playerMaxHp`
8. `playerMana`
9. `playerMaxMana`
10. `targetHp`
11. `targetMaxHp`
由 Axum 自动补齐:
1. `battleStateId`
2. `actorUserId`
3. `createdAtMicros`
响应返回:
1. `battleState`
### 5.3 `POST /api/story/battles/resolve`
请求体只提交单次动作推进所需字段:
1. `battleStateId`
2. `functionId`
3. `actionText`
4. `baseDamage`
5. `manaCost`
6. `heal`
7. `manaRestore`
8. `counterMultiplierBasisPoints`
由 Axum 自动补齐:
1. `updatedAtMicros`
响应返回:
1. `battleState`
2. `combat`
其中 `combat` 至少包含:
1. `damageDealt`
2. `damageTaken`
3. `outcome`
---
## 6. 认证与字段真相边界
### 6.1 `actorUserId`
`actorUserId` 不接受前端自填。
必须由:
1. `AuthenticatedAccessToken`
2. `claims.user_id`
直接生成。
### 6.2 时间字段
`createdAtMicros``updatedAtMicros` 不接受外部写入。
必须由 Axum 在请求时生成,原因如下:
1. 避免客户端伪造 battle 创建时间
2. 保持 Rust 后端各 facade 的时间字段风格一致
3. 让后续 battle / story / npc 联调时便于统一日志与排障
---
## 7. 错误映射口径
当前 battle facade 的错误映射冻结如下:
1. battle mode 非法、请求 JSON 非法、字段校验失败:`400`
2. `SpacetimeClientError::Runtime(_)``400`
3. 其他 `SpacetimeClientError``502`
返回 `details.provider` 统一写:
1. battle 输入准备错误:`story-battle`
2. SpacetimeDB 上游错误:`spacetimedb`
---
## 8. 本轮验收
满足以下条件,视为本轮 facade 基线完成:
1. `module-combat` 已新增 procedure 返回 DTO
2. `spacetime-module` 已新增 `create_battle_state_and_return`
3. `spacetime-module` 已新增 `resolve_combat_action_and_return`
4. `spacetime-client` 已可同步创建战斗并推进单次动作
5. `api-server` 已新增两条最小 battle facade 路由
6. `cargo check -p module-combat -p spacetime-client -p api-server -p spacetime-module` 通过
---
## 9. 下一步建议
本轮完成后,后续最稳的顺序是:
1. 把 battle facade 接入 `resolve_story_action`
2. 设计 battle 结束后的 `story_event` 追加口径
3. 再把 `quest / progression / inventory` 的联动收回到显式子域流程里

View File

@@ -0,0 +1,336 @@
# M4 module-combat SpacetimeDB 基线设计2026-04-21
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把 `module-combat` 从“只有 README 占位”推进到“首版 battle_state 与 resolve_combat_action 可真实编码、可编译、可继续扩展”的工程基线。**
本轮不宣称完成完整 `runtime story action` 迁移,也不把 `inventory / npc / story AI 续写` 直接耦进战斗 reducer跨子域写入继续收敛在 `spacetime-module` 聚合层。
---
## 1. 本轮落地范围
本轮只做下面 5 件事:
1. 新增 `server-rs/crates/module-combat/` 真实 crate。
2. 冻结 `battle_state` 的首版领域类型、枚举、输入结构与字段校验 helper。
3. 冻结 `resolve_combat_action` 的首版输入、输出与纯规则推进逻辑。
4.`server-rs/crates/spacetime-module/` 中新增 `battle_state` 表。
5.`spacetime-module` 中新增 `create_battle_state``resolve_combat_action` 两个 reducer。
---
## 2. 当前冻结的实现边界
### 2.1 首版必须支持的战斗 function
首版与 [../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md) 保持一致,只支持以下单行为入口:
1. `battle_attack_basic`
2. `battle_recover_breath`
3. `battle_use_skill`
4. `battle_escape_breakout`
5. 旧兼容攻击类:
- `battle_all_in_crush`
- `battle_guard_break`
- `battle_probe_pressure`
- `battle_feint_step`
- `battle_finisher_window`
本轮刻意不接入:
1. `inventory_use`
2. 技能与物品的正式外部明细读取
3.`quest_record``npc_state` 的联动写入
4. 脱战后 `story_event` 追加与 AI 续写触发
### 2.2 为什么先不做 `inventory_use`
当前 Rust 侧还没有 `inventory_slot` 正式表,也没有稳定的战斗内物品快照输入。
如果现在把 `inventory_use` 硬塞进 `module-combat`,只会出现两种坏结果:
1. reducer 内部引入并不存在的 inventory 真相依赖;
2. 退回成“让 Axum 先算完再写 battle_state”的伪迁移。
因此本轮明确冻结为:
1. `module-combat` 先完成纯战斗状态推进;
2. `inventory_use` 留到 `inventory_slot` 与 runtime snapshot projection 口径稳定后再接。
---
## 3. `battle_state` 首版字段
首版 `battle_state` 冻结为以下字段:
1. `battle_state_id`
2. `story_session_id`
3. `runtime_session_id`
4. `actor_user_id`
5. `target_npc_id`
6. `target_name`
7. `battle_mode`
8. `status`
9. `player_hp`
10. `player_max_hp`
11. `player_mana`
12. `player_max_mana`
13. `target_hp`
14. `target_max_hp`
15. `chapter_id`
16. `experience_reward`
17. `reward_items`
18. `turn_index`
19. `last_action_function_id`
20. `last_action_text`
21. `last_result_text`
22. `last_damage_dealt`
23. `last_damage_taken`
24. `last_outcome`
25. `version`
26. `created_at`
27. `updated_at`
### 3.1 设计意图
首版只解决下面这些真相问题:
1. 当前战斗是否存在、是否仍在进行中;
2. 玩家与当前目标的 HP / MP 最小数值状态;
3. 当前是 `fight` 还是 `spar`
4. 当前战斗归属哪个章节;
5. 本场战斗若胜利应发多少经验;
6. 本场战斗若胜利应发哪些已编译好的 reward item
7. 最近一次动作结算了什么;
8. 当前 battle reducer 是否发生过版本推进。
### 3.2 当前刻意不放入的字段
本轮明确不放:
1. 多目标列表
2. 技能冷却 map
3. build buff 详情
4. 掉落预算、好感预算、剧情上下文大对象
5. 大型 `rawGameState` 镜像字段
原因很直接:这些都属于后续跨子域联动层,不适合在 `battle_state` 首版里重新堆一个大 JSON。
---
## 4. 枚举与动作口径
### 4.1 `BattleMode`
只保留两种:
1. `Fight`
2. `Spar`
### 4.2 `BattleStatus`
只保留三种:
1. `Ongoing`
2. `Resolved`
3. `Aborted`
说明:
1. `Resolved` 表示战斗已正常收束,包括胜利、切磋结束、成功逃脱。
2. `Aborted` 预留给后续 session 中断、外部清理、投影回滚等异常收束场景。
### 4.3 `CombatOutcome`
首版冻结:
1. `Ongoing`
2. `Victory`
3. `SparComplete`
4. `Escaped`
这与当前共享契约里的 `RuntimeBattlePresentation.outcome` 一致,避免首版就制造新的枚举翻译成本。
---
## 5. `resolve_combat_action` 首版规则
### 5.1 输入
首版 reducer 输入只包含:
1. `battle_state_id`
2. `function_id`
3. `action_text`
4. `base_damage`
5. `mana_cost`
6. `heal`
7. `mana_restore`
8. `counter_multiplier`
9. `updated_at_micros`
### 5.2 为什么允许输入 `base_damage`
本轮 `module-combat` 的职责是把战斗推进规则固定到 SpacetimeDB。
但玩家技能、装备 build、物品 buff、成长曲线这些正式真相仍未迁完因此首版允许上游把已算好的 `base_damage / mana_cost / heal / mana_restore` 作为确定输入传进 reducer。
这意味着当前模块边界是:
1. `module-combat` 负责状态推进、反击、逃跑、战斗收束规则;
2. 更高层的 build / skill / item 数值来源仍可在后续模块中逐步收敛;
3.`inventory / progression / runtime build` 真相表稳定后,再继续把这些输入收得更窄。
### 5.3 动作规则
#### A. `battle_escape_breakout`
直接结束战斗:
1. `status = Resolved`
2. `last_outcome = Escaped`
3. `last_damage_dealt = 0`
4. `last_damage_taken = 0`
#### B. `battle_recover_breath`
恢复类动作:
1. 玩家回复 `heal`
2. 玩家回复 `mana_restore`
3. 若战斗仍持续,则按 `counter_multiplier` 吃一次敌方反击
#### C. `battle_attack_basic` / 旧兼容攻击类 / `battle_use_skill`
攻击类动作:
1. 目标扣除 `base_damage`
2. 若目标已收束,则按 `battle_mode` 进入 `Victory / SparComplete`
3. 若目标未收束,则玩家按 `counter_multiplier` 吃一次敌方反击
### 5.4 反击规则
首版固定:
1. `fight` 下敌方基础反击伤害 = `max(4, round(target_max_hp * 0.14 * counter_multiplier))`
2. `spar` 下敌方基础反击伤害固定为 `1`
这是对当前 Node 逻辑的直接收敛,先保证行为方向不漂移,不在本轮发明新的战斗公式。
### 5.5 HP 下限规则
1. `fight` 下正常下限为 `0`
2. `spar` 下双方 HP 最低保留为 `1`
这样能保留当前“切磋点到为止”的旧行为,不把 `spar` 错结算成死亡战斗。
---
## 6. `spacetime-module` 接线口径
### 6.1 battle_state 表
`spacetime-module` 首版只新增一张 private 真相表:
1. `battle_state`
建议索引:
1. `by_story_session_id`
2. `by_runtime_session_id`
3. `by_actor_user_id`
### 6.2 reducer
当前仍只保留两个战斗 reducer
1. `create_battle_state`
2. `resolve_combat_action`
职责:
1. `create_battle_state` 只负责插入 battle 真相,不负责故事会话编排。
2. `resolve_combat_action` 负责推进 battle 真相。
3.`Victory` 收束时,由 `spacetime-module` 聚合层继续把 `experience_reward` 联动写入 `player_progression / chapter_progression`
4.`Victory` 收束且 `reward_items` 非空时,由 `spacetime-module` 聚合层继续把战利品写入 `inventory_slot`
5. `resolve_combat_action` 仍不负责 AI 续写和 quest signal 全量分发。
---
## 7. 与后续子域的边界
### 7.1 与 `story`
当前关系:
1. `story` 负责更高层 action 路由与后续 story_event 追加;
2. `combat` 只返回 battle 真相推进结果。
后续再补:
1. 战斗结束时的 `story_event`
2. 脱战后的 `continue_story` / `resolve_story_action`
### 7.2 与 `inventory`
当前不直接耦合到 `module-combat` reducer。
后续再补:
1. 战斗内 `inventory_use`
2. 消耗品扣减
3. 战斗 buff 写入
当前已存在的聚合层联动:
1. `Victory` 时可把 `battle_state.reward_items` 写入 `inventory_slot`
### 7.3 与 `progression`
当前不直接在 `module-combat` reducer 内发经验与等级变更。
后续再补:
1. hostile scaling 与 reward 编译口径
当前已存在的聚合层联动:
1. `fight_victory` 的经验发放
2. 章节账本写入
### 7.4 与 `npc`
当前不直接改好感。
后续再补:
1. `spar_complete` 的 affinity 变化
2. `fight / spar` 与 encounter 状态同步
---
## 8. 本轮验收口径
满足以下条件,视为本轮 `module-combat` 基线完成:
1. `server-rs/crates/module-combat` 已从 README 占位升级为真实 crate。
2. `battle_state``BattleMode``BattleStatus``CombatOutcome``ResolveCombatActionInput` 已冻结到代码。
3. `spacetime-module` 已新增 `battle_state` 表。
4. `spacetime-module` 已新增 `create_battle_state``resolve_combat_action` reducer。
5. `cargo check -p module-combat -p spacetime-module` 通过。
---
## 9. 下一步建议
在本轮基线稳定后,下一步按以下顺序推进最稳:
1. 设计 `inventory_slot` 与战斗内 `inventory_use` 的最小真相输入。
2. 设计 `resolve_story_action` 如何编排 `story + combat + npc + quest + inventory`
3.`battle_state` 结束事件接入 `story_event`
4. 再把 Axum facade 与 `RuntimeStoryActionResponse.battle` 真正打通。

View File

@@ -0,0 +1,202 @@
# M4 module-combat battle state 查询设计2026-04-22
更新时间:`2026-04-22`
补充状态:`2026-04-22`
当前 battle query 纵切片已经完成到“真实可编译、可生成 binding、可被 Axum 调用”的状态:
1. `spacetime-module` 中的 `get_battle_state` procedure 已稳定存在。
2. `spacetime-client/src/module_bindings` 已重新执行 `spacetime generate`,当前已真实包含:
- `battle_state_query_input_type`
- `get_battle_state_procedure`
- `battle_state.reward_items` 对应字段
3. `spacetime-client/src/lib.rs` 里原本返回“binding 尚未生成”的占位 `get_battle_state(...)` 已替换为真实 procedure 调用。
4. `cargo check -p spacetime-client``cargo check -p api-server` 已再次通过。
当前仍未完成的只有长时回归验证:
1. `cargo test -p api-server --bin api-server story_battles --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 在当前机器上编译耗时较长,尚未在单次时窗内拿到最终断言结果。
2. `npm run check:encoding` 已启动但尚未在单次时窗内跑完。
## 0. 文档目标
本文件只冻结当前 `M4` 的一个最小新增切片:
**新增 `GET /api/story/battles/:battleStateId`,让 Axum 能从 `SpacetimeDB` 同步读取单个 `battle_state` 当前快照,不提前承诺旧 runtime story state 兼容。**
这轮目标不是实现旧 `GET /api/runtime/story/state/:sessionId` 的战斗子视图兼容,也不是把 `battle + story_event + currentStory` 一次性收口进 `resolve_story_action`
---
## 1. 为什么先补这个切片
当前 battle 链路已经具备:
1. `module-combat` 已冻结 `battle_state` 领域类型与纯结算规则。
2. `spacetime-module` 已有 `create_battle_state_and_return``resolve_combat_action_and_return`
3. `spacetime-client``api-server` 已能创建战斗并推进单次动作。
但现在仍缺一个最基本的恢复能力:
1. battle 建立后Axum 还不能按 `battle_state_id` 重新读取真相态。
2. 页面刷新、重连或后续 story 编排都缺一个稳定的单战斗查询入口。
3. 后续若要把 battle 收口进 `resolve_story_action`,也需要先有独立 battle query 可复用。
因此本轮先补最小 `battle state` 查询切片,不提前跳到更重的 runtime story 兼容。
---
## 2. 当前冻结范围
本轮只包含以下能力:
1. 新增公开接口:`GET /api/story/battles/:battleStateId`
2. 认证方式Bearer JWT
3. 数据来源:`SpacetimeDB procedure get_battle_state`
4. 返回体只包含:
- `battleState`
本轮明确不做:
1. 不兼容旧 `GET /api/runtime/story/state/:sessionId`
2. 不补 battle 列表查询
3. 不做 `battle_state` 订阅与 cache 读模型
4. 不在查询链路里拼装 `story_event / npc / quest / inventory`
5. 不把 battle query 直接拼回旧 `RuntimeStoryActionResponse`
---
## 3. 接口 contract
### 3.1 请求
- 方法:`GET`
- 路径:`/api/story/battles/:battleStateId`
- 认证:必须携带 Bearer JWT
- 路径参数:
- `battleStateId`:目标战斗状态 ID
### 3.2 成功响应
成功响应延续当前 `api-server` 统一 envelope`data` 字段结构为:
```json
{
"battleState": {
"battleStateId": "battle_xxx",
"storySessionId": "storysess_xxx",
"runtimeSessionId": "runtime_xxx",
"actorUserId": "user_xxx",
"chapterId": "chapter_xxx",
"targetNpcId": "npc_xxx",
"targetName": "黑爪狼",
"battleMode": "fight",
"status": "ongoing",
"playerHp": 42,
"playerMaxHp": 60,
"playerMana": 12,
"playerMaxMana": 20,
"targetHp": 18,
"targetMaxHp": 30,
"experienceReward": 18,
"rewardItems": [],
"turnIndex": 1,
"lastActionFunctionId": "battle_attack_basic",
"lastActionText": "普通攻击",
"lastResultText": "普通攻击命中了黑爪狼,本次攻击已经完成结算。",
"lastDamageDealt": 12,
"lastDamageTaken": 4,
"lastOutcome": "ongoing",
"version": 2,
"createdAt": "2026-04-22T00:00:00.000000Z",
"updatedAt": "2026-04-22T00:01:00.000000Z"
}
}
```
### 3.3 错误响应
当前延续 battle facade 已有策略:
1. `SpacetimeClientError::Runtime(_)` 映射为 `400`
2. 其他 `SpacetimeClientError` 映射为 `502`
3. 错误 `details.provider` 固定为 `spacetimedb`
---
## 4. 分层职责
### 4.1 `module-combat`
职责:
1. 冻结 `BattleStateQueryInput`
2. 负责 query input builder 与 validator
3. 继续复用 `BattleStateProcedureResult` 作为最小查询返回壳
不负责:
1. HTTP 路径解析
2. JWT 鉴权
3. battle view model 编译
### 4.2 `spacetime-module`
职责:
1. 读取 `battle_state`
2. 校验 `battle_state_id`
3. 返回单个 `BattleStateSnapshot`
### 4.3 `spacetime-client`
职责:
1. 构造 `BattleStateQueryInput`
2. 调用 `get_battle_state`
3. 把 generated binding 结果映射为 `BattleStateRecord`
当前实现补充:
1. `reward_items` 已按 generated binding 映射回 `BattleStateRecord.reward_items`,不再用空集合占位。
2. battle query 当前不再依赖 façade stub 或手写假返回。
### 4.4 `api-server`
职责:
1. 暴露 `GET /api/story/battles/:battleStateId`
2. 做 Bearer JWT 鉴权
3. 透传 `battleStateId`
4.`BattleStateRecord` 映射到 battle JSON payload
---
## 5. 验收口径
本轮验收只要求以下几点:
1. `api-server` 路由树已真实挂出该接口
2. 未登录访问返回 `401`
3.`SpacetimeDB` 未发布或未连通时返回 `502`
4. `cargo test -p api-server story_battles` 可通过
5. `cargo check -p module-combat -p spacetime-module -p spacetime-client -p api-server` 可通过
6. `npm run check:encoding` 已执行,确保新增中文文档没有编码损坏
当前验证状态:
1. 第 5 条已达成。
2. 第 4、6 条仍在继续追,不应提前宣称通过。
---
## 6. 后续边界
这条最小 battle query 落地后,后续再继续拆下一层:
1. 评估 battle 查询是否需要补 actor ownership 校验
2. 设计 battle 结束事件如何接入 `story_event`
3. 再把 battle query 与 `story state / resolve_story_action / currentStory` 汇总到更高层编排
在这些 contract 未冻结前,不应把当前接口误称为“旧 runtime story state 已迁移完成”。

View File

@@ -0,0 +1,188 @@
# M4 module-npc battle Axum facade 设计2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把已经在 `spacetime-module` 落地的 `resolve_npc_battle_interaction_and_return` procedure 再向上接到 `spacetime-client` 与 `api-server`,并允许 HTTP 侧透传已编译好的 `experience_reward / reward_items`,形成可直接调用的 NPC 开战同步返回链。**
这不是完整 `resolve_story_action` 兼容设计,也不替代后续 runtime story 总入口编排。
---
## 1. 本轮要解决的问题
当前仓库已经具备:
1. `module-npc`
- `resolve_npc_interaction`
- `npc_fight / npc_spar -> BattlePending`
2. `module-combat`
- `battle_state`
- `resolve_combat_action`
3. `spacetime-module`
- `resolve_npc_battle_interaction_and_return`
- 同事务写入 `npc_state + battle_state`
但当前仍缺两层:
1. `spacetime-client` 还没有对应 facade
2. `api-server` 还没有独立 NPC 开战 HTTP 入口
因此本轮只补下面两层:
1. `spacetime-client` facade
2. `api-server` HTTP route
---
## 2. 当前刻意不做的事
本轮明确不做:
1. 不兼容旧 `POST /api/runtime/story/actions/resolve`
2. 不把 `npc_chat / npc_help / npc_recruit / npc_leave` 一起搬成统一 HTTP facade
3. 不在接口层现场计算章节自动定级、经验奖励、掉落、story_event 自动联动
4. 不把 battle 结果直接拼进旧 `RuntimeStoryActionResponse`
原因很直接:
1. 这轮目标只是把 `npc_fight / npc_spar` 的同步返回链闭环
2. 更高层 story action 编排仍应等待 `resolve_story_action` 统一设计
---
## 3. `spacetime-client` 口径
### 3.1 新增 facade
本轮新增:
1. `resolve_npc_battle_interaction`
### 3.2 输入
直接复用 `spacetime-module` procedure 输入:
1. `ResolveNpcBattleInteractionInput`
客户端 facade 负责:
1.`resolve_npc_battle_interaction_and_return`
2. 把 binding 结果映射成 Rust record
3. 统一沿用现有 `oneshot + timeout` 返回模式
### 3.3 输出
本轮冻结新的 client record
1. `NpcStateRecord`
2. `NpcInteractionRecord`
3. `NpcBattleInteractionRecord`
这样可以避免 `api-server` 直接依赖 generated binding 结构。
---
## 4. `api-server` 口径
### 4.1 路由
本轮新增:
1. `POST /api/story/npc/battle`
这条路由的定位是:
1. 独立的 NPC 开战 facade
2. 明确只处理 `npc_fight / npc_spar`
3. 返回:
- `npcInteraction`
- `battleState`
### 4.2 输入
首版 HTTP 请求字段冻结为:
1. `storySessionId`
2. `runtimeSessionId`
3. `npcId`
4. `npcName`
5. `interactionFunctionId`
- 当前只允许:
- `npc_fight`
- `npc_spar`
6. `releaseNpcId`
7. `battleStateId`
8. `playerHp`
9. `playerMaxHp`
10. `playerMana`
11. `playerMaxMana`
12. `targetHp`
13. `targetMaxHp`
14. `experienceReward`
- 默认 `0`
- 只接受上游已编译好的确定值
15. `rewardItems`
- 默认空数组
- 每项字段与 `RuntimeItemRewardItemSnapshot` 对齐
- `rarity` 固定使用:
- `common`
- `uncommon`
- `rare`
- `epic`
- `legendary`
- `equipmentSlotId` 当前只允许:
- `weapon`
- `armor`
- `relic`
### 4.3 返回
当前 HTTP 成功响应冻结为:
1. `npcInteraction`
2. `battleState`
其中:
1. `npcInteraction` 保留:
- `npcState`
- `interactionStatus`
- `actionText`
- `resultText`
- `storyText`
- `battleMode`
- `encounterClosed`
- `affinityChanged`
- `previousAffinity`
- `nextAffinity`
2. `battleState` 继续复用 battle facade 已有 payload 结构
---
## 5. 错误策略
与现有 `story_battles` / `story_sessions` 保持一致:
1. `SpacetimeClientError::Runtime`
- 映射 `400`
2. 其他 Spacetime 调用错误
- 映射 `502`
错误 body 继续统一返回:
1. `provider`
2. `message`
---
## 6. 后续建议
在这条 facade 稳定后,下一步按下面顺序推进:
1. 让前端 runtime story action 先走这条独立 NPC 开战入口
2. 再把 battle 初始化所需的 NPC 等级、经验奖励、reward item 编译来源、章节信息收口进更高层编排
3. 最后再统一进完整 `resolve_story_action`

View File

@@ -0,0 +1,151 @@
# M4 module-npc 与 module-combat 联合编排基线2026-04-21
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**在不污染 `module-npc` 纯领域边界的前提下,把 `npc_fight / npc_spar` 从“只返回 `BattlePending` 语义”推进到“可在 `spacetime-module` 聚合层同步创建 `battle_state`”的最小联合编排口径。**
这不是完整 `resolve_story_action` 设计,也不是完整战斗奖励编译、经验预算和剧情续写迁移。
---
## 1. 本轮落地范围
本轮只落实下面 4 件事:
1. 明确 `module-npc` 继续只负责 NPC 交互语义,不直接依赖 `module-combat`
2.`spacetime-module` 聚合层新增 `resolve_npc_battle_interaction_and_return` procedure。
3. 让该 procedure 在同一事务内完成:
- `resolve_npc_interaction`
- `battle_state` 初始化写入
4. 返回统一结果,供后续 `spacetime-client` / Axum facade 直接消费。
---
## 2. 为什么不把 battle 初始化塞进 module-npc
原因很直接:
1. `module-npc` 当前职责是 `npc_state / relation_state / stance_profile / interaction contract`
2. `battle_state` 属于 `module-combat` 真相,不应倒灌进 NPC 领域 crate。
3. 如果把玩家 HP / MP、战斗生命、故事会话 ID 这些字段直接塞进 `ResolveNpcInteractionInput`,会把 `module-npc` 再次膨胀成跨子域入口。
因此本轮明确冻结为:
1. `module-npc`
- 继续只返回 `BattlePending + battle_mode`
2. `spacetime-module`
- 负责把 NPC 交互结果编排成真正的 `battle_state`
---
## 3. 新增 procedure 口径
### 3.1 名称
新增:
1. `resolve_npc_battle_interaction_and_return`
### 3.2 输入
首版输入冻结为:
1. `npc_interaction`
- 原样复用 `ResolveNpcInteractionInput`
- 当前只允许 `npc_fight / npc_spar`
2. `story_session_id`
3. `actor_user_id`
4. `battle_state_id`
- 允许为空
- 为空时按 `updated_at_micros` 自动派生
5. `player_hp`
6. `player_max_hp`
7. `player_mana`
8. `player_max_mana`
9. `target_hp`
10. `target_max_hp`
11. `experience_reward`
- 由上游作为已编译好的确定奖励透传
- 当前允许为 `0`
12. `reward_items`
- 类型固定为 `Vec<module-runtime-item::RuntimeItemRewardItemSnapshot>`
- 只承接已经编译好的战利品快照,不在 procedure 内现场生成
### 3.3 输出
当前返回:
1. `interaction`
- `module-npc::NpcInteractionResult`
2. `battle_state`
- `module-combat::BattleStateSnapshot`
也就是说,这个 procedure 明确是一个**聚合返回口径**,不是新的底层领域真相。
---
## 4. 当前事务流程
单次调用按下面顺序执行:
1. 校验 `story_session_id / actor_user_id`
2. 校验 `interaction_function_id` 必须是:
- `npc_fight`
- `npc_spar`
3. 先执行 `resolve_npc_interaction_record`
- 写入最新 `npc_state`
- 拿到 `NpcInteractionResult`
4.`NpcInteractionResult.battle_mode` 映射出 `BattleMode`
5. 组装 `BattleStateInput`
- 透传 `experience_reward`
- 透传 `reward_items`
6. 复用 `module-combat``validate_battle_state_input`
7. 插入 `battle_state`
8. 返回:
- `interaction`
- `battle_state`
---
## 5. 当前刻意未做
本轮明确不做下面这些扩张:
1. 不在这个 procedure 里直接发经验
2. 不在这个 procedure 里直接记 `chapter_progression`
3. 不在这个 procedure 里直接写 `story_event`
4. 不在这个 procedure 里现场计算掉落或经验预算
5. 不在这个 procedure 里直接执行 `inventory_slot` 发物
5. 不在这个 procedure 里直接接 `resolve_combat_action`
6. 不在这个 procedure 里推导敌方等级、强度、掉落预算
也就是说,这一层当前只解决:
**NPC 宣告开战后,如何立刻把 battle 真相表连同已编译奖励真相一起建立起来。**
---
## 6. 与现有文档的关系
本文件是对下面两份基线文档的补充,而不是替代:
1. `M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md`
- 继续定义 NPC 领域 contract
2. `M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md`
- 继续定义 battle_state 与单行为战斗推进规则
新增编排只发生在 `spacetime-module` 聚合层。
---
## 7. 下一步建议
在这条最小联合编排稳定后,后续按下面顺序推进最稳:
1. 把 Node 侧 `monster_drop` / hostile reward 编译逻辑收口到 Rust 聚合层。
2. 再把章节自动定级、敌对经验预算和 `chapter_progression` 所需章节上下文收口进 battle 初始化编译器。
3. 最后把这条链收口进完整 `resolve_story_action`

View File

@@ -0,0 +1,273 @@
# M4 module-npc SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-npc` 从“只有占位 README”推进到“已有可编译 Rust 领域 contract并接入 `SpacetimeDB` 最小 `npc_state` 真相表与社交动作 reducer/procedure”的真实落地结果。**
本轮只做最小基座,不扩到完整 `npc_trade / npc_gift / npc_recruit` 的全链结算迁移,也不改前端 UI。
---
## 1. 本轮落地范围
本轮只落实下面 6 件事:
1. 新增 `server-rs/crates/module-npc/` 真实 crate而不是继续停留在目录占位。
2.`module-npc` 中冻结 `relation_state / stance_profile / npc_state` 的首版领域类型与校验 helper。
3.`module-npc` 中补齐 `build_initial_stance_profile``normalize_npc_state_snapshot``apply_npc_social_action` 等最小规则原语。
4.`server-rs/crates/spacetime-module/` 中新增 `npc_state` 表。
5.`spacetime-module` 中新增 `upsert_npc_state``resolve_npc_social_action` 及对应 procedure形成最小可编译写入口。
6.`module-npc` 中新增 `resolve_npc_interaction` 的首版领域 contract并在 `spacetime-module` 中补对应 reducer / procedure。
---
## 2. 本轮新增的真实工程落点
### 2.1 新增 crate
1. `server-rs/crates/module-npc/Cargo.toml`
2. `server-rs/crates/module-npc/src/lib.rs`
### 2.2 workspace 与主工程聚合
1. `server-rs/Cargo.toml`
- 已把 `crates/module-npc` 纳入 workspace members
2. `server-rs/crates/spacetime-module/Cargo.toml`
- 已接入 `module-npc` 依赖
3. `server-rs/crates/spacetime-module/src/lib.rs`
- 已接入 `module-npc` 类型
- 已新增 `npc_state`
- 已新增 `upsert_npc_state`
- 已新增 `upsert_npc_state_and_return`
- 已新增 `resolve_npc_social_action`
- 已新增 `resolve_npc_social_action_and_return`
- 已新增 `resolve_npc_interaction`
- 已新增 `resolve_npc_interaction_and_return`
---
## 3. 当前冻结的数据口径
### 3.1 `relation_state`
当前首版冻结为:
1. `affinity`
2. `stance`
`stance` 当前只冻结 5 档:
1. `Hostile`
2. `Guarded`
3. `Neutral`
4. `Cooperative`
5. `Bonded`
当前阈值直接对齐现有前端 / Node 原语:
1. `< 0` -> `Hostile`
2. `< 15` -> `Guarded`
3. `< 30` -> `Neutral`
4. `< 60` -> `Cooperative`
5. `>= 60` -> `Bonded`
### 3.2 `stance_profile`
当前首版冻结为:
1. `trust`
2. `warmth`
3. `ideological_fit`
4. `fear_or_guard`
5. `loyalty`
6. `current_conflict_tag`
7. `recent_approvals`
8. `recent_disapprovals`
字段策略:
1. 数值统一收敛到 `0 ~ 100`
2. 最近好评 / 反感文本统一只保留最近 `3` 条。
3. `current_conflict_tag` 仍允许为空,不在本轮强绑世界线程 ID。
### 3.3 `npc_state`
当前首版字段冻结为:
1. `npc_state_id`
2. `runtime_session_id`
3. `npc_id`
4. `npc_name`
5. `affinity`
6. `relation_state`
7. `help_used`
8. `chatted_count`
9. `gifts_given`
10. `recruited`
11. `trade_stock_signature`
12. `revealed_facts`
13. `known_attribute_rumors`
14. `first_meaningful_contact_resolved`
15. `seen_backstory_chapter_ids`
16. `stance_profile`
17. `created_at`
18. `updated_at`
当前策略:
1. `npc_state` 保持 private 真相表口径。
2. `npc_state_id` 允许由 `runtime_session_id + npc_id` 自动派生,避免外部每次重复拼接。
3. `relation_state` 作为显式冗余字段落表,避免每次读取都重复派生。
4. `npc_name` 当前保留为调试与兼容聚合字段,不承担唯一键职责。
---
## 4. 当前 reducer / procedure 口径
### 4.1 `upsert_npc_state`
当前负责:
1. 校验 `runtime_session_id / npc_id / npc_name`
2. 归一化 `stance_profile`
3. 归一化 `relation_state`
4.`npc_state_id` 为主键执行幂等写入
### 4.2 `resolve_npc_social_action`
当前只承接 **纯 NPC 关系状态** 的最小变更,不负责背包、任务、队伍、战斗副作用。
当前动作冻结为:
1. `Chat`
2. `Help`
3. `Gift`
4. `Recruit`
5. `QuestAccept`
当前规则:
1. `Chat`
- 默认按 `max(2, 6 - chatted_count)` 推进好感
- 递增 `chatted_count`
- 强制标记 `first_meaningful_contact_resolved = true`
2. `Help`
- 若已使用过援手则拒绝
- 默认推进 `4` 点好感
- 写入 `help_used = true`
3. `Gift`
- 递增 `gifts_given`
- 默认按 `4` 点好感推进,允许外部显式传入覆盖值
4. `Recruit`
- 若当前好感 `< 60` 则拒绝
- 写入 `recruited = true`
- 同时标记首遇已完成
5. `QuestAccept`
- 默认推进 `3` 点好感
- 只改 NPC 关系侧立场数据,不直接落 quest 真相
当前 procedure 仅返回最新 `NpcStateSnapshot`,不在本轮提前扩出 story patch / UI 文案 contract。
### 4.3 `resolve_npc_interaction`
当前首版 `resolve_npc_interaction` 不直接承担所有跨子域副作用,而是先固定 **NPC 单次正式交互** 的最小统一结果口径。
当前输入冻结为:
1. `runtime_session_id`
2. `npc_id`
3. `npc_name`
4. `interaction_function_id`
5. `updated_at_micros`
6. `release_npc_id`(仅为后续招募换队预留,当前不在 Rust 侧正式消费)
当前支持的 function 只冻结为:
1. `npc_preview_talk`
2. `npc_chat`
3. `npc_help`
4. `npc_recruit`
5. `npc_fight`
6. `npc_spar`
7. `npc_leave`
当前输出冻结为:
1. `npc_state`
2. `interaction_status`
3. `action_text`
4. `result_text`
5. `story_text`
6. `battle_mode`
7. `encounter_closed`
8. `affinity_changed`
9. `previous_affinity`
10. `next_affinity`
当前规则:
1. `npc_preview_talk`
- 只把交互状态切到 `Previewed`
- 不改好感
2. `npc_chat`
- 复用 `resolve_npc_social_action(Chat)` 的关系推进
- 返回 `interaction_status = Dialogue`
3. `npc_help`
- 复用 `resolve_npc_social_action(Help)`
- 返回 `interaction_status = Resolved`
4. `npc_recruit`
- 当前只负责把 `npc_state.recruited = true`
- 不在本轮承担 companion / roster 真相写入
- 返回 `interaction_status = Recruited`
5. `npc_fight`
- 不改 `npc_state.affinity`
- 返回 `interaction_status = BattlePending`
- `battle_mode = Fight`
6. `npc_spar`
- 不改 `npc_state.affinity`
- 返回 `interaction_status = BattlePending`
- `battle_mode = Spar`
7. `npc_leave`
- 不改关系真相
- 返回 `interaction_status = Left`
- `encounter_closed = true`
当前刻意不做:
1. 不直接生成 `RuntimeStoryPatch`
2. 不直接写 `companions / roster / inventory_slot`
3. 不直接把玩家 HP / MP、切磋战斗目标、战斗奖励塞进这个 reducer
也就是说,这一层当前只负责把 **Node 侧 `resolveNpcInteraction` 的统一入口语义** 先冻结为可编译 contract不宣称已经迁完全部副作用。
---
## 5. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有落 `npc_trade` 的库存与价格正式结算
2. 还没有落 `npc_gift` 的背包扣减与物品收益结算
3. 还没有落 `npc_recruit` 的队伍替换与 companion 真相迁移
4. `npc_fight / npc_spar` 的正式 `battle_state` 初始化编排不在 `module-npc` crate 内部完成,而是下沉到 `spacetime-module` 聚合 procedure
5. 还没有把 `custom world``narrativeProfile / backstoryReveal` 真正投影进 SpacetimeDB
6. 还没有把 Node 侧 `npcInteractionService` 全量切到 `server-rs`
7. 还没有给前端接入 `SpacetimeDB` 的 NPC 订阅读模型
也就是说,本轮只是把 **NPC 关系状态基座** 立起来,不宣称已经完成完整 NPC 子域迁移。
---
## 6. 下一步建议
后续应继续按下面顺序推进:
1.`npc_recruit` 的 companion / roster 真相迁移拆成 `module-npc + module-runtime + module-story` 的联合 reducer 设计。
2.`spacetime-client` / Axum 侧继续把 `npc_fight / npc_spar``battle_state` 联合编排接口接出来。
3.`npc_trade / npc_gift` 的正式库存、扣减与收益迁到 `inventory / runtime-item` 联动链。
4.`backstoryReveal / privateChatUnlockAffinity / narrativeProfile` 的可见性规则投成显式读模型。
5. 再接 `api-server` 的 NPC facade 与前端 runtime action。

View File

@@ -0,0 +1,174 @@
# M4 Module Progression SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-progression` 从“只有 README 占位”推进到“SpacetimeDB 侧已有最小可编译成长真相基座”的真实落地结果。**
本轮先落等级/经验/章节计划与记账的最小领域骨架,并补上任务交付与战斗胜利的自动联动;不扩到完整 `custom-world` 章节蓝图编译或 Axum facade 全链迁移。
---
## 1. 本轮落地范围
本轮按 `LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md``SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md` 的交叉口径,只落实下面 6 件事:
1. 新增 `server-rs/crates/module-progression/` 真实 crate而不是继续停留在 README 占位。
2.`module-progression` 中冻结 `LevelBenchmark``PlayerProgressionSnapshot``ChapterProgressionSnapshot``RuntimeEntityLevelProfile` 的首版领域类型。
3.`module-progression` 中落地与当前 Node 版一致的等级经验曲线、参考强度曲线、章节 pseudo level 曲线与敌对战斗 fallback 规则。
4.`server-rs/crates/spacetime-module/` 中新增 `player_progression``chapter_progression` 两张表。
5.`spacetime-module` 中新增 `get_player_progression_or_default``grant_player_progression_experience``upsert_chapter_progression``apply_chapter_progression_ledger_entry` 及对应 procedure。
6.`module-story / module-quest` 同口径方式,把成长状态固定成“单用户成长表 + 单章节计划/记账表”的最小可编译基座。
---
## 2. 本轮新增的真实工程落点
### 2.1 新增 crate
1. `server-rs/crates/module-progression/Cargo.toml`
2. `server-rs/crates/module-progression/src/lib.rs`
### 2.2 workspace 与主工程聚合
1. `server-rs/Cargo.toml`
- 已把 `crates/module-progression` 纳入 workspace members
2. `server-rs/crates/spacetime-module/Cargo.toml`
- 已接入 `module-progression` 依赖
3. `server-rs/crates/spacetime-module/src/lib.rs`
- 已接入 `module-progression` 类型
- 已新增 `player_progression`
- 已新增 `chapter_progression`
- 已新增成长相关 reducer / procedure
---
## 3. 当前冻结的数据口径
### 3.1 `player_progression`
当前首版字段冻结为:
1. `user_id`
2. `level`
3. `current_level_xp`
4. `total_xp`
5. `xp_to_next_level`
6. `pending_level_ups`
7. `last_granted_source`
8. `created_at`
9. `updated_at`
当前策略:
1. `player_progression` 保持 private 真相表口径。
2. 当前统一按 `user_id` 单行持久化,不在本轮拆历史 grant 日志表。
3. 若记录不存在,`procedure` 返回 `Lv.1 / 0 XP` 默认快照,但不额外写库。
### 3.2 `chapter_progression`
当前首版字段冻结为:
1. `chapter_progression_id`
2. `user_id`
3. `chapter_id`
4. `chapter_index`
5. `total_chapters`
6. `entry_pseudo_level_millis`
7. `exit_pseudo_level_millis`
8. `entry_level`
9. `exit_level`
10. `planned_total_xp`
11. `planned_quest_xp`
12. `planned_hostile_xp`
13. `actual_quest_xp`
14. `actual_hostile_xp`
15. `expected_hostile_defeat_count`
16. `actual_hostile_defeat_count`
17. `level_at_entry`
18. `level_at_exit`
19. `pace_band`
20. `created_at`
21. `updated_at`
当前策略:
1. `chapter_progression` 先用一张表同时承接 `ChapterProgressionPlan``ChapterExperienceLedger`
2. 当前不再额外拆计划表和记账表,避免首轮 schema 还没稳定就二次改表。
3. 主键固定为 `user_id + chapter_id` 组合衍生 ID保证同一玩家每章只有一条真相记录。
---
## 4. 当前 reducer / procedure 口径
### 4.1 `get_player_progression_or_default`
当前负责:
1.`user_id` 读取 `player_progression`
2. 若不存在则返回默认 `Lv.1 / 0 XP`
3. 不产生额外写入
### 4.2 `grant_player_progression_experience`
当前负责:
1. 读取当前 `player_progression`,不存在则按默认成长态开始
2. 根据 `PlayerProgressionGrantInput` 发放经验
3. 统一更新 `level / current_level_xp / total_xp / xp_to_next_level / pending_level_ups`
4. 固定 `last_granted_source`
### 4.3 `upsert_chapter_progression`
当前负责:
1. 写入或覆盖某玩家某章节的计划快照
2. 固定章节预算、目标等级带与预期击杀数
3. 把实际记账字段初始化为 `0`
### 4.4 `apply_chapter_progression_ledger_entry`
当前负责:
1. 在已存在的章节计划上累计 `actual_quest_xp`
2. 累计 `actual_hostile_xp`
3. 累计 `actual_hostile_defeat_count`
4. 可选更新 `level_at_exit`
---
## 5. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有把 `sceneChapterBlueprints` 的完整编译逻辑迁到 Rust。
2. 还没有落 `repeatPenalty`、超预算衰减与章节偏差评级输出。
3. 还没有把完整章节蓝图、掉落和全量 quest signal 都自动串进 `player_progression / chapter_progression`
4. 还没有新增 Axum 的 progression facade。
5. 还没有把前端 `Lv.` 展示、任务奖励经验提示或敌对等级徽标切到 Rust 后端真相源。
也就是说,本轮只是把 `module-progression` 的 SpacetimeDB 成长基座立起来,不宣称已经完成成长系统迁移。
---
## 6. 验证要求
本轮工程变更完成后,至少执行下面两类验证:
1. `npm run check:encoding`
2. `cargo test -p module-progression`
3. `cargo check -p spacetime-module`
---
## 7. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 先把章节预算编译从 Node `chapterProgressionPlanner` 平移到 Rust 领域层。
2. 再把 `npc` / `combat` 入口改为消费 `RuntimeEntityLevelProfile``chapter_progression`
3. 再把掉落、任务物品、好感奖励并入统一奖励结算。
4. 最后再接 Axum facade、兼容 DTO 与前端成长主链。

View File

@@ -0,0 +1,154 @@
# M4 成长与 quest/combat 联动设计2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只冻结一件事:
**把 `player_progression / chapter_progression` 从“可单独调用的成长基座”推进到“任务交付与战斗胜利可自动写入的最小联动闭环”。**
本轮只落 `turn_in_quest``resolve_combat_action(Victory)` 两条经验链,不扩到完整章节蓝图 Rust 化、掉落分配、好感奖励或前端展示切换。
---
## 1. 本轮联动范围
本轮只接下面两条确定链路:
1. `turn_in_quest` 成功后,把 `quest_record.reward.experience` 发放到 `player_progression`
2. `resolve_combat_action` 结算为 `Victory` 后,把 `battle_state.experience_reward` 发放到 `player_progression`
补充规则:
1. 若存在 `chapter_id`,同时尝试把经验记到 `chapter_progression` 账本。
2. 若对应 `chapter_progression` 不存在,联动必须静默跳过,不能让任务交付或战斗结算失败。
3. `SparComplete``Escaped``Ongoing` 都不发经验。
---
## 2. `turn_in_quest` 联动口径
### 2.1 经验来源
任务交付经验固定读取:
1. `quest_record.reward.experience.unwrap_or(0)`
### 2.2 成长写入
当经验值 `> 0` 时,`spacetime-module::turn_in_quest` 需要在任务状态切换为 `TurnedIn` 后调用:
1. `upsert_player_progression_after_grant_tx`
写入参数固定为:
1. `user_id = next.actor_user_id`
2. `amount = reward_experience`
3. `source = PlayerProgressionGrantSource::Quest`
4. `updated_at_micros = next.updated_at_micros`
### 2.3 章节账本写入
`next.chapter_id` 存在,则在成长写入后继续尝试调用章节账本 helper
1. `granted_quest_xp = reward_experience`
2. `granted_hostile_xp = 0`
3. `hostile_defeat_increment = 0`
4. `level_at_exit = Some(updated_player.level)`
若章节记录不存在:
1. 静默跳过
2. 保留任务交付成功
3. 不把“章节计划尚未初始化”视为任务错误
---
## 3. `resolve_combat_action` 联动口径
### 3.1 battle_state 新增字段
为避免在 reducer 里临时反查外部上下文,本轮给 `BattleStateInput / BattleStateSnapshot / battle_state` 表补两个最小字段:
1. `chapter_id: Option<String>`
2. `experience_reward: u32`
设计意图:
1. `chapter_id` 决定战斗胜利时是否记章节账本。
2. `experience_reward` 作为已编译好的确定奖励,避免本轮就把章节蓝图和敌对档位计算重新耦回 battle reducer。
### 3.2 胜利经验发放
`resolve_combat_action` 返回:
1. `CombatOutcome::Victory`
`spacetime-module` 需要继续执行:
1. `upsert_player_progression_after_grant_tx`
写入参数固定为:
1. `user_id = result.snapshot.actor_user_id`
2. `amount = result.snapshot.experience_reward`
3. `source = PlayerProgressionGrantSource::HostileNpc`
4. `updated_at_micros = result.snapshot.updated_at_micros`
补充规则:
1. 只有 `experience_reward > 0` 时才真正写成长表。
2. `SparComplete` 不发经验,因为切磋不算敌对击杀。
### 3.3 章节账本写入
`result.snapshot.chapter_id` 存在,且本次为 `Victory`,则继续尝试:
1. `granted_quest_xp = 0`
2. `granted_hostile_xp = experience_reward`
3. `hostile_defeat_increment = 1`
4. `level_at_exit = Some(updated_player.level)`
同样地,若章节记录不存在:
1. 静默跳过
2. 仍保留 battle_state 的正常收束结果
---
## 4. reducer 分层约束
本轮保持以下分层不变:
1. `module-combat` 仍只承接纯战斗状态推进,不直接依赖 `module-progression`
2. `module-quest` 仍只承接纯任务状态流转,不直接依赖 `module-progression`
3. 真正的跨域写入统一放在 `crates/spacetime-module` reducer / transaction helper 中完成。
这样做的原因是:
1. 领域 crate 保持纯规则,便于后续单测和重用。
2. SpacetimeDB 事务内的表写顺序集中在同一层,避免跨 crate 重复持久化策略。
---
## 5. 本轮明确不做
本轮明确不扩到以下内容:
1. 还不把 battle reward 在 reducer 内现场计算为经验值。
2. 还不把 `quest` 奖励里的物品、货币、好感奖励统一并入同一事务。
3. 还不把 `quest signal` 自动从战斗/剧情全量分发到任务系统。
4. 还不把 `chapter_progression` 缺失时自动补建计划记录。
---
## 6. 验证要求
本轮变更完成后,至少执行:
1. `npm run check:encoding`
2. `cargo test -p module-combat`
3. `cargo test -p module-progression`
4. `cargo test -p module-quest`
5. `cargo check -p spacetime-module`

View File

@@ -0,0 +1,156 @@
# M4 RPG Runtime Inventory SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-inventory` 从“只有 README 占位”推进到“已有首版背包领域契约、SpacetimeDB `inventory_slot` 真相表与 `apply_inventory_mutation` reducer”的真实落地结果。**
本轮目标不是一次性迁完 Node 版所有背包玩法,而是先把后续 `story / quest / runtime-item / npc` 都能稳定复用的最小背包真相源立起来。
---
## 1. 本轮落地范围
本轮只落实下面 4 件事:
1. 新增 `server-rs/crates/module-inventory/` 真实 crate而不是继续停留在 README 占位。
2.`module-inventory` 中冻结 `inventory_slot``apply_inventory_mutation` 的首版领域类型、输入输出和字段校验 helper。
3.`server-rs/crates/spacetime-module/` 中新增 `inventory_slot` 表。
4.`spacetime-module` 中新增 `apply_inventory_mutation` reducer形成最小可编译背包主链。
---
## 2. 当前冻结的数据口径
### 2.1 `inventory_slot`
当前首版字段冻结为:
1. `slot_id`
2. `runtime_session_id`
3. `story_session_id`
4. `actor_user_id`
5. `container_kind`
6. `slot_key`
7. `item_id`
8. `category`
9. `name`
10. `description`
11. `quantity`
12. `rarity`
13. `tags`
14. `stackable`
15. `stack_key`
16. `equipment_slot_id`
17. `source_kind`
18. `source_reference_id`
19. `created_at`
20. `updated_at`
当前策略:
1. `inventory_slot` 采用“单槽位即单真相行”的口径,不再把背包塞回 runtime snapshot 大 JSON。
2. `Backpack / Equipment` 统一进同一张表,通过 `container_kind + slot_key` 区分容器和装备位。
3. 首版堆叠不再依赖 Node 版的隐式 heuristic统一冻结为 `stackable + stack_key` 显式口径。
### 2.2 `apply_inventory_mutation`
当前首版只支持 4 类 mutation
1. `GrantItem`
2. `ConsumeItem`
3. `EquipItem`
4. `UnequipItem`
当前策略:
1. `GrantItem` 负责发放新物品,并在 `Backpack` 内按 `stack_key` 合并可堆叠物品。
2. `ConsumeItem` 负责安全扣减堆叠数量,数量归零时删除槽位。
3. `EquipItem` 负责把背包中的可装备物品移动到目标装备位,并自动把原装备挪回背包。
4. `UnequipItem` 负责把装备位物品退回背包。
---
## 3. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有落 `UseItem / Craft / Dismantle / Reforge` 这类更高阶背包动作。
2. `quest_turn_in` 奖励物品链当前已进入聚合 reducer 接线,但 `npc_trade``npc_gift` 仍未落专属 reducer。
3. 当前已经补上最小同步查询切片 `GET /api/runtime/sessions/:runtimeSessionId/inventory`
但还没有落背包 public view也没有让前端直读 `inventory_slot`
4. 还没有把 Node 版 `inventoryMutationService.ts` 整体迁到 Rust只先冻结首版真相表和最小规则。
也就是说,本轮只是把 `module-inventory` 的基座立起来,不宣称已经完成完整背包玩法迁移。
---
## 4. 关键规则冻结
### 4.1 非堆叠物品固定单槽位单数量
当前规则:
1. `stackable = false` 的物品必须固定 `quantity = 1`
2. 可装备物品固定 `equipment_slot_id != None` 且必须 `stackable = false`
3. 后续如果要支持“同名但独立词缀装备”,继续沿用“一件装备一条 `inventory_slot`”。
### 4.2 装备切换不引入新真相副本
当前规则:
1. 装备和卸下都只是在同一条 `inventory_slot` 上切换 `container_kind + slot_key`
2. 遇到同装备位冲突时,原装备直接回到 `Backpack`,不额外创建临时副本。
3. 这样后续做 Axum façade 或前端 view 时,可以稳定用 `slot_id` 追踪同一件物品。
### 4.3 背包真相优先,展示读模型后置
当前规则:
1. `module-inventory` 只负责状态真相与 mutation 规则。
2. 若后续需要“前端背包列表”“装备面板读模型”,优先通过 `view` 或 Axum façade 暴露。
3. 不新增第二份背包真相副本,也不回退到多个 service 各自改 JSON。
---
## 5. 本轮新增的真实工程落点
### 5.1 新增 crate
1. `server-rs/crates/module-inventory/Cargo.toml`
2. `server-rs/crates/module-inventory/src/lib.rs`
### 5.2 workspace 与主工程聚合
1. `server-rs/Cargo.toml`
- 已把 `crates/module-inventory` 纳入 workspace members
2. `server-rs/crates/spacetime-module/Cargo.toml`
- 已接入 `module-inventory` 依赖
3. `server-rs/crates/spacetime-module/src/lib.rs`
- 已接入 `module-inventory` 类型
- 已新增 `inventory_slot`
- 已新增 `apply_inventory_mutation`
---
## 6. 验证目标
本轮应至少验证:
1. `module-inventory` crate 可以独立 `cargo check / cargo test`
2. `spacetime-module` 能成功编译并接入新表与 reducer
3. 不会把现有中文内容写坏,编码检查继续通过
---
## 7. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1.`module-inventory` 中继续补 `UseItem / Craft / Dismantle / Reforge` 对应的纯规则契约。
2. 继续把 `npc_trade / npc_gift / runtime-item` 发物链改成显式调用 `apply_inventory_mutation`,并补齐 quest / treasure 之外的奖励入口。
3. 在最小同步查询切片稳定后,再评估是否继续补 `inventory view`、旧前端背包读模型兼容或 public subscription。
4. 最后再把 Node 版 `inventoryMutationService.ts` 的玩法细节逐步迁走。

View File

@@ -0,0 +1,204 @@
# M4 RPG Runtime Quest SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-quest` 从“只有 README 占位”推进到“SpacetimeDB 侧已有最小可编译任务运行时基座”的真实落地结果。**
本轮只落任务主状态、任务日志与最小 reducer并补上任务交付到成长表、背包表的最小联动不扩到完整奖励结算、Axum facade、前端任务面板或 story action 全量迁移。
---
## 1. 本轮落地范围
本轮按 `AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md``AI_NATIVE_TASK_DRIVEN_GOAL_EXPERIENCE_PRD_2026-04-07.md``SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md` 的交叉口径,只落实下面 5 件事:
1. 新增 `server-rs/crates/module-quest/` 真实 crate而不是继续停留在 README 占位。
2.`module-quest` 中冻结 `QuestRecord``QuestStep``QuestReward``QuestProgressSignal` 的首版 Rust 领域类型与校验/归一化 helper。
3.`server-rs/crates/spacetime-module/` 中新增 `quest_record``quest_log` 两张表。
4.`spacetime-module` 中新增 `accept_quest``apply_quest_signal``acknowledge_quest_completion``turn_in_quest` 四个 reducer。
5.`module-story` 同口径方式,把任务推进固定成“主记录 + 日志追加”的最小可编译基座。
6.`turn_in_quest.reward.items` 显式映射为 `InventoryMutation::GrantItem`,在任务交付时同步写入 `inventory_slot`
---
## 2. 本轮新增的真实工程落点
### 2.1 新增 crate
1. `server-rs/crates/module-quest/Cargo.toml`
2. `server-rs/crates/module-quest/src/lib.rs`
### 2.2 workspace 与主工程聚合
1. `server-rs/Cargo.toml`
- 已把 `crates/module-quest` 纳入 workspace members
2. `server-rs/crates/spacetime-module/Cargo.toml`
- 已接入 `module-quest` 依赖
3. `server-rs/crates/spacetime-module/src/lib.rs`
- 已接入 `module-quest` 类型
- 已新增 `quest_record`
- 已新增 `quest_log`
- 已新增 `accept_quest`
- 已新增 `apply_quest_signal`
- 已新增 `acknowledge_quest_completion`
- 已新增 `turn_in_quest`
---
## 3. 当前冻结的数据口径
### 3.1 `quest_record`
当前首版字段冻结为:
1. `quest_id`
2. `runtime_session_id`
3. `story_session_id`
4. `actor_user_id`
5. `issuer_npc_id`
6. `issuer_npc_name`
7. `scene_id`
8. `chapter_id`
9. `act_id`
10. `thread_id`
11. `contract_id`
12. `title`
13. `description`
14. `summary`
15. `objective`
16. `progress`
17. `status`
18. `completion_notified`
19. `reward`
20. `reward_text`
21. `narrative_binding`
22. `steps`
23. `active_step_id`
24. `visible_stage`
25. `hidden_flags`
26. `discovered_fact_ids`
27. `related_carrier_ids`
28. `consequence_ids`
29. `created_at`
30. `updated_at`
31. `completed_at`
32. `turned_in_at`
当前策略:
1. `quest_record` 保持 private 真相表口径。
2. 当前仍沿用“一个任务一条主记录”的最小可编译策略,不在本轮拆 `quest_step` 独立表。
3. `objective / progress / active_step_id / status` 统一由 `steps` 归一化导出,避免旧接口和新真相源在同一条记录里出现漂移。
### 3.2 `quest_log`
当前首版字段冻结为:
1. `log_id`
2. `quest_id`
3. `runtime_session_id`
4. `actor_user_id`
5. `event_kind`
6. `status_after`
7. `signal_kind`
8. `signal`
9. `step_id`
10. `step_progress`
11. `created_at`
当前策略:
1. `quest_log` 先作为 private 结构化日志表,不提前做 public event table。
2. 当前只承接 `Accepted / Progressed / Completed / CompletionAcknowledged / TurnedIn` 五类事件。
3. 后续若要接前端实时提示,再决定是否补 event table 或投影表,而不是现在先塞 UI 专用字段。
---
## 4. 当前 reducer 口径
### 4.1 `accept_quest`
当前负责:
1. 校验 `QuestRecordInput`
2. 拒绝重复 `quest_id`
3. 写入 `quest_record`
4. 追加一条 `Accepted` 日志
### 4.2 `apply_quest_signal`
当前负责:
1. 校验 `QuestSignalApplyInput`
2. 校验目标 `quest_record` 必须存在
3. 只对当前 active step 应用任务信号
4. 在命中信号时推进 step progress、刷新 `objective / active_step_id / progress / status`
5. 命中且未完成时追加 `Progressed`
6. 最后一条 step 完成时追加 `Completed`
补充约束:
1. 已经 `Completed / ReadyToTurnIn / TurnedIn / Failed / Expired` 的任务不会被重复推进。
2. 信号未命中当前 active step 时,本轮 reducer 允许静默 no-op保持幂等。
### 4.3 `acknowledge_quest_completion`
当前负责:
1.`completion_notified` 标为 `true`
2. 追加一条 `CompletionAcknowledged` 日志
### 4.4 `turn_in_quest`
当前负责:
1. 校验任务已处于 `Completed / ReadyToTurnIn`
2. 固定所有 step progress 为完成态
3. 把任务状态切到 `TurnedIn`
4. 补齐 `turned_in_at`
5. 追加一条 `TurnedIn` 日志
6. 若存在 `reward.items`,同步发放到 `inventory_slot`
7. 若存在 `reward.items`,同步发放到 `inventory_slot`
8.`reward.experience > 0`,同步发放 `player_progression` 经验
9. 若存在已初始化的 `chapter_progression`,同步累计章节任务经验账本
---
## 5. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有落任务奖励的货币、好感、情报统一发放 reducer
2. 还没有接 `runtime_snapshot` projection / sync
3. 还没有接 `story_session``npc_state` 的更多跨域联动
4. 还没有新增 Axum 的 runtime quest facade
5. 还没有把 `server-node` 现有 quest API 切到 `server-rs`
6. 还没有把 Goal Director、章节目标 handoff、前端任务 UI 切到 SpacetimeDB 真相源
也就是说,本轮只是把 `module-quest` 的 SpacetimeDB 任务基座立起来,不宣称已经完成任务系统迁移。
---
## 6. 验证要求
本轮工程变更完成后,至少执行下面两类验证:
1. `npm run check:encoding`
2. `cargo test -p module-quest`
3. `cargo check -p spacetime-module`
---
## 7. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 先把 `quest_record``runtime_snapshot` 的投影边界补清。
2. 再把 `resolve_story_action` 内的 quest signal 分发迁到 `apply_quest_signal`
3. 再把 `turn_in_quest``npc affinity / currency / intel projection` 的奖励结算接成显式 reducer。
4. 再把 quest reward item 的映射 helper 上提到独立领域 crate减少 `spacetime-module` 聚合层重复转换。
5. 最后再接 Axum facade、兼容 DTO 与前端任务主链。

View File

@@ -0,0 +1,171 @@
# M4 RPG Runtime Story Session State Query 设计2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结当前 `M4` 的一个最小新增切片:
**新增 `GET /api/story/sessions/:storySessionId/state`,让 Axum 能从 `SpacetimeDB` 同步读取 `story session` 当前快照与事件流,不提前承诺旧 `runtime story state` 兼容。**
本轮目标不是实现旧 `GET /api/runtime/story/state/:sessionId` 的等价替换,也不是把 `resolve_story_action``currentStory``view model compiler` 一次性补齐。
---
## 1. 为什么先做这个切片
当前仓库里已经有三块能力先行落地:
1. `module-story` 已有 `StorySessionStateInput``StorySessionStateRecord` 与 builder / validator。
2. `spacetime-module` 已有 `get_story_session_state` procedure可在单次事务内返回 `story_session + story_events`
3. `spacetime-client``shared-contracts``api-server/story_sessions.rs` 已存在大部分适配代码。
真正缺的是:
1. 把这条查询链路正式挂到 Axum 路由树。
2. 用文档明确当前只开放“真相态查询”,不误导为旧 runtime story 状态恢复已完成。
因此本轮选择先把最小查询切片收口,而不是直接跳到更重的旧接口兼容。
---
## 2. 当前冻结范围
本轮只包含以下能力:
1. 新增公开接口:`GET /api/story/sessions/:storySessionId/state`
2. 认证方式Bearer JWT
3. 数据来源:`SpacetimeDB procedure get_story_session_state`
4. 返回体只包含:
- `storySession`
- `storyEvents`
本轮明确不做:
1. 不兼容旧 `GET /api/runtime/story/state/:sessionId`
2. 不补 `POST /api/runtime/story/state/resolve`
3. 不返回旧 `RuntimeStoryViewModel`
4. 不回填旧 `currentStory`
5. 不拼装 `availableOptions / presentation / patch`
6. 不在查询链路里混入 `npc / quest / combat / inventory` 聚合快照
---
## 3. 接口 contract
### 3.1 请求
- 方法:`GET`
- 路径:`/api/story/sessions/:storySessionId/state`
- 认证:必须携带 Bearer JWT
- 路径参数:
- `storySessionId`:目标故事会话 ID
### 3.2 成功响应
成功响应延续当前 `api-server` 统一 envelope`data` 字段结构为:
```json
{
"storySession": {
"storySessionId": "storysess_xxx",
"runtimeSessionId": "runtime_xxx",
"actorUserId": "user_xxx",
"worldProfileId": "profile_xxx",
"initialPrompt": "进入营地",
"openingSummary": "营地开场",
"latestNarrativeText": "你看见篝火边有人招手。",
"latestChoiceFunctionId": "talk_to_npc",
"status": "active",
"version": 2,
"createdAt": "2026-04-22T00:00:00.000000Z",
"updatedAt": "2026-04-22T00:01:00.000000Z"
},
"storyEvents": [
{
"eventId": "storyevt_xxx",
"storySessionId": "storysess_xxx",
"eventKind": "story_continued",
"narrativeText": "你看见篝火边有人招手。",
"choiceFunctionId": "talk_to_npc",
"createdAt": "2026-04-22T00:01:00.000000Z"
}
]
}
```
### 3.3 错误响应
当前延续 story session facade 已有策略:
1. `SpacetimeClientError::Runtime(_)` 映射为 `400`
2. 其他 `SpacetimeClientError` 映射为 `502`
3. 错误 `details.provider` 固定为 `spacetimedb`
---
## 4. 分层职责
### 4.1 `module-story`
职责:
1. 冻结 `StorySessionStateInput`
2. 冻结 `StorySessionStateRecord`
3. 负责 builder、validator 与 record 映射
不负责:
1. HTTP 参数解析
2. JWT 鉴权
3. 旧前端 view model 编译
### 4.2 `spacetime-module`
职责:
1. 读取 `story_session`
2. 读取同一 `story_session_id` 下的 `story_event`
3. 按时间与 `event_id` 排序
4. 返回最小 `StorySessionStateProcedureResult`
### 4.3 `spacetime-client`
职责:
1. 构造 `StorySessionStateInput`
2. 调用 `get_story_session_state`
3. 把 generated binding 结果映射为 `StorySessionStateRecord`
### 4.4 `api-server`
职责:
1. 暴露 `GET /api/story/sessions/:storySessionId/state`
2. 做 Bearer JWT 鉴权
3. 透传 `storySessionId`
4.`StorySessionStateRecord` 映射到 `StorySessionStateResponse`
---
## 5. 验收口径
本轮验收只要求以下几点:
1. `api-server` 路由树已真实挂出该接口
2. 未登录访问返回 `401`
3.`SpacetimeDB` 未发布或未连通时返回 `502`
4. `cargo test -p api-server get_story_session_state` 可通过
5. `npm run check:encoding` 通过,确保新增中文文档没有编码损坏
---
## 6. 后续边界
这条最小查询链路落地后,后续再继续拆下一层:
1. 评估是否需要把旧 `runtime story state` 查询兼容到新 facade
2. 设计 `resolve_story_action` 真正冻结后的输入/输出 contract
3. 再考虑 `currentStory``availableOptions``presentation``patch` 的旧 view model 兼容
在这些 contract 未冻结前,不应把当前接口误称为“旧 runtime story state 已迁移完成”。

View File

@@ -0,0 +1,250 @@
# M4 RPG Runtime Story SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-22`
## 0. 文档目标
本文件只记录一件事:
**把 `M4` 从“只有任务清单和 crate 占位”推进到“SpacetimeDB 侧已有最小可编译 story 会话基座”的真实落地结果。**
本轮只落最小骨架,不扩到完整 runtime story action 迁移,不改前端交互界面设计。
---
## 1. 本轮落地范围
本轮按 `M4``RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 的交叉口径,只落实下面 6 件事:
1. 新增 `server-rs/crates/module-story/` 真实 crate而不是继续停留在 README 占位。
2.`module-story` 中冻结 `story_session / story_event` 的首版领域类型、ID 前缀、状态枚举与字段校验 helper。
3.`server-rs/crates/spacetime-module/` 中新增 `story_session``story_event` 两张表。
4.`spacetime-module` 中新增 `begin_story_session``continue_story` 两个 reducer形成最小可编译会话主链。
5.`spacetime-module` 中新增 `begin_story_session_and_return``continue_story_and_return` 两个 procedure让 Axum 可同步拿到结果快照。
6.`spacetime-client``api-server` 中新增最小 story session facade打通 `server-rs` 侧纵向调用链。
---
## 2. 本轮新增的真实工程落点
### 2.1 新增 crate
1. `server-rs/crates/module-story/Cargo.toml`
2. `server-rs/crates/module-story/src/lib.rs`
### 2.2 workspace 与主工程聚合
1. `server-rs/Cargo.toml`
- 已把 `crates/module-story` 纳入 workspace members
2. `server-rs/crates/spacetime-module/Cargo.toml`
- 已接入 `module-story` 依赖
3. `server-rs/crates/spacetime-module/src/lib.rs`
- 已接入 `module-story` 类型
- 已新增 `story_session`
- 已新增 `story_event`
- 已新增 `begin_story_session`
- 已新增 `continue_story`
- 已新增 `begin_story_session_and_return`
- 已新增 `continue_story_and_return`
4. `server-rs/crates/spacetime-client/src/lib.rs`
- 已新增 `begin_story_session(...)`
- 已新增 `continue_story(...)`
5. `server-rs/crates/api-server/src/story_sessions.rs`
- 已新增 `POST /api/story/sessions`
- 已新增 `POST /api/story/sessions/continue`
---
## 3. 当前冻结的数据口径
### 3.1 `story_session`
当前首版字段冻结为:
1. `story_session_id`
2. `runtime_session_id`
3. `actor_user_id`
4. `world_profile_id`
5. `initial_prompt`
6. `opening_summary`
7. `latest_narrative_text`
8. `latest_choice_function_id`
9. `status`
10. `version`
11. `created_at`
12. `updated_at`
当前策略:
1. `story_session` 保持 private 真相表口径。
2. 当前只解决“故事会话存在、版本递增、最新叙事状态可追踪”。
3. 不在本轮提前塞入 quest、combat、npc、inventory 混合字段。
### 3.2 `story_event`
当前首版字段冻结为:
1. `event_id`
2. `story_session_id`
3. `event_kind`
4. `narrative_text`
5. `choice_function_id`
6. `created_at`
当前策略:
1. 事件先只承接 `SessionStarted / StoryContinued` 两类最小事件。
2. 先证明事件追加模型能工作,再扩到 `resolve_story_action` 真实子域事件。
---
## 4. 当前 reducer 口径
### 4.1 `begin_story_session`
当前负责:
1. 校验 `StorySessionInput`
2. 拒绝重复 `story_session_id`
3. 写入 `story_session`
4. 追加一条 `SessionStarted` 事件
### 4.2 `continue_story`
当前负责:
1. 校验 `StoryContinueInput`
2. 校验目标 `story_session` 必须存在
3. 以事件追加方式写入 `story_event`
4. 递增 `story_session.version`
5. 更新 `latest_narrative_text / latest_choice_function_id / updated_at`
---
## 5. 当前 procedure / facade 口径
### 5.1 `begin_story_session_and_return`
当前负责:
1. 在单次 procedure 调用里执行 `begin_story_session_tx`
2. 直接返回 `storySession + storyEvent` 快照
3.`spacetime-client``api-server` 直接同步消费
### 5.2 `continue_story_and_return`
当前负责:
1. 在单次 procedure 调用里执行 `continue_story_tx`
2. 直接返回推进后的 `storySession + storyEvent` 快照
3. 避免 Axum 再额外读取 private table
### 5.3 `spacetime-client` story facade
当前已新增:
1. `begin_story_session(...)`
2. `continue_story(...)`
当前策略:
1. `spacetime-client` 负责把 `module-story` 输入 builder 映射到 generated bindings。
2. procedure 错误统一折叠为 `SpacetimeClientError`,供 Axum 映射为 `400 / 502`
### 5.4 `api-server` story session facade
当前已新增:
1. `POST /api/story/sessions`
2. `POST /api/story/sessions/continue`
当前 contract
1. 两个接口都要求 Bearer JWT。
2. `actorUserId` 由 JWT claims 提供,不允许前端透传。
3. `storySessionId` / `eventId` 由 Rust 服务端使用 `module-story` 的 ID helper 生成。
4. 两个接口当前都返回:
- `storySession`
- `storyEvent`
---
## 6. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有落 `resolve_story_action`
2. 还没有落 `sync_runtime_snapshot_projection`
3. 还没有接入 `npc_state / quest_record / battle_state / inventory_slot`
4. 还没有兼容旧 `POST /api/runtime/story/actions/resolve`
5. 还没有兼容旧 `GET /api/runtime/story/state/:sessionId`
6. 还没有兼容旧 `POST /api/runtime/story/state/resolve`
7. 还没有兼容旧 `POST /api/runtime/story/initial`
8. 还没有兼容旧 `POST /api/runtime/story/continue`
9. 还没有把 `server-node` 现有 `rpg-runtime-story` 主链切换到 `server-rs`
10. 还没有改任何前端交互界面设计
也就是说,本轮只是把 `M4` 的 SpacetimeDB 会话基座与最小 Axum facade 立起来,不宣称已经完成 runtime story 兼容迁移。
---
## 7. 验证结果
本轮已执行:
1. `cargo check -p module-story --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
2. `cargo check -p spacetime-module --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
3. `cargo check -p spacetime-module --target wasm32-unknown-unknown --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
4. `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes`
5. `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
6. `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
7. `npm run check:encoding`
结果:
1. 全部通过。
---
## 8. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 先冻结 `story state` 查询 contract明确是新 `/api/story/sessions/:storySessionId/state` 还是兼容旧 `/api/runtime/story/state/*`
2. 再把 `story_session``runtime_snapshot` 的 projection / sync 边界补清。
3. 再把 `resolve_story_action` 的输入/输出与 `RuntimeStoryActionRequest` 对齐成下一个 reducer / procedure 设计。
4. 再逐步把 `npc / quest / treasure / combat` 子域动作接成显式事件与独立 reducer。
5. 最后再处理旧 Node `runtime story` 兼容接口与前端实际切换。
---
## 9. 后续增量状态(`2026-04-22`
在本文件记录的首轮 story session 基座之上,当前仓库又继续补了两条与 `M4 story runtime` 直接相关的增量切片:
1. 已补 `GET /api/story/sessions/:storySessionId/state`
- 当前只返回 `storySession + storyEvents`
- 不兼容旧 `RuntimeStoryActionResponse`
2. 已补 `GET /api/story/battles/:battleStateId`
- 当前只返回单个 `battleState`
- 供 battle 刷新、重连和后续 story 编排复用
3. 已补 `POST /api/story/npc/battle`
- 当前只承接 `npc_fight / npc_spar`
- 同步返回 `npcInteraction + battleState`
同时,本轮还完成了以下工程收口:
1. 已重新执行 `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes`
2. 已把 `spacetime-client` 中 battle query 的占位实现替换为真实 procedure 调用。
3. 已再次执行 `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` 并通过。
当前仍需继续追的验证项:
1. `story_sessions` / `story_battles` 相关二进制测试在当前机器上编译时间较长,还没有在单次时窗内拿到最终断言结果。
2. `npm run check:encoding` 已启动,但尚未在单次时窗内跑完。
因此,当前准确口径应为:
1. `M4 story session / story state / battle state / NPC 开战` 的最小后端编译链已经打通。
2.`runtime story` 兼容接口与旧 view model 兼容仍未完成。
3. 长时回归测试与编码检查仍在继续推进,不应提前宣称整阶段验收完成。

View File

@@ -0,0 +1,232 @@
# M4 Runtime Inventory State Query 设计2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结当前 `M4` 的一个最小新增切片:
**新增 `GET /api/runtime/sessions/:runtimeSessionId/inventory`,让 Axum 能从 `SpacetimeDB` 同步读取当前玩家在指定 `runtime_session` 下的 `inventory_slot` 真相态,不提前承诺旧 `GameState.playerInventory / playerEquipment` 全量兼容。**
本轮目标不是把旧前端背包 view model 一次性全迁到 Rust也不是把 `inventory_use / craft / dismantle / reforge` 一次性补齐。
---
## 1. 为什么先做这个切片
当前 inventory 主链已经具备:
1. `module-inventory` 已冻结 `inventory_slot``apply_inventory_mutation` 的首版领域 contract。
2. `spacetime-module` 已有 `inventory_slot` 真相表与 `apply_inventory_mutation` reducer。
3. `treasure / quest / battle` 奖励物品已经能同步写入 `inventory_slot`
真正缺的部分是:
1. 背包真相态还没有稳定查询入口。
2. `api-server` 还不能按 `runtime_session_id + 当前用户` 同步返回当前背包与装备状态。
3. 后续如果要把背包面板、装备面板或 runtime story projection 收口到 `SpacetimeDB`,需要先有一个最小 inventory query 切片可复用。
因此本轮先补“只读查询”能力,不提前跳到更重的旧前端状态兼容。
---
## 2. 当前冻结范围
本轮只包含以下能力:
1. 新增公开接口:`GET /api/runtime/sessions/:runtimeSessionId/inventory`
2. 认证方式Bearer JWT
3. 查询 scope`runtime_session_id + 当前登录用户`
4. 数据来源:`SpacetimeDB procedure get_runtime_inventory_state`
5. 返回体只包含:
- `runtimeSessionId`
- `actorUserId`
- `backpackItems`
- `equipmentItems`
本轮明确不做:
1. 不兼容旧 `GameState.playerInventory`
2. 不兼容旧 `GameState.playerEquipment`
3. 不补按 `story_session_id` 或全局用户维度的 inventory 查询
4. 不做 inventory public subscription / view
5. 不在查询链路里拼装 quest / npc / battle / story_event
6. 不提前做 `inventory_use / craft / dismantle / reforge`
---
## 3. 为什么按 `runtime_session_id + 当前用户` 查询
当前 `inventory_slot` 的主作用域字段是:
1. `runtime_session_id`
2. `actor_user_id`
3. `story_session_id`
本轮选择 `runtime_session_id + actor_user_id` 作为最小 query scope原因如下
1. `inventory_slot` 当前是运行态背包真相,不是单个 story session 的临时投影。
2. 同一 `runtime_session` 下的宝箱、任务、战斗奖励都已经按这个 scope 汇入同一张表。
3. 旧前端背包面板语义本质上也是“当前运行态玩家的背包”,不是“某个 story session 的局部背包”。
4. 若后续确实需要更细的 `story_session` 投影,应通过上层 façade 或专门 view 提供,而不是先把真相查询 scope 做窄。
---
## 4. 接口 contract
### 4.1 请求
- 方法:`GET`
- 路径:`/api/runtime/sessions/:runtimeSessionId/inventory`
- 认证:必须携带 Bearer JWT
- 路径参数:
- `runtimeSessionId`:目标运行时会话 ID
### 4.2 成功响应
成功响应延续当前 `api-server` 统一 envelope`data` 字段结构为:
```json
{
"runtimeSessionId": "runtime_xxx",
"actorUserId": "user_xxx",
"backpackItems": [
{
"slotId": "invslot_xxx",
"containerKind": "backpack",
"slotKey": "invslot_xxx",
"itemId": "consumable_heal_potion",
"category": "消耗品",
"name": "疗伤药",
"description": "用于恢复少量气血。",
"quantity": 3,
"rarity": "common",
"tags": ["healing"],
"stackable": true,
"stackKey": "heal_potion",
"equipmentSlotId": null,
"sourceKind": "treasure_reward",
"sourceReferenceId": "treasure_xxx",
"createdAt": "2026-04-22T00:00:00.000000Z",
"updatedAt": "2026-04-22T00:01:00.000000Z"
}
],
"equipmentItems": [
{
"slotId": "invslot_weapon_xxx",
"containerKind": "equipment",
"slotKey": "weapon",
"itemId": "weapon_trial_blade",
"category": "武器",
"name": "试作短剑",
"description": "一柄适合入门者的短剑。",
"quantity": 1,
"rarity": "rare",
"tags": ["weapon"],
"stackable": false,
"stackKey": "weapon_trial_blade",
"equipmentSlotId": "weapon",
"sourceKind": "quest_reward",
"sourceReferenceId": "quest_xxx",
"createdAt": "2026-04-22T00:00:00.000000Z",
"updatedAt": "2026-04-22T00:05:00.000000Z"
}
]
}
```
### 4.3 错误响应
当前延续 runtime/profile/story 查询已有策略:
1. `SpacetimeClientError::Runtime(_)` 映射为 `400`
2. 其他 `SpacetimeClientError` 映射为 `502`
3. 错误 `details.provider`
- 参数构建或本地语义错误:`runtime-inventory`
- 下游 `SpacetimeDB` 失败:`spacetimedb`
---
## 5. 分层职责
### 5.1 `module-inventory`
职责:
1. 冻结 `RuntimeInventoryStateQueryInput`
2. 冻结 `RuntimeInventoryStateSnapshot`
3. 冻结 `RuntimeInventorySlotRecord`
4. 负责 builder、validator 与 record 映射
不负责:
1. HTTP 路径解析
2. JWT 鉴权
3. 旧前端背包 view model 编译
### 5.2 `spacetime-module`
职责:
1. 读取指定 `runtime_session_id + actor_user_id` 下的 `inventory_slot`
2.`container_kind` 拆成 `backpackItems / equipmentItems`
3. 复用 `module-inventory` 的 query input / snapshot 结构
4. 通过 `get_runtime_inventory_state` procedure 返回当前真相态
### 5.3 `spacetime-client`
职责:
1. 构造 `RuntimeInventoryStateQueryInput`
2. 调用 `get_runtime_inventory_state`
3. 把返回结果映射为稳定 Rust record
### 5.4 `api-server`
职责:
1. 暴露 `GET /api/runtime/sessions/:runtimeSessionId/inventory`
2. 做 Bearer JWT 鉴权
3. 从 token 中注入 `actorUserId`
4. 把 inventory record 映射到 JSON payload
---
## 6. 排序规则
为了保证前端和后续 façade 读到稳定顺序,本轮冻结以下排序口径:
1. `backpackItems`
- 先按 `slot_key`
- 再按 `slot_id`
2. `equipmentItems`
- 先按装备位固定顺序:`weapon -> armor -> relic`
- 再按 `slot_id`
当前不在 query 层额外做“按稀有度”“按分类”“按来源”的排序投影。
---
## 7. 验收口径
本轮验收只要求以下几点:
1. `module-inventory` 已补 inventory query contract 与最小测试
2. `spacetime-module` 已新增 `get_runtime_inventory_state` procedure
3. `spacetime-client` 已能同步读取 inventory 真相态
4. `api-server` 已真实挂出 `GET /api/runtime/sessions/:runtimeSessionId/inventory`
5. `cargo check -p module-inventory -p spacetime-module -p spacetime-client -p api-server` 可通过
6. `npm run check:encoding` 已执行,确保新增中文文档与接口文件没有编码损坏
---
## 8. 后续边界
这条最小 inventory query 落地后,后续再继续拆下一层:
1. 评估是否需要补 `story session` 局部 inventory projection
2. 评估是否需要把 inventory query 接成旧背包面板 view model
3. 再继续补 `inventory_use / craft / dismantle / reforge`
4. 最后再考虑 inventory subscription / public view
在这些 contract 未冻结前,不应把当前接口误称为“旧背包系统已完整迁移完成”。

View File

@@ -0,0 +1,142 @@
# M4 Runtime Item Treasure SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-runtime-item` 从“只有 README 占位”推进到“SpacetimeDB 侧已有 `treasure_record` 真相表、`resolve_treasure_interaction` reducer/procedure以及可桥接 `inventory_slot` 的奖励 contract”的真实落地结果。**
本轮目标不是一次性迁完 Node 版所有 runtime item 玩法,而是先把宝藏奖励记录、奖励字段口径与后续背包接入边界固定下来。
---
## 1. 本轮落地范围
本轮只落实下面 5 件事:
1. 新增 `server-rs/crates/module-runtime-item/` 真实 crate而不是继续停留在 README 占位。
2.`module-runtime-item` 中冻结 `TreasureResolveInput / TreasureRecordSnapshot / RuntimeItemRewardItemSnapshot` 的首版领域类型与校验 helper。
3.`server-rs/crates/spacetime-module/` 中新增 `treasure_record` 表。
4.`spacetime-module` 中新增 `resolve_treasure_interaction` reducer 与 `resolve_treasure_interaction_and_return` procedure。
5. 把宝藏奖励物品字段扩到可无损映射 `inventory_slot` 的粒度,并在聚合层接通宝藏奖励到背包真相表的最小发物链。
---
## 2. 当前冻结的数据口径
### 2.1 `treasure_record`
当前首版字段冻结为:
1. `treasure_record_id`
2. `runtime_session_id`
3. `story_session_id`
4. `actor_user_id`
5. `encounter_id`
6. `encounter_name`
7. `scene_id`
8. `scene_name`
9. `action`
10. `reward_items`
11. `reward_hp`
12. `reward_mana`
13. `reward_currency`
14. `story_hint`
15. `created_at`
16. `updated_at`
当前策略:
1. `treasure_record` 保持 private 真相表口径。
2. `Inspect / Secure / Leave` 都会留下正式记录,避免宝藏交互继续散落在 runtime snapshot 大 JSON 或 story 文本里。
3. `Leave` 允许不发物;`Inspect / Secure` 的奖励字段当前已经通过聚合层同步写入 `inventory_slot`
4. 同一 `treasure_record_id` 重放时直接返回既有记录,不重复执行发物,保证宝藏奖励幂等。
### 2.2 `RuntimeItemRewardItemSnapshot`
当前奖励物品字段冻结为:
1. `item_id`
2. `category`
3. `item_name`
4. `description`
5. `quantity`
6. `rarity`
7. `tags`
8. `stackable`
9. `stack_key`
10. `equipment_slot_id`
当前策略:
1. 奖励物品不再只保留 `item_id + item_name + quantity` 的轻量占位结构。
2. 当前字段已经与 `inventory_slot` 的首版真相字段对齐到可桥接程度,避免后续发物时再回头猜品类、堆叠策略和装备位。
3. `build_inventory_item_snapshot_from_reward_item(...)` 负责把宝藏奖励快照稳定映射为 `module-inventory::InventoryItemSnapshot`
---
## 3. 当前 reducer / procedure 口径
### 3.1 `resolve_treasure_interaction`
当前负责:
1. 校验 `TreasureResolveInput`
2. 校验 `story_session_id / runtime_session_id / actor_user_id` 作用域一致
3. 同一 `treasure_record_id` 重放时直接返回已落库快照
4. 初次落库时写入 `treasure_record`
5. 初次落库后把 `reward_items` 同步发放到 `inventory_slot`
### 3.2 `resolve_treasure_interaction_and_return`
当前负责:
1. 复用同一套 `treasure_record` upsert 规则
2. 返回最终 `TreasureRecordSnapshot`
3. 避免 Axum facade 再额外读取 private table
---
## 4. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有把 `reward_hp / reward_mana / reward_currency` 接到运行时资源真相表
2. 还没有把 runtime item director 的完整物品导演链迁到 Rust
3. 还没有新增 Axum 的 runtime treasure facade
4. 还没有把前端 `treasureInteractions.ts` 主链切到 `server-rs`
也就是说,本轮只是把宝藏结算真相表和奖励 contract 立起来,不宣称已经完成完整宝藏奖励迁移。
---
## 5. 本轮新增的真实工程落点
### 5.1 新增 crate
1. `server-rs/crates/module-runtime-item/Cargo.toml`
2. `server-rs/crates/module-runtime-item/src/lib.rs`
### 5.2 workspace 与主工程聚合
1. `server-rs/Cargo.toml`
- 已把 `crates/module-runtime-item` 纳入 workspace members
2. `server-rs/crates/spacetime-module/Cargo.toml`
- 已接入 `module-runtime-item` 依赖
3. `server-rs/crates/spacetime-module/src/lib.rs`
- 已接入 `module-runtime-item` 类型
- 已新增 `treasure_record`
- 已新增 `resolve_treasure_interaction`
- 已新增 `resolve_treasure_interaction_and_return`
---
## 6. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 先把 `treasure / quest / battle` 的奖励发物 helper 继续收敛,减少 `spacetime-module` 聚合层的重复映射代码。
2. 再补 runtime 资源恢复、货币与 story projection 的跨域聚合写入。
3. 最后再接 Axum facade 与前端真实 treasure 主链切换。

View File

@@ -0,0 +1,264 @@
# M4 Runtime Story 兼容状态桥设计2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结 `M4` 当前下一条最小可落地兼容桥:
**先把 Rust `api-server` 侧旧 `runtime story state` 兼容返回所需的 DTO 与状态桥边界冻结清楚,再进入 Axum handler 与状态编译迁移。**
当前仓库已经有两条并行现实:
1. `server-node` 侧旧兼容接口 `POST /api/runtime/story/state/resolve` 仍然在真实服务前端。
2. `server-rs` 侧已经有 `story_session / battle_state / npc battle / inventory state` 等真相态接口,但还没有编译成旧前端消费的 `RuntimeStoryActionResponse`
因此本轮不直接宣称“runtime story 已迁完”,而是先把兼容桥 contract 冻结为下一段可编码的工程基线。
---
## 1. 当前真实现状
### 1.1 前端真实调用入口
当前前端 `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` 的真实行为是:
1. 加载 option catalog 时优先调用 `POST /api/runtime/story/state/resolve`
2. story 选项点击时调用 `POST /api/runtime/story/actions/resolve`
3. 请求体默认携带:
- `sessionId`
- `clientVersion`
- `snapshot`
其中 `snapshot` 当前来自前端内存态:
1. `gameState`
2. `bottomTab`
3. `currentStory`
这意味着兼容桥的第一优先级不是“把所有真相态一次性并回旧结构”,而是:
**先让 Rust 侧能吃下同样的 `snapshot + sessionId + clientVersion`,并返回旧前端已经稳定消费的 `RuntimeStoryActionResponse` 形状。**
### 1.2 Rust 侧当前已存在的真相态
当前 `server-rs` 已经真实挂出的接口包括:
1. `POST /api/story/sessions`
2. `POST /api/story/sessions/continue`
3. `GET /api/story/sessions/:storySessionId/state`
4. `POST /api/story/battles`
5. `POST /api/story/battles/resolve`
6. `GET /api/story/battles/:battleStateId`
7. `POST /api/story/npc/battle`
8. `GET /api/runtime/sessions/:runtimeSessionId/inventory`
但这些接口的返回仍是“真相态切片”,还没有拼成旧前端直接依赖的:
1. `viewModel`
2. `presentation`
3. `patches`
4. `snapshot`
### 1.3 Node 侧兼容链的真实落点
当前旧兼容链不再是任务清单里早期写法的 `server-node/src/modules/story/*`,而是已经迁到:
1. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`
2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts`
3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts`
因此,后续对照迁移时必须以这些新域路径为准,不再以已删除或已降级的旧 `modules/story/*` 口径作为实施依据。
---
## 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` 继续后置
本轮明确不做:
1. 不在 `server-rs` 里直接落完整 `resolve_story_action`
2. 不迁移 Node 侧全部 story 行为决策
3. 不把 `runtime snapshot` 正式持久化真相一次性迁到 Rust
4. 不在本轮让前端切到 Rust `api-server`
---
## 3. 为什么先做状态桥
当前如果直接做 `POST /api/runtime/story/actions/resolve`,会同时撞上 4 个未冻结边界:
1. `resolve_story_action` 本体 reducer / procedure 还没设计完成
2. `runtime snapshot projection` 还没有 Rust 侧正式真相模型
3. `battle / npc / quest / inventory` 旧 patch 结构还没完全映射
4. LLM story 文本生成口径还没决定是继续经 Node 还是挂到 Rust `platform-llm`
`POST /api/runtime/story/state/resolve` 的依赖更小:
1. 前端已经会把当前 `snapshot` 一并传上来
2. Node 侧现有状态读取逻辑本质上就是基于 `snapshot` 编译 `viewModel + availableOptions + currentStory`
3. Rust 侧可以先承接“兼容状态查询 + DTO 编译”这一条独立切片
因此当前正确顺序是:
```text
先冻结 shared-contracts DTO
-> 再做 Rust state bridge compiler
-> 再挂 POST /api/runtime/story/state/resolve
-> 最后再进入 actions/resolve
```
---
## 4. 兼容桥 contract 冻结
## 4.1 请求:`POST /api/runtime/story/state/resolve`
首版仍沿用前端现有字段名:
```json
{
"sessionId": "runtime-main",
"clientVersion": 7,
"snapshot": {
"savedAt": "2026-04-22T12:00:00.000Z",
"bottomTab": "adventure",
"gameState": {},
"currentStory": {}
}
}
```
字段要求:
1. `sessionId` 必填
2. `clientVersion` 可选
3. `snapshot` 可选
4. `snapshot.gameState` 当前保持 `serde_json::Value`,不在本轮提前强类型化
5. `snapshot.currentStory` 当前保持 `serde_json::Value | null`
### 4.1.1 `snapshot` 缺省策略
在 Rust 状态桥首版里:
1. 如果 `snapshot` 存在,则优先用它编译兼容状态
2. 如果 `snapshot` 缺失,则允许后续 bridge handler 退回:
- 新真相态聚合
- 或返回当前无法恢复状态的明确错误
本轮只冻结 DTO不在文档里提前承诺缺省路径的最终实现方式。
## 4.2 成功响应:`RuntimeStoryActionResponse`
为了兼容当前前端 `rpgRuntimeStoryClient.ts`Rust 侧成功响应字段必须与现有共享 TS contract 保持同形:
1. `sessionId`
2. `serverVersion`
3. `viewModel`
4. `presentation`
5. `patches`
6. `snapshot`
其中:
1. `viewModel.availableOptions` 必须继续使用旧 `RuntimeStoryOptionView`
2. `presentation.storyText` 必须保留
3. `snapshot` 必须继续包含:
- `savedAt`
- `bottomTab`
- `gameState`
- `currentStory`
### 4.2.1 `patches` 首版策略
状态查询接口本身不产生新的行为变更,因此 `state/resolve` 首版兼容桥返回:
1. `patches` 固定为空数组
这与当前 Node `getRuntimeStoryState(...)` 的行为一致,不需要在状态查询时伪造 patch。
---
## 5. DTO 分层
## 5.1 `shared-contracts::runtime_story`
新模块负责:
1. `RuntimeStorySnapshotPayload`
2. `RuntimeStoryStateResolveRequest`
3. `RuntimeStoryActionResponse`
4. `RuntimeStoryViewModel`
5. `RuntimeStoryPresentation`
6. `RuntimeStoryPatch`
7. `RuntimeStoryOptionView`
8. `RuntimeBattlePresentation`
9. `RuntimeStoryOptionInteraction`
当前策略:
1. 兼容层 DTO 独立成新模块,不继续塞进 `story.rs`
2. `runtime.rs` 继续保留 settings / browse history / profile / inventory / custom world 的公开 DTO
3. `story.rs` 继续只承接 `story session` 真相链 DTO
## 5.2 字段类型策略
为了先稳住兼容层:
1. `snapshot.game_state` 使用 `serde_json::Value`
2. `snapshot.current_story` 使用 `Option<serde_json::Value>`
3. `RuntimeStoryOptionView.payload` 使用 `Option<serde_json::Value>`
原因:
1. 这些字段当前本来就是旧前端快照结构
2. Rust 侧正式领域模型尚未冻结
3. 提前强类型化只会放大后续迁移返工面
---
## 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
---
## 7. 当前刻意不冻结的内容
以下内容继续明确后置:
1. `POST /api/runtime/story/actions/resolve` 的请求 DTO 是否直接复用旧 TS contract 全量字段
2. `resolve_story_action` 是否拆成:
- `resolve_story_action`
- `resolve_story_combat_action`
- `resolve_story_interaction_action`
3. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory`
4. `LLM` 文本续写是在 Rust bridge 内继续调用,还是继续通过 Node 兼容层兜底
这些边界在状态桥稳定前都不应提前拍死。
---
## 8. 完成定义
这一轮“兼容状态桥基线完成”的定义是:
1. 已有独立技术文档冻结 `state/resolve` 兼容桥边界
2. `shared-contracts` 已拥有旧 `runtime story` 兼容 DTO
3. DTO 字段名与当前前端消费口径保持一致
4. `cargo test -p shared-contracts` 通过
5. `npm run check:encoding` 通过,确保新增中文文档与 Rust 源文件编码未损坏
达到以上条件后,下一轮即可直接进入 Rust `state bridge compiler` 与 Axum handler 落地。

View File

@@ -0,0 +1,218 @@
# `platform-llm` 文本网关首版设计2026-04-21
## 1. 背景
`server-rs/crates/platform-llm/``2026-04-20` 只完成了目录占位,但当前仓库里已经存在一条稳定的 Node 侧文本模型主链:
1. `server-node/src/services/llmClient.ts`
2. `server-node/src/modules/ai/*`
3. `server-node/src/services/storyService.ts`
4. `server-node/src/services/questService.ts`
5. `server-node/src/services/runtimeItemService.ts`
这些调用点已经依赖一套隐含约束:
1. 使用 OpenAI 兼容的 `/chat/completions`
2. 统一 Bearer 鉴权
3. 同时支持非流式 JSON 响应与 SSE 流式增量
4. 要求有超时、连接失败、上游错误和空响应兜底
如果 Rust 侧继续只保留 README 占位,后续 `api-server``module-ai``module-story``module-npc` 在落地时又会各自复制一份私有上游 client重新造成平台层分叉。
因此本次先把 `platform-llm` 收口成一个真实可编译、可测试、可复用的 Rust crate冻结文本主链基础设施。
## 2. 本次落地范围
### 2.1 本次明确实现
1. `LlmProvider`:冻结 provider 来源标签,首版包含 `ark``dash_scope``openai_compatible`
2. `LlmConfig`:统一 base url、api key、model、timeout、retry 配置
3. `LlmMessageRole``LlmMessage``LlmTextRequest`:统一请求 DTO
4. `LlmClient::request_text(...)`:统一非流式文本调用
5. `LlmClient::stream_text(...)`:统一流式 SSE 文本调用
6. `LlmTextResponse``LlmStreamDelta``LlmTokenUsage`:统一响应 DTO
7. `LlmError`:统一配置错误、请求错误、超时、连接失败、上游错误、反序列化错误、空响应错误
8. 基础重试策略:对 `408``429``5xx`、超时、连接失败重试
### 2.2 本次明确不做
1. 不在 `platform-llm` 内承接业务 prompt 组织
2. 不在 `platform-llm` 内承接模块级状态写回
3. 不在 `platform-llm` 内做 HTTP Route/SSE façade
4. 不提前把图片、视频、异步任务轮询混进同一个 crate
5. 不声称已经打通 DashScope 图像 API当前首版只做文本网关
## 3. 当前边界口径
### 3.1 文本协议边界
首版只冻结 **OpenAI 兼容 chat completion**
1. 请求路径固定为 `base_url + /chat/completions`
2. Bearer Token 由 `Authorization: Bearer <api_key>` 注入
3. 非流式返回解析 `choices[0].message.content`
4. 流式返回解析 `choices[0].delta.content`
5. `content` 若返回数组文本片段,也统一拼成单字符串
### 3.2 Provider 边界
1. `Ark`:当前仓库已有真实默认 base url可直接作为 Rust 首版默认值
2. `DashScope`:当前只保留 provider 标签,不在 crate 内硬编码其文本兼容入口
3. `OpenAiCompatible`:用于其他兼容网关
这里故意不把 DashScope 文本 base url 写死,是因为当前仓库的真实 Node 主链并没有用 DashScope 跑文本 `/chat/completions`而是主要用于图像任务Rust 首版不应在没有仓库事实对齐的前提下硬塞一个未经验证的默认路径。
## 4. 对外 API 设计
### 4.1 `LlmConfig`
字段:
1. `provider`
2. `base_url`
3. `api_key`
4. `model`
5. `request_timeout_ms`
6. `max_retries`
7. `retry_backoff_ms`
约束:
1. `base_url``api_key``model` 不允许为空
2. `request_timeout_ms` 必须大于 `0`
3. `max_retries` 表示“首轮之外还允许重试多少次”
### 4.2 `LlmTextRequest`
字段:
1. `model: Option<String>`
2. `messages: Vec<LlmMessage>`
3. `max_tokens: Option<u32>`
约束:
1. `messages` 不能为空
2. 每条 `message.content` 不能为空字符串
3. `model` 如果传入,则 trim 后不能为空
### 4.3 `LlmTextResponse`
字段:
1. `provider`
2. `model`
3. `content`
4. `finish_reason`
5. `response_id`
6. `usage`
设计目的:
1. 上层只拿统一文本结果,不再接触 `choices``delta``message` 等上游细节
2. 后续 `api-server` 可以直接把这些字段映射到自己的 HTTP / SSE contract
## 5. 错误与重试策略
### 5.1 错误分层
`LlmError` 首版固定为:
1. `InvalidConfig`
2. `InvalidRequest`
3. `Timeout`
4. `Connectivity`
5. `Upstream`
6. `StreamUnavailable`
7. `EmptyResponse`
8. `Transport`
9. `Deserialize`
### 5.2 重试规则
允许重试:
1. 请求超时
2. 连接失败
3. `408`
4. `429`
5. `5xx`
不重试:
1. 配置错误
2. 请求体无效
3. 上游返回 `4xx` 非限流类错误
4. 已成功开始返回流之后的解析错误
### 5.3 Backoff 规则
首版采用线性 backoff
1. 第 1 次重试等待 `retry_backoff_ms`
2. 第 2 次重试等待 `retry_backoff_ms * 2`
3. 依此类推
原因:
1. 先保持实现简单
2. 足以覆盖当前仓库文本上游的偶发抖动
3. 真正需要指数退避时,再在平台层单点升级即可
## 6. 与 Node 现状对齐
Rust 首版有意对齐 `server-node/src/services/llmClient.ts` 的事实边界:
1. 同样走 `/chat/completions`
2. 同样区分非流式与流式
3. 同样在空文本时直接报错
4. 同样把上游 JSON 错误体里的 `error.message` / `message` 提取出来
5. 同样把重试、超时、连接失败收口在一个平台层里
但 Rust 版这次额外收紧两点:
1. 不混入 Express Request/Response 转发逻辑
2. 不把业务 prompt 参数与上游 client 绑定在一起
这样后续 `api-server``module-ai` 都只能依赖一套稳定基础设施,而不是复制旧 Node 的“传 HTTP 对象进去直接转发”的实现方式。
## 7. 当前测试覆盖
首版要求至少覆盖:
1. 配置校验
2. URL 归一化
3. SSE 事件解析
4. 非流式成功响应解析
5. `500 -> retry -> 200` 的重试闭环
6. 流式累计文本拼接
## 8. 后续衔接
### 8.1 `api-server`
后续 `api-server` 应该:
1. 在自身配置层解析环境变量
2. 组装 `LlmConfig`
3. 注入 `LlmClient`
4. 在 handler / application façade 中调用 `request_text``stream_text`
### 8.2 `module-ai`
后续 `module-ai` 应该:
1. 只负责 prompt 组织、阶段状态和结果引用
2. 不直接依赖 `reqwest`
3. 不再自己解析 SSE 增量
4. 统一通过 `platform-llm` 调模型
## 9. 本次验收标准
本次实现完成后,应满足:
1. `server-rs` workspace 能识别 `platform-llm` crate
2. `cargo test -p platform-llm` 通过
3. `cargo check -p platform-llm` 通过
4. `platform-llm` README 不再是“仅目录占位”
5. `docs/technical/README.md` 有正式文档索引

View File

@@ -4,6 +4,8 @@
## 文档列表
- [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md)`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。
- [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md)`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。
- [PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md](./PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md):冻结手机号验证码登录第一阶段的真实落地边界,明确游客兜底默认关闭、公开请求不污染登录态,以及 smoke 必须覆盖短信登录主链。
- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md)`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract以及用户不存在时的 `401` 语义。
@@ -19,6 +21,13 @@
- [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。
- [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth``SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。
- [RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](./RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md)Rust 工作区统一日志模块 `shared-logging` 的职责边界、API、输出风格与 `api-server` 迁移规则。
- [RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md](./RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md):把 `shared-contracts` 从占位目录推进成真实共享协议 crate冻结统一 envelope、`auth/*``runtime/settings``assets/*``story-sessions/*` 首批公开 DTO 的迁移边界。
- [RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md](./RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md):继续把 `assets/*``story-sessions/*` 的成功响应从 handler 内 `json!` 手拼收口到 `shared-contracts`,冻结显式响应 DTO 与适配边界。
- [RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md](./RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md):把 `shared-kernel` 从目录占位推进到首批真实共享内核冻结第一阶段只允许上提的基础字符串、ID 与时间处理能力,以及首批接入 crate 范围。
- [RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md](./RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md):在不扩公共 API 的前提下,把 `shared-kernel` 第一阶段已冻结的字符串、UUID 与时间处理能力继续接入 `module-runtime``module-story``spacetime-client``api-server`
- [RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md](./RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md):继续把 `shared-kernel` 扩到跨多个纯领域 crate 已稳定重复的字符串列表归一化与前缀种子 ID 拼接能力,明确第三阶段仍不进入 JSON、配置与平台语义处理。
- [RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md](./RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md):继续把 `shared-kernel::normalize_required_string(...)` 接入更多纯领域 crate收口跨模块重复的“trim + 判空 + 映射字段错误”逻辑,同时明确不进入平台与 JSON 语义。
- [RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md](./RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md):继续把 `shared-kernel::normalize_required_string(...)` 接入 `module-runtime``module-assets` 剩余的纯字段级归一化逻辑,保留 `object_key` 等模块局部语义不变。
- [SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)`M2` 第七张微信 OAuth 状态表 `wechat_auth_state` 的字段、过期/消费语义、`wechat/start``wechat/callback` 的单次消费规则,以及多实例下的清理策略。
- [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)`M2` 第六张短信鉴权统计表 `sms_auth_event` 的事件范围、统计口径、索引与和风控/审计表的协作边界。
- [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md)`M2` 第五张风控状态表 `auth_risk_block` 的作用域、活跃态、刷新/解除规则与读取派生约束。
@@ -29,9 +38,16 @@
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
- [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md):冻结 M6 剩余的 STS 与服务端上传 helper 落地口径,明确当前上传主链为服务器上传 OSSWeb 端只负责签名读下载。
- [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。
- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 首批 `runtime settings` 纵向切片的表字段、默认值、procedure、Axum facade、错误 contract 与测试策略。
- [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` 事件。
- [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 路由扩展方式。
- [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 后端的实施方案与验收口径。
- [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。
- [ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md](./ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md):冻结编码检查不扫描临时 Cargo / verify 工作区、同时把 Rust 源文件纳入 UTF-8 校验的修复口径。
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
- [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。
- [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。
@@ -51,6 +67,23 @@
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的前端 runtime story 主链真实迁移、NPC 交互与 gateway/client 收口、旧入口兼容降级,以及定向回归验证结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的后端 runtime session / action service 物理迁移、新域原语导出、旧热点兼容降级,以及定向 runtime story 回归验证结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的 RPG 运行时仓储拆分、shared runtime contract 分文件、旧 `story.ts` façade 兼容与定向回归结果。
- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs``M4` 首轮已落地的 `story_session / story_event` SpacetimeDB 基座、`begin_story_session / continue_story` reducer、同步返回快照的 story procedure、`spacetime-client` facade 与新的 `/api/story/sessions*` Axum 接口,以及当前尚未兼容旧 `runtime story` 路由的边界。
- [M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/sessions/:storySessionId/state` 这条最小 story state 查询切片,明确当前只返回 `storySession + storyEvents`,不等价于旧 `runtime story state` 兼容完成。
- [M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md](./M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md):冻结旧 `POST /api/runtime/story/state/resolve` 兼容桥的首版边界,明确先补 `RuntimeStoryActionResponse` DTO 与状态桥,再继续进入 Rust `actions/resolve` 与正式 snapshot projection。
- [M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](./M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md):冻结 `module-ai` 首版的任务/阶段/流式片段/结果引用领域模型、最小内存服务与后续 `platform-llm` / `api-server` / `spacetime-module` 的边界。
- [M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-ai``spacetime-module` 中首轮已落地的 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 真相表、最小 reducer/procedure 与当前仍未扩到真实模型调用和 Axum facade 的边界。
- [M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `module-ai``shared-contracts``spacetime-client``api-server` 的最小 AI task mutation facade明确 `start` 路由当前只返回 `202 Accepted`
- [M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-quest` 首轮已落地的 `quest_record / quest_log` 字段模型、`accept_quest / apply_quest_signal / acknowledge_quest_completion / turn_in_quest` reducer 边界,以及当前刻意未扩到奖励结算和 Axum facade 的范围。
- [M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-runtime-item` 首轮已落地的 `treasure_record` 字段模型、奖励快照 contract、`resolve_treasure_interaction` reducer/procedure 边界,以及当前刻意未扩到 `inventory_slot` 和 Axum facade 的范围。
- [M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md):冻结 `module-combat` 首版 `battle_state``resolve_combat_action``fight / spar` 收束规则与 `spacetime-module` 接线边界,明确当前暂不接入 `inventory_use` 与跨子域奖励联动。
- [M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](./M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md):冻结 `module-combat``spacetime-module procedure``spacetime-client` 再到 `api-server` 的最小同步返回链,明确当前只新增独立 battle facade不直接兼容旧 runtime story 总入口。
- [M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](./M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/battles/:battleStateId` 这条最小 battle state 查询切片,明确当前只返回单个 `battleState` 真相态,不等价于旧 runtime story state 兼容完成。
- [M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/runtime/sessions/:runtimeSessionId/inventory` 这条最小 inventory 查询切片,明确当前只返回 `inventory_slot` 真相表拆分后的 `backpackItems + equipmentItems`
- [M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs``module-npc` 首轮已落地的 `npc_state / relation_state / stance_profile` 领域 contract、`resolve_npc_social_action` 规则原语,以及 `spacetime-module` 的最小 reducer / procedure 接线边界。
- [M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md](./M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md):冻结 `npc_fight / npc_spar``spacetime-module` 聚合层初始化 `battle_state` 的最小联合 procedure 边界,明确仍不把战斗初始化字段回灌到 `module-npc` 纯领域 crate。
- [M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `resolve_npc_battle_interaction_and_return` 向上接入 `spacetime-client``api-server` 的最小同步返回链,明确当前只新增独立 `POST /api/story/npc/battle` facade。
- [M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs``module-progression` 首轮已落地的 `player_progression / chapter_progression` 字段模型、成长曲线、章节预算与记账 reducer / procedure 边界,以及当前刻意未扩到完整章节蓝图迁移和 quest/combat 自动联动的范围。
- [M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](./M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md):冻结 `turn_in_quest``resolve_combat_action(Victory)``player_progression / chapter_progression` 的最小联动口径,明确 battle 奖励字段、章节账本静默跳过规则与本轮不扩到的范围。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md):对照执行计划逐项复核第一批与第二批并行工作的真实落地状态,记录本轮确认到的测试合流收口遗漏与文档索引补齐结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md):记录 RPG 执行计划第三批收口已完成的前端新域主链接回、后端新仓储接线、shared contract 直连收紧、旧兼容脚本物理删除,以及明确未扩到 UI 和无关历史文档的边界。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md):记录 RPG 主链旧 `GameShell``useGame*``hooks/story``runtimeRoutes``modules/story/*``contracts/story.ts` 脚本的物理删除范围、残留依赖扫描和定向验证结果。

View File

@@ -0,0 +1,207 @@
# Rust `shared-contracts` Stage1/Stage3 落地设计
日期:`2026-04-21`
关联任务:
- [../../backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md](../../backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md)
- [../../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](../../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)
关联现状:
- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md)
- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md)
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)
- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md)
## 1. 文档目的
`server-rs/crates/shared-contracts` 当前只有 README 占位,没有真正落到可被 `api-server`、后续模块 crate 与前端兼容层复用的共享协议代码。
本文件先解决 Stage1 的最小可编码切片,并补充 Stage2 的鉴权 DTO 扩展,以及 Stage3 的资产 / 叙事请求 DTO 收口:
1. 把统一 response envelope / 头部常量落到真实 crate。
2. 把已经冻结的 `auth/login-options``auth/me``auth/sessions``runtime/settings` DTO 收进共享 crate。
3.`api-server` 首次真实依赖 `shared-contracts`,而不是继续把协议类型散落在 handler 文件里。
4. 继续把 `auth/entry``auth/refresh``auth/logout``auth/logout-all``auth/phone/*``auth/wechat/*` 的对外 HTTP DTO 收进共享 crate。
5.`assets/*``story-sessions/*` 已冻结的公开请求 DTO 收进共享 crate。
本文件不在本轮解决:
1. SSE 事件总线的统一事件模型。
2. 前端 TypeScript 与 Rust crate 自动生成同构协议。
3. handler 里用 `json!` 拼装的响应体进一步升级为显式共享响应 DTO。
## 2. 当前问题
当前 Rust 工作区虽然已经创建了 `crates/shared-contracts` 目录,但协议仍然散落在 `crates/api-server/src/*.rs`
1. `api_response.rs` 自己维护 `API_VERSION``ApiResponseMeta`
2. `http_error.rs` 自己维护 `ApiErrorPayload`
3. `login_options.rs``auth_me.rs``auth_sessions.rs``runtime_settings.rs` 继续把 DTO 定义在 handler 文件顶部。
4. `password_entry.rs``phone_auth.rs``refresh_session.rs``logout.rs``logout_all.rs``wechat_auth.rs` 也继续各自维护请求/响应结构。
这会带来三个直接问题:
1. `api-server` 无法和后续模块 crate 共用同一份协议定义。
2. DTO 一旦增多handler 会重新退化成“协议 + 业务 + 装配”混写。
3. `shared-contracts` 仍是空壳,无法作为多 crate 架构中的真实依赖节点。
## 3. Stage1 范围冻结
### 3.1 本轮允许进入 `shared-contracts` 的内容
1. HTTP response envelope 常量与结构:
- `API_VERSION`
- `x-genarrative-response-envelope`
- `x-request-id`
- `x-api-version`
- `x-route-version`
- `x-response-time-ms`
- `ApiResponseMeta`
- `ApiErrorPayload`
- success / error envelope 结构
2. 鉴权相关 DTO
- `AuthLoginOptionsResponse`
- `AuthUserPayload`
- `AuthMeResponse`
- `AuthSessionSummaryPayload`
- `AuthSessionsResponse`
- `PasswordEntryRequest`
- `PasswordEntryResponse`
- `RefreshSessionResponse`
- `LogoutResponse`
- `LogoutAllResponse`
- `PhoneSendCodeRequest`
- `PhoneSendCodeResponse`
- `PhoneLoginRequest`
- `PhoneLoginResponse`
- `WechatStartQuery`
- `WechatStartResponse`
- `WechatCallbackQuery`
- `WechatBindPhoneRequest`
- `WechatBindPhoneResponse`
3. runtime settings DTO
- `RuntimeSettingsResponse`
- `PutRuntimeSettingsRequest`
4. 资产与叙事边界 DTO
- `CreateDirectUploadTicketRequest`
- `GetReadUrlQuery`
- `ConfirmAssetObjectRequest`
- `BindAssetObjectRequest`
- `ConfirmAssetObjectAccessPolicy`
- `BeginStorySessionRequest`
- `ContinueStoryRequest`
### 3.2 本轮明确不进入 `shared-contracts` 的内容
1. 业务规则、归一化逻辑、数据库字段验证。
2. `module-auth``module-runtime` 内部领域对象。
3. 供应商回包 DTO例如微信 OAuth provider 响应、OSS SDK 内部结构。
4. `RequestContext` 这类框架运行时对象。
## 4. crate 设计
### 4.1 目录结构
```text
server-rs/crates/shared-contracts/
├─ Cargo.toml
└─ src/
├─ lib.rs
├─ api.rs
├─ auth.rs
├─ runtime.rs
├─ assets.rs
└─ story.rs
```
### 4.2 模块职责
1. `api.rs`
- 统一 API 版本常量、头部常量。
- 定义 success / error envelope 的共享序列化结构。
2. `auth.rs`
- 只放对外 HTTP DTO 与协议常量。
- 不承接账号仓储、session 轮换、手机号/微信规则。
3. `runtime.rs`
- 只放 `runtime/settings` 的请求响应 DTO。
- 不承接 `module-runtime` 的默认值、归一化与 SpacetimeDB 表结构。
4. `assets.rs`
- 只放资产上传、读签名、对象确认、实体绑定的公开 HTTP DTO。
- 允许依赖 `platform-oss::OssObjectAccess` 这类跨 crate 协议字段类型,但不承接 OSS client、provider 回包与上传流程实现。
5. `story.rs`
- 只放 `story-sessions` 的公开请求 DTO。
- 不承接 `module-story` 的会话状态机、事件生成与持久化细节。
## 5. 关键约束
### 5.1 只放协议,不放业务
`shared-contracts` 的判断标准是:
1. 这个类型是否直接出现在 HTTP / SSE 边界上。
2. 它是否不需要访问仓储、配置、时钟、网络或第三方 SDK。
如果答案不是这两项都满足,就不应该进入本 crate。
### 5.2 Stage1 先接受字符串字段
本轮共享 DTO 里的部分值域虽然已经在文档中冻结,例如:
1. `loginMethod`
2. `bindingStatus`
3. `platformTheme`
但为了避免第一轮就把 Rust 领域枚举与 HTTP 枚举强绑定,本轮仍以字符串字段为主,只提供协议常量,不在 `shared-contracts` 内复制业务枚举和归一化规则。
这样做的原因:
1. 当前 `api-server` 已有 `module-auth``module-runtime` 的真实值域来源。
2. 先把协议层抽出来,能更快形成稳定依赖。
3. 后续若要升级成显式枚举,可在 `shared-contracts` 单独做 breaking change而不是把 Stage1 扩成大量语义迁移。
## 6. 首批接入策略
### 6.1 `api-server`
本轮直接接入以下共享定义:
1. `api_response.rs` 改为依赖 `shared-contracts::api::*`
2. `http_error.rs` 改为依赖 `shared-contracts::api::ApiErrorPayload`
3. `login_options.rs``auth_me.rs``auth_sessions.rs``runtime_settings.rs` 改为依赖 `shared-contracts` DTO
4. `password_entry.rs``phone_auth.rs``refresh_session.rs``logout.rs``logout_all.rs``wechat_auth.rs` 改为依赖 `shared-contracts::auth::*`
5. `assets.rs` 改为依赖 `shared-contracts::assets::*`
6. `story_sessions.rs` 改为依赖 `shared-contracts::story::*`
### 6.2 其他 crate
本轮暂不要求 `module-auth``module-runtime``module-story` 直接依赖 `shared-contracts`
原因:
1. 这些 crate 当前主要暴露领域模型,而不是对外 HTTP DTO。
2. 先让 `api-server` 真实消费共享协议,才能验证 crate 边界是否稳定。
## 7. 验证要求
至少完成以下验证:
1. `cargo test -p shared-contracts`
2. `cargo test -p api-server`
3. 文档索引补齐,确保后续任务能直接找到这份设计。
## 8. 完成定义
满足以下条件时,`实现 shared-contracts` 的 Stage1 到 Stage3 视为完成:
1. `server-rs/crates/shared-contracts` 不再只有 README占位 crate 变成真实可编译 crate。
2. `server-rs/Cargo.toml` 已把它纳入 workspace members。
3. `api-server` 已真实依赖并消费首批共享协议定义。
4. 统一 envelope、auth/runtime/assets/story 的公开 DTO 不再散落定义在 handler 文件顶部。
5. `api-server` 中剩余本地 `Request/Response/Query` 类型只保留框架运行时对象与第三方 provider 私有 DTO。
6. 文档与技术索引已同步更新。

View File

@@ -0,0 +1,126 @@
# Rust `shared-contracts` Stage4 响应 DTO 落地设计
日期:`2026-04-21`
关联文档:
- [RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md](./RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md)
- [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md)
## 1. 文档目的
Stage1 到 Stage3 已经把 `auth/runtime/assets/story` 的公开请求 DTO 收进 `shared-contracts`,但 `api-server` 里仍有两组成功响应还在 handler 内用 `json!` 直接手拼:
1. `assets.rs`
2. `story_sessions.rs`
这会让 handler 继续承担协议字段名维护职责,也会让共享 crate 在“请求已收口、响应仍分散”的中间态停太久。
本文件冻结 Stage4 的最小追加范围:
1.`assets/*` 的成功响应 DTO 显式落到 `shared-contracts::assets`
2.`story-sessions/*` 的成功响应 DTO 显式落到 `shared-contracts::story`
3.`api-server` 的这两组 handler 改为直接返回共享响应类型
## 2. 当前问题
当前 `api-server` 已经不再本地定义 `assets/story` 的请求 DTO但成功响应仍然是字面量 JSON
1. `create_direct_upload_ticket`
2. `get_asset_read_url`
3. `confirm_asset_object`
4. `bind_asset_object_to_entity`
5. `begin_story_session`
6. `continue_story`
风险有三点:
1. 字段名仍散落在 handler 内,后续字段调整容易漏改测试或文档。
2. `shared-contracts` 无法完整代表这几条公开接口的成功 contract。
3. handler 重新退化成“业务编排 + 协议拼装”混写。
## 3. Stage4 范围冻结
### 3.1 本轮新增进入 `shared-contracts` 的类型
`shared-contracts::assets`
1. `CreateDirectUploadTicketResponse`
2. `DirectUploadTicketPayload`
3. `DirectUploadTicketFormFields`
4. `GetAssetReadUrlResponse`
5. `AssetReadUrlPayload`
6. `ConfirmAssetObjectResponse`
7. `AssetObjectPayload`
8. `BindAssetObjectResponse`
9. `AssetBindingPayload`
`shared-contracts::story`
1. `StorySessionPayload`
2. `StoryEventPayload`
3. `StorySessionMutationResponse`
### 3.2 本轮明确不进入 `shared-contracts` 的内容
1. `AppError.details` 里的错误明细 JSON 结构。
2. `healthz`、内部调试路由或测试专用返回体。
3. `spacetime-client``module-assets``module-story` 的领域记录类型。
4. `platform-oss` 的上传客户端实现与原始返回对象直接对外暴露。
## 4. 设计约束
### 4.1 响应字段名必须完全兼容当前 JSON
本轮不是协议重设计,而是把现有稳定输出显式类型化,因此:
1. 字段名必须与当前测试断言一致。
2. `assets` 返回中的 `formFields` 结构必须保持现有 key 命名。
3. `story` 返回中的 `status``eventKind` 继续保持字符串,不升级成 Rust 枚举。
### 4.2 允许以适配层方式消费下游 crate
`shared-contracts` 可以为 `platform-oss` 的稳定协议返回对象提供转换适配,但不直接把这些类型当作最终 HTTP contract 暴露出去。
这样做的原因:
1. HTTP contract 仍由 `shared-contracts` 持有。
2. `platform-oss` 仍只负责 OSS 协议与签名实现。
3. 后续若 HTTP 返回要和底层实现解耦,可以只改适配层,不影响 handler 调用方式。
## 5. `api-server` 接入方式
### 5.1 `assets.rs`
改造后 handler 只负责:
1. 调用 `oss_client` / `spacetime_client`
2. 把结果映射成 `shared-contracts::assets::*`
3. 交给 `json_success_body` 输出 envelope 或裸数据
### 5.2 `story_sessions.rs`
改造后 handler 只负责:
1. 组装 `begin_story_session` / `continue_story` 调用参数
2.`StorySessionResultRecord` 映射成 `StorySessionMutationResponse`
3. 复用现有错误 envelope 输出
## 6. 验证要求
至少完成以下验证:
1. `cargo fmt --all`
2. `cargo test -p shared-contracts`
3. `cargo test -p api-server`
## 7. 完成定义
满足以下条件时Stage4 视为完成:
1. `shared-contracts::assets``shared-contracts::story` 已拥有这轮新增的成功响应 DTO。
2. `api-server/src/assets.rs``api-server/src/story_sessions.rs` 不再直接手拼成功响应字段。
3. 现有接口 JSON 输出对测试保持兼容。
4. 文档索引与 crate README 已同步更新。

View File

@@ -0,0 +1,106 @@
# Rust `shared-kernel` crate 第一阶段设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `server-rs/crates/shared-kernel` 从“目录占位”推进到“首批真实可复用能力”,并明确第一阶段只实现已经在多个 Rust crate 中重复出现的最小领域内核。
本阶段目标不是提前把所有业务模型都上提,而是先解决当前已经出现的共享基础能力重复:
1. 字符串归一化
2. 前缀 ID 生成
3. RFC3339 / 微秒时间戳格式化与解析
## 2. 当前问题
截至 `2026-04-21``shared-kernel` 已有目录与 README 占位,但仍存在以下工程问题:
1. `server-rs/Cargo.toml` 尚未把 `shared-kernel` 纳入 workspace member
2. `module-auth``module-assets``platform-auth` 已经各自重复实现字符串裁剪、UUID 生成、时间格式化等基础逻辑
3. 这些重复逻辑已经跨多个 crate 出现,继续分散会让后续 `SpacetimeDB` 表、reducer、Axum facade 和平台适配层继续复制
## 3. 第一阶段职责边界
### 3.1 `shared-kernel` 本阶段负责
1. 提供跨模块共享的基础字符串归一化函数
2. 提供跨模块共享的前缀 ID 生成函数
3. 提供跨模块共享的时间文本格式化与解析函数
4. 为后续 `SpacetimeDB` 表字段、模块输入输出和平台适配提供统一基础值处理
### 3.2 `shared-kernel` 本阶段不负责
1. 不上提 `AuthProvider``BindingStatus``AssetObjectAccessPolicy` 这类仍带模块语义的业务枚举
2. 不上提 `AuthUser``RefreshSessionRecord``AssetObjectRecord` 这类仍明显属于单模块的实体结构
3. 不承担 `Axum``JWT``Cookie``OSS``SpacetimeDB SDK` 等框架或平台适配职责
4. 不把 `shared-kernel` 做成新的“大公共工具箱”
## 4. 第一阶段落地范围
首批只允许进入 `shared-kernel` 的能力如下:
1. `normalize_required_string(...)`
2. `normalize_optional_string(...)`
3. `build_prefixed_seed_id(prefix, seed_micros)`
4. `build_prefixed_uuid_id(prefix)`
5. `new_uuid_simple_string()`
6. `format_timestamp_micros(...)`
7. `format_rfc3339(...)`
8. `parse_rfc3339(...)`
选择这些能力的原因:
1. 已经在 `module-auth``module-assets``platform-auth` 至少两处出现重复模式
2. 它们只表达基础值处理,不携带模块私有业务语义
3. 后续 `SpacetimeDB` 表、snapshot、procedure/reducer 输入输出会继续使用这些能力
## 5. 首批接入 crate
第一阶段要求至少完成以下真实接入避免出现“crate 存在但没有复用”的假落地:
1. `module-assets`
- 接管 `normalize_optional_value`
- 接管 `generate_asset_object_id`
- 接管 `generate_asset_binding_id`
- 接管微秒时间戳格式化
2. `module-auth`
- 接管随机会话 / state ID 生成
- 接管可选字符串归一化
- 接管 RFC3339 格式化与解析
3. `platform-auth`
- 接管必填/可选字符串归一化
- 接管 refresh session token UUID simple 生成
## 6. 与 SpacetimeDB 的边界
根据 `spacetimedb-rust``spacetimedb-concepts` 约束,`shared-kernel` 本阶段保持以下边界:
1. 不在这里定义 `#[table]` 结构
2. 不在这里实现 reducer / procedure
3. 不在这里引入外部副作用
4. 只沉淀可以同时服务 `Axum` 模块、领域模块和后续 `SpacetimeDB` 类型的基础值对象处理
## 7. 完成定义
当以下条件满足时,本阶段 `shared-kernel` 落地视为完成:
1. `shared-kernel` 已有正式 `Cargo.toml``src/lib.rs`
2. `server-rs/Cargo.toml` 已纳入 workspace member
3. `module-assets``module-auth``platform-auth` 至少有一批重复基础逻辑已改为复用 `shared-kernel`
4. `shared-kernel` 自身带有最小单元测试
5. 文档索引已收录本设计文档
## 8. 后续阶段
第一阶段完成后,后续若要继续扩展 `shared-kernel`,必须满足以下前置条件:
1. 新上提类型已经在多个模块稳定复用
2. 已有文档能证明它不是单模块私有语义
3. 不会把模块边界重新压回共享层
优先候选方向包括:
1. 更稳定的核心 ID 新类型
2. 共享版本号 / 状态值对象
3. 更明确的时间或审计基础结构

View File

@@ -0,0 +1,112 @@
# Rust `shared-kernel` crate 第二阶段接入设计
日期:`2026-04-21`
## 1. 文档目的
第一阶段已经把 `shared-kernel` 从占位目录推进成真实 crate并冻结了最小共享 API。
第二阶段不新增共享 API只继续把已经确认稳定的第一阶段能力接入更多 Rust crate避免重复 helper 在工作区继续扩散。
## 2. 本阶段问题
截至当前,工作区里仍有几类已经被第一阶段覆盖、但尚未切到 `shared-kernel` 的重复实现:
1. `module-runtime` 仍本地维护可选字符串归一化与 RFC3339 / 微秒时间戳格式化解析
2. `module-story` 仍本地维护可选字符串归一化与微秒时间戳格式化
3. `spacetime-client` 仍本地维护微秒时间戳格式化
4. `api-server``session_client``assets` 测试仍各自维护可选字符串归一化、UUID simple 生成
这些逻辑都属于第一阶段已经冻结的共享值处理能力,继续保留本地副本只会增加后续漂移风险。
## 3. 本阶段边界
### 3.1 本阶段允许做的事
1. 继续复用第一阶段已存在的 `shared-kernel` API
2. 删除业务 crate 内与这些 API 完全等价的本地 helper
3. 在文档中补充第二阶段接入范围与验证结果
### 3.2 本阶段明确不做的事
1. 不新增新的 `shared-kernel` 公共函数
2. 不上提 `module-runtime``module-story``api-server` 私有业务规则
3. 不修改 `SpacetimeDB` table、reducer、procedure 的领域边界
4. 不把 `shared-kernel` 扩成新的“通用工具箱”
## 4. 第二阶段接入范围
### 4.1 `module-runtime`
接入以下共享能力:
1. `normalize_optional_string(...)`
2. `format_rfc3339(...)`
3. `parse_rfc3339(...)`
保留本地 `format_utc_micros(...)` 作为 runtime 领域语义包装,但内部改为调用共享格式化/解析能力,统一异常兜底口径。
### 4.2 `module-story`
接入以下共享能力:
1. `normalize_optional_string(...)`
2. `format_timestamp_micros(...)`
### 4.3 `spacetime-client`
接入以下共享能力:
1. `format_timestamp_micros(...)`
### 4.4 `api-server`
接入以下共享能力:
1. `session_client` 使用 `normalize_optional_string(...)`
2. `assets` 测试使用 `new_uuid_simple_string(...)`
## 5. 完成定义
当以下条件满足时,`shared-kernel` 第二阶段接入视为完成:
1. 第二阶段设计文档已补齐并收录到技术索引
2. 上述四个 crate 不再保留完全等价的重复 helper
3. 相关 crate 编译通过
4. 关键测试与编码检查通过
## 6. 后续约束
后续若继续扩展 `shared-kernel`,仍必须满足:
1. 已在多个 crate 稳定复用
2. 已有文档证明它不是单模块私有语义
3. 能通过“共享值处理”边界审核,而不是把业务规则上提
## 7. 本轮落地结果
### 7.1 实际收口情况
1. `module-runtime` 改为直接使用 `shared-kernel::normalize_optional_string(...)`,并保留 `format_utc_micros(...)` 作为 runtime 领域语义包装
2. `module-story` 改为直接使用 `shared-kernel::format_timestamp_micros(...)`,并保留 `normalize_optional_value(...)` 作为现有公共 API 兼容入口
3. `spacetime-client` 改为直接使用 `shared-kernel::format_timestamp_micros(...)`,同时移除 battle state 查询遗留的未使用导入噪音
4. `api-server::session_client` 改为直接使用 `shared-kernel::normalize_optional_string(...)`
5. `module-auth` 第一阶段遗留的可选字符串归一化私有副本也一并收口到 `shared-kernel`
### 7.2 验证结果
本轮基于独立 `CARGO_TARGET_DIR` 进行最小验证,避免与工作区内其他并行 `cargo` 任务争抢默认构建目录。
已通过的命令:
1. `cargo check -p module-auth --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short`
2. `cargo check -p module-runtime --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short`
3. `cargo check -p module-story --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short`
4. `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short`
5. `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short`
### 7.3 当前刻意保留的兼容说明
1. 第二阶段没有新增 `shared-kernel` 公共 API只扩大既有共享能力的复用范围
2. `spacetime-client::get_battle_state(...)` 仍保持“明确报错”的临时兼容语义,因为当前生成的 SpacetimeDB binding 尚未带出对应可调用 procedure
3. 本轮没有修改自动生成的 `server-rs/crates/spacetime-client/src/module_bindings/*`

View File

@@ -0,0 +1,84 @@
# Rust `shared-kernel` crate 第三阶段值归一化扩展设计
日期:`2026-04-22`
## 1. 文档目的
第二阶段已经把第一阶段冻结的字符串、UUID 与时间处理能力继续接入到更多 crate`server-rs` 里仍有一批完全等价、且已经跨多个纯领域 crate 重复出现的“字符串列表归一化”和“前缀种子 ID 拼接”逻辑。
第三阶段目标不是继续扩大共享边界到业务规则,而是只补一个已经被多个 crate 重复验证过的最小共享能力,并把现有 `build_prefixed_seed_id(...)` 继续接到更多领域 crate。
## 2. 本阶段问题
截至当前,以下重复模式已在多个 crate 中稳定出现:
1. `normalize_string_list(Vec<String>) -> Vec<String>`:逐项 `trim()`,过滤空白项
2. `format!("{}{:x}", PREFIX, seed_micros)`:前缀 + 十六进制微秒种子 ID 生成
这些逻辑已经出现在 `module-ai``module-inventory``module-runtime-item``module-npc``module-quest``module-combat``module-story` 等多个纯领域 crate 中,继续分散维护会增加漂移风险。
## 3. 本阶段边界
### 3.1 本阶段允许做的事
1.`shared-kernel` 中新增 `normalize_string_list(...)`
2. 把更多纯领域 crate 的前缀种子 ID 生成切到 `build_prefixed_seed_id(...)`
3. 把更多纯领域 crate 的字符串列表归一化切到 `normalize_string_list(...)`
4. 保留对外已有的领域兼容函数名,只替换其内部实现或导出方式
### 3.2 本阶段明确不做的事
1. 不上提 JSON payload、配置、HTTP header、OSS object key 等带平台语义的归一化规则
2. 不上提 `task_id + stage_kind` 这种带模块专属结构的 ID 生成规则
3. 不把单个字段的业务校验错误语义挪进 `shared-kernel`
4. 不修改 `SpacetimeDB` table、reducer、procedure 的领域边界
## 4. 第三阶段接入范围
### 4.1 `shared-kernel`
新增:
1. `normalize_string_list(values: Vec<String>) -> Vec<String>`
函数职责只包含:
1. 逐项裁剪字符串首尾空白
2. 过滤归一化后为空的条目
3. 返回顺序保持不变
### 4.2 继续接入 `build_prefixed_seed_id(...)`
接入以下 crate
1. `module-ai`
2. `module-inventory`
3. `module-combat`
4. `module-story`
### 4.3 继续接入 `normalize_string_list(...)`
接入以下 crate
1. `module-ai`
2. `module-inventory`
3. `module-runtime-item`
4. `module-npc`
5. `module-quest`
## 5. 完成定义
当以下条件满足时,第三阶段视为完成:
1. 设计文档已收录到技术索引
2. `shared-kernel` 新增 `normalize_string_list(...)` 且带最小测试
3. 上述 crate 不再保留完全等价的重复列表归一化与前缀种子 ID 拼接实现
4. 相关 crate 编译通过
## 6. 后续约束
后续若继续扩展 `shared-kernel`,仍必须满足:
1. 已在多个 crate 稳定复用
2. 仍属于共享值处理,而不是业务规则
3. 必须先补文档再落代码

View File

@@ -0,0 +1,72 @@
# Rust `shared-kernel` crate 第四阶段必填字符串接入设计
日期:`2026-04-22`
## 1. 文档目的
第三阶段已经把前缀种子 ID 和字符串列表归一化继续接入到更多纯领域 crate但工作区里仍有一批完全等价的“必填字符串裁剪后判空”逻辑分散在多个领域模块内。
第四阶段目标仍然不是新增业务规则,而是继续把已有的 `normalize_required_string(...)` 扩到更多纯领域 crate减少重复实现。
## 2. 本阶段问题
截至当前,以下重复模式仍在多个领域 crate 中出现:
1. `value.trim().to_string()` 后判空并返回模块自己的字段错误
2. 构造输入 DTO 时先 `trim().to_string()`,再走统一校验函数
这些模式已经出现在 `module-progression``module-story``module-combat``module-ai``module-npc``module-runtime-item``module-inventory``module-quest` 中,且都只属于共享值处理能力。
## 3. 本阶段边界
### 3.1 本阶段允许做的事
1. 继续复用 `shared-kernel::normalize_required_string(...)`
2. 在各领域 crate 中保留原有错误枚举与错误语义
3. 把本地 `normalize_required_*` 改成调用共享实现
4. 把“构造时先 trim再 validate”的输入构造改成共享归一化
### 3.2 本阶段明确不做的事
1. 不新增新的 `shared-kernel` 公共函数
2. 不修改 JSON、配置、HTTP、OSS 等平台语义归一化逻辑
3. 不改动 `SpacetimeDB` table、reducer、procedure 边界
4. 不改动模块对外错误文案
## 4. 第四阶段接入范围
### 4.1 继续接入 `normalize_required_string(...)`
接入以下 crate
1. `module-progression`
2. `module-story`
3. `module-combat`
4. `module-ai`
5. `module-npc`
6. `module-runtime-item`
7. `module-inventory`
8. `module-quest`
### 4.2 接入策略
1. 若本地 helper 只是“trim + 判空 + 映射错误”,则改为调用 `normalize_required_string(...)`
2. 若本地构造函数只是先 `trim` 再调用 validate则直接在构造阶段复用共享必填归一化
3. 若逻辑同时包含模块私有结构规则,则只替换其中的基础字符串归一化部分
## 5. 完成定义
当以下条件满足时,第四阶段视为完成:
1. 文档已收录到技术索引
2. 上述 crate 中完全等价的必填字符串归一化重复实现已继续减少
3. 相关 crate 编译通过
4. 编码检查通过
## 6. 后续约束
后续若继续扩展 `shared-kernel`,仍必须满足:
1. 已在多个 crate 稳定复用
2. 仍属于共享值处理,而不是业务规则
3. 先补文档再落代码

View File

@@ -0,0 +1,89 @@
# Rust `shared-kernel` crate 第五阶段纯领域字段接入设计
日期:`2026-04-22`
## 1. 文档目的
第四阶段已经把 `normalize_required_string(...)` 接入到更多纯领域 crate`module-runtime``module-assets` 中仍保留一批完全等价的“必填字符串裁剪后判空”逻辑。
第五阶段目标仍然不是扩展新的共享 API而是继续把已冻结的共享能力接入剩余纯领域字段进一步减少重复实现同时保持各模块既有错误语义与字段特有规则不变。
## 2. 本阶段问题
截至当前,以下重复模式仍然存在:
1. `user_id` 一类稳定必填字段在多个 runtime 输入构造函数里重复 `trim + 判空`
2. 浏览历史同步中 `owner_user_id / profile_id / world_name` 的字段级过滤仍然手写 `trim + is_empty`
3. 资产对象与绑定输入中的 `asset_object_id / bucket / asset_kind / binding_id / entity_kind / entity_id / slot` 等稳定字段仍然手写 `trim + 判空`
这些逻辑都属于共享值处理,不涉及业务流程、平台配置或外部服务语义。
## 3. 本阶段边界
### 3.1 本阶段允许做的事
1. 继续复用 `shared-kernel::normalize_required_string(...)`
2. 在模块内增加薄包装 helper把共享归一化结果映射到模块自己的错误枚举
3. 对仍然需要字段特有语义的场景,仅替换其中的基础必填字符串归一化部分
### 3.2 本阶段明确不做的事
1. 不新增新的 `shared-kernel` 公共函数
2. 不把 `theme_mode``visited_at`、RFC3339 解析等 runtime 语义上提到共享层
3. 不把 OSS endpoint、metadata、HTTP request、JSON payload 归一化上提到共享层
4. 不改动 `SpacetimeDB` reducer / procedure / table 结构
5. 不改动模块对外错误文案
## 4. 第五阶段接入范围
### 4.1 `module-runtime`
接入以下稳定字段:
1. `RuntimeSettingGetInput.user_id`
2. `RuntimeSettingUpsertInput.user_id`
3. `RuntimeBrowseHistoryListInput.user_id`
4. `RuntimeBrowseHistoryClearInput.user_id`
5. `RuntimeBrowseHistorySyncInput.user_id`
6. `RuntimeProfileDashboardGetInput.user_id`
7. `RuntimeProfileWalletLedgerListInput.user_id`
8. `RuntimeProfilePlayStatsGetInput.user_id`
9. 浏览历史同步时单条记录过滤使用的 `owner_user_id / profile_id / world_name`
### 4.2 `module-assets`
接入以下稳定字段:
1. `asset_object_id`
2. `bucket`
3. `asset_kind`
4. `binding_id`
5. `asset_object_id`
6. `entity_kind`
7. `entity_id`
8. `slot`
其中 `object_key` 保持模块本地语义:先裁剪空白,再去掉前导 `/`,最后再做必填校验;该规则不抽到 `shared-kernel`
## 5. 接入策略
1. 若字段只是“trim + 判空 + 返回模块错误”,则直接改为 `normalize_required_string(...)`
2. 若字段同时有局部规则,例如 `object_key` 需要去前导 `/`,则先在模块内保留局部处理,再复用共享必填归一化
3. 若字段缺失时需要“静默过滤单条记录而非整批失败”,则继续保留该行为,只替换字段归一化实现
## 6. 完成定义
当以下条件满足时,第五阶段视为完成:
1. 文档已收录到技术索引
2. `module-runtime``module-assets` 中上述纯字段级重复实现已继续减少
3. 相关 crate 编译通过
4. 编码检查通过
## 7. 后续约束
后续若继续扩展 `shared-kernel`,仍必须满足:
1. 已在多个 crate 中稳定重复出现
2. 只属于共享值处理,不属于模块私有语义
3. 先补文档,再落代码

View File

@@ -0,0 +1,190 @@
# `M5` custom world Agent message / operation Stage 7 设计
日期:`2026-04-22`
## 1. 文档目的
这份文档冻结 `M5` 的下一段最小闭环:在 Stage 6 已具备 `session create / session snapshot` 的前提下,把 RPG 创作 Agent 的 `message submit / operation query` 接到 `SpacetimeDB` 真相表与 `Axum` facade。
本轮只做:
1. `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages`
2. `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`
3. `custom_world_agent_message` 写入用户消息
4. `custom_world_agent_operation` 写入并返回 `process_message` 操作状态
5.`custom_world_agent_session` 内同步更新最小会话进度与 assistant 回复
本轮不做:
1. LLM 编排
2. SSE `message stream`
3. 真正的异步后台任务
4. `card detail / card update`
5. `draft_foundation`
6. 真实 `pendingClarifications` 推导
## 2. 设计目标
Stage 6 已能创建和读取 session`POST /messages``GET /operations/:operationId` 仍未迁移,导致旧前端无法继续沿 `session -> message -> operation -> session` 的基本主链工作。
本轮目标不是还原旧 Node 的完整单轮推理,而是先提供 deterministic、同步完成的最小消息处理链确保
1. 前端可以提交消息
2. 后端会记录 user / assistant 双消息
3. 前端可以轮询 operation
4. session snapshot 会体现最新 turn、progress、stage 与最后回复
## 3. SpacetimeDB contract
新增输入:
1. `CustomWorldAgentMessageSubmitInput`
2. `CustomWorldAgentOperationGetInput`
新增返回:
1. `CustomWorldAgentOperationProcedureResult`
新增 procedure
1. `submit_custom_world_agent_message`
2. `get_custom_world_agent_operation`
## 4. 最小 deterministic 处理规则
### 4.1 message submit
输入字段:
1. `session_id`
2. `owner_user_id`
3. `user_message_id`
4. `user_message_text`
5. `operation_id`
6. `submitted_at_micros`
处理步骤固定如下:
1. 校验 session 存在且归属当前用户
2. 校验 `user_message_id``operation_id` 未重复
3. 写入一条 `role = user``kind = chat` 的消息
4. 写入一条 `operation_type = process_message` 的操作,状态直接落为 `completed`
5. 写入一条 `role = assistant``kind = chat` 的 deterministic 回复消息
6. 更新 `custom_world_agent_session`
- `current_turn += 1`
- `last_assistant_reply = deterministic reply`
- `updated_at = submitted_at`
- `progress_percent` 按最小规则推进
- `stage` 按最小规则推进
### 4.2 deterministic 回复规则
本轮不用 LLM固定返回规则
1. 当消息为空白时直接报错,不写表
2. 普通消息统一返回:
- `已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。`
3. 如果文本包含 `__phase1_force_fail__`,直接返回 procedure 错误:
- `forced failure`
这个保留字只用于兼容旧 Node 测试中的失败路径,不代表正式产品行为。
### 4.3 最小 session 推进规则
1. 初次提交消息后,`progress_percent` 至少推进到 `20`
2. 当累计 user 消息数达到 `2` 条及以上时:
- `progress_percent = 100`
- `stage = foundation_review`
- `creator_intent_readiness_json` 改为 `{ "isReady": true, "completedKeys": ["seed_input"], "missingKeys": [] }`
3. 当累计 user 消息数只有 `1` 条时:
- `progress_percent = max(existing, 20)`
- `stage = clarifying`
- `creator_intent_readiness_json` 保持 `isReady = false`
- `pending_clarifications_json` 写入最小数组,至少包含一条问题
这样做的目的不是伪装完整意图抽取,而是提供一个能被旧前端继续驱动的最小状态机。
## 5. HTTP contract
### 5.1 `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages`
请求:
```json
{
"clientMessageId": "client-001",
"text": "一条新的世界设定",
"quickFillRequested": false,
"focusCardId": null,
"selectedCardIds": []
}
```
说明:
1. `quickFillRequested``focusCardId``selectedCardIds` 本轮先只做兼容解析,不进入 SpacetimeDB 真相表
2. `clientMessageId` 直接作为 user message 主键
3. operation id 由 Axum 生成,前缀固定 `operation-`
响应:
```json
{
"operation": {
"operationId": "operation-...",
"type": "process_message",
"status": "completed",
"phaseLabel": "消息已处理",
"phaseDetail": "...",
"progress": 100,
"error": null
}
}
```
### 5.2 `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`
返回裸 `operation` JSON不额外包 `{ operation }`,保持旧 Node contract。
## 6. Axum 与 client 边界
`api-server` 负责:
1. Bearer JWT 鉴权
2. 参数校验与错误 envelope
3. 生成 `operation-` 主键
4. 把请求映射到 `spacetime-client`
`spacetime-client` 负责:
1. 调用 `submit_custom_world_agent_message`
2. 调用 `get_custom_world_agent_operation`
3. 把 procedure 结果映射成领域 record
`spacetime-module` 负责:
1. 真相表写入与最小 deterministic 状态推进
2. 会话归属校验
3. 快照与 operation 单条读取
## 7. 完成定义
当以下条件满足时,本轮 Stage 7 视为完成:
1. `module-custom-world` 冻结 message submit / operation get 输入输出类型
2. `spacetime-module` 可提交消息并返回 operation
3. `spacetime-module` 可查询单条 operation
4. `spacetime-client` 封装 submit / get operation
5. `api-server` 挂载:
- `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages`
- `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`
6. `cargo check -p module-custom-world`
7. `cargo check -p spacetime-module`
8. `cargo check -p spacetime-client`
9. `cargo check -p api-server`
## 8. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md)
3. [./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md)

View File

@@ -0,0 +1,188 @@
# `M5` custom world Agent message stream Stage 8 设计
日期:`2026-04-22`
## 1. 文档目的
这份文档冻结 `M5` 在 Stage 7 之后的下一段最小闭环:补齐 RPG 创作 Agent 的 `message stream` 兼容接口,让当前前端默认使用的流式消息提交主链可以重新接上 `Axum` 后端。
本轮只做:
1. `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`
2. 兼容前端当前消费的最小 SSE 事件集合
3. 复用 Stage 7 已落地的 deterministic `message submit`
4. 在流式结束前返回最新 session snapshot
本轮不做:
1. 真实 LLM token streaming
2. SpacetimeDB 内部异步 operation 编排
3. 持续多段增量推理
4. `card detail / card update`
5. `draft_foundation`
## 2. 现状与问题
当前 Rust 后端已经完成:
1. `POST /messages`
2. `GET /operations/:operationId`
3. session snapshot 读取
但前端 RPG 创作主链默认并不是调用 `POST /messages`,而是调用:
1. `POST /messages/stream`
并消费以下 SSE 事件:
1. `reply_delta`
2. `session`
3. `error`
4. `done`
如果这条流式路由缺失,当前前端提交共创消息时会直接失败,导致 `message submit` 虽然已落地,但主链仍不可用。
## 3. 设计目标
本轮目标不是恢复旧 Node 那套真实按回调逐字增量刷新的内部推理过程,而是先提供一个最小兼容 SSE facade满足当前前端协议
1. 前端仍然可以继续走 `/messages/stream`
2. 后端内部复用 Stage 7 的同步 deterministic 写表逻辑
3. SSE 至少发送一条 `reply_delta`
4. SSE 最终发送一条 `session`
5. SSE 发送 `done` 后结束连接
## 4. 最小兼容 SSE 规则
### 4.1 请求体
请求体沿用 Stage 7
```json
{
"clientMessageId": "client-001",
"text": "一条新的世界设定",
"quickFillRequested": false,
"focusCardId": null,
"selectedCardIds": []
}
```
本轮继续保持:
1. `quickFillRequested`
2. `focusCardId`
3. `selectedCardIds`
只做兼容解析,不进入真相表。
### 4.2 处理顺序
`api-server` 收到 `/messages/stream` 后按以下顺序处理:
1. 校验 `sessionId``clientMessageId``text`
2. 调用 `spacetime-client.submit_custom_world_agent_message(...)`
3. 再调用 `spacetime-client.get_custom_world_agent_session(...)`
4. 从 session snapshot 中取最后一条 assistant 消息文本
5. 组装 SSE 文本并一次性返回
### 4.3 SSE 事件集合
返回事件严格控制为以下几类:
1. `reply_delta`
- 载荷:
```json
{ "text": "assistant 最终回复全文" }
```
2. `session`
- 载荷:
```json
{ "session": { ...最新 session snapshot... } }
```
3. `done`
- 载荷:
```json
{ "ok": true }
```
4. `error`
- 仅错误时返回:
```json
{ "message": "..." }
```
本轮不引入:
1. 多段 `reply_delta`
2. operation 事件
3. keepalive 心跳
4. token / chunk 级别 streaming
## 5. 与 Stage 7 的关系
本轮 `message stream` 不是重新发明一套消息处理逻辑,而是一个 facade
1. 真相写入仍然完全走 Stage 7 的 `submit_custom_world_agent_message`
2. session 读取仍然走 Stage 6 的 `get_custom_world_agent_session`
3. SSE 只是把“同步完成后的最终结果”包装成旧前端可消费的流式文本
因此本轮不会新增:
1. `module-custom-world` 输入输出类型
2. `spacetime-module` reducer / procedure
3. `spacetime-client` 新的 message stream 方法
## 6. HTTP contract
### 6.1 成功响应
响应头:
1. `Content-Type: text/event-stream; charset=utf-8`
2. `Cache-Control: no-cache`
3. `X-Accel-Buffering: no`
响应体示例:
```text
event: reply_delta
data: {"text":"已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。"}
event: session
data: {"session":{"sessionId":"..."}}
event: done
data: {"ok":true}
```
### 6.2 错误响应
如果在真正开始返回 SSE 前就失败,仍按普通 JSON 错误 envelope 返回 HTTP 错误。
如果已经进入 SSE 写出阶段才失败,则返回:
```text
event: error
data: {"message":"..."}
```
然后结束连接。
## 7. 完成定义
当以下条件满足时,本轮 Stage 8 视为完成:
1. `api-server` 挂载 `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`
2. 当前前端 `streamRpgCreationMessage(...)` 可解析返回内容
3. `message stream` 内部复用 Stage 7 的 deterministic 消息写入
4. `backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md` 勾选 `message stream`
5. `docs/technical/README.md` 收录本设计文档
6. `cargo check -p api-server`
## 8. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md)
3. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md)

View File

@@ -0,0 +1,187 @@
# `M5` custom world Agent session Stage 6 设计
日期:`2026-04-22`
## 1. 文档目的
这份文档冻结 `M5` 的下一段最小闭环:把 RPG 创作 Agent 的 `session create / session snapshot` 从旧 Node 单大 JSON 会话体,迁到 `SpacetimeDB` 真相表与 `Axum` facade。
本轮只做:
1. `custom_world_agent_session` 会话骨架创建
2. 初始 assistant 欢迎消息写入 `custom_world_agent_message`
3. `session snapshot` 聚合读取
4. `POST /api/runtime/custom-world/agent/sessions`
5. `GET /api/runtime/custom-world/agent/sessions/:sessionId`
本轮不做:
1. LLM 意图抽取与澄清问题生成
2. message submit / message stream
3. card detail / card update
4. operation query
5. 完整 action registry
6. result preview compiler
## 2. 当前问题
Stage 5 已接入 agent `publish_world` action但调用方仍需要显式提供 `draftProfile``settingText`。根因是 Rust 侧还没有可读取的 Agent session snapshot。
现有 `custom_world_agent_session` 表已有大部分会话级字段,但缺少旧前端必填的 `pendingClarifications` 真相源。本轮必须补上 `pending_clarifications_json`,否则 Axum 只能用空数组猜测,后续 message turn 迁移会继续漂移。
## 3. 表结构调整
`custom_world_agent_session` 新增:
1. `pending_clarifications_json: String`
字段语义:
1. 存储旧 contract 的 `pendingClarifications` 数组 JSON
2. 初始创建时固定为 `[]`
3. 后续 message turn / clarification 迁移后由服务端派生写入
4. 读取 snapshot 时必须反序列化为 JSON 数组,非法 JSON 返回 procedure 错误
## 4. SpacetimeDB contract
新增输入:
1. `CustomWorldAgentSessionCreateInput`
2. `CustomWorldAgentSessionGetInput`
新增快照:
1. `CustomWorldAgentMessageSnapshot`
2. `CustomWorldAgentOperationSnapshot`
3. `CustomWorldDraftCardSnapshot`
4. `CustomWorldAgentSessionSnapshot`
新增返回:
1. `CustomWorldAgentSessionProcedureResult`
新增 procedure
1. `create_custom_world_agent_session`
2. `get_custom_world_agent_session`
创建规则:
1. `session_id` 由 Axum 生成并传入,前缀保持 `custom-world-agent-session-`
2. `owner_user_id` 来自 Bearer JWT不信任请求体
3. `seed_text` 可为空
4. `stage` 首版固定为 `collecting_intent`
5. `progress_percent` 首版固定为 `0`
6. `anchor_content_json` 固定写入八锚点空结构
7. `creator_intent_json``anchor_pack_json``lock_state_json``draft_profile_json` 写入 `{}` 或最小草稿
8. `creator_intent_readiness_json` 固定写入 `{ "isReady": false, "completedKeys": [], "missingKeys": [] }`
9. `pending_clarifications_json` 固定写入 `[]`
10. `asset_coverage_json` 固定写入空覆盖率结构
11. 初始 assistant 消息写入 `custom_world_agent_message`
## 5. Axum HTTP contract
### 5.1 `POST /api/runtime/custom-world/agent/sessions`
请求:
```json
{
"seedText": "可选的世界设定起点"
}
```
响应:
```json
{
"session": {
"sessionId": "custom-world-agent-session-...",
"currentTurn": 0,
"anchorContent": {},
"progressPercent": 0,
"lastAssistantReply": "...",
"stage": "collecting_intent",
"focusCardId": null,
"creatorIntent": {},
"creatorIntentReadiness": {
"isReady": false,
"completedKeys": [],
"missingKeys": []
},
"anchorPack": {},
"lockState": {},
"draftProfile": {},
"messages": [],
"draftCards": [],
"pendingClarifications": [],
"suggestedActions": [],
"recommendedReplies": [],
"qualityFindings": [],
"assetCoverage": {},
"checkpoints": [],
"supportedActions": [],
"resultPreview": null,
"updatedAt": "..."
}
}
```
说明:
1. 返回字段保持旧 `RpgAgentSessionSnapshot` / `CustomWorldAgentSessionSnapshot` camelCase shape
2. 初始欢迎语只做 deterministic 文本,不调用 LLM
3. `supportedActions` 首版只给出保守能力状态,不伪装完整 registry
### 5.2 `GET /api/runtime/custom-world/agent/sessions/:sessionId`
读取同一个 snapshot。
权限:
1. 必须 Bearer JWT
2. 只能读取 `owner_user_id = claims.user_id` 的 session
3. 不存在或不属于当前用户时返回 SpacetimeDB procedure 错误,由 Axum 映射到当前统一错误 envelope
## 6. 完成定义
当以下条件满足时Stage 6 视为完成:
1. `module-custom-world` 冻结 session create/get/snapshot 领域类型
2. `spacetime-module` 可创建并读取 agent session snapshot
3. `spacetime-client` 封装 create/get procedure
4. `api-server` 挂载 `POST /sessions``GET /sessions/:sessionId`
5. `M5` 任务清单勾选 `session create / session snapshot`
6. `cargo check -p module-custom-world`
7. `cargo check -p spacetime-module`
8. `cargo check -p spacetime-client`
9. `cargo check -p api-server`
## 7. bindings 刷新约束
`spacetime-client/src/module_bindings` 必须视为生成产物,不允许手工补假类型来绕过 `spacetime-module` 与客户端之间的 schema 漂移。
本仓默认刷新命令:
```bash
spacetime generate --no-config --lang rust --out-dir server-rs/crates/spacetime-client/src/module_bindings --module-path server-rs/crates/spacetime-module --include-private --yes
```
如果当前工作树里存在其他并行 `cargo build/test/check` 导致 `spacetime generate` 内部构建长期卡在锁竞争,则先在独立目标目录编译 wasm再使用 `--bin-path` 生成:
```bash
CARGO_TARGET_DIR=server-rs/target-spacetime-bindgen cargo build --manifest-path server-rs/crates/spacetime-module/Cargo.toml --target wasm32-unknown-unknown --release
spacetime generate --no-config --lang rust --out-dir server-rs/crates/spacetime-client/src/module_bindings --bin-path server-rs/target-spacetime-bindgen/wasm32-unknown-unknown/release/spacetime_module.wasm --include-private --yes
```
这样做的目的固定如下:
1. 避开根目录 `spacetime.json` 多 generate target 对单次 Rust 生成的参数冲突。
2. 避开共享 `target/` 目录被其他任务占锁时的构建阻塞。
3. 保证 `create_custom_world_agent_session` / `get_custom_world_agent_session` 这类新 procedure 与输入输出类型能同步进入 Rust bindings。
## 8. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
3. [./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md)

View File

@@ -0,0 +1,313 @@
# `M5` 首批 `custom world / agent` 表设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `M5` 从“任务清单”推进到可直接编码的级别。
本轮只冻结首批最小可落地表:
1. `custom_world_profile`
2. `custom_world_session`
3. `custom_world_agent_session`
4. `custom_world_agent_message`
5. `custom_world_agent_operation`
6. `custom_world_draft_card`
7. `custom_world_gallery_entry`
本轮不直接落 `custom_world_asset_link`
## 2. 本轮为什么不落 `custom_world_asset_link`
`custom_world_asset_link` 的正式真相必须与:
1. `asset_object`
2. `asset_entity_binding`
3. `M6 assets / OSS`
三者的对象定位与业务槽位规则保持一致。
当前这些规则在 `custom world` 域还没有完全冻结:
1. 角色主图槽位
2. 动作集槽位
3. 场景图槽位
4. profile / preview / card 的引用回写规则
所以本轮先不硬落,避免在 `M6` 再拆主键和字段。
## 3. 设计原则
### 3.1 不允许回到单大 JSON 会话
当前允许存在边界清晰的 JSON 字符串列,例如:
1. `draft_profile_json`
2. `result_preview_json`
3. `quality_findings_json`
4. `asset_coverage_json`
但不允许再把 `message / operation / card` 混回一个整包 `session payload`
### 3.2 发布态 profile 允许先存编译工件
`custom_world_profile` 当前承担:
1. library
2. works 的发布态
3. publish / unpublish
4. enter-world
因此允许先保留 `profile_payload_json` 作为正式发布态工件。
### 3.3 gallery 单独投影
`custom_world_gallery_entry` 作为公开读模型单独建表,不再从 profile 运行时拼装。
## 4. 表设计
## 4.1 `custom_world_profile`
### 职责
1. 存正式 profile 工件
2. 承接 library / publish / enter-world 的发布态真相
### 访问级别
`private`
### 字段
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `profile_id` | `String` | 是 | 主键 |
| `owner_user_id` | `String` | 是 | 归属用户 |
| `source_agent_session_id` | `Option<String>` | 否 | 来源 Agent 会话 |
| `publication_status` | `CustomWorldPublicationStatus` | 是 | `draft / published` |
| `world_name` | `String` | 是 | 世界名 |
| `subtitle` | `String` | 是 | 副标题 |
| `summary_text` | `String` | 是 | 摘要 |
| `theme_mode` | `CustomWorldThemeMode` | 是 | 主题模式 |
| `cover_image_src` | `Option<String>` | 否 | 封面 |
| `profile_payload_json` | `String` | 是 | 正式 profile JSON |
| `playable_npc_count` | `u32` | 是 | 角色数量摘要 |
| `landmark_count` | `u32` | 是 | 地标数量摘要 |
| `published_at` | `Option<Timestamp>` | 否 | 发布时间 |
| `created_at` | `Timestamp` | 是 | 创建时间 |
| `updated_at` | `Timestamp` | 是 | 更新时间 |
### 索引
1. `owner_user_id`
2. `publication_status`
## 4.2 `custom_world_session`
### 职责
1. 承接旧 `custom-world/sessions` 传统问答流历史兼容
### 访问级别
`private`
### 字段
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `session_id` | `String` | 是 | 主键 |
| `owner_user_id` | `String` | 是 | 归属用户 |
| `generation_mode` | `CustomWorldGenerationMode` | 是 | `fast / full` |
| `status` | `CustomWorldSessionStatus` | 是 | 传统问答流状态 |
| `setting_text` | `String` | 是 | 原始设定输入 |
| `creator_intent_json` | `Option<String>` | 否 | creator intent |
| `question_snapshot_json` | `String` | 是 | 问答快照 |
| `result_payload_json` | `Option<String>` | 否 | 编译结果 |
| `last_error_message` | `Option<String>` | 否 | 最近错误 |
| `created_at` | `Timestamp` | 是 | 创建时间 |
| `updated_at` | `Timestamp` | 是 | 更新时间 |
### 索引
1. `owner_user_id`
## 4.3 `custom_world_agent_session`
### 职责
1. 承接 RPG 创作 Agent 会话真相
2. 会话级元数据与 preview / readiness / quality / asset coverage
### 访问级别
`private`
### 字段
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `session_id` | `String` | 是 | 主键 |
| `owner_user_id` | `String` | 是 | 归属用户 |
| `seed_text` | `String` | 是 | 初始输入 |
| `current_turn` | `u32` | 是 | 当前轮次 |
| `progress_percent` | `u32` | 是 | 进度 0~100 |
| `stage` | `RpgAgentStage` | 是 | 当前阶段 |
| `focus_card_id` | `Option<String>` | 否 | 当前焦点卡 |
| `anchor_content_json` | `String` | 是 | 八锚点 |
| `creator_intent_json` | `Option<String>` | 否 | creator intent |
| `creator_intent_readiness_json` | `String` | 是 | readiness |
| `anchor_pack_json` | `Option<String>` | 否 | anchor pack |
| `lock_state_json` | `Option<String>` | 否 | lock state |
| `draft_profile_json` | `Option<String>` | 否 | foundation draft |
| `last_assistant_reply` | `Option<String>` | 否 | 最近回复 |
| `result_preview_json` | `Option<String>` | 否 | 结果页预览 |
| `quality_findings_json` | `String` | 是 | 质量结论 |
| `suggested_actions_json` | `String` | 是 | 建议动作 |
| `recommended_replies_json` | `String` | 是 | 推荐回复 |
| `asset_coverage_json` | `String` | 是 | 资产覆盖率 |
| `checkpoints_json` | `String` | 是 | checkpoint 摘要 |
| `created_at` | `Timestamp` | 是 | 创建时间 |
| `updated_at` | `Timestamp` | 是 | 更新时间 |
### 索引
1. `owner_user_id`
2. `stage`
## 4.4 `custom_world_agent_message`
### 职责
1. 存消息流
### 访问级别
`private`
### 字段
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `message_id` | `String` | 是 | 主键 |
| `session_id` | `String` | 是 | 所属会话 |
| `role` | `RpgAgentMessageRole` | 是 | user / assistant / system |
| `kind` | `RpgAgentMessageKind` | 是 | chat / clarification / summary / checkpoint / warning / action_result |
| `text` | `String` | 是 | 正文 |
| `related_operation_id` | `Option<String>` | 否 | 关联操作 |
| `created_at` | `Timestamp` | 是 | 创建时间 |
### 索引
1. `session_id`
## 4.5 `custom_world_agent_operation`
### 职责
1. 存异步操作
### 访问级别
`private`
### 字段
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `operation_id` | `String` | 是 | 主键 |
| `session_id` | `String` | 是 | 所属会话 |
| `operation_type` | `RpgAgentOperationType` | 是 | 动作类型或 `process_message` |
| `status` | `RpgAgentOperationStatus` | 是 | queued / running / completed / failed |
| `phase_label` | `String` | 是 | 阶段标题 |
| `phase_detail` | `String` | 是 | 阶段说明 |
| `progress` | `u32` | 是 | 进度 0~100 |
| `error_message` | `Option<String>` | 否 | 失败原因 |
| `created_at` | `Timestamp` | 是 | 创建时间 |
| `updated_at` | `Timestamp` | 是 | 更新时间 |
### 索引
1. `session_id`
## 4.6 `custom_world_draft_card`
### 职责
1. 把 card 从会话主体拆出
2. 支撑 card detail / update
### 访问级别
`private`
### 字段
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `card_id` | `String` | 是 | 主键 |
| `session_id` | `String` | 是 | 所属会话 |
| `kind` | `RpgAgentDraftCardKind` | 是 | 卡片类型 |
| `status` | `RpgAgentDraftCardStatus` | 是 | suggested / confirmed / locked / warning |
| `title` | `String` | 是 | 标题 |
| `subtitle` | `String` | 是 | 副标题 |
| `summary` | `String` | 是 | 摘要 |
| `linked_ids_json` | `String` | 是 | 关联对象 ID 列表 |
| `warning_count` | `u32` | 是 | 警告数 |
| `asset_status` | `Option<CustomWorldRoleAssetStatus>` | 否 | 资产状态 |
| `asset_status_label` | `Option<String>` | 否 | 资产状态文案 |
| `detail_payload_json` | `Option<String>` | 否 | 详情与 section |
| `created_at` | `Timestamp` | 是 | 创建时间 |
| `updated_at` | `Timestamp` | 是 | 更新时间 |
### 索引
1. `session_id`
2. `kind`
## 4.7 `custom_world_gallery_entry`
### 职责
1. 作为公开画廊投影
### 访问级别
`public`
### 字段
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `profile_id` | `String` | 是 | 主键 |
| `owner_user_id` | `String` | 是 | 作者 |
| `author_display_name` | `String` | 是 | 作者展示名 |
| `world_name` | `String` | 是 | 世界名 |
| `subtitle` | `String` | 是 | 副标题 |
| `summary_text` | `String` | 是 | 摘要 |
| `cover_image_src` | `Option<String>` | 否 | 封面 |
| `theme_mode` | `CustomWorldThemeMode` | 是 | 主题模式 |
| `playable_npc_count` | `u32` | 是 | 角色数 |
| `landmark_count` | `u32` | 是 | 地标数 |
| `published_at` | `Timestamp` | 是 | 发布时间 |
| `updated_at` | `Timestamp` | 是 | 更新时间 |
### 索引
1. `owner_user_id`
2. `theme_mode`
## 5. 本轮完成定义
当以下条件满足时,本轮 `M5` 首批设计视为完成:
1. 上述 7 张表字段、索引、访问级别已具体到可直接编码
2. `module-custom-world` 已成为真实 crate
3. `spacetime-module` 已接入上述表骨架
## 6. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
3. [./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)

View File

@@ -0,0 +1,213 @@
# `M5` custom world Axum facade Stage 5 设计
日期:`2026-04-22`
## 1. 文档目的
这份文档用于把 `M5` 已落地的 SpacetimeDB custom world 主链,接入 `server-rs/crates/api-server` 的 Axum 兼容接口首批 facade。
本轮只冻结:
1. `custom-world-library` 首批读写接口
2. `custom-world-gallery` 首批读接口
3. agent `publish_world` action 的最小 HTTP facade
4. `api-server -> spacetime-client -> spacetime-module procedure` 的调用边界
本轮不做:
1. `DELETE /api/runtime/custom-world-library/:profileId`
2. 完整 agent session create / snapshot / message / SSE
3. `publish gate` / `enter-world gate` blocker 规则迁移
4. works 聚合读模型
5. LLM、图片生成、OSS 副作用编排
## 2. 当前可复用能力
当前 SpacetimeDB module 已有:
1. `list_custom_world_profiles`
2. `upsert_custom_world_profile_and_return`
3. `publish_custom_world_profile_and_return`
4. `unpublish_custom_world_profile_and_return`
5. `list_custom_world_gallery_entries`
6. `get_custom_world_gallery_detail`
7. `publish_custom_world_world`
其中 `publish_custom_world_world` 已固定为:
1. compile published profile
2. upsert profile
3. publish profile
4. 推进 `custom_world_agent_session.stage = published`
## 3. 本轮冻结的 HTTP 兼容接口
### 3.1 library
本轮接入:
1. `GET /api/runtime/custom-world-library`
2. `PUT /api/runtime/custom-world-library/:profileId`
3. `POST /api/runtime/custom-world-library/:profileId/publish`
4. `POST /api/runtime/custom-world-library/:profileId/unpublish`
本轮暂不接:
1. `DELETE /api/runtime/custom-world-library/:profileId`
原因:
1. 当前 `spacetime-module` 还没有冻结 profile delete / soft delete contract
2. 不能在 Axum 层绕开 SpacetimeDB 直接制造第二套删除语义
### 3.2 gallery
本轮接入:
1. `GET /api/runtime/custom-world-gallery`
2. `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId`
### 3.3 agent publish action
本轮接入:
1. `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions`
但只支持 payload
```json
{ "action": "publish_world" }
```
其他 agent action 继续返回 `501 NOT_IMPLEMENTED`,等待 agent session / operation / card 主链冻结。
## 4. 请求与响应 contract
### 4.1 library entry 响应
继续兼容旧 Node 的 `CustomWorldLibraryEntry` camelCase shape
1. `ownerUserId`
2. `profileId`
3. `profile`
4. `visibility`
5. `publishedAt`
6. `updatedAt`
7. `authorDisplayName`
8. `worldName`
9. `subtitle`
10. `summaryText`
11. `coverImageSrc`
12. `themeMode`
13. `playableNpcCount`
14. `landmarkCount`
### 4.2 `PUT /custom-world-library/:profileId`
请求 body
```json
{
"profile": {}
}
```
字段映射:
1. `profile` 原样序列化为 `profile_payload_json`
2. 元数据首版由 `api-server` 从 profile JSON 提取:
- `name`
- `subtitle`
- `summary`
- `cover.imageSrc / camp.imageSrc / landmarks[0].imageSrc`
- `themeMode`
- `playableNpcs.length + storyNpcs.length`
- `landmarks.length`
说明:
1. 后续更完整的 metadata 推断可以迁回 `module-custom-world`
2. 本轮不把 Node 的全部 `customWorldLibraryMetadata.ts` 逻辑一次性强迁
### 4.3 `POST /custom-world-library/:profileId/publish`
普通 library profile
1. 调用 `publish_custom_world_profile_and_return`
2. 返回 `CustomWorldLibraryMutationResponse`
agent draft profile
1. 如果 profileId 形如 `agent-draft-${sessionId}`,但请求没有提供 draft payload本轮不会在 library publish 里自动从 agent session 重建发布内容
2. agent 发布主链应优先走 agent action facade
原因:
1. Stage 4 的 `publish_custom_world_world` 需要显式传入 `draft_profile_json`
2. session snapshot 主链尚未冻结library publish 不能私自从 session 大 JSON 读取未定义字段
### 4.4 `POST /custom-world/agent/sessions/:sessionId/actions`
请求 body
```json
{
"action": "publish_world",
"profileId": "agent-draft-session-001",
"draftProfile": {},
"legacyResultProfile": {},
"settingText": "..."
}
```
说明:
1. 旧 Node 的 `{ "action": "publish_world" }` 仍是最终目标
2. 但当前 Rust 还没有完整 session snapshot/read model所以本轮先要求 facade 调用方显式传入 `draftProfile`
3. 这不是最终产品 contract后续在 agent session snapshot 迁移后再收口为旧 payload
响应 body 继续使用旧 action 形状:
```json
{
"operation": {
"operationId": "...",
"type": "publish_world",
"status": "completed",
"phaseLabel": "世界已发布",
"phaseDetail": "...",
"progress": 100,
"error": null
}
}
```
## 5. `spacetime-client` 边界
`api-server` 不直接引用 generated bindings。
本轮在 `spacetime-client` 中新增领域侧 record
1. `CustomWorldLibraryEntryRecord`
2. `CustomWorldGalleryEntryRecord`
3. `CustomWorldLibraryMutationRecord`
4. `CustomWorldPublishWorldRecord`
`api-server` 只消费这些 record再映射到 `shared-contracts` 的 HTTP response。
## 6. 完成定义
当以下条件满足时,本轮 Stage 5 视为完成:
1. `shared-contracts` 已补 custom world library/gallery/action 首批响应类型
2. `spacetime-client` 已封装 custom world procedures
3. `api-server` 已挂载首批 custom world routes
4. `cargo check -p spacetime-client` 通过
5. `cargo check -p api-server` 通过
6. `npm run check:encoding` 通过
## 7. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md)
3. [./SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)

View File

@@ -0,0 +1,198 @@
# `M5` custom world library detail Stage 5 扩展设计
日期:`2026-04-22`
## 1. 文档目的
这份文档用于补齐 `Stage 5` 首批 Axum facade 中遗漏的 owner-only library detail 查询接口:
1. `GET /api/runtime/custom-world-library/:profileId`
本轮目标不是扩新系统,而是在当前已经落地的 `custom_world_profile``spacetime-client``api-server` 基座上补完正式接口台账中的缺口。
## 2. 问题背景
当前仓库状态已经具备:
1. `GET /api/runtime/custom-world-library`
2. `PUT /api/runtime/custom-world-library/:profileId`
3. `POST /api/runtime/custom-world-library/:profileId/publish`
4. `POST /api/runtime/custom-world-library/:profileId/unpublish`
5. `GET /api/runtime/custom-world-gallery`
6. `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId`
但正式接口台账里仍缺:
1. `GET /api/runtime/custom-world-library/:profileId`
同时,当前 `spacetime-module` 只有:
1. `list_custom_world_profiles`
2. `get_custom_world_gallery_detail`
缺少 owner-only 的单条 profile detail procedure。
## 3. 兼容目标
### 3.1 查询语义
`GET /api/runtime/custom-world-library/:profileId` 只允许查询当前 Bearer 用户自己的 profile。
等价语义:
1. `ownerUserId = authenticated user id`
2. `profileId = path param`
3. 不要求 profile 已发布
4. 不从 gallery 投影取数,直接以 `custom_world_profile` 真相表为准
### 3.2 响应形状
响应继续复用 gallery detail 的稳定 shape
```json
{
"entry": {
"ownerUserId": "user_xxx",
"profileId": "profile_xxx",
"profile": {},
"visibility": "draft",
"publishedAt": null,
"updatedAt": "2026-04-22T00:00:00Z",
"authorDisplayName": "玩家",
"worldName": "潮雾群岛",
"subtitle": "旧航道与失控灯塔",
"summaryText": "......",
"coverImageSrc": null,
"themeMode": "tide",
"playableNpcCount": 2,
"landmarkCount": 3
}
}
```
原因:
1. 旧前端 detail 消费点本来就只认单个 `entry`
2. 当前 `shared-contracts` 已有 `CustomWorldGalleryDetailResponse { entry }`
3. 没必要为了 owner-only detail 再新增一套重复 envelope
## 4. SpacetimeDB 设计
### 4.1 `module-custom-world`
新增一个独立输入:
1. `CustomWorldLibraryDetailInput`
字段:
1. `owner_user_id`
2. `profile_id`
并新增对应校验:
1. `validate_custom_world_library_detail_input(...)`
### 4.2 `spacetime-module`
新增 procedure
1. `get_custom_world_library_detail`
返回继续复用:
1. `CustomWorldLibraryMutationResult`
原因:
1. 当前已有 `entry + gallery_entry + error_message` 组合
2. owner-only detail 只需要 `entry`
3. `gallery_entry` 在这个接口中固定允许为 `None`
内部查询规则:
1.`profile_id` 命中单条 `custom_world_profile`
2. 再校验 `owner_user_id == input.owner_user_id`
3. 命中后返回 `Some(profile_snapshot)`
4. 不存在时返回 `Ok((None, None))`
这里刻意不在 SpacetimeDB procedure 内把“不存在”包装成失败字符串避免把“记录缺失”和“procedure 运行失败”混成一个错误通道。
## 5. `spacetime-client` 设计
新增 facade
1. `get_custom_world_library_detail(owner_user_id, profile_id)`
客户端返回值继续复用:
1. `CustomWorldLibraryMutationRecord`
但需要单独增加一个 detail result mapper在进入 HTTP 层之前就完成:
1. `result.ok == false` 时返回 procedure error
2. `result.entry == None` 时返回 `SpacetimeClientError::Procedure("custom_world_profile 不存在")`
这样 `api-server` 可以统一通过已有错误映射把缺失记录转成 `404`
## 6. `api-server` 设计
### 6.1 路由
把:
1. `/api/runtime/custom-world-library/{profile_id}`
从仅支持 `PUT` 扩展为:
1. `GET`
2. `PUT`
### 6.2 handler
新增:
1. `get_custom_world_library_detail`
流程:
1. 读取 Bearer 身份
2. 校验 `profile_id` 非空
3.`spacetime_client.get_custom_world_library_detail(...)`
4. 映射为 `CustomWorldGalleryDetailResponse { entry }`
### 6.3 错误语义
本轮明确:
1. `profileId` 为空 -> `400`
2. 当前用户下找不到 profile -> `404`
3. SpacetimeDB transport / procedure 异常 -> 继续按现有 `502/400` 规则返回
## 7. 任务清单同步
这轮还需要同步 `backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md`
1. 把已落地的 `message stream` 勾为完成
2.`/agent/sessions/:sessionId/messages/stream` 勾为完成
3. 在本接口落地后,把 `/api/runtime/custom-world-library/:profileId` 勾为完成
## 8. 完成定义
当以下条件满足时,本轮扩展视为完成:
1. 新设计文档已落到 `docs/technical/`
2. `docs/technical/README.md` 已补索引
3. `module-custom-world` 已新增 library detail input 与校验
4. `spacetime-module` 已新增 owner-only detail procedure
5. `spacetime-client` 已新增 detail facade
6. `api-server` 已挂 `GET /api/runtime/custom-world-library/:profileId`
7. `cargo check -p api-server` 通过
8. `npm run check:encoding` 通过
## 9. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md)
3. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md)
4. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md)

View File

@@ -0,0 +1,275 @@
# `M5` custom world library / publish / gallery Stage 2 设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `M5` 的下一段从“已有表骨架”推进到“可以直接开始写 reducer / procedure”的级别。
本轮只冻结以下最小主链:
1. `custom_world_profile` upsert
2. `custom_world_profile` publish
3. `custom_world_profile` unpublish
4. `custom_world_gallery_entry` 与发布态的同步规则
5. library / gallery 的最小 procedure 返回口径
本轮仍不进入:
1. Agent session -> profile 的自动编译
2. works 聚合读模型
3. enter-world gate
4. Axum HTTP façade
## 2. Node 当前口径对齐结论
对照当前 Node 实现:
1. `RpgWorldProfileRepository.ts`
2. `RpgWorldLibraryRepository.ts`
3. `customWorldAgentPublishingService.ts`
4. `packages/shared/src/contracts/runtime.ts`
可以确认当前正式主链是:
1. 先 upsert profile
2. publish 时把 profile 元数据重算后写回
3. published profile 同时作为 gallery 公开读模型来源
4. unpublish 后从公开画廊移除
因此 Rust / SpacetimeDB 首版不需要额外发明一套“发布态缓存”。
当前更稳妥的落法是:
1. `custom_world_profile` 继续作为私有正式作品真相
2. `custom_world_gallery_entry` 继续作为公开投影
3. publish / unpublish 明确负责两张表的同步
## 3. 当前冻结的 procedure 契约
### 3.1 `CustomWorldProfileSnapshot`
用于承接 library detail / publish / unpublish 的返回对象。
字段冻结为:
1. `profile_id`
2. `owner_user_id`
3. `source_agent_session_id`
4. `publication_status`
5. `world_name`
6. `subtitle`
7. `summary_text`
8. `theme_mode`
9. `cover_image_src`
10. `profile_payload_json`
11. `playable_npc_count`
12. `landmark_count`
13. `author_display_name`
14. `published_at_micros`
15. `created_at_micros`
16. `updated_at_micros`
说明:
1. 虽然 `author_display_name` 当前不在 `custom_world_profile` 表骨架里,但 publish / gallery 主链已经需要它。
2. 因此本轮允许给 `custom_world_profile` 补这一列,避免 publish procedure 只能从外部临时参数现拼。
### 3.2 `CustomWorldGalleryEntrySnapshot`
用于承接 gallery 列表项和 detail 返回项。
字段冻结为:
1. `profile_id`
2. `owner_user_id`
3. `author_display_name`
4. `world_name`
5. `subtitle`
6. `summary_text`
7. `cover_image_src`
8. `theme_mode`
9. `playable_npc_count`
10. `landmark_count`
11. `published_at_micros`
12. `updated_at_micros`
### 3.3 `CustomWorldLibraryMutationResult`
用于 upsert / publish / unpublish 的最小 procedure 返回。
字段冻结为:
1. `ok`
2. `entry`
3. `gallery_entry`
4. `error_message`
说明:
1. 本轮不直接返回整包 `entries` 列表。
2. 列表读取交给单独 list procedure避免每次写入都重复扫描全表。
### 3.4 `CustomWorldProfileListResult`
用于 list own library。
字段冻结为:
1. `ok`
2. `entries`
3. `error_message`
### 3.5 `CustomWorldGalleryListResult`
用于 list public gallery。
字段冻结为:
1. `ok`
2. `entries`
3. `error_message`
## 4. 当前冻结的输入契约
### 4.1 `CustomWorldProfileUpsertInput`
字段冻结为:
1. `profile_id`
2. `owner_user_id`
3. `source_agent_session_id`
4. `world_name`
5. `subtitle`
6. `summary_text`
7. `theme_mode`
8. `cover_image_src`
9. `profile_payload_json`
10. `playable_npc_count`
11. `landmark_count`
12. `author_display_name`
13. `updated_at_micros`
规则:
1. upsert 默认把 `publication_status` 固定为当前行已有值;若不存在则为 `draft`
2. upsert 不直接负责 publish
3. 若 profile 已经处于 `published`,则需要同步刷新 gallery 投影
### 4.2 `CustomWorldProfilePublishInput`
字段冻结为:
1. `profile_id`
2. `owner_user_id`
3. `author_display_name`
4. `published_at_micros`
规则:
1. 目标 profile 必须存在
2. publish 会把 `publication_status` 改为 `published`
3. publish 会把 `published_at``updated_at` 一并更新
4. publish 会 upsert `custom_world_gallery_entry`
### 4.3 `CustomWorldProfileUnpublishInput`
字段冻结为:
1. `profile_id`
2. `owner_user_id`
3. `author_display_name`
4. `updated_at_micros`
规则:
1. 目标 profile 必须存在
2. unpublish 会把 `publication_status` 改为 `draft`
3. unpublish 会清空 `published_at`
4. unpublish 会删除 `custom_world_gallery_entry`
### 4.4 `CustomWorldProfileListInput`
字段冻结为:
1. `owner_user_id`
### 4.5 `CustomWorldGalleryDetailInput`
字段冻结为:
1. `owner_user_id`
2. `profile_id`
## 5. 表同步规则冻结
### 5.1 `custom_world_profile`
本轮允许补充:
1. `author_display_name: String`
原因:
1. 当前 library / publish / gallery 全链路都依赖作者展示名
2. 若不存回 profile 真相publish / unpublish 过程会丢失元数据来源一致性
### 5.2 `custom_world_gallery_entry`
同步规则冻结为:
1.`published` profile 允许存在 gallery entry
2. `published_at` 必须与 profile 的正式发布时间一致
3. `updated_at` 跟随 profile 最新更新时间
4. `theme_mode / cover / title / subtitle / summary / counts` 全部以 profile 当前元数据为准
## 6. reducer / procedure 范围
本轮推荐直接落以下入口:
1. reducer: `upsert_custom_world_profile`
2. procedure: `upsert_custom_world_profile_and_return`
3. reducer: `publish_custom_world_profile`
4. procedure: `publish_custom_world_profile_and_return`
5. reducer: `unpublish_custom_world_profile`
6. procedure: `unpublish_custom_world_profile_and_return`
7. procedure: `list_custom_world_profiles`
8. procedure: `list_custom_world_gallery_entries`
9. procedure: `get_custom_world_gallery_detail`
说明:
1. list / detail 当前优先做 procedure避免先引入 view 设计。
2. 这些入口足以支撑后续 Axum facade 接 `/runtime/custom-world-library``/runtime/custom-world-gallery`
## 7. 当前刻意不做
本轮明确不做:
1. `soft delete`
2. `MAX_*` limit 常量与分页
3. `works` 聚合输出
4. publish gate blocker 校验
5. `custom_world_profile``agent session` 自动编译
6. `agent session` 发布后自动推进 `published` stage
这些内容后续会分别进入:
1. M5 works 主链
2. M5 agent publish 主链
3. Axum facade 兼容层
## 8. 本轮完成定义
当以下条件满足时,本轮 Stage 2 视为完成:
1. `module-custom-world` 已补齐上述输入 / 输出 snapshot 契约
2. `spacetime-module` 已补 profile/library/gallery 的 reducer / procedure
3. `cargo check -p spacetime-module` 通过
4. 文档与任务清单同步更新
## 9. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
3. [./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)

View File

@@ -0,0 +1,208 @@
# `M5` published profile compile Stage 3 设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `M5` 的 “published profile compile” 从 Node 兼容实现推进到 Rust / SpacetimeDB 可直接编码的级别。
本轮只冻结:
1. `agent draft -> published profile payload` 的最小编译边界
2. 输入输出 contract
3.`custom_world_profile` / `publish` 主链的衔接方式
本轮不做:
1. publish gate blocker 规则迁移
2. Axum HTTP 接口兼容
3. works 聚合读模型
4. `resultPreview` 全量生成主链迁移
## 2. 当前 Node 主链结论
对照当前实现:
1. `rpgCreationPreviewProfileBuilder.ts`
2. `RpgWorldPreviewCompiler.ts`
3. `customWorldAgentPublishingService.ts`
4. `runtime-profile/buildCompiledProfile.ts` 及其 normalize 子模块
当前发布链不是“把 draft 原样存进去”,而是:
1. 先从 `draftProfile` 取 foundation draft
2. 如果存在 `legacyResultProfile`,把旧 runtime-rich 字段当作 base profile
3. 用最新草稿资产覆盖 base profile 中的角色 / 地点 / 场景幕资产字段
4. 再通过 runtime profile compiler 归一化为正式 `CustomWorldProfile`
因此 Rust 首版不能只做“字段透传”。
但当前仓库约束也要求:
1. 不做大爆炸重写
2. 优先先把最小可运行主链迁过去
所以本轮采用折中方案:
1. 先在 Rust 里落“编译输入 contract + profile payload 归一化 helper”
2. 首版继续允许 `compiled_profile_payload_json` 作为正式工件
3. 不在本轮把 Node runtime-profile 的所有细节一次性 1:1 重写为强类型结构
## 3. 本轮冻结的输入 contract
### 3.1 `CustomWorldPublishedProfileCompileInput`
字段冻结为:
1. `session_id`
2. `profile_id`
3. `owner_user_id`
4. `draft_profile_json`
5. `legacy_result_profile_json`
6. `setting_text`
7. `author_display_name`
8. `updated_at_micros`
说明:
1. `draft_profile_json` 是 foundation draft 的正式输入。
2. `legacy_result_profile_json` 允许为空,用于兼容 Node 当前 Phase 5 的 runtime-rich 字段回灌。
3. `setting_text` 单独传入,避免编译阶段再从松散 JSON 猜测。
## 4. 本轮冻结的输出 contract
### 4.1 `CustomWorldPublishedProfileCompileSnapshot`
字段冻结为:
1. `profile_id`
2. `owner_user_id`
3. `world_name`
4. `subtitle`
5. `summary_text`
6. `theme_mode`
7. `cover_image_src`
8. `playable_npc_count`
9. `landmark_count`
10. `author_display_name`
11. `compiled_profile_payload_json`
12. `updated_at_micros`
说明:
1. 这不是完整 `custom_world_profile` 行快照,而是“编译产物摘要”。
2. publish / upsert profile reducer 后续直接消费这里的结果写回 `custom_world_profile`
### 4.2 `CustomWorldPublishedProfileCompileResult`
字段冻结为:
1. `ok`
2. `record`
3. `error_message`
## 5. 本轮编译规则冻结
### 5.1 最小字段来源规则
首版 Rust 编译先固定以下字段:
1. `world_name`
- 优先 `draft_profile.name`
- 否则 `legacy_result_profile.name`
2. `subtitle`
- 优先 `draft_profile.subtitle`
- 否则 `legacy_result_profile.subtitle`
3. `summary_text`
- 优先 `draft_profile.summary`
- 否则 `legacy_result_profile.summary`
4. `cover_image_src`
- 优先 `draft_profile.camp.imageSrc`
- 否则首个 `landmark.imageSrc`
- 否则 `legacy_result_profile.cover.imageSrc`
5. `playable_npc_count`
- `draft_profile.playableNpcs + draft_profile.storyNpcs` 去重后的总量
6. `landmark_count`
- `draft_profile.landmarks.length`
### 5.2 `theme_mode` 映射规则
首版 Rust 不强行完整复刻 Node 的主题推断器,而是采用稳定回退:
1. 如果 `legacy_result_profile` 中已有合法主题枚举,则沿用
2. 否则默认 `CustomWorldThemeMode::Mythic`
原因:
1. 当前 shared contract 中 `themeMode` 已经是摘要元数据,不影响 runtime profile 主体结构
2. 真正复杂的主题推断不应在没有专项文档的情况下直接硬迁
### 5.3 payload 输出规则
本轮 `compiled_profile_payload_json` 先采用:
1.`legacy_result_profile_json` 解析为 base object
2.`draft_profile_json` 中的关键字段覆盖到 base object
3. 至少保证以下键被同步:
- `id`
- `settingText`
- `name`
- `subtitle`
- `summary`
- `playableNpcs`
- `storyNpcs`
- `landmarks`
- `camp`
- `sceneChapterBlueprints`
- `updatedAt`
当前刻意不做:
1. 完整 runtime-profile normalize
2. attribute schema 重建
3. theme pack / story graph / knowledge facts 的智能合并
这些继续留待后续 Stage 4 单独迁移。
## 6. 与 `custom_world_profile` 的衔接方式
本轮推荐新增:
1. `module-custom-world`
- `CustomWorldPublishedProfileCompileInput`
- `CustomWorldPublishedProfileCompileSnapshot`
- `CustomWorldPublishedProfileCompileResult`
2. `spacetime-module`
- `compile_custom_world_published_profile`
- `compile_custom_world_published_profile_and_return`
当前策略:
1. compile procedure 只产出编译结果,不直接写表
2. publish 主链下一步可以显式执行:
- compile
- upsert profile
- publish profile
这样可以保持:
1. 编译
2. 持久化
3. 发布
三段职责清晰分离。
## 7. 本轮完成定义
当以下条件满足时,本轮 Stage 3 视为完成:
1. `module-custom-world` 已补编译输入输出 contract
2. `spacetime-module` 已新增 compile procedure
3. compile procedure 能基于最小字段规则返回合法 profile payload 与摘要
4. `cargo check -p spacetime-module` 通过
## 8. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md)
3. [./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)

View File

@@ -0,0 +1,177 @@
# `M5` publish_world Stage 4 设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `M5` 里的 `publish_world` 动作,从 Node 兼容实现推进到 Rust / SpacetimeDB 可直接编码的最小主链级别。
本轮只冻结:
1. `publish_world` 的输入输出 contract
2. `compile -> upsert profile -> publish profile` 的事务顺序
3. `custom_world_agent_session.stage` 推进到 `published` 的最小写法
本轮不做:
1. `publish gate` / `enter-world gate` blocker 规则迁移
2. `qualityFindings` 的 blocker 清洗与 warning 摘要回写
3. Axum HTTP 接口兼容
4. works 聚合读模型
5. 资产对象绑定、OSS、封面上传等 `M6` 相关能力
## 2. 当前 Node 主链结论
对照当前 Node 链路:
1. `customWorldAgentActionExecutors/publishWorldExecutor.ts`
2. `customWorldAgentPublishingService.ts`
当前 `publish_world` 的职责可拆成两段:
1. 发布门禁校验
2. 发布写入串联
其中发布写入串联又固定为:
1. 基于当前 session 草稿构建 publish readiness
2. 生成正式 published profile
3. 写入 repository / library
4. 推进 session stage 到 `published`
Rust / SpacetimeDB 这一轮只迁第 2 段,也就是“发布写入串联”。
原因:
1. `publish gate` 当前仍依赖 `qualityFindings`、角色资产覆盖率、场景图资产完整度等一系列未冻结的 blocker 口径
2. 如果这一轮把 gate 一起硬迁,会把后续 `M5/M6` 的资产和 blocker 设计提前锁死
3. 当前最需要的是先把 `published profile compile + library + publish + session stage` 串成一个可复用的后端真相主链
## 3. 本轮冻结的输入 contract
### 3.1 `CustomWorldPublishWorldInput`
字段冻结为:
1. `session_id`
2. `profile_id`
3. `owner_user_id`
4. `draft_profile_json`
5. `legacy_result_profile_json`
6. `setting_text`
7. `author_display_name`
8. `published_at_micros`
说明:
1. 当前仍延续 Stage 2 / Stage 3 的显式 `owner_user_id` 传参口径,后续等 OIDC claims 真正确认后再切到 `ctx.sender()` 授权真相。
2. `draft_profile_json``legacy_result_profile_json` 直接复用 Stage 3 compile 的输入来源,避免这一轮再新增 session 内部 JSON 解析耦合。
3. `published_at_micros` 同时作为:
- profile publish 的 `published_at`
- profile upsert 的 `updated_at`
- session stage 推进的 `updated_at`
当前不额外引入:
1. `quality_findings_json`
2. `publish_gate_summary`
3. `result_preview_json`
这些都留到后续 gate / works 迁移时再单独冻结。
## 4. 本轮冻结的输出 contract
### 4.1 `CustomWorldPublishWorldResult`
字段冻结为:
1. `ok`
2. `compiled_record`
3. `entry`
4. `gallery_entry`
5. `session_stage`
6. `error_message`
说明:
1. `compiled_record` 返回 Stage 3 编译产物,方便 Axum 或后续 facade 直接复用,不必再次 compile。
2. `entry` 返回最新 `custom_world_profile` 快照。
3. `gallery_entry` 返回同步后的公开 gallery 投影。
4. `session_stage` 当前只返回最终阶段枚举,最小闭环固定为 `published`
## 5. 串联顺序冻结
本轮 `publish_world` procedure 固定按以下顺序执行:
1. 先基于 `CustomWorldPublishWorldInput` 构造 Stage 3 compile 输入
2. 调用 `build_custom_world_published_profile_compile_snapshot(...)`
3. 将 compile 结果映射成 `CustomWorldProfileUpsertInput`
4. 调用 `upsert_custom_world_profile_record(...)`
5. 调用 `publish_custom_world_profile_record(...)`
6. 根据 `session_id + owner_user_id` 查找 `custom_world_agent_session`
7. 将 session 的 `stage` 推进到 `RpgAgentStage::Published`
8. 返回 compile 结果、profile 快照、gallery 快照与最终 session stage
### 5.1 为什么先 upsert 再 publish
因为当前 Stage 2 已经冻结了两段职责:
1. `upsert_custom_world_profile_record(...)`
- 负责把 profile 最新内容写回正式表
2. `publish_custom_world_profile_record(...)`
- 负责把 profile 状态切到 `published`
- 同步 gallery 公开投影
Stage 4 不应绕开这两段既有入口重写一遍 publish 逻辑,否则会制造第二套 profile / gallery 写入口。
## 6. session stage 推进规则
本轮最小冻结规则:
1. 只推进 `custom_world_agent_session.stage = published`
2. 其余字段保持原样
3. `updated_at` 使用 `published_at_micros`
本轮刻意不做:
1. `quality_findings_json` 删除 blocker
2. `supportedActions` / `suggestedActions` 重算
3. `progress_percent` 二次推断
原因:
1. 当前 `custom_world_agent_session` 还没有冻结完整的 action registry / supportedActions 主链
2. 如果这轮顺手改太多 session 派生字段,后面 Axum / agent 主链接入时容易和旧 Node 行为再次漂移
## 7. 与 Node 当前语义的最小对齐边界
这一轮只要求对齐以下事实:
1. session draft 可以被编译成正式 published profile
2. 正式 profile 会写入 `custom_world_profile`
3. publish 后 gallery 有公开投影
4. session 最终会进入 `published` 阶段
这一轮不要求 1:1 对齐:
1. blocker message 文案
2. publish readiness 的细粒度缺失项列表
3. session 消息追加与 checkpoint 追加
4. warning 数量摘要
## 8. 本轮完成定义
当以下条件满足时,本轮 Stage 4 视为完成:
1. `module-custom-world` 已补 `CustomWorldPublishWorldInput / Result`
2. `spacetime-module` 已新增 `publish_world` procedure
3. procedure 能复用 Stage 2 / Stage 3 helper 串联 compile、upsert、publish
4. procedure 能把 `custom_world_agent_session.stage` 推进到 `published`
5. `cargo test -p module-custom-world` 通过
6. `cargo check -p spacetime-module` 通过
## 9. 相关文档
1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
2. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md)
3. [./SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)

View File

@@ -16,6 +16,7 @@ const TEXT_EXTENSIONS = new Set([
'.mjs',
'.ps1',
'.py',
'.rs',
'.scss',
'.sh',
'.toml',
@@ -40,12 +41,15 @@ const TEXT_FILENAMES = new Set([
const EXCLUDED_PREFIXES = [
'.codex-logs/',
'.git/',
'.codex-cargo-home-',
'dist/',
'dist_check/',
'dist_check_monster_position/',
'media/',
'node_modules/',
'public/Icons/',
'server-rs-codex-',
'server-rs/target-',
];
const IGNORE_FILE = '.encoding-check-ignore';
@@ -58,6 +62,7 @@ function normalizePath(filePath) {
function shouldCheck(filePath) {
const normalizedPath = normalizePath(filePath);
// 本地 cargo cache / verify copy 不属于主工程源码,避免把临时工作区扫进仓库级编码检查。
if (EXCLUDED_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) {
return false;
}

184
server-rs/Cargo.lock generated
View File

@@ -75,14 +75,25 @@ dependencies = [
"hmac",
"http-body-util",
"httpdate",
"module-ai",
"module-assets",
"module-auth",
"module-combat",
"module-custom-world",
"module-inventory",
"module-npc",
"module-runtime",
"module-runtime-item",
"module-story",
"platform-auth",
"platform-llm",
"platform-oss",
"reqwest",
"serde",
"serde_json",
"sha1",
"shared-contracts",
"shared-kernel",
"shared-logging",
"spacetime-client",
"time",
@@ -1382,6 +1393,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "module-ai"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-assets"
version = "0.1.0"
@@ -1389,6 +1410,7 @@ dependencies = [
"platform-oss",
"reqwest",
"serde",
"shared-kernel",
"spacetimedb",
]
@@ -1397,11 +1419,96 @@ name = "module-auth"
version = "0.1.0"
dependencies = [
"platform-auth",
"shared-kernel",
"time",
"tokio",
"uuid",
]
[[package]]
name = "module-combat"
version = "0.1.0"
dependencies = [
"module-runtime-item",
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-custom-world"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"spacetimedb",
]
[[package]]
name = "module-inventory"
version = "0.1.0"
dependencies = [
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-npc"
version = "0.1.0"
dependencies = [
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-progression"
version = "0.1.0"
dependencies = [
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-quest"
version = "0.1.0"
dependencies = [
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-runtime"
version = "0.1.0"
dependencies = [
"serde",
"shared-kernel",
"spacetimedb",
"time",
]
[[package]]
name = "module-runtime-item"
version = "0.1.0"
dependencies = [
"module-inventory",
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-story"
version = "0.1.0"
dependencies = [
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1605,12 +1712,24 @@ dependencies = [
"rand_core 0.6.4",
"serde",
"sha2",
"shared-kernel",
"time",
"tokio",
"urlencoding",
"uuid",
]
[[package]]
name = "platform-llm"
version = "0.1.0"
dependencies = [
"log",
"reqwest",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "platform-oss"
version = "0.1.0"
@@ -1947,12 +2066,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -2273,6 +2394,23 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared-contracts"
version = "0.1.0"
dependencies = [
"platform-oss",
"serde",
"serde_json",
]
[[package]]
name = "shared-kernel"
version = "0.1.0"
dependencies = [
"time",
"uuid",
]
[[package]]
name = "shared-logging"
version = "0.1.0"
@@ -2346,7 +2484,17 @@ dependencies = [
name = "spacetime-client"
version = "0.1.0"
dependencies = [
"module-ai",
"module-assets",
"module-combat",
"module-custom-world",
"module-inventory",
"module-npc",
"module-runtime",
"module-runtime-item",
"module-story",
"serde_json",
"shared-kernel",
"spacetimedb-sdk",
"tokio",
]
@@ -2356,7 +2504,17 @@ name = "spacetime-module"
version = "0.1.0"
dependencies = [
"log",
"module-ai",
"module-assets",
"module-combat",
"module-custom-world",
"module-inventory",
"module-npc",
"module-progression",
"module-quest",
"module-runtime",
"module-runtime-item",
"module-story",
"spacetimedb",
]
@@ -2889,6 +3047,19 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -3276,6 +3447,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"

View File

@@ -5,10 +5,23 @@
resolver = "2"
members = [
"crates/api-server",
"crates/module-ai",
"crates/module-assets",
"crates/module-auth",
"crates/module-combat",
"crates/module-inventory",
"crates/module-custom-world",
"crates/module-npc",
"crates/module-progression",
"crates/module-quest",
"crates/module-runtime",
"crates/module-runtime-item",
"crates/module-story",
"crates/platform-oss",
"crates/platform-auth",
"crates/platform-llm",
"crates/shared-contracts",
"crates/shared-kernel",
"crates/shared-logging",
"crates/spacetime-client",
"crates/spacetime-module",

View File

@@ -8,12 +8,23 @@ license.workspace = true
axum = "0.8"
dotenvy = "0.15"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
module-ai = { path = "../module-ai" }
module-assets = { path = "../module-assets" }
module-auth = { path = "../module-auth" }
module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-npc = { path = "../module-npc" }
module-runtime = { path = "../module-runtime" }
module-runtime-item = { path = "../module-runtime-item" }
module-story = { path = "../module-story" }
platform-auth = { path = "../platform-auth" }
platform-llm = { path = "../platform-llm" }
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
shared-logging = { path = "../shared-logging" }
spacetime-client = { path = "../spacetime-client" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }

View File

@@ -42,6 +42,8 @@
20. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路
21. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路
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
后续与本 crate 直接相关的任务包括:
@@ -64,6 +66,8 @@
17. [x] 接入 `/api/auth/wechat/bind-phone`
18. [x] 接入 `/api/assets/objects/bind`
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
当前 tracing 约定:
@@ -131,3 +135,4 @@
11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。
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 的完整能力。

View File

@@ -0,0 +1,674 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use module_ai::{
AiResultReferenceInput, AiResultReferenceKind, AiStageCompletionInput, AiTaskCancelInput,
AiTaskCreateInput, AiTaskFailureInput, AiTaskFinishInput, AiTaskKind, AiTaskStageBlueprint,
AiTaskStageKind, AiTaskStageStartInput, AiTaskStartInput, AiTextChunkAppendInput,
generate_ai_task_id,
};
use serde_json::{Value, json};
use shared_contracts::ai::{
AiResultReferencePayload, AiTaskAcceptedResponse, AiTaskMutationResponse, AiTaskPayload,
AiTaskStagePayload, AiTextChunkPayload, AppendAiTextChunkRequest,
AttachAiResultReferenceRequest, CompleteAiStageRequest, CreateAiTaskRequest, FailAiTaskRequest,
};
use spacetime_client::{AiTaskMutationRecord, SpacetimeClientError};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn create_ai_task(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateAiTaskRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let task_kind = parse_ai_task_kind_strict(&payload.task_kind).ok_or_else(|| {
ai_tasks_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "ai-task",
"message": "taskKind 非法",
})),
)
})?;
let stages = build_stage_blueprints(task_kind, payload.stage_kinds, &request_context)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.create_ai_task(AiTaskCreateInput {
task_id: generate_ai_task_id(now_micros),
task_kind,
owner_user_id,
request_label: payload.request_label,
source_module: payload.source_module,
source_entity_id: payload.source_entity_id,
request_payload_json: payload.request_payload_json,
stages,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_ai_task_mutation_response(result),
))
}
pub async fn start_ai_task(
State(state): State<AppState>,
Path(task_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Response, Response> {
state
.spacetime_client()
.start_ai_task(AiTaskStartInput {
task_id: task_id.clone(),
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(ai_task_accepted_response(
&request_context,
AiTaskAcceptedResponse {
accepted: true,
task_id,
action: "start_task".to_string(),
stage_kind: None,
},
))
}
pub async fn start_ai_task_stage(
State(state): State<AppState>,
Path((task_id, stage_kind_text)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Response, Response> {
let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
ai_tasks_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "ai-task-stage",
"message": "stageKind 非法",
})),
)
})?;
state
.spacetime_client()
.start_ai_task_stage(AiTaskStageStartInput {
task_id: task_id.clone(),
stage_kind,
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(ai_task_accepted_response(
&request_context,
AiTaskAcceptedResponse {
accepted: true,
task_id,
action: "start_stage".to_string(),
stage_kind: Some(stage_kind.as_str().to_string()),
},
))
}
pub async fn append_ai_text_chunk(
State(state): State<AppState>,
Path(task_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<AppendAiTextChunkRequest>,
) -> Result<Json<Value>, Response> {
let stage_kind = parse_ai_task_stage_kind_strict(&payload.stage_kind).ok_or_else(|| {
ai_tasks_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "ai-task-stage",
"message": "stageKind 非法",
})),
)
})?;
let result = state
.spacetime_client()
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id,
stage_kind,
sequence: payload.sequence,
delta_text: payload.delta_text,
created_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_ai_task_mutation_response(result),
))
}
pub async fn complete_ai_stage(
State(state): State<AppState>,
Path((task_id, stage_kind_text)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CompleteAiStageRequest>,
) -> Result<Json<Value>, Response> {
let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
ai_tasks_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "ai-task-stage",
"message": "stageKind 非法",
})),
)
})?;
let result = state
.spacetime_client()
.complete_ai_stage(AiStageCompletionInput {
task_id,
stage_kind,
text_output: payload.text_output,
structured_payload_json: payload.structured_payload_json,
warning_messages: payload.warning_messages,
completed_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_ai_task_mutation_response(result),
))
}
pub async fn attach_ai_result_reference(
State(state): State<AppState>,
Path(task_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<AttachAiResultReferenceRequest>,
) -> Result<Json<Value>, Response> {
let reference_kind = parse_ai_result_reference_kind_strict(&payload.reference_kind)
.ok_or_else(|| {
ai_tasks_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "ai-task-reference",
"message": "referenceKind 非法",
})),
)
})?;
let result = state
.spacetime_client()
.attach_ai_result_reference(AiResultReferenceInput {
task_id,
reference_kind,
reference_id: payload.reference_id,
label: payload.label,
created_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_ai_task_mutation_response(result),
))
}
pub async fn complete_ai_task(
State(state): State<AppState>,
Path(task_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let result = state
.spacetime_client()
.complete_ai_task(AiTaskFinishInput {
task_id,
completed_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_ai_task_mutation_response(result),
))
}
pub async fn fail_ai_task(
State(state): State<AppState>,
Path(task_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<FailAiTaskRequest>,
) -> Result<Json<Value>, Response> {
let result = state
.spacetime_client()
.fail_ai_task(AiTaskFailureInput {
task_id,
failure_message: payload.failure_message,
completed_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_ai_task_mutation_response(result),
))
}
pub async fn cancel_ai_task(
State(state): State<AppState>,
Path(task_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let result = state
.spacetime_client()
.cancel_ai_task(AiTaskCancelInput {
task_id,
completed_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_ai_task_mutation_response(result),
))
}
fn build_stage_blueprints(
task_kind: AiTaskKind,
stage_kinds: Vec<String>,
request_context: &RequestContext,
) -> Result<Vec<AiTaskStageBlueprint>, Response> {
if stage_kinds.is_empty() {
return Ok(task_kind.default_stage_blueprints());
}
stage_kinds
.into_iter()
.enumerate()
.map(|(index, stage_kind_text)| {
let stage_kind =
parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
ai_tasks_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "ai-task-stage",
"message": format!("stageKinds[{index}] 非法"),
})),
)
})?;
Ok(AiTaskStageBlueprint {
stage_kind,
label: stage_kind.default_label().to_string(),
detail: stage_kind.default_detail().to_string(),
order: index as u32,
})
})
.collect()
}
fn build_ai_task_mutation_response(record: AiTaskMutationRecord) -> AiTaskMutationResponse {
AiTaskMutationResponse {
ai_task: build_ai_task_payload(record.task),
ai_text_chunk: record.text_chunk.map(build_ai_text_chunk_payload),
}
}
fn build_ai_task_payload(record: spacetime_client::AiTaskRecord) -> AiTaskPayload {
AiTaskPayload {
task_id: record.task_id,
task_kind: record.task_kind,
owner_user_id: record.owner_user_id,
request_label: record.request_label,
source_module: record.source_module,
source_entity_id: record.source_entity_id,
request_payload_json: record.request_payload_json,
status: record.status,
failure_message: record.failure_message,
stages: record
.stages
.into_iter()
.map(build_ai_task_stage_payload)
.collect(),
result_references: record
.result_references
.into_iter()
.map(build_ai_result_reference_payload)
.collect(),
latest_text_output: record.latest_text_output,
latest_structured_payload_json: record.latest_structured_payload_json,
version: record.version,
created_at: record.created_at,
started_at: record.started_at,
completed_at: record.completed_at,
updated_at: record.updated_at,
}
}
fn build_ai_task_stage_payload(record: spacetime_client::AiTaskStageRecord) -> AiTaskStagePayload {
AiTaskStagePayload {
stage_kind: record.stage_kind,
label: record.label,
detail: record.detail,
order: record.order,
status: record.status,
text_output: record.text_output,
structured_payload_json: record.structured_payload_json,
warning_messages: record.warning_messages,
started_at: record.started_at,
completed_at: record.completed_at,
}
}
fn build_ai_result_reference_payload(
record: spacetime_client::AiResultReferenceRecord,
) -> AiResultReferencePayload {
AiResultReferencePayload {
result_ref_id: record.result_ref_id,
task_id: record.task_id,
reference_kind: record.reference_kind,
reference_id: record.reference_id,
label: record.label,
created_at: record.created_at,
}
}
fn build_ai_text_chunk_payload(record: spacetime_client::AiTextChunkRecord) -> AiTextChunkPayload {
AiTextChunkPayload {
chunk_id: record.chunk_id,
task_id: record.task_id,
stage_kind: record.stage_kind,
sequence: record.sequence,
delta_text: record.delta_text,
created_at: record.created_at,
}
}
fn parse_ai_task_kind_strict(value: &str) -> Option<AiTaskKind> {
match value.trim() {
"story_generation" => Some(AiTaskKind::StoryGeneration),
"character_chat" => Some(AiTaskKind::CharacterChat),
"npc_chat" => Some(AiTaskKind::NpcChat),
"custom_world_generation" => Some(AiTaskKind::CustomWorldGeneration),
"quest_intent" => Some(AiTaskKind::QuestIntent),
"runtime_item_intent" => Some(AiTaskKind::RuntimeItemIntent),
_ => None,
}
}
fn parse_ai_task_stage_kind_strict(value: &str) -> Option<AiTaskStageKind> {
match value.trim() {
"prepare_prompt" => Some(AiTaskStageKind::PreparePrompt),
"request_model" => Some(AiTaskStageKind::RequestModel),
"repair_response" => Some(AiTaskStageKind::RepairResponse),
"normalize_result" => Some(AiTaskStageKind::NormalizeResult),
"persist_result" => Some(AiTaskStageKind::PersistResult),
_ => None,
}
}
fn parse_ai_result_reference_kind_strict(value: &str) -> Option<AiResultReferenceKind> {
match value.trim() {
"story_session" => Some(AiResultReferenceKind::StorySession),
"story_event" => Some(AiResultReferenceKind::StoryEvent),
"custom_world_profile" => Some(AiResultReferenceKind::CustomWorldProfile),
"quest_record" => Some(AiResultReferenceKind::QuestRecord),
"runtime_item_record" => Some(AiResultReferenceKind::RuntimeItemRecord),
"asset_object" => Some(AiResultReferenceKind::AssetObject),
_ => None,
}
}
fn map_ai_task_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn ai_tasks_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
fn ai_task_accepted_response(
request_context: &RequestContext,
payload: AiTaskAcceptedResponse,
) -> Response {
let mut response = json_success_body(Some(request_context), payload).into_response();
*response.status_mut() = StatusCode::ACCEPTED;
response
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn create_ai_task_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai/tasks")
.header("content-type", "application/json")
.body(Body::from(
json!({
"taskKind": "story_generation",
"requestLabel": "营地开场",
"sourceModule": "story"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_ai_task_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai/tasks")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"taskKind": "npc_chat",
"requestLabel": "试探问话",
"sourceModule": "npc"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn start_ai_task_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai/tasks/aitask_001/start")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn start_ai_task_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai/tasks/aitask_001/start")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "ai_tasks_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_ai_tasks".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("AI 任务用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -1,25 +1,15 @@
use axum::Json;
use serde::Serialize;
use serde_json::{Value, json};
use serde_json::Value;
#[cfg(test)]
use serde_json::json;
use shared_contracts::api::{
API_VERSION, ApiErrorEnvelope, ApiErrorPayload, ApiResponseMeta, ApiSuccessEnvelope,
LegacyApiErrorResponse,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{http_error::ApiErrorPayload, request_context::RequestContext};
pub const API_VERSION: &str = "2026-04-08";
#[derive(Debug, Serialize)]
struct ApiResponseMeta {
#[serde(rename = "apiVersion")]
api_version: &'static str,
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(rename = "routeVersion")]
route_version: &'static str,
operation: Option<String>,
#[serde(rename = "latencyMs")]
latency_ms: u64,
timestamp: String,
}
use crate::request_context::RequestContext;
// 当前阶段先把成功响应 envelope helper 准备好,后续 `/healthz` 与业务 handler 会直接复用这里的输出逻辑。
#[allow(dead_code)]
@@ -30,12 +20,13 @@ where
if let Some(context) = request_context
&& context.wants_envelope()
{
return Json(json!({
"ok": true,
"data": data,
"error": null,
"meta": build_api_response_meta(Some(context)),
}));
return Json(
serde_json::to_value(ApiSuccessEnvelope::new(
data,
build_api_response_meta(Some(context)),
))
.unwrap_or(Value::Null),
);
}
Json(serde_json::to_value(data).unwrap_or(Value::Null))
@@ -48,33 +39,30 @@ pub fn json_error_body(
let meta = build_api_response_meta(request_context);
if request_context.is_some_and(RequestContext::wants_envelope) {
return Json(json!({
"ok": false,
"data": null,
"error": error,
"meta": meta,
}));
return Json(
serde_json::to_value(ApiErrorEnvelope::new(error.clone(), meta)).unwrap_or(Value::Null),
);
}
Json(json!({
"error": error,
"meta": meta,
}))
Json(
serde_json::to_value(LegacyApiErrorResponse::new(error.clone(), meta))
.unwrap_or(Value::Null),
)
}
fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiResponseMeta {
ApiResponseMeta {
api_version: API_VERSION,
request_id: request_context.map(|context| context.request_id().to_string()),
route_version: API_VERSION,
operation: request_context.map(|context| context.operation().to_string()),
latency_ms: request_context
ApiResponseMeta::new(
API_VERSION,
request_context.map(|context| context.request_id().to_string()),
API_VERSION,
request_context.map(|context| context.operation().to_string()),
request_context
.map(RequestContext::elapsed)
.unwrap_or_default(),
timestamp: OffsetDateTime::now_utc()
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
}
)
}
#[cfg(test)]
@@ -122,7 +110,7 @@ mod tests {
fn error_body_returns_legacy_shape_without_envelope_header() {
let request_context = build_request_context(false);
let error = ApiErrorPayload {
code: "NOT_FOUND",
code: "NOT_FOUND".to_string(),
message: "资源不存在".to_string(),
details: None,
};

View File

@@ -10,6 +10,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
use tracing::{Level, info_span};
use crate::{
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
},
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_read_url,
@@ -20,8 +24,18 @@ use crate::{
},
auth_me::auth_me,
auth_sessions::auth_sessions,
custom_world::{
create_custom_world_agent_session, execute_custom_world_agent_action,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_gallery_detail, get_custom_world_library,
get_custom_world_library_detail, 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,
},
error_middleware::normalize_error_response,
health::health_check,
llm::proxy_llm_chat_completions,
login_options::auth_login_options,
logout::logout,
logout_all::logout_all,
@@ -30,7 +44,18 @@ use crate::{
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
runtime_browse_history::{
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
},
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::resolve_runtime_story_state,
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
},
story_sessions::{begin_story_session, continue_story, get_story_session_state},
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
};
@@ -95,6 +120,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/llm/chat/completions",
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout",
post(logout)
@@ -114,6 +146,69 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks",
post(create_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/start",
post(start_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/chunks",
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/references",
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/complete",
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/fail",
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/cancel",
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
@@ -128,6 +223,219 @@ pub fn build_router(state: AppState) -> Router {
post(bind_asset_object_to_entity),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route(
"/api/runtime/settings",
get(get_runtime_settings)
.put(put_runtime_settings)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library",
get(get_custom_world_library).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}",
get(get_custom_world_library_detail)
.put(put_custom_world_library_profile)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}/publish",
post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}/unpublish",
post(unpublish_custom_world_library_profile).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world-gallery",
get(list_custom_world_gallery),
)
.route(
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
get(get_custom_world_gallery_detail),
)
.route(
"/api/runtime/custom-world/agent/sessions",
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}",
get(get_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream",
post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/actions",
post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}",
get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/sessions/{runtime_session_id}/inventory",
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/resolve",
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions",
post(begin_story_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/state",
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/continue",
post(continue_story).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles",
post(create_story_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles/{battle_state_id}",
get(get_story_battle_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/npc/battle",
post(create_story_npc_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles/resolve",
post(resolve_story_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/entry", post(password_entry))
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))

View File

@@ -1,5 +1,3 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{Extension, Query, State},
@@ -14,8 +12,13 @@ use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPostObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::assets::{
AssetBindingPayload, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest,
BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest,
ConfirmAssetObjectResponse, CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse,
DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery,
};
use spacetime_client::SpacetimeClientError;
use crate::{
@@ -23,84 +26,6 @@ use crate::{
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectUploadTicketRequest {
pub legacy_prefix: String,
#[serde(default)]
pub path_segments: Vec<String>,
pub file_name: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub access: Option<OssObjectAccess>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub max_size_bytes: Option<u64>,
#[serde(default)]
pub expire_seconds: Option<u64>,
#[serde(default)]
pub success_action_status: Option<u16>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetReadUrlQuery {
#[serde(default)]
pub object_key: Option<String>,
#[serde(default)]
pub legacy_public_path: Option<String>,
#[serde(default)]
pub expire_seconds: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmAssetObjectRequest {
#[serde(default)]
pub bucket: Option<String>,
pub object_key: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub content_length: Option<u64>,
#[serde(default)]
pub content_hash: Option<String>,
pub asset_kind: String,
#[serde(default)]
pub access_policy: Option<ConfirmAssetObjectAccessPolicy>,
#[serde(default)]
pub source_job_id: Option<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub entity_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BindAssetObjectRequest {
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmAssetObjectAccessPolicy {
Private,
PublicRead,
}
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -141,9 +66,9 @@ pub async fn create_direct_upload_ticket(
Ok(json_success_body(
Some(&request_context),
json!({
"upload": signed,
}),
CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(signed),
},
))
}
@@ -180,9 +105,9 @@ pub async fn get_asset_read_url(
Ok(json_success_body(
Some(&request_context),
json!({
"read": signed,
}),
GetAssetReadUrlResponse {
read: AssetReadUrlPayload::from(signed),
},
))
}
@@ -223,25 +148,25 @@ pub async fn confirm_asset_object(
Ok(json_success_body(
Some(&request_context),
json!({
"assetObject": {
"assetObjectId": result.asset_object_id,
"bucket": result.bucket,
"objectKey": result.object_key,
"accessPolicy": result.access_policy.as_str(),
"contentType": result.content_type,
"contentLength": result.content_length,
"contentHash": result.content_hash,
"version": result.version,
"sourceJobId": result.source_job_id,
"ownerUserId": result.owner_user_id,
"profileId": result.profile_id,
"entityId": result.entity_id,
"assetKind": result.asset_kind,
"createdAt": result.created_at,
"updatedAt": result.updated_at,
}
}),
ConfirmAssetObjectResponse {
asset_object: AssetObjectPayload {
asset_object_id: result.asset_object_id,
bucket: result.bucket,
object_key: result.object_key,
access_policy: result.access_policy.as_str().to_string(),
content_type: result.content_type,
content_length: result.content_length,
content_hash: result.content_hash,
version: result.version,
source_job_id: result.source_job_id,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
entity_id: result.entity_id,
asset_kind: result.asset_kind,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
))
}
@@ -272,20 +197,20 @@ pub async fn bind_asset_object_to_entity(
Ok(json_success_body(
Some(&request_context),
json!({
"assetBinding": {
"bindingId": result.binding_id,
"assetObjectId": result.asset_object_id,
"entityKind": result.entity_kind,
"entityId": result.entity_id,
"slot": result.slot,
"assetKind": result.asset_kind,
"ownerUserId": result.owner_user_id,
"profileId": result.profile_id,
"createdAt": result.created_at,
"updatedAt": result.updated_at,
}
}),
BindAssetObjectResponse {
asset_binding: AssetBindingPayload {
binding_id: result.binding_id,
asset_object_id: result.asset_object_id,
entity_kind: result.entity_kind,
entity_id: result.entity_id,
slot: result.slot,
asset_kind: result.asset_kind,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
))
}
@@ -355,7 +280,7 @@ async fn build_confirm_asset_object_upsert_input(
head.object_key,
payload
.access_policy
.map(Into::into)
.map(map_confirm_asset_object_access_policy)
.unwrap_or(AssetObjectAccessPolicy::Private),
head.content_type
.or_else(|| normalize_optional_value(payload.content_type)),
@@ -439,12 +364,12 @@ impl std::fmt::Display for ConfirmAssetObjectPrepareError {
}
}
impl From<ConfirmAssetObjectAccessPolicy> for AssetObjectAccessPolicy {
fn from(value: ConfirmAssetObjectAccessPolicy) -> Self {
match value {
ConfirmAssetObjectAccessPolicy::Private => Self::Private,
ConfirmAssetObjectAccessPolicy::PublicRead => Self::PublicRead,
}
fn map_confirm_asset_object_access_policy(
value: ConfirmAssetObjectAccessPolicy,
) -> AssetObjectAccessPolicy {
match value {
ConfirmAssetObjectAccessPolicy::Private => AssetObjectAccessPolicy::Private,
ConfirmAssetObjectAccessPolicy::PublicRead => AssetObjectAccessPolicy::PublicRead,
}
}
@@ -469,8 +394,8 @@ mod tests {
use reqwest::{Method, multipart};
use serde_json::{Value, json};
use sha1::Sha1;
use shared_kernel::new_uuid_simple_string;
use tower::ServiceExt;
use uuid::Uuid;
use crate::{app::build_router, config::AppConfig, state::AppState};
@@ -885,7 +810,7 @@ mod tests {
ensure_success_status(bucket_head.status().as_u16(), "bucket HEAD 应成功")?;
let app = build_router(AppState::new(config.clone()).expect("state should build"));
let run_id = Uuid::new_v4().simple().to_string();
let run_id = new_uuid_simple_string();
let file_name = format!("oss-live-{run_id}.txt");
let file_content = format!("Genarrative OSS Rust live test {run_id}");
@@ -1032,7 +957,7 @@ mod tests {
let test_result = async {
let app = build_router(AppState::new(config.clone()).expect("state should build"));
let run_id = Uuid::new_v4().simple().to_string();
let run_id = new_uuid_simple_string();
let file_content = format!("Genarrative confirm asset object live test {run_id}");
let ticket_response = app

View File

@@ -3,32 +3,13 @@ use axum::{
extract::{Extension, State},
http::StatusCode,
};
use serde::Serialize;
use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMeResponse {
pub user: AuthMeUserPayload,
pub available_login_methods: Vec<&'static str>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMeUserPayload {
pub id: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: &'static str,
pub binding_status: &'static str,
pub wechat_bound: bool,
}
pub async fn auth_me(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -49,27 +30,19 @@ pub async fn auth_me(
Ok(json_success_body(
Some(&request_context),
AuthMeResponse {
user: AuthMeUserPayload {
user: AuthUserPayload {
id: user.user.id,
username: user.user.username,
display_name: user.user.display_name,
phone_number_masked: user.user.phone_number_masked,
login_method: user.user.login_method.as_str(),
binding_status: user.user.binding_status.as_str(),
login_method: user.user.login_method.as_str().to_string(),
binding_status: user.user.binding_status.as_str().to_string(),
wechat_bound: user.user.wechat_bound,
},
available_login_methods: build_available_login_methods(&state),
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
state.config.wechat_auth_enabled,
),
},
))
}
fn build_available_login_methods(state: &AppState) -> Vec<&'static str> {
let mut methods = Vec::new();
if state.config.sms_auth_enabled {
methods.push("phone");
}
if state.config.wechat_auth_enabled {
methods.push("wechat");
}
methods
}

View File

@@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
};
use platform_auth::hash_refresh_session_token;
use serde::Serialize;
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
use time::OffsetDateTime;
use crate::{
@@ -16,31 +16,6 @@ use crate::{
state::AppState,
};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthSessionsResponse {
pub sessions: Vec<AuthSessionSummaryPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthSessionSummaryPayload {
pub session_id: String,
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_label: String,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip_masked: Option<String>,
pub is_current: bool,
pub created_at: String,
pub last_seen_at: String,
pub expires_at: String,
}
pub async fn auth_sessions(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -1,5 +1,12 @@
use std::{env, net::SocketAddr};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_RETRY_BACKOFF_MS, LlmProvider,
};
const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715";
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
pub struct AppConfig {
@@ -40,6 +47,13 @@ pub struct AppConfig {
pub spacetime_server_url: String,
pub spacetime_database: String,
pub spacetime_token: Option<String>,
pub llm_provider: LlmProvider,
pub llm_base_url: String,
pub llm_api_key: Option<String>,
pub llm_model: String,
pub llm_request_timeout_ms: u64,
pub llm_max_retries: u32,
pub llm_retry_backoff_ms: u64,
}
impl Default for AppConfig {
@@ -83,6 +97,13 @@ impl Default for AppConfig {
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
spacetime_database: "genarrative-dev".to_string(),
spacetime_token: None,
llm_provider: LlmProvider::Ark,
llm_base_url: DEFAULT_ARK_BASE_URL.to_string(),
llm_api_key: None,
llm_model: DEFAULT_LLM_MODEL.to_string(),
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
llm_max_retries: DEFAULT_MAX_RETRIES,
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
}
}
}
@@ -244,6 +265,46 @@ impl AppConfig {
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
if let Some(llm_provider) =
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
{
config.llm_provider = llm_provider;
}
if let Some(llm_base_url) =
read_first_non_empty_env(&["GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL"])
{
config.llm_base_url = llm_base_url;
}
config.llm_api_key =
read_first_non_empty_env(&["GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY"]);
if let Some(llm_model) =
read_first_non_empty_env(&["GENARRATIVE_LLM_MODEL", "LLM_MODEL", "VITE_LLM_MODEL"])
{
config.llm_model = llm_model;
}
if let Some(llm_request_timeout_ms) = read_first_positive_u64_env(&[
"GENARRATIVE_LLM_REQUEST_TIMEOUT_MS",
"LLM_REQUEST_TIMEOUT_MS",
]) {
config.llm_request_timeout_ms = llm_request_timeout_ms;
}
if let Some(llm_max_retries) =
read_first_u32_env(&["GENARRATIVE_LLM_MAX_RETRIES", "LLM_MAX_RETRIES"])
{
config.llm_max_retries = llm_max_retries;
}
if let Some(llm_retry_backoff_ms) =
read_first_u64_env(&["GENARRATIVE_LLM_RETRY_BACKOFF_MS", "LLM_RETRY_BACKOFF_MS"])
{
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
}
config
}
@@ -281,6 +342,14 @@ fn read_first_bool_env(keys: &[&str]) -> Option<bool> {
.find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value)))
}
fn read_first_llm_provider_env(keys: &[&str]) -> Option<LlmProvider> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_llm_provider(&value))
})
}
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
keys.iter().find_map(|key| {
env::var(key)
@@ -297,6 +366,16 @@ fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
})
}
fn read_first_u32_env(keys: &[&str]) -> Option<u32> {
keys.iter()
.find_map(|key| env::var(key).ok().and_then(|value| parse_u32(&value)))
}
fn read_first_u64_env(keys: &[&str]) -> Option<u64> {
keys.iter()
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
}
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
keys.iter().find_map(|key| {
env::var(key)
@@ -338,6 +417,15 @@ fn parse_bool(raw: &str) -> Option<bool> {
}
}
fn parse_llm_provider(raw: &str) -> Option<LlmProvider> {
match raw.trim().to_ascii_lowercase().as_str() {
"ark" => Some(LlmProvider::Ark),
"dash_scope" | "dashscope" => Some(LlmProvider::DashScope),
"openai_compatible" | "openai-compatible" | "openai" => Some(LlmProvider::OpenAiCompatible),
_ => None,
}
}
fn parse_positive_u32(raw: &str) -> Option<u32> {
let value = raw.trim().parse::<u32>().ok()?;
if value == 0 {
@@ -347,6 +435,10 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
Some(value)
}
fn parse_u32(raw: &str) -> Option<u32> {
raw.trim().parse::<u32>().ok()
}
fn parse_positive_u64(raw: &str) -> Option<u64> {
let value = raw.trim().parse::<u64>().ok()?;
if value == 0 {
@@ -356,6 +448,10 @@ fn parse_positive_u64(raw: &str) -> Option<u64> {
Some(value)
}
fn parse_u64(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
fn parse_positive_u16(raw: &str) -> Option<u16> {
let value = raw.trim().parse::<u16>().ok()?;
if value == 0 {

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@ use axum::{
http::{HeaderMap, HeaderValue},
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::Value;
use shared_contracts::api::ApiErrorPayload;
use crate::{api_response::json_error_body, request_context::RequestContext};
@@ -17,14 +17,6 @@ pub struct AppError {
headers: HeaderMap,
}
#[derive(Clone, Debug, Serialize)]
pub struct ApiErrorPayload {
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl AppError {
pub fn from_status(status_code: StatusCode) -> Self {
let (code, message) = resolve_http_error(status_code);
@@ -71,11 +63,7 @@ impl AppError {
}
fn to_payload(&self) -> ApiErrorPayload {
ApiErrorPayload {
code: self.code,
message: self.message.clone(),
details: self.details.clone(),
}
ApiErrorPayload::new(self.code, self.message.clone(), self.details.clone())
}
}
@@ -91,6 +79,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"),
StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"),
StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"),
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),

View File

@@ -0,0 +1,376 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
};
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest};
use serde_json::Value;
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn proxy_llm_chat_completions(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<LlmChatCompletionRequest>,
) -> Result<Json<Value>, Response> {
if payload.stream {
return Err(llm_error_response(
&request_context,
AppError::from_status(StatusCode::NOT_IMPLEMENTED)
.with_message("Rust `api-server` 首版暂不支持流式 LLM 代理"),
));
}
let llm_client = state.llm_client().ok_or_else(|| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("服务端尚未配置可用的 LLM API Key"),
)
})?;
let request = LlmTextRequest {
model: payload.model,
messages: payload
.messages
.into_iter()
.map(map_chat_message)
.collect::<Vec<_>>(),
max_tokens: None,
};
let response = llm_client
.request_text(request)
.await
.map_err(|error| llm_error_response(&request_context, map_llm_error(error)))?;
Ok(json_success_body(
Some(&request_context),
LlmChatCompletionResponse {
id: response.response_id,
model: response.model,
content: response.content,
finish_reason: response.finish_reason,
},
))
}
fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
let role = match message.role {
LlmChatMessageRole::System => LlmMessageRole::System,
LlmChatMessageRole::User => LlmMessageRole::User,
LlmChatMessageRole::Assistant => LlmMessageRole::Assistant,
};
LlmMessage::new(role, message.content)
}
fn map_llm_error(error: LlmError) -> AppError {
match error {
LlmError::InvalidRequest(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
}
LlmError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
}
LlmError::Upstream {
status_code: 429,
message,
} => AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(message),
LlmError::Upstream { message, .. } => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
LlmError::Timeout { attempts } => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 请求超时,累计尝试 {attempts}")),
LlmError::Connectivity { attempts, message } => {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 连接失败,累计尝试 {attempts} 次:{message}"))
}
LlmError::StreamUnavailable => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 流式响应体不可用")
}
LlmError::EmptyResponse => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 返回内容为空")
}
LlmError::Transport(message) | LlmError::Deserialize(message) => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
}
}
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
time::Duration as StdDuration,
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
struct MockResponse {
status_line: &'static str,
content_type: &'static str,
body: String,
}
#[tokio::test]
async fn llm_chat_completions_returns_non_stream_text_payload() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_api_server_01","model":"ark-router-test","choices":[{"message":{"content":""},"finish_reason":"stop"}]}"#.to_string(),
}]);
let state = seed_authenticated_state(AppConfig {
llm_base_url: server_url,
llm_api_key: Some("test-key".to_string()),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/llm/chat/completions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"messages": [
{ "role": "system", "content": "系统" },
{ "role": "user", "content": "用户" }
]
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["id"],
Value::String("resp_api_server_01".to_string())
);
assert_eq!(
payload["data"]["model"],
Value::String("ark-router-test".to_string())
);
assert_eq!(
payload["data"]["content"],
Value::String("代理成功".to_string())
);
assert_eq!(
payload["data"]["finishReason"],
Value::String("stop".to_string())
);
}
#[tokio::test]
async fn llm_chat_completions_rejects_stream_mode() {
let state = seed_authenticated_state(AppConfig::default()).await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/llm/chat/completions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"stream": true,
"messages": [
{ "role": "user", "content": "用户" }
]
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["code"],
Value::String("NOT_IMPLEMENTED".to_string())
);
}
async fn seed_authenticated_state(config: AppConfig) -> AppState {
let state = AppState::new(config).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "llm_proxy_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_llm_proxy".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("LLM 代理用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn spawn_mock_server(responses: Vec<MockResponse>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("request should connect");
read_request(&mut stream);
write_response(&mut stream, response);
}
});
format!("http://{address}")
}
fn read_request(stream: &mut std::net::TcpStream) {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be set");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
}
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {
let body = response.body;
let raw_response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.status_line,
response.content_type,
body.len(),
body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}

View File

@@ -2,32 +2,21 @@ use axum::{
Json,
extract::{Extension, State},
};
use serde::Serialize;
use shared_contracts::auth::{AuthLoginOptionsResponse, build_available_login_methods};
use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthLoginOptionsResponse {
pub available_login_methods: Vec<&'static str>,
}
pub async fn auth_login_options(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Json<serde_json::Value> {
let mut methods = Vec::new();
if state.config.sms_auth_enabled {
methods.push("phone");
}
if state.config.wechat_auth_enabled {
methods.push("wechat");
}
json_success_body(
Some(&request_context),
AuthLoginOptionsResponse {
available_login_methods: methods,
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
state.config.wechat_auth_enabled,
),
},
)
}

View File

@@ -5,7 +5,7 @@ use axum::{
};
use module_auth::LogoutCurrentSessionInput;
use platform_auth::hash_refresh_session_token;
use serde::Serialize;
use shared_contracts::auth::LogoutResponse;
use time::OffsetDateTime;
use crate::{
@@ -19,11 +19,6 @@ use crate::{
state::AppState,
};
#[derive(Debug, Serialize)]
pub struct LogoutResponse {
pub ok: bool,
}
pub async fn logout(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -4,7 +4,7 @@ use axum::{
response::IntoResponse,
};
use module_auth::LogoutAllSessionsInput;
use serde::Serialize;
use shared_contracts::auth::LogoutAllResponse;
use time::OffsetDateTime;
use crate::{
@@ -18,11 +18,6 @@ use crate::{
state::AppState,
};
#[derive(Debug, Serialize)]
pub struct LogoutAllResponse {
pub ok: bool,
}
pub async fn logout_all(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -1,3 +1,4 @@
mod ai_tasks;
mod api_response;
mod app;
mod assets;
@@ -6,9 +7,11 @@ mod auth_me;
mod auth_session;
mod auth_sessions;
mod config;
mod custom_world;
mod error_middleware;
mod health;
mod http_error;
mod llm;
mod login_options;
mod logout;
mod logout_all;
@@ -17,8 +20,15 @@ mod phone_auth;
mod refresh_session;
mod request_context;
mod response_headers;
mod runtime_browse_history;
mod runtime_inventory;
mod runtime_profile;
mod runtime_settings;
mod runtime_story;
mod session_client;
mod state;
mod story_battles;
mod story_sessions;
mod wechat_auth;
mod wechat_provider;

View File

@@ -5,8 +5,8 @@ use axum::{
response::IntoResponse,
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use serde::{Deserialize, Serialize};
use serde_json::json;
use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse};
use crate::{
api_response::json_success_body,
@@ -19,32 +19,6 @@ use crate::{
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryResponse {
pub token: String,
pub user: PasswordEntryUserPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryUserPayload {
pub id: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: &'static str,
pub binding_status: &'static str,
pub wechat_bound: bool,
}
pub async fn password_entry(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -74,13 +48,13 @@ pub async fn password_entry(
Some(&request_context),
PasswordEntryResponse {
token: signed_session.access_token,
user: PasswordEntryUserPayload {
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str(),
binding_status: result.user.binding_status.as_str(),
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
},

View File

@@ -7,8 +7,11 @@ use axum::{
use module_auth::{
AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use shared_contracts::auth::{
AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
PhoneSendCodeResponse,
};
use time::OffsetDateTime;
use crate::{
@@ -17,42 +20,11 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
password_entry::PasswordEntryUserPayload,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneSendCodeRequest {
pub phone: String,
pub scene: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneSendCodeResponse {
pub ok: bool,
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginRequest {
pub phone: String,
pub code: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginResponse {
pub token: String,
pub user: PasswordEntryUserPayload,
}
pub async fn send_phone_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -130,13 +102,13 @@ pub async fn phone_login(
Some(&request_context),
PhoneLoginResponse {
token: signed_session.access_token,
user: PasswordEntryUserPayload {
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str(),
binding_status: result.user.binding_status.as_str(),
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
},

View File

@@ -5,7 +5,7 @@ use axum::{
};
use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
use platform_auth::hash_refresh_session_token;
use serde::Serialize;
use shared_contracts::auth::RefreshSessionResponse;
use time::OffsetDateTime;
use crate::{
@@ -20,12 +20,6 @@ use crate::{
state::AppState,
};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RefreshSessionResponse {
pub token: String,
}
pub async fn refresh_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -6,10 +6,10 @@ use axum::{
middleware::Next,
response::Response,
};
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
use uuid::Uuid;
pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
pub const X_REQUEST_ID_HEADER: &str = "x-request-id";
pub use shared_contracts::api::X_REQUEST_ID_HEADER;
// 当前阶段先把请求级元信息统一挂到 extensions后续响应头、envelope 与错误处理中间件继续复用。
#[derive(Clone, Debug)]

View File

@@ -4,15 +4,11 @@ use axum::{
middleware::Next,
response::Response,
};
use crate::{
api_response::API_VERSION,
request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id},
use shared_contracts::api::{
API_VERSION, API_VERSION_HEADER, RESPONSE_TIME_HEADER, ROUTE_VERSION_HEADER,
};
pub const API_VERSION_HEADER: &str = "x-api-version";
pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms";
pub const ROUTE_VERSION_HEADER: &str = "x-route-version";
use crate::request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id};
pub async fn propagate_request_id_header(request: Request, next: Next) -> Response {
let request_id = resolve_request_id(&request);

View File

@@ -0,0 +1,454 @@
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use module_runtime::{MAX_BROWSE_HISTORY_BATCH_SIZE, RuntimeBrowseHistoryWriteInput};
use serde_json::{Value, json};
use shared_contracts::runtime::{
BROWSE_HISTORY_THEME_MODE_ARCANE, BROWSE_HISTORY_THEME_MODE_MACHINA,
BROWSE_HISTORY_THEME_MODE_MARTIAL, BROWSE_HISTORY_THEME_MODE_MYTHIC,
BROWSE_HISTORY_THEME_MODE_RIFT, BROWSE_HISTORY_THEME_MODE_TIDE,
PlatformBrowseHistoryEntryResponse, PlatformBrowseHistoryResponse,
PlatformBrowseHistoryUpsertRequest, PlatformBrowseHistoryWriteEntryRequest,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn get_runtime_browse_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let entries = state
.spacetime_client()
.list_platform_browse_history(user_id)
.await
.map_err(|error| {
runtime_browse_history_error_response(
&request_context,
map_runtime_browse_history_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PlatformBrowseHistoryResponse {
entries: entries
.into_iter()
.map(map_browse_history_entry_response)
.collect(),
},
))
}
pub async fn post_runtime_browse_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PlatformBrowseHistoryUpsertRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
runtime_browse_history_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "browse-history",
"message": error.body_text(),
})),
)
})?;
let now_micros = current_utc_micros();
let user_id = authenticated.claims().user_id().to_string();
let request_entries = payload.into_entries();
validate_browse_history_request_entries(&request_context, &request_entries)?;
let entries = request_entries
.into_iter()
.map(|entry| RuntimeBrowseHistoryWriteInput {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
author_display_name: entry.author_display_name,
visited_at: entry.visited_at,
})
.collect::<Vec<_>>();
let entries = state
.spacetime_client()
.upsert_platform_browse_history_entries(user_id, entries, now_micros)
.await
.map_err(|error| {
runtime_browse_history_error_response(
&request_context,
map_runtime_browse_history_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PlatformBrowseHistoryResponse {
entries: entries
.into_iter()
.map(map_browse_history_entry_response)
.collect(),
},
))
}
pub async fn delete_runtime_browse_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let entries = state
.spacetime_client()
.clear_platform_browse_history(user_id)
.await
.map_err(|error| {
runtime_browse_history_error_response(
&request_context,
map_runtime_browse_history_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PlatformBrowseHistoryResponse {
entries: entries
.into_iter()
.map(map_browse_history_entry_response)
.collect(),
},
))
}
fn map_browse_history_entry_response(
entry: module_runtime::RuntimeBrowseHistoryRecord,
) -> PlatformBrowseHistoryEntryResponse {
PlatformBrowseHistoryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: map_browse_history_theme_mode(entry.theme_mode).to_string(),
author_display_name: entry.author_display_name,
visited_at: entry.visited_at,
}
}
fn map_browse_history_theme_mode(
value: module_runtime::RuntimeBrowseHistoryThemeMode,
) -> &'static str {
match value {
module_runtime::RuntimeBrowseHistoryThemeMode::Martial => BROWSE_HISTORY_THEME_MODE_MARTIAL,
module_runtime::RuntimeBrowseHistoryThemeMode::Arcane => BROWSE_HISTORY_THEME_MODE_ARCANE,
module_runtime::RuntimeBrowseHistoryThemeMode::Machina => BROWSE_HISTORY_THEME_MODE_MACHINA,
module_runtime::RuntimeBrowseHistoryThemeMode::Tide => BROWSE_HISTORY_THEME_MODE_TIDE,
module_runtime::RuntimeBrowseHistoryThemeMode::Rift => BROWSE_HISTORY_THEME_MODE_RIFT,
module_runtime::RuntimeBrowseHistoryThemeMode::Mythic => BROWSE_HISTORY_THEME_MODE_MYTHIC,
}
}
fn map_runtime_browse_history_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
// 这类错误发生在 Rust 本地 DTO 构建阶段,语义上属于请求不合法,而不是下游不可用。
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "browse-history"),
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
fn runtime_browse_history_error_response(
request_context: &RequestContext,
error: AppError,
) -> Response {
error.into_response_with_context(Some(request_context))
}
fn validate_browse_history_request_entries(
request_context: &RequestContext,
entries: &[PlatformBrowseHistoryWriteEntryRequest],
) -> Result<(), Response> {
if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE {
return Err(runtime_browse_history_error_response(
request_context,
browse_history_bad_request(format!(
"entries 单次最多只允许 {}",
MAX_BROWSE_HISTORY_BATCH_SIZE
)),
));
}
for entry in entries {
if entry.owner_user_id.trim().is_empty() {
return Err(runtime_browse_history_error_response(
request_context,
browse_history_bad_request("ownerUserId 不能为空"),
));
}
if entry.profile_id.trim().is_empty() {
return Err(runtime_browse_history_error_response(
request_context,
browse_history_bad_request("profileId 不能为空"),
));
}
if entry.world_name.trim().is_empty() {
return Err(runtime_browse_history_error_response(
request_context,
browse_history_bad_request("worldName 不能为空"),
));
}
}
Ok(())
}
fn browse_history_bad_request(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "browse-history",
"message": message.into(),
}))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn runtime_browse_history_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn runtime_browse_history_rejects_blank_required_fields() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"ownerUserId": " ",
"profileId": "profile-1",
"worldName": "世界A"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("browse-history".to_string())
);
}
#[tokio::test]
async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway()
{
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"entries": [{
"ownerUserId": "owner-1",
"profileId": "profile-1",
"worldName": "世界A"
}]
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn runtime_browse_history_compat_route_matches_main_route_error_shape() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "browse_history_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_browse_history".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("浏览历史用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,196 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{RuntimeInventorySlotResponse, RuntimeInventoryStateResponse};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn get_runtime_inventory_state(
State(state): State<AppState>,
Path(runtime_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_runtime_inventory_state(runtime_session_id, actor_user_id)
.await
.map_err(|error| {
runtime_inventory_error_response(
&request_context,
map_runtime_inventory_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
RuntimeInventoryStateResponse {
runtime_session_id: record.runtime_session_id,
actor_user_id: record.actor_user_id,
backpack_items: record
.backpack_items
.into_iter()
.map(map_runtime_inventory_slot_response)
.collect(),
equipment_items: record
.equipment_items
.into_iter()
.map(map_runtime_inventory_slot_response)
.collect(),
},
))
}
fn map_runtime_inventory_slot_response(
record: module_inventory::RuntimeInventorySlotRecord,
) -> RuntimeInventorySlotResponse {
RuntimeInventorySlotResponse {
slot_id: record.slot_id,
container_kind: record.container_kind,
slot_key: record.slot_key,
item_id: record.item_id,
category: record.category,
name: record.name,
description: record.description,
quantity: record.quantity,
rarity: record.rarity,
tags: record.tags,
stackable: record.stackable,
stack_key: record.stack_key,
equipment_slot_id: record.equipment_slot_id,
source_kind: record.source_kind,
source_reference_id: record.source_reference_id,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn map_runtime_inventory_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-inventory"),
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
fn runtime_inventory_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn runtime_inventory_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/sessions/runtime_001/inventory")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn runtime_inventory_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/sessions/runtime_001/inventory")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_inventory_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_inventory".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("背包查询用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,332 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn get_profile_dashboard(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_dashboard(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileDashboardSummaryResponse {
wallet_balance: record.wallet_balance,
total_play_time_ms: record.total_play_time_ms,
played_world_count: record.played_world_count,
updated_at: record.updated_at,
},
))
}
pub async fn get_profile_wallet_ledger(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let entries = state
.spacetime_client()
.list_profile_wallet_ledger(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileWalletLedgerResponse {
entries: entries
.into_iter()
.map(|entry| ProfileWalletLedgerEntryResponse {
id: entry.wallet_ledger_id,
amount_delta: entry.amount_delta,
balance_after: entry.balance_after,
source_type: match entry.source_type {
module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string()
}
},
created_at: entry.created_at,
})
.collect(),
},
))
}
pub async fn get_profile_play_stats(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_play_stats(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfilePlayStatsResponse {
total_play_time_ms: record.total_play_time_ms,
played_works: record
.played_works
.into_iter()
.map(|entry| ProfilePlayedWorkSummaryResponse {
world_key: entry.world_key,
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
world_type: entry.world_type,
world_title: entry.world_title,
world_subtitle: entry.world_subtitle,
first_played_at: entry.first_played_at,
last_played_at: entry.last_played_at,
last_observed_play_time_ms: entry.last_observed_play_time_ms,
})
.collect(),
updated_at: record.updated_at,
},
))
}
fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"),
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn profile_dashboard_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/dashboard")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_wallet_ledger_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/wallet-ledger")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_play_stats_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/play-stats")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/dashboard",
"/api/profile/dashboard",
)
.await;
}
#[tokio::test]
async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/wallet-ledger",
"/api/profile/wallet-ledger",
)
.await;
}
#[tokio::test]
async fn profile_play_stats_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/play-stats",
"/api/profile/play-stats",
)
.await;
}
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_profile_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_profile".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("资料页用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,372 @@
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use module_runtime::{
RuntimePlatformTheme, RuntimeSettingsFieldError, build_runtime_setting_upsert_input,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
PutRuntimeSettingsRequest, RUNTIME_PLATFORM_THEME_DARK, RUNTIME_PLATFORM_THEME_LIGHT,
RuntimeSettingsResponse,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn get_runtime_settings(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let settings = state
.spacetime_client()
.get_runtime_settings(user_id)
.await
.map_err(|error| {
runtime_settings_error_response(
&request_context,
map_runtime_settings_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
RuntimeSettingsResponse {
music_volume: settings.music_volume,
platform_theme: settings.platform_theme.as_str().to_string(),
},
))
}
pub async fn put_runtime_settings(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PutRuntimeSettingsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
runtime_settings_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": error.body_text(),
})),
)
})?;
let user_id = authenticated.claims().user_id().to_string();
let theme = parse_platform_theme_strict(&payload.platform_theme).ok_or_else(|| {
runtime_settings_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": "platformTheme 仅支持 light 或 dark",
})),
)
})?;
if !(0.0..=1.0).contains(&payload.music_volume) {
return Err(runtime_settings_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": "musicVolume 必须在 0 到 1 之间",
})),
));
}
let now_micros = current_utc_micros();
let prepared =
build_runtime_setting_upsert_input(user_id, payload.music_volume, theme, now_micros)
.map_err(|error| {
runtime_settings_error_response(
&request_context,
map_runtime_settings_prepare_error(error),
)
})?;
let settings = state
.spacetime_client()
.put_runtime_settings(
prepared.user_id,
prepared.music_volume,
prepared.platform_theme,
prepared.updated_at_micros,
)
.await
.map_err(|error| {
runtime_settings_error_response(
&request_context,
map_runtime_settings_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
RuntimeSettingsResponse {
music_volume: settings.music_volume,
platform_theme: settings.platform_theme.as_str().to_string(),
},
))
}
fn map_runtime_settings_prepare_error(error: RuntimeSettingsFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": error.to_string(),
}))
}
fn map_runtime_settings_client_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn runtime_settings_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
fn parse_platform_theme_strict(raw: &str) -> Option<RuntimePlatformTheme> {
match raw.trim() {
RUNTIME_PLATFORM_THEME_LIGHT => Some(RuntimePlatformTheme::Light),
RUNTIME_PLATFORM_THEME_DARK => Some(RuntimePlatformTheme::Dark),
_ => None,
}
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn runtime_settings_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/settings")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn runtime_settings_rejects_invalid_theme_with_envelope() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"musicVolume": 0.42,
"platformTheme": "mythic"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("runtime-settings".to_string())
);
}
#[tokio::test]
#[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module验证 PUT/GET settings 主链"]
async fn runtime_settings_round_trip_against_local_spacetimedb() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let put_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"musicVolume": 1.4,
"platformTheme": "dark"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(put_response.status(), StatusCode::OK);
let put_body = put_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let put_payload: Value =
serde_json::from_slice(&put_body).expect("response body should be valid json");
assert_eq!(
put_payload["data"]["platformTheme"],
Value::String("dark".to_string())
);
assert_eq!(put_payload["data"]["musicVolume"], json!(1.0));
let get_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(get_response.status(), StatusCode::OK);
let get_body = get_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let get_payload: Value =
serde_json::from_slice(&get_body).expect("response body should be valid json");
assert_eq!(
get_payload["data"]["platformTheme"],
Value::String("dark".to_string())
);
assert_eq!(get_payload["data"]["musicVolume"], json!(1.0));
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_settings_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_settings".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("设置用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,593 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::runtime_story::{
RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel,
RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn resolve_runtime_story_state(
State(_state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryStateResolveRequest>,
) -> Result<Json<Value>, Response> {
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
runtime_story_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"field": "sessionId",
"message": "sessionId 不能为空",
})),
)
})?;
let snapshot = payload.snapshot.ok_or_else(|| {
runtime_story_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"field": "snapshot",
"message": "当前首版兼容状态桥要求随请求提交 snapshot",
})),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_state_response(
&session_id,
payload.client_version,
snapshot,
),
))
}
fn build_runtime_story_state_response(
requested_session_id: &str,
client_version: Option<u32>,
snapshot: RuntimeStorySnapshotPayload,
) -> RuntimeStoryActionResponse {
let session_id = read_runtime_session_id(&snapshot.game_state)
.unwrap_or_else(|| requested_session_id.to_string());
let options = build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
let story_text =
read_story_text(snapshot.current_story.as_ref()).unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
let server_version =
read_u32_field(&snapshot.game_state, "runtimeActionVersion").or(client_version).unwrap_or(0);
RuntimeStoryActionResponse {
session_id,
server_version,
view_model: RuntimeStoryViewModel {
player: RuntimeStoryPlayerViewModel {
hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0),
max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1),
mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0),
max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1),
},
encounter: build_runtime_story_encounter(&snapshot.game_state),
companions: build_runtime_story_companions(&snapshot.game_state),
available_options: options.clone(),
status: RuntimeStoryStatusViewModel {
in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false),
npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive")
.unwrap_or(false),
current_npc_battle_mode: read_optional_string_field(
&snapshot.game_state,
"currentNpcBattleMode",
),
current_npc_battle_outcome: read_optional_string_field(
&snapshot.game_state,
"currentNpcBattleOutcome",
),
},
},
presentation: RuntimeStoryPresentation {
action_text: String::new(),
result_text: String::new(),
story_text,
options,
toast: None,
battle: None,
},
patches: Vec::new(),
snapshot,
}
}
fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
read_array_field(game_state, "companions")
.into_iter()
.filter_map(|entry| {
let npc_id = read_required_string_field(entry, "npcId")?;
Some(RuntimeStoryCompanionViewModel {
npc_id,
character_id: read_optional_string_field(entry, "characterId"),
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
})
})
.collect()
}
fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
let encounter = read_object_field(game_state, "currentEncounter")?;
let npc_name = read_required_string_field(encounter, "npcName")?;
let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
Some(RuntimeStoryEncounterViewModel {
id: encounter_id,
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
npc_name,
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
})
}
fn resolve_current_encounter_npc_state<'a>(
game_state: &'a Value,
encounter_id: &str,
npc_name: &str,
) -> Option<&'a Value> {
let npc_states = read_object_field(game_state, "npcStates")?;
npc_states
.get(encounter_id)
.or_else(|| npc_states.get(npc_name))
}
fn build_runtime_story_options(
current_story: Option<&Value>,
game_state: &Value,
) -> Vec<RuntimeStoryOptionView> {
if let Some(story) = current_story {
let prefers_deferred = read_required_string_field(story, "displayMode")
.is_some_and(|value| value == "dialogue")
&& !read_array_field(story, "deferredOptions").is_empty();
let source = if prefers_deferred {
read_array_field(story, "deferredOptions")
} else {
read_array_field(story, "options")
};
let compiled = source
.into_iter()
.filter_map(build_runtime_story_option_from_story_option)
.collect::<Vec<_>>();
if !compiled.is_empty() {
return compiled;
}
}
build_fallback_runtime_story_options(game_state)
}
fn build_runtime_story_option_from_story_option(value: &Value) -> Option<RuntimeStoryOptionView> {
let function_id = read_required_string_field(value, "functionId")?;
let action_text = read_required_string_field(value, "actionText")
.or_else(|| read_required_string_field(value, "text"))
.unwrap_or_else(|| function_id.clone());
Some(RuntimeStoryOptionView {
scope: infer_option_scope(function_id.as_str()).to_string(),
detail_text: read_optional_string_field(value, "detailText"),
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
payload: read_field(value, "runtimePayload").cloned(),
disabled: read_bool_field(value, "disabled"),
reason: read_optional_string_field(value, "disabledReason")
.or_else(|| read_optional_string_field(value, "reason")),
function_id,
action_text,
})
}
fn build_runtime_story_option_interaction(
value: Option<&Value>,
) -> Option<RuntimeStoryOptionInteraction> {
let interaction = value?;
match read_required_string_field(interaction, "kind")?.as_str() {
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
npc_id: read_required_string_field(interaction, "npcId")?,
action: read_required_string_field(interaction, "action")?,
quest_id: read_optional_string_field(interaction, "questId"),
}),
"treasure" => Some(RuntimeStoryOptionInteraction::Treasure {
action: read_required_string_field(interaction, "action")?,
}),
_ => None,
}
}
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return vec![
build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"),
build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"),
build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"),
];
}
let encounter = read_object_field(game_state, "currentEncounter");
if let Some(encounter) = encounter {
match read_required_string_field(encounter, "kind").as_deref() {
Some("npc") => {
let interaction_active =
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
if interaction_active {
return vec![
build_static_runtime_story_option("npc_chat", "继续交谈", "npc"),
build_static_runtime_story_option("npc_help", "请求援手", "npc"),
build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"),
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
];
}
return vec![
build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"),
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
];
}
Some("treasure") => {
return vec![
build_static_runtime_story_option("treasure_secure", "直接收取", "story"),
build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"),
build_static_runtime_story_option("treasure_leave", "先记下位置", "story"),
];
}
_ => {}
}
}
vec![
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
build_static_runtime_story_option("story_continue_adventure", "继续推进冒险", "story"),
]
}
fn build_static_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
function_id: function_id.to_string(),
action_text: action_text.to_string(),
detail_text: None,
scope: scope.to_string(),
interaction: None,
payload: None,
disabled: None,
reason: None,
}
}
fn infer_option_scope(function_id: &str) -> &'static str {
if function_id.starts_with("battle_") || function_id == "inventory_use" {
"combat"
} else if function_id.starts_with("npc_") {
"npc"
} else {
"story"
}
}
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
current_story.and_then(|story| read_optional_string_field(story, "text"))
}
fn build_fallback_story_text(game_state: &Value) -> String {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
let encounter_name = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
.unwrap_or_else(|| "眼前的敌人".to_string());
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
}
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
{
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
}
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
}
fn read_runtime_session_id(game_state: &Value) -> Option<String> {
read_optional_string_field(game_state, "runtimeSessionId")
}
fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
value.as_object()?.get(key)
}
fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let field = read_field(value, key)?;
field.is_object().then_some(field)
}
fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
read_field(value, key)
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
normalize_required_string(read_field(value, key)?.as_str()?)
}
fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
}
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
read_field(value, key).and_then(Value::as_bool)
}
fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
read_field(value, key)
.and_then(Value::as_i64)
.and_then(|number| i32::try_from(number).ok())
}
fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
read_field(value, key)
.and_then(Value::as_u64)
.and_then(|number| u32::try_from(number).ok())
}
fn normalize_required_string(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(normalize_required_string)
}
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn runtime_story_state_resolve_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/state/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"snapshot": {
"savedAt": "2026-04-22T12:00:00.000Z",
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main"
},
"currentStory": null
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn runtime_story_state_resolve_rejects_missing_snapshot() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/state/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"sessionId": "runtime-main"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn runtime_story_state_resolve_returns_compiled_snapshot_response() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/state/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"clientVersion": 7,
"snapshot": {
"savedAt": "2026-04-22T12:00:00.000Z",
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main",
"runtimeActionVersion": 7,
"playerHp": 32,
"playerMaxHp": 40,
"playerMana": 18,
"playerMaxMana": 20,
"inBattle": false,
"npcInteractionActive": true,
"currentEncounter": {
"id": "npc_camp_firekeeper",
"kind": "npc",
"npcName": "守火人",
"hostile": false
},
"npcStates": {
"npc_camp_firekeeper": {
"affinity": 12,
"recruited": false
}
},
"companions": [{
"npcId": "npc_companion_001",
"characterId": "char_companion_001",
"joinedAtAffinity": 64
}]
},
"currentStory": {
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。",
"displayMode": "dialogue",
"options": [{
"functionId": "story_continue_adventure",
"actionText": "继续冒险"
}],
"deferredOptions": [{
"functionId": "npc_chat",
"actionText": "继续交谈",
"detailText": "围绕当前话题继续推进关系判断。",
"interaction": {
"kind": "npc",
"npcId": "npc_camp_firekeeper",
"action": "chat"
},
"runtimePayload": {
"note": "server-runtime-test"
}
}]
}
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(payload["data"]["sessionId"], json!("runtime-main"));
assert_eq!(payload["data"]["serverVersion"], json!(7));
assert_eq!(
payload["data"]["viewModel"]["encounter"]["npcName"],
json!("守火人")
);
assert_eq!(
payload["data"]["viewModel"]["availableOptions"][0]["functionId"],
json!("npc_chat")
);
assert_eq!(
payload["data"]["presentation"]["options"][0]["interaction"]["npcId"],
json!("npc_camp_firekeeper")
);
assert_eq!(
payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"],
json!("npc_chat")
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_story_state_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_story_state".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("运行时剧情状态用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -1,6 +1,7 @@
use axum::http::HeaderMap;
use module_auth::RefreshSessionClientInfo;
use platform_auth::hash_refresh_session_token;
use shared_kernel::normalize_optional_string;
const X_CLIENT_TYPE_HEADER: &str = "x-client-type";
const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime";
@@ -104,17 +105,6 @@ fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_string();
if normalized.is_empty() {
return None;
}
Some(normalized)
})
}
fn normalize_client_type(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_ascii_lowercase();

View File

@@ -1,5 +1,6 @@
use std::{error::Error, fmt};
use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
@@ -7,6 +8,7 @@ use module_auth::{
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};
@@ -29,7 +31,10 @@ pub struct AppState {
wechat_auth_state_service: WechatAuthStateService,
wechat_auth_service: WechatAuthService,
wechat_provider: WechatProvider,
#[cfg_attr(not(test), allow(dead_code))]
ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient,
llm_client: Option<LlmClient>,
}
#[derive(Debug)]
@@ -37,6 +42,7 @@ pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
Oss(OssError),
Llm(LlmError),
}
impl AppState {
@@ -68,11 +74,14 @@ impl AppState {
let wechat_provider = build_wechat_provider(&config);
let refresh_session_service =
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
// AI 编排服务当前先挂接内存态 store后续再按 task table / procedure 接到 SpacetimeDB 真相源。
let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default());
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
server_url: config.spacetime_server_url.clone(),
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
});
let llm_client = build_llm_client(&config)?;
Ok(Self {
config,
@@ -86,7 +95,9 @@ impl AppState {
wechat_auth_state_service,
wechat_auth_service,
wechat_provider,
ai_task_service,
spacetime_client,
llm_client,
})
}
@@ -130,9 +141,18 @@ impl AppState {
&self.wechat_provider
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn ai_task_service(&self) -> &AiTaskService {
&self.ai_task_service
}
pub fn spacetime_client(&self) -> &SpacetimeClient {
&self.spacetime_client
}
pub fn llm_client(&self) -> Option<&LlmClient> {
self.llm_client.as_ref()
}
}
impl fmt::Display for AppStateInitError {
@@ -141,6 +161,7 @@ impl fmt::Display for AppStateInitError {
Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
Self::Llm(error) => write!(f, "{error}"),
}
}
}
@@ -165,6 +186,12 @@ impl From<OssError> for AppStateInitError {
}
}
impl From<LlmError> for AppStateInitError {
fn from(value: LlmError) -> Self {
Self::Llm(value)
}
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let has_any_oss_field = config.oss_bucket.is_some()
|| config.oss_endpoint.is_some()
@@ -188,3 +215,65 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
Ok(Some(OssClient::new(oss_config)))
}
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> {
let Some(api_key) = config
.llm_api_key
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let llm_config = LlmConfig::new(
config.llm_provider,
config.llm_base_url.clone(),
api_key.to_string(),
config.llm_model.clone(),
config.llm_request_timeout_ms,
config.llm_max_retries,
config.llm_retry_backoff_ms,
)?;
Ok(Some(LlmClient::new(llm_config)?))
}
#[cfg(test)]
mod tests {
use module_ai::{AiTaskKind, generate_ai_task_id};
use super::*;
#[test]
fn app_state_exposes_usable_ai_task_service() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let task_id = generate_ai_task_id(1_713_680_000_000_000);
let created = state
.ai_task_service()
.create_task(module_ai::AiTaskCreateInput {
task_id: task_id.clone(),
task_kind: AiTaskKind::StoryGeneration,
owner_user_id: "user_001".to_string(),
request_label: "营地开场".to_string(),
source_module: "story".to_string(),
source_entity_id: Some("storysess_001".to_string()),
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
stages: AiTaskKind::StoryGeneration.default_stage_blueprints(),
created_at_micros: 1_713_680_000_000_000,
})
.expect("ai task should create");
assert_eq!(created.task_id, task_id);
assert_eq!(created.task_kind, AiTaskKind::StoryGeneration);
assert_eq!(created.stages.len(), 4);
}
#[test]
fn app_state_skips_llm_client_when_api_key_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(state.llm_client().is_none());
}
}

View File

@@ -0,0 +1,829 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
use module_combat::{
BattleMode, BattleStateInput, ResolveCombatActionInput, generate_battle_state_id,
};
use module_npc::{NPC_FIGHT_FUNCTION_ID, NPC_SPAR_FUNCTION_ID, ResolveNpcInteractionInput};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
use spacetime_client::{ResolveNpcBattleInteractionInput, SpacetimeClientError};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateStoryBattleRequest {
pub story_session_id: String,
pub runtime_session_id: String,
#[serde(default)]
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: String,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
#[serde(default)]
pub experience_reward: u32,
#[serde(default)]
pub reward_items: Vec<StoryBattleRewardItemRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveStoryBattleRequest {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateStoryNpcBattleRequest {
pub story_session_id: String,
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub interaction_function_id: String,
#[serde(default)]
pub release_npc_id: Option<String>,
#[serde(default)]
pub battle_state_id: Option<String>,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
#[serde(default)]
pub experience_reward: u32,
#[serde(default)]
pub reward_items: Vec<StoryBattleRewardItemRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoryBattleRewardItemRequest {
pub item_id: String,
pub category: String,
pub item_name: String,
#[serde(default)]
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
#[serde(default)]
pub tags: Vec<String>,
pub stackable: bool,
#[serde(default)]
pub stack_key: String,
#[serde(default)]
pub equipment_slot_id: Option<String>,
}
pub async fn create_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateStoryBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let battle_mode = parse_battle_mode_strict(&payload.battle_mode).ok_or_else(|| {
story_battles_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-battle",
"message": "battleMode 仅支持 fight 或 spar",
})),
)
})?;
let reward_items =
parse_story_battle_reward_items(&payload.reward_items).map_err(|message| {
story_battles_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-battle",
"message": message,
})),
)
})?;
let result = state
.spacetime_client()
.create_battle_state(BattleStateInput {
battle_state_id: generate_battle_state_id(now_micros),
story_session_id: payload.story_session_id,
runtime_session_id: payload.runtime_session_id,
actor_user_id,
chapter_id: payload.chapter_id,
target_npc_id: payload.target_npc_id,
target_name: payload.target_name,
battle_mode,
player_hp: payload.player_hp,
player_max_hp: payload.player_max_hp,
player_mana: payload.player_mana,
player_max_mana: payload.player_max_mana,
target_hp: payload.target_hp,
target_max_hp: payload.target_max_hp,
experience_reward: payload.experience_reward,
reward_items,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result),
}),
))
}
pub async fn resolve_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let result = state
.spacetime_client()
.resolve_combat_action(ResolveCombatActionInput {
battle_state_id: payload.battle_state_id,
function_id: payload.function_id,
action_text: payload.action_text,
base_damage: payload.base_damage,
mana_cost: payload.mana_cost,
heal: payload.heal,
mana_restore: payload.mana_restore,
counter_multiplier_basis_points: payload.counter_multiplier_basis_points,
updated_at_micros: now_micros,
})
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result.battle_state),
"combat": {
"damageDealt": result.damage_dealt,
"damageTaken": result.damage_taken,
"outcome": result.outcome,
}
}),
))
}
pub async fn get_story_battle_state(
State(state): State<AppState>,
Path(battle_state_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let result = state
.spacetime_client()
.get_battle_state(battle_state_id)
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result),
}),
))
}
pub async fn create_story_npc_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateStoryNpcBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let interaction_function_id =
parse_npc_battle_interaction_function_id_strict(&payload.interaction_function_id)
.ok_or_else(|| {
story_battles_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-npc-battle",
"message": "interactionFunctionId 仅支持 npc_fight 或 npc_spar",
})),
)
})?;
let reward_items =
parse_story_battle_reward_items(&payload.reward_items).map_err(|message| {
story_battles_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-npc-battle",
"message": message,
})),
)
})?;
let result = state
.spacetime_client()
.resolve_npc_battle_interaction(ResolveNpcBattleInteractionInput {
npc_interaction: ResolveNpcInteractionInput {
runtime_session_id: payload.runtime_session_id,
npc_id: payload.npc_id,
npc_name: payload.npc_name,
interaction_function_id,
release_npc_id: payload.release_npc_id,
updated_at_micros: now_micros,
},
story_session_id: payload.story_session_id,
actor_user_id,
battle_state_id: payload.battle_state_id,
player_hp: payload.player_hp,
player_max_hp: payload.player_max_hp,
player_mana: payload.player_mana,
player_max_mana: payload.player_max_mana,
target_hp: payload.target_hp,
target_max_hp: payload.target_max_hp,
experience_reward: payload.experience_reward,
reward_items,
})
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
json!({
"npcInteraction": build_npc_interaction_payload(&result.npc_interaction),
"battleState": build_battle_state_payload(&result.battle_state),
}),
))
}
fn build_battle_state_payload(record: &spacetime_client::BattleStateRecord) -> Value {
json!({
"battleStateId": record.battle_state_id,
"storySessionId": record.story_session_id,
"runtimeSessionId": record.runtime_session_id,
"actorUserId": record.actor_user_id,
"chapterId": record.chapter_id,
"targetNpcId": record.target_npc_id,
"targetName": record.target_name,
"battleMode": record.battle_mode,
"status": record.status,
"playerHp": record.player_hp,
"playerMaxHp": record.player_max_hp,
"playerMana": record.player_mana,
"playerMaxMana": record.player_max_mana,
"targetHp": record.target_hp,
"targetMaxHp": record.target_max_hp,
"experienceReward": record.experience_reward,
"rewardItems": record.reward_items.iter().map(|item| {
json!({
"itemId": item.item_id,
"category": item.category,
"itemName": item.item_name,
"description": item.description,
"quantity": item.quantity,
"rarity": format_runtime_item_reward_item_rarity(item.rarity),
"tags": item.tags,
"stackable": item.stackable,
"stackKey": item.stack_key,
"equipmentSlotId": item
.equipment_slot_id
.map(format_runtime_item_equipment_slot),
})
}).collect::<Vec<_>>(),
"turnIndex": record.turn_index,
"lastActionFunctionId": record.last_action_function_id,
"lastActionText": record.last_action_text,
"lastResultText": record.last_result_text,
"lastDamageDealt": record.last_damage_dealt,
"lastDamageTaken": record.last_damage_taken,
"lastOutcome": record.last_outcome,
"version": record.version,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
})
}
fn format_runtime_item_reward_item_rarity(
value: module_runtime_item::RuntimeItemRewardItemRarity,
) -> &'static str {
match value {
module_runtime_item::RuntimeItemRewardItemRarity::Common => "common",
module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => "uncommon",
module_runtime_item::RuntimeItemRewardItemRarity::Rare => "rare",
module_runtime_item::RuntimeItemRewardItemRarity::Epic => "epic",
module_runtime_item::RuntimeItemRewardItemRarity::Legendary => "legendary",
}
}
fn format_runtime_item_equipment_slot(
value: module_runtime_item::RuntimeItemEquipmentSlot,
) -> &'static str {
match value {
module_runtime_item::RuntimeItemEquipmentSlot::Weapon => "weapon",
module_runtime_item::RuntimeItemEquipmentSlot::Armor => "armor",
module_runtime_item::RuntimeItemEquipmentSlot::Relic => "relic",
}
}
fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> Value {
json!({
"npcStateId": record.npc_state_id,
"runtimeSessionId": record.runtime_session_id,
"npcId": record.npc_id,
"npcName": record.npc_name,
"affinity": record.affinity,
"relationStance": record.relation_stance,
"helpUsed": record.help_used,
"chattedCount": record.chatted_count,
"giftsGiven": record.gifts_given,
"recruited": record.recruited,
"tradeStockSignature": record.trade_stock_signature,
"revealedFacts": record.revealed_facts,
"knownAttributeRumors": record.known_attribute_rumors,
"firstMeaningfulContactResolved": record.first_meaningful_contact_resolved,
"seenBackstoryChapterIds": record.seen_backstory_chapter_ids,
"stanceProfile": {
"trust": record.trust,
"warmth": record.warmth,
"ideologicalFit": record.ideological_fit,
"fearOrGuard": record.fear_or_guard,
"loyalty": record.loyalty,
"currentConflictTag": record.current_conflict_tag,
"recentApprovals": record.recent_approvals,
"recentDisapprovals": record.recent_disapprovals,
},
"createdAt": record.created_at,
"updatedAt": record.updated_at,
})
}
fn build_npc_interaction_payload(record: &spacetime_client::NpcInteractionRecord) -> Value {
json!({
"npcState": build_npc_state_payload(&record.npc_state),
"interactionStatus": record.interaction_status,
"actionText": record.action_text,
"resultText": record.result_text,
"storyText": record.story_text,
"battleMode": record.battle_mode,
"encounterClosed": record.encounter_closed,
"affinityChanged": record.affinity_changed,
"previousAffinity": record.previous_affinity,
"nextAffinity": record.next_affinity,
})
}
fn parse_battle_mode_strict(raw: &str) -> Option<BattleMode> {
match raw.trim() {
"fight" => Some(BattleMode::Fight),
"spar" => Some(BattleMode::Spar),
_ => None,
}
}
fn parse_npc_battle_interaction_function_id_strict(raw: &str) -> Option<String> {
match raw.trim() {
NPC_FIGHT_FUNCTION_ID => Some(NPC_FIGHT_FUNCTION_ID.to_string()),
NPC_SPAR_FUNCTION_ID => Some(NPC_SPAR_FUNCTION_ID.to_string()),
_ => None,
}
}
fn parse_story_battle_reward_items(
values: &[StoryBattleRewardItemRequest],
) -> Result<Vec<module_runtime_item::RuntimeItemRewardItemSnapshot>, String> {
values.iter().map(parse_story_battle_reward_item).collect()
}
fn parse_story_battle_reward_item(
value: &StoryBattleRewardItemRequest,
) -> Result<module_runtime_item::RuntimeItemRewardItemSnapshot, String> {
Ok(module_runtime_item::RuntimeItemRewardItemSnapshot {
item_id: normalize_required_string(&value.item_id)
.ok_or_else(|| "battleState.rewardItems[].itemId 不能为空".to_string())?,
category: normalize_required_string(&value.category)
.ok_or_else(|| "battleState.rewardItems[].category 不能为空".to_string())?,
item_name: normalize_required_string(&value.item_name)
.ok_or_else(|| "battleState.rewardItems[].itemName 不能为空".to_string())?,
description: normalize_optional_string(value.description.clone()),
quantity: value.quantity,
rarity: parse_runtime_item_reward_item_rarity(&value.rarity)?,
tags: normalize_string_list(value.tags.clone()),
stackable: value.stackable,
stack_key: value.stack_key.trim().to_string(),
equipment_slot_id: value
.equipment_slot_id
.as_deref()
.map(parse_runtime_item_equipment_slot)
.transpose()?,
})
}
fn parse_runtime_item_reward_item_rarity(
raw: &str,
) -> Result<module_runtime_item::RuntimeItemRewardItemRarity, String> {
match raw.trim() {
"common" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Common),
"uncommon" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Uncommon),
"rare" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Rare),
"epic" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Epic),
"legendary" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Legendary),
_ => Err(
"battleState.rewardItems[].rarity 仅支持 common/uncommon/rare/epic/legendary"
.to_string(),
),
}
}
fn parse_runtime_item_equipment_slot(
raw: &str,
) -> Result<module_runtime_item::RuntimeItemEquipmentSlot, String> {
match raw.trim() {
"weapon" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Weapon),
"armor" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Armor),
"relic" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Relic),
_ => Err("battleState.rewardItems[].equipmentSlotId 仅支持 weapon/armor/relic".to_string()),
}
}
fn map_story_battle_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn story_battles_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn create_story_battle_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/battles")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"runtimeSessionId": "runtime_001",
"targetNpcId": "npc_001",
"targetName": "黑爪狼",
"battleMode": "fight",
"playerHp": 60,
"playerMaxHp": 60,
"playerMana": 20,
"playerMaxMana": 20,
"targetHp": 30,
"targetMaxHp": 30
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_story_npc_battle_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/npc/battle")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"runtimeSessionId": "runtime_001",
"npcId": "npc_001",
"npcName": "试剑门徒",
"interactionFunctionId": "npc_fight",
"playerHp": 60,
"playerMaxHp": 60,
"playerMana": 20,
"playerMaxMana": 20,
"targetHp": 30,
"targetMaxHp": 30
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_story_battle_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/battles")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"runtimeSessionId": "runtime_001",
"targetNpcId": "npc_001",
"targetName": "黑爪狼",
"battleMode": "fight",
"playerHp": 60,
"playerMaxHp": 60,
"playerMana": 20,
"playerMaxMana": 20,
"targetHp": 30,
"targetMaxHp": 30
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn create_story_npc_battle_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/npc/battle")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"runtimeSessionId": "runtime_001",
"npcId": "npc_001",
"npcName": "试剑门徒",
"interactionFunctionId": "npc_fight",
"playerHp": 60,
"playerMaxHp": 60,
"playerMana": 20,
"playerMaxMana": 20,
"targetHp": 30,
"targetMaxHp": 30
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn get_story_battle_state_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/battles/battle_001")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/battles/battle_001")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn resolve_story_battle_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/battles/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"battleStateId": "battle_001",
"functionId": "battle_attack_basic",
"actionText": "普通攻击",
"baseDamage": 10,
"manaCost": 0,
"heal": 0,
"manaRestore": 0,
"counterMultiplierBasisPoints": 10000
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "story_battles_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_story_battles".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("战斗接口用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,416 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::story::{
BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload,
StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn begin_story_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BeginStorySessionRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.begin_story_session(
module_story::generate_story_session_id(now_micros),
payload.runtime_session_id,
actor_user_id,
payload.world_profile_id,
payload.initial_prompt,
payload.opening_summary,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
StorySessionMutationResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_event: StoryEventPayload {
event_id: result.event.event_id,
story_session_id: result.event.story_session_id,
event_kind: result.event.event_kind,
narrative_text: result.event.narrative_text,
choice_function_id: result.event.choice_function_id,
created_at: result.event.created_at,
},
},
))
}
pub async fn continue_story(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ContinueStoryRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let result = state
.spacetime_client()
.continue_story(
payload.story_session_id,
module_story::generate_story_event_id(now_micros),
payload.narrative_text,
payload.choice_function_id,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
StorySessionMutationResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_event: StoryEventPayload {
event_id: result.event.event_id,
story_session_id: result.event.story_session_id,
event_kind: result.event.event_kind,
narrative_text: result.event.narrative_text,
choice_function_id: result.event.choice_function_id,
created_at: result.event.created_at,
},
},
))
}
pub async fn get_story_session_state(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let result = state
.spacetime_client()
.get_story_session_state(story_session_id)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
StorySessionStateResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_events: result
.events
.into_iter()
.map(|event| StoryEventPayload {
event_id: event.event_id,
story_session_id: event.story_session_id,
event_kind: event.event_kind,
narrative_text: event.narrative_text,
choice_function_id: event.choice_function_id,
created_at: event.created_at,
})
.collect(),
},
))
}
fn map_story_session_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn story_sessions_error_response(request_context: &RequestContext, error: AppError) -> Response {
// story session 路由需要保留 request_context确保错误 envelope 与 requestId 一致。
error.into_response_with_context(Some(request_context))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn begin_story_session_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions")
.header("content-type", "application/json")
.body(Body::from(
json!({
"runtimeSessionId": "runtime_001",
"worldProfileId": "profile_001",
"initialPrompt": "进入营地",
"openingSummary": "营地开场"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn begin_story_session_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"runtimeSessionId": "runtime_001",
"worldProfileId": "profile_001",
"initialPrompt": "进入营地",
"openingSummary": "营地开场"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/continue")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"narrativeText": "你看见篝火边有人招手。",
"choiceFunctionId": "talk_to_npc"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn get_story_session_state_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/sessions/storysess_001/state")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_session_state_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/sessions/storysess_001/state")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "story_sessions_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_story_sessions".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("故事会话用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -8,7 +8,10 @@ use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
WechatAuthScene,
};
use serde::{Deserialize, Serialize};
use shared_contracts::auth::{
AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
WechatStartQuery, WechatStartResponse,
};
use time::OffsetDateTime;
use url::Url;
@@ -19,45 +22,11 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
password_entry::PasswordEntryUserPayload,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatStartQuery {
pub redirect_path: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatStartResponse {
pub authorization_url: String,
}
#[derive(Debug, Deserialize)]
pub struct WechatCallbackQuery {
pub state: Option<String>,
pub code: Option<String>,
pub mock_code: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatBindPhoneRequest {
pub phone: String,
pub code: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatBindPhoneResponse {
pub token: String,
pub user: PasswordEntryUserPayload,
}
pub async fn start_wechat_login(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -230,13 +199,13 @@ pub async fn bind_wechat_phone(
Some(&request_context),
WechatBindPhoneResponse {
token: signed_session.access_token,
user: PasswordEntryUserPayload {
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str(),
binding_status: result.user.binding_status.as_str(),
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
},

View File

@@ -0,0 +1,15 @@
[package]
name = "module-ai"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-kernel = { path = "../shared-kernel" }
spacetimedb = { workspace = true, optional = true }

View File

@@ -1,29 +1,67 @@
# module-ai 独立模块 package 占位说明
# module-ai 模块说明
日期:`2026-04-20`
日期:`2026-04-21`
## 1. package 职责
`module-ai` 是 AI 编排模块 package后续负责:
`module-ai` 是 AI 编排模块 crate当前已经落地首版领域基座负责:
1. 剧情、聊天、自定义世界、运行时物品等生成型流程的模块级编排
2. prompt 组织、阶段状态、结果引用与模块间协同
3. `apps/api-server` 的流式输出与兼容接口对接
4. `apps/spacetime-module` 的任务状态、结果引用聚合对接
1. 统一 AI 任务类型、任务状态、阶段状态与任务快照
2. 统一流式文本片段、阶段输出、结果引用与最终结果聚合
3. `api-server` 与后续 `platform-llm` 接线提供稳定的模块领域服务接口
4. `spacetime-module` 映射 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 提供稳定类型基础
## 2. 当前阶段说明
当前提交完成目录占位,不提前进入模型调用、流式编排与结果回写实现。
当前提交完成
后续与本 package 直接相关的任务包括:
1. `module-ai``Cargo.toml`
2. 首版核心类型:
- `AiTaskKind`
- `AiTaskStatus`
- `AiTaskStageKind`
- `AiTaskSnapshot`
- `AiTextChunkSnapshot`
- `AiResultReferenceSnapshot`
3. 默认阶段蓝图与 ID 前缀
4. `InMemoryAiTaskStore`
5. `AiTaskService`
6. 面向 `SpacetimeDB` 的输入类型与 ID helper
- `AiTaskStartInput`
- `AiTaskStageStartInput`
- `AiTextChunkAppendInput`
- `AiResultReferenceInput`
- `AiTaskFinishInput`
- `AiTaskCancelInput`
- `AiTaskFailureInput`
7. 基础单元测试
1. 设计多模型编排与任务状态抽象
2. 对齐剧情、聊天、自定义世界等生成链路
3. 对齐流式输出、阶段事件与兼容响应结构
4. 接入模块级结果回写与任务引用绑定
首版详细设计见:
## 3. 边界约束
1. [../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md)
1. `module-ai` 负责生成型流程的模块级编排,不把供应商 SDK 直接散落到各业务模块里。
2. 实际模型接入通过 `packages/platform-llm` 完成,状态与结果引用最终回写到 `apps/spacetime-module` 聚合的状态模型中。
3. 前端兼容 REST 与 SSE 由 `apps/api-server` 暴露,但 AI 编排过程不能再次退回单个大 orchestrator 的黑盒写法。
## 3. 当前仍未进入的范围
当前刻意未进入:
1. 真实供应商 SDK 与模型请求
2. SSE 协议输出
3. 任务订阅 projection 与清理调度
4. 业务模块自己的 prompt 组装实现
这些后续分别由:
1. `platform-llm`
2. `api-server`
3. `spacetime-module + spacetime-client`
4. `module-story` / `module-npc` / `module-custom-world` / `module-quest` / `module-runtime-item`
继续接入。
## 4. 边界约束
1. `module-ai` 只负责生成型流程的模块级编排领域模型与最小服务,不直接承接供应商 HTTP 适配。
2. 真实模型接入通过 `platform-llm` 完成,任务真相状态最终应下沉到 `spacetime-module`
3. `api-server` 负责 REST / SSE 对外协议,`module-ai` 不返回 HTTP DTO。

File diff suppressed because it is too large Load Diff

View File

@@ -14,3 +14,4 @@ serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true }
spacetimedb = { workspace = true, optional = true }
platform-oss = { path = "../platform-oss", optional = true }
shared-kernel = { path = "../shared-kernel" }

View File

@@ -1,6 +1,10 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_seed_id, format_timestamp_micros, normalize_optional_string,
normalize_required_string,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
@@ -175,6 +179,28 @@ impl AssetObjectAccessPolicy {
}
}
// 资产核心对象字段需要继续保留模块自己的错误语义,但基础必填字符串归一化统一走 shared-kernel。
fn normalize_required_asset_field(
value: impl AsRef<str>,
error: AssetObjectFieldError,
) -> Result<String, AssetObjectFieldError> {
normalize_required_string(value).ok_or(error)
}
fn normalize_asset_object_key(value: impl AsRef<str>) -> Result<String, AssetObjectFieldError> {
let normalized = value.as_ref().trim();
let normalized = normalized.trim_start_matches('/');
normalize_required_string(normalized).ok_or(AssetObjectFieldError::MissingObjectKey)
}
fn validate_asset_object_version(version: u32) -> Result<(), AssetObjectFieldError> {
if version == 0 {
return Err(AssetObjectFieldError::InvalidVersion);
}
Ok(())
}
// bucket 与 object_key 是正式真相字段,因此这里只做字段校验,不回退成单字符串路径字段。
pub fn validate_asset_object_fields(
bucket: &str,
@@ -182,22 +208,10 @@ pub fn validate_asset_object_fields(
asset_kind: &str,
version: u32,
) -> Result<(), AssetObjectFieldError> {
if bucket.trim().is_empty() {
return Err(AssetObjectFieldError::MissingBucket);
}
if object_key.trim().trim_start_matches('/').is_empty() {
return Err(AssetObjectFieldError::MissingObjectKey);
}
if asset_kind.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetKind);
}
if version == 0 {
return Err(AssetObjectFieldError::InvalidVersion);
}
normalize_required_asset_field(bucket, AssetObjectFieldError::MissingBucket)?;
normalize_asset_object_key(object_key)?;
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
validate_asset_object_version(version)?;
Ok(())
}
@@ -210,30 +224,12 @@ pub fn validate_asset_entity_binding_fields(
slot: &str,
asset_kind: &str,
) -> Result<(), AssetObjectFieldError> {
if binding_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingBindingId);
}
if asset_object_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetObjectId);
}
if entity_kind.trim().is_empty() {
return Err(AssetObjectFieldError::MissingEntityKind);
}
if entity_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingEntityId);
}
if slot.trim().is_empty() {
return Err(AssetObjectFieldError::MissingSlot);
}
if asset_kind.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetKind);
}
normalize_required_asset_field(binding_id, AssetObjectFieldError::MissingBindingId)?;
normalize_required_asset_field(asset_object_id, AssetObjectFieldError::MissingAssetObjectId)?;
normalize_required_asset_field(entity_kind, AssetObjectFieldError::MissingEntityKind)?;
normalize_required_asset_field(entity_id, AssetObjectFieldError::MissingEntityId)?;
normalize_required_asset_field(slot, AssetObjectFieldError::MissingSlot)?;
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
Ok(())
}
@@ -253,21 +249,20 @@ pub fn build_asset_object_upsert_input(
entity_id: Option<String>,
updated_at_micros: i64,
) -> Result<AssetObjectUpsertInput, AssetObjectFieldError> {
if asset_object_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetObjectId);
}
validate_asset_object_fields(
&bucket,
&object_key,
&asset_kind,
INITIAL_ASSET_OBJECT_VERSION,
let asset_object_id = normalize_required_asset_field(
asset_object_id,
AssetObjectFieldError::MissingAssetObjectId,
)?;
let bucket = normalize_required_asset_field(bucket, AssetObjectFieldError::MissingBucket)?;
let object_key = normalize_asset_object_key(object_key)?;
let asset_kind =
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
validate_asset_object_version(INITIAL_ASSET_OBJECT_VERSION)?;
Ok(AssetObjectUpsertInput {
asset_object_id: asset_object_id.trim().to_string(),
bucket: bucket.trim().to_string(),
object_key: object_key.trim().trim_start_matches('/').to_string(),
asset_object_id,
bucket,
object_key,
access_policy,
content_type: normalize_optional_value(content_type),
content_length,
@@ -277,7 +272,7 @@ pub fn build_asset_object_upsert_input(
owner_user_id: normalize_optional_value(owner_user_id),
profile_id: normalize_optional_value(profile_id),
entity_id: normalize_optional_value(entity_id),
asset_kind: asset_kind.trim().to_string(),
asset_kind,
updated_at_micros,
})
}
@@ -314,22 +309,27 @@ pub fn build_asset_entity_binding_input(
profile_id: Option<String>,
updated_at_micros: i64,
) -> Result<AssetEntityBindingInput, AssetObjectFieldError> {
validate_asset_entity_binding_fields(
&binding_id,
&asset_object_id,
&entity_kind,
&entity_id,
&slot,
&asset_kind,
let binding_id =
normalize_required_asset_field(binding_id, AssetObjectFieldError::MissingBindingId)?;
let asset_object_id = normalize_required_asset_field(
asset_object_id,
AssetObjectFieldError::MissingAssetObjectId,
)?;
let entity_kind =
normalize_required_asset_field(entity_kind, AssetObjectFieldError::MissingEntityKind)?;
let entity_id =
normalize_required_asset_field(entity_id, AssetObjectFieldError::MissingEntityId)?;
let slot = normalize_required_asset_field(slot, AssetObjectFieldError::MissingSlot)?;
let asset_kind =
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
Ok(AssetEntityBindingInput {
binding_id: binding_id.trim().to_string(),
asset_object_id: asset_object_id.trim().to_string(),
entity_kind: entity_kind.trim().to_string(),
entity_id: entity_id.trim().to_string(),
slot: slot.trim().to_string(),
asset_kind: asset_kind.trim().to_string(),
binding_id,
asset_object_id,
entity_kind,
entity_id,
slot,
asset_kind,
owner_user_id: normalize_optional_value(owner_user_id),
profile_id: normalize_optional_value(profile_id),
updated_at_micros,
@@ -354,24 +354,15 @@ pub fn build_asset_entity_binding_record(
}
pub fn generate_asset_object_id(seed_micros: i64) -> String {
format!("{}{:x}", ASSET_OBJECT_ID_PREFIX, seed_micros)
build_prefixed_seed_id(ASSET_OBJECT_ID_PREFIX, seed_micros)
}
pub fn generate_asset_binding_id(seed_micros: i64) -> String {
format!("{}{:x}", ASSET_BINDING_ID_PREFIX, seed_micros)
build_prefixed_seed_id(ASSET_BINDING_ID_PREFIX, seed_micros)
}
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
})
}
fn format_timestamp_micros(micros: i64) -> String {
let seconds = micros.div_euclid(1_000_000);
let subsec_micros = micros.rem_euclid(1_000_000);
format!("{seconds}.{subsec_micros:06}Z")
normalize_optional_string(value)
}
impl fmt::Display for AssetObjectFieldError {

View File

@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
platform-auth = { path = "../platform-auth" }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["formatting", "parsing"] }
uuid = { version = "1", features = ["v4"] }

View File

@@ -6,8 +6,11 @@ use std::{
};
use platform_auth::{hash_password, verify_password};
use shared_kernel::{
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
normalize_optional_string, normalize_required_string, parse_rfc3339,
};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
const USERNAME_MIN_LENGTH: usize = 3;
const USERNAME_MAX_LENGTH: usize = 24;
@@ -463,22 +466,14 @@ impl RefreshSessionService {
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let session_id = format!("usess_{}", Uuid::new_v4().simple());
let session_id = build_prefixed_uuid_id("usess_");
let expires_at = now
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
.ok_or_else(|| {
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
})?;
let now_iso = now
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
})?;
let expires_at_iso = expires_at
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
})?;
let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?;
let expires_at_iso = format_rfc3339_with_context(expires_at, "refresh session 过期时间")?;
let session = RefreshSessionRecord {
session_id,
user_id: input.user_id,
@@ -502,10 +497,9 @@ impl RefreshSessionService {
input: RotateRefreshSessionInput,
now: OffsetDateTime,
) -> Result<RotateRefreshSessionResult, RefreshSessionError> {
let refresh_token_hash = input.refresh_token_hash.trim().to_string();
if refresh_token_hash.is_empty() {
let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else {
return Err(RefreshSessionError::MissingToken);
}
};
let session = self
.store
@@ -516,13 +510,8 @@ impl RefreshSessionService {
return Err(RefreshSessionError::SessionNotFound);
}
let expires_at = OffsetDateTime::parse(
&session.session.expires_at,
&time::format_description::well_known::Rfc3339,
)
.map_err(|error| {
RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}"))
})?;
let expires_at =
parse_rfc3339_with_context(&session.session.expires_at, "refresh session 过期时间")?;
if expires_at <= now {
return Err(RefreshSessionError::SessionExpired);
}
@@ -538,16 +527,9 @@ impl RefreshSessionService {
.ok_or_else(|| {
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
})?;
let now_iso = now
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
})?;
let next_expires_at_iso = next_expires_at
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
})?;
let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?;
let next_expires_at_iso =
format_rfc3339_with_context(next_expires_at, "refresh session 过期时间")?;
let updated_session = self.store.rotate_session(
&session.session.session_id,
@@ -719,9 +701,9 @@ impl WechatAuthStateService {
WechatAuthError::Store(format!("微信 state 过期时间格式化失败:{message}"))
})?;
let state = WechatAuthStateRecord {
wechat_state_id: format!("wxstate_{}", Uuid::new_v4().simple()),
wechat_state_id: build_prefixed_uuid_id("wxstate_"),
state_token: create_wechat_state_token(),
redirect_path: input.redirect_path.trim().to_string(),
redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(),
scene: input.scene,
request_user_agent: normalize_optional_string(input.request_user_agent),
expires_at,
@@ -1035,7 +1017,7 @@ impl InMemoryAuthStore {
);
let identity = StoredWechatIdentity {
user_id: user_id.clone(),
provider_uid: profile.provider_uid.trim().to_string(),
provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
provider_union_id: normalize_optional_string(profile.provider_union_id),
display_name: normalize_optional_string(profile.display_name),
avatar_url: normalize_optional_string(profile.avatar_url),
@@ -1100,7 +1082,8 @@ impl InMemoryAuthStore {
let next_display_name = normalize_optional_string(profile.display_name);
let next_avatar_url = normalize_optional_string(profile.avatar_url);
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
let next_provider_uid = profile.provider_uid.trim().to_string();
let next_provider_uid =
normalize_required_string(&profile.provider_uid).unwrap_or_default();
{
let identity = state
.wechat_identity_by_provider_uid
@@ -1717,7 +1700,7 @@ fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError
}
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
let username = raw_username.trim().to_string();
let username = normalize_required_string(raw_username).unwrap_or_default();
let valid_length =
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
let valid_chars = username
@@ -1775,21 +1758,11 @@ fn mask_phone_number(phone_number: &str) -> String {
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|field| {
let trimmed = field.trim().to_string();
if trimmed.is_empty() {
return None;
}
Some(trimmed)
})
}
fn build_random_password_seed() -> String {
format!(
"seed_{}_{}",
Uuid::new_v4().simple(),
Uuid::new_v4().simple()
new_uuid_simple_string(),
new_uuid_simple_string()
)
}
@@ -1798,13 +1771,11 @@ fn build_system_username(prefix: &str, sequence: u64) -> String {
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
value
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| error.to_string())
format_shared_rfc3339(value)
}
fn parse_phone_code_time(value: &str, field_label: &str) -> Result<OffsetDateTime, PhoneAuthError> {
OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
parse_rfc3339(value)
.map_err(|error| PhoneAuthError::Store(format!("短信验证码{field_label}解析失败:{error}")))
}
@@ -1818,7 +1789,23 @@ fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
}
fn create_wechat_state_token() -> String {
Uuid::new_v4().simple().to_string()
new_uuid_simple_string()
}
fn format_rfc3339_with_context(
value: OffsetDateTime,
field_label: &str,
) -> Result<String, RefreshSessionError> {
format_shared_rfc3339(value)
.map_err(|error| RefreshSessionError::Store(format!("{field_label}格式化失败:{error}")))
}
fn parse_rfc3339_with_context(
value: &str,
field_label: &str,
) -> Result<OffsetDateTime, RefreshSessionError> {
parse_rfc3339(value)
.map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}")))
}
impl PhoneAuthScene {

View File

@@ -0,0 +1,15 @@
[package]
name = "module-combat"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
module-runtime-item = { path = "../module-runtime-item", default-features = false }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
spacetimedb = { workspace = true, optional = true }

View File

@@ -1,29 +1,47 @@
# module-combat 独立模块 package 占位说明
# module-combat
日期:`2026-04-20`
日期:`2026-04-21`
## 1. package 职责
`module-combat`战斗规则模块 package后续负责:
`module-combat` M4 阶段的战斗规则 crate当前负责:
1. `battle_state` 等战斗状态模型
2. 战斗指令、伤害结算、战斗阶段推进规则
3. 与 story action 主循环的战斗联动
4. `apps/spacetime-module` 的战斗表、reducer、view 聚合对接
1. `battle_state` 首版领域类型与字段校验
2. `resolve_combat_action` 的纯规则推进
3. `fight / spar` 两种模式下的战斗收束规则
4. `spacetime-module` 提供可直接复用的战斗状态与 reducer 输入输出类型
## 2. 当前阶段说明
## 2. 当前实现范围
当前提交仅完成目录占位,不提前进入具体战斗规则与数值实现。
当前已经真实落地:
后续与本 package 直接相关的任务包括:
1. `BattleMode / BattleStatus / CombatOutcome`
2. `BattleStateInput / BattleStateSnapshot / BattleStateQueryInput`
3. `ResolveCombatActionInput / ResolveCombatActionResult`
4. `BattleStateProcedureResult / ResolveCombatActionProcedureResult`
5. `battle_attack_basic / battle_recover_breath / battle_use_skill / battle_escape_breakout`
6. 旧攻击类 function 的兼容解析
7. `chapter_id / experience_reward` 最小承载字段,供 `spacetime-module` 在胜利时联动成长结算
1. 设计 `battle_state`
2. 设计 `resolve_combat_action`
3. 对齐 battle 结果与兼容响应结构
4. 接入 story 主循环的战斗型 action 结算
当前刻意未做:
## 3. 边界约束
1. `inventory_use`
2. 掉落、好感、任务信号联动
3. story AI 续写触发
4. 多目标或完整 build / cooldown 真相建模
1. `module-combat` 保持纯规则、纯状态计算,不直接承接 HTTP、LLM、OSS 或其他外部副作用。
2. 战斗联动通过明确 reducer 与模块边界协作,不回到散落在多个 service 的过程式写法。
3. 前端兼容输出由 `apps/api-server` 暴露,战斗真相由 `apps/spacetime-module` 聚合。
## 3. 配套文档
落地依据见:
1. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
2. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md)
4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
5. [../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md)
## 4. 边界约束
1. `module-combat` 只做纯规则、纯状态推进,不承接 HTTP、LLM、OSS 或文件 IO。
2. 任何与 `inventory / progression / npc / story` 的联动,都应先在文档里冻结边界后再继续接入。
3. 该 crate 的目标不是替代 Axum facade而是成为 `spacetime-module` 里的战斗真相规则层。

View File

@@ -0,0 +1,835 @@
use std::{error::Error, fmt};
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
use serde::{Deserialize, Serialize};
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const BATTLE_STATE_ID_PREFIX: &str = "battle_";
pub const INITIAL_BATTLE_VERSION: u32 = 1;
pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14;
pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4;
pub const SPAR_MIN_HP: i32 = 1;
const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [
"battle_all_in_crush",
"battle_guard_break",
"battle_probe_pressure",
"battle_feint_step",
"battle_finisher_window",
];
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleMode {
Fight,
Spar,
}
impl BattleMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fight => "fight",
Self::Spar => "spar",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleStatus {
Ongoing,
Resolved,
Aborted,
}
impl BattleStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Resolved => "resolved",
Self::Aborted => "aborted",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatOutcome {
Ongoing,
Victory,
SparComplete,
Escaped,
}
impl CombatOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Victory => "victory",
Self::SparComplete => "spar_complete",
Self::Escaped => "escaped",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CombatFieldError {
MissingBattleStateId,
MissingStorySessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingTargetNpcId,
MissingTargetName,
MissingFunctionId,
InvalidVersion,
InvalidPlayerVitals,
InvalidTargetVitals,
InvalidRewardItem(String),
BattleAlreadyResolved,
UnsupportedFunctionId,
InsufficientMana,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateInput {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
pub experience_reward: u32,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateSnapshot {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub status: BattleStatus,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
pub experience_reward: u32,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub turn_index: u32,
pub last_action_function_id: Option<String>,
pub last_action_text: Option<String>,
pub last_result_text: Option<String>,
pub last_damage_dealt: i32,
pub last_damage_taken: i32,
pub last_outcome: CombatOutcome,
pub version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionInput {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateQueryInput {
pub battle_state_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionResult {
pub snapshot: BattleStateSnapshot,
pub damage_dealt: i32,
pub damage_taken: i32,
pub outcome: CombatOutcome,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateProcedureResult {
pub ok: bool,
pub snapshot: Option<BattleStateSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionProcedureResult {
pub ok: bool,
pub result: Option<ResolveCombatActionResult>,
pub error_message: Option<String>,
}
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.story_session_id).is_none() {
return Err(CombatFieldError::MissingStorySessionId);
}
if normalize_required_string(&input.runtime_session_id).is_none() {
return Err(CombatFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(&input.actor_user_id).is_none() {
return Err(CombatFieldError::MissingActorUserId);
}
if normalize_required_string(&input.target_npc_id).is_none() {
return Err(CombatFieldError::MissingTargetNpcId);
}
if normalize_required_string(&input.target_name).is_none() {
return Err(CombatFieldError::MissingTargetName);
}
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.player_max_mana < 0
|| input.player_mana < 0
|| input.player_mana > input.player_max_mana
{
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
return Err(CombatFieldError::InvalidTargetVitals);
}
for reward_item in input.reward_items.iter().cloned() {
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
}
Ok(())
}
pub fn validate_resolve_combat_action_input(
input: &ResolveCombatActionInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.function_id).is_none() {
return Err(CombatFieldError::MissingFunctionId);
}
if !is_supported_combat_function_id(&input.function_id) {
return Err(CombatFieldError::UnsupportedFunctionId);
}
Ok(())
}
pub fn build_battle_state_query_input(
battle_state_id: String,
) -> Result<BattleStateQueryInput, CombatFieldError> {
let input = BattleStateQueryInput {
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
};
validate_battle_state_query_input(&input)?;
Ok(input)
}
pub fn validate_battle_state_query_input(
input: &BattleStateQueryInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
Ok(())
}
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
BattleStateSnapshot {
battle_state_id: input.battle_state_id,
story_session_id: input.story_session_id,
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
chapter_id: input.chapter_id,
target_npc_id: input.target_npc_id,
target_name: input.target_name,
battle_mode: input.battle_mode,
status: BattleStatus::Ongoing,
player_hp: input.player_hp,
player_max_hp: input.player_max_hp,
player_mana: input.player_mana,
player_max_mana: input.player_max_mana,
target_hp: input.target_hp,
target_max_hp: input.target_max_hp,
experience_reward: input.experience_reward,
reward_items: input.reward_items,
turn_index: 0,
last_action_function_id: None,
last_action_text: None,
last_result_text: None,
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Ongoing,
version: INITIAL_BATTLE_VERSION,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
}
}
pub fn resolve_combat_action(
current: BattleStateSnapshot,
input: ResolveCombatActionInput,
) -> Result<ResolveCombatActionResult, CombatFieldError> {
validate_resolve_combat_action_input(&input)?;
if current.version == 0 {
return Err(CombatFieldError::InvalidVersion);
}
if current.status != BattleStatus::Ongoing {
return Err(CombatFieldError::BattleAlreadyResolved);
}
if current.player_mana < input.mana_cost.max(0) {
return Err(CombatFieldError::InsufficientMana);
}
let action_text = if input.action_text.trim().is_empty() {
input.function_id.clone()
} else {
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
};
if input.function_id == "battle_escape_breakout" {
let next = BattleStateSnapshot {
status: BattleStatus::Resolved,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Escaped,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
return Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt: 0,
damage_taken: 0,
outcome: CombatOutcome::Escaped,
});
}
let mana_cost = input.mana_cost.max(0);
let heal = input.heal.max(0);
let mana_restore = input.mana_restore.max(0);
let base_damage = input.base_damage.max(0);
let mut next_player_hp = current.player_hp;
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
let mut next_target_hp = current.target_hp;
let mut damage_dealt = 0;
let mut damage_taken = 0;
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp + heal,
current.player_max_hp,
);
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
if base_damage > 0 {
next_target_hp =
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
damage_dealt = current.target_hp - next_target_hp;
}
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
{
let outcome = match current.battle_mode {
BattleMode::Fight => CombatOutcome::Victory,
BattleMode::Spar => CombatOutcome::SparComplete,
};
(
BattleStatus::Resolved,
outcome,
build_resolved_result_text(&action_text, &current.target_name, outcome),
)
} else {
damage_taken = compute_counter_damage(
current.battle_mode,
current.target_max_hp,
input.counter_multiplier_basis_points,
);
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp - damage_taken,
current.player_max_hp,
);
(
BattleStatus::Ongoing,
CombatOutcome::Ongoing,
build_ongoing_result_text(&input.function_id, &action_text, &current.target_name),
)
};
let next = BattleStateSnapshot {
player_hp: next_player_hp,
player_mana: next_player_mana,
target_hp: next_target_hp,
status,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(result_text),
last_damage_dealt: damage_dealt,
last_damage_taken: damage_taken,
last_outcome: outcome,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt,
damage_taken,
outcome,
})
}
pub fn generate_battle_state_id(seed_micros: i64) -> String {
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
}
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
matches!(
function_id,
"battle_attack_basic"
| "battle_recover_breath"
| "battle_use_skill"
| "battle_escape_breakout"
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
}
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
let min_hp = match mode {
BattleMode::Fight => 0,
BattleMode::Spar => SPAR_MIN_HP,
};
value.clamp(min_hp, max_hp)
}
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
value.clamp(0, max_mana)
}
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
match mode {
BattleMode::Fight => (current_hp - damage).max(0),
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
}
}
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
match mode {
BattleMode::Fight => target_hp <= 0,
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
}
}
fn compute_counter_damage(
mode: BattleMode,
target_max_hp: i32,
counter_multiplier_basis_points: u32,
) -> i32 {
match mode {
BattleMode::Spar => 1,
BattleMode::Fight => {
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
let raw =
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
}
}
}
fn build_resolved_result_text(
action_text: &str,
target_name: &str,
outcome: CombatOutcome,
) -> String {
match outcome {
CombatOutcome::Victory => {
format!(
"{}命中了{},这轮战斗已经正式结束。",
action_text, target_name
)
}
CombatOutcome::SparComplete => {
format!(
"{}压住了{}的节奏,这场切磋已经分出高下。",
action_text, target_name
)
}
CombatOutcome::Escaped => {
format!("{}后你成功脱离了当前战斗。", action_text)
}
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
}
}
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
match function_id {
"battle_recover_breath" => {
format!(
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
target_name
)
}
"battle_use_skill" => {
format!(
"{}命中了{},这一轮技能效果已经直接结算。",
action_text, target_name
)
}
_ => format!(
"{}命中了{},本次攻击已经完成结算。",
action_text, target_name
),
}
}
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
let message = match error {
TreasureFieldError::MissingRewardItemId => {
"battle_state.reward_items[].item_id 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemCategory => {
"battle_state.reward_items[].category 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemName => {
"battle_state.reward_items[].item_name 不能为空".to_string()
}
TreasureFieldError::InvalidRewardItemQuantity => {
"battle_state.reward_items[].quantity 必须大于 0".to_string()
}
TreasureFieldError::MissingRewardItemStackKey => {
"battle_state.reward_items[].stack_key 不能为空".to_string()
}
TreasureFieldError::RewardEquipmentItemCannotStack => {
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
}
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
}
other => other.to_string(),
};
CombatFieldError::InvalidRewardItem(message)
}
impl fmt::Display for CombatFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("battle_state.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
Self::InvalidRewardItem(message) => f.write_str(message),
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
Self::UnsupportedFunctionId => {
f.write_str("resolve_combat_action.function_id 当前不受支持")
}
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
}
}
}
impl Error for CombatFieldError {}
#[cfg(test)]
mod tests {
use super::*;
fn build_fight_snapshot() -> BattleStateSnapshot {
build_battle_state_snapshot(BattleStateInput {
battle_state_id: "battle_001".to_string(),
story_session_id: "storysess_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_001".to_string(),
chapter_id: Some("chapter_001".to_string()),
target_npc_id: "npc_001".to_string(),
target_name: "黑爪狼".to_string(),
battle_mode: BattleMode::Fight,
player_hp: 60,
player_max_hp: 60,
player_mana: 20,
player_max_mana: 20,
target_hp: 30,
target_max_hp: 30,
experience_reward: 18,
reward_items: vec![],
created_at_micros: 10,
})
}
#[test]
fn validate_battle_state_input_accepts_minimal_contract() {
let result = validate_battle_state_input(&BattleStateInput {
battle_state_id: "battle_001".to_string(),
story_session_id: "storysess_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_001".to_string(),
chapter_id: Some("chapter_001".to_string()),
target_npc_id: "npc_001".to_string(),
target_name: "黑爪狼".to_string(),
battle_mode: BattleMode::Fight,
player_hp: 50,
player_max_hp: 60,
player_mana: 10,
player_max_mana: 20,
target_hp: 30,
target_max_hp: 30,
experience_reward: 12,
reward_items: vec![],
created_at_micros: 1,
});
assert!(result.is_ok());
}
#[test]
fn validate_battle_state_input_rejects_invalid_reward_items() {
let error = validate_battle_state_input(&BattleStateInput {
battle_state_id: "battle_001".to_string(),
story_session_id: "storysess_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_001".to_string(),
chapter_id: Some("chapter_001".to_string()),
target_npc_id: "npc_001".to_string(),
target_name: "黑爪狼".to_string(),
battle_mode: BattleMode::Fight,
player_hp: 50,
player_max_hp: 60,
player_mana: 10,
player_max_mana: 20,
target_hp: 30,
target_max_hp: 30,
experience_reward: 12,
reward_items: vec![RuntimeItemRewardItemSnapshot {
item_id: String::new(),
category: "遗物".to_string(),
item_name: "铜钥残片".to_string(),
description: None,
quantity: 1,
rarity: module_runtime_item::RuntimeItemRewardItemRarity::Rare,
tags: vec![],
stackable: false,
stack_key: String::new(),
equipment_slot_id: None,
}],
created_at_micros: 1,
})
.expect_err("invalid reward item should be rejected");
assert_eq!(
error,
CombatFieldError::InvalidRewardItem(
"battle_state.reward_items[].item_id 不能为空".to_string()
)
);
}
#[test]
fn build_battle_state_query_input_trims_and_validates_id() {
let input = build_battle_state_query_input(" battle_001 ".to_string())
.expect("query input should build");
assert_eq!(input.battle_state_id, "battle_001");
}
#[test]
fn build_battle_state_query_input_rejects_empty_id() {
let error =
build_battle_state_query_input(" ".to_string()).expect_err("empty id should fail");
assert_eq!(error, CombatFieldError::MissingBattleStateId);
}
#[test]
fn resolve_basic_attack_advances_turn_and_applies_counter_damage() {
let result = resolve_combat_action(
build_fight_snapshot(),
ResolveCombatActionInput {
battle_state_id: "battle_001".to_string(),
function_id: "battle_attack_basic".to_string(),
action_text: "普通攻击".to_string(),
base_damage: 10,
mana_cost: 0,
heal: 0,
mana_restore: 0,
counter_multiplier_basis_points: 10_000,
updated_at_micros: 20,
},
)
.expect("basic attack should succeed");
assert_eq!(result.snapshot.turn_index, 1);
assert_eq!(result.snapshot.target_hp, 20);
assert_eq!(result.snapshot.player_hp, 56);
assert_eq!(result.snapshot.last_damage_dealt, 10);
assert_eq!(result.snapshot.last_damage_taken, 4);
assert_eq!(result.outcome, CombatOutcome::Ongoing);
}
#[test]
fn resolve_escape_marks_battle_resolved() {
let result = resolve_combat_action(
build_fight_snapshot(),
ResolveCombatActionInput {
battle_state_id: "battle_001".to_string(),
function_id: "battle_escape_breakout".to_string(),
action_text: "逃跑".to_string(),
base_damage: 0,
mana_cost: 0,
heal: 0,
mana_restore: 0,
counter_multiplier_basis_points: 0,
updated_at_micros: 20,
},
)
.expect("escape should succeed");
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
assert_eq!(result.snapshot.last_outcome, CombatOutcome::Escaped);
assert_eq!(result.damage_dealt, 0);
assert_eq!(result.damage_taken, 0);
}
#[test]
fn resolve_skill_can_finish_fight() {
let result = resolve_combat_action(
build_fight_snapshot(),
ResolveCombatActionInput {
battle_state_id: "battle_001".to_string(),
function_id: "battle_use_skill".to_string(),
action_text: "试锋斩".to_string(),
base_damage: 35,
mana_cost: 8,
heal: 0,
mana_restore: 0,
counter_multiplier_basis_points: 9_500,
updated_at_micros: 20,
},
)
.expect("skill should succeed");
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
assert_eq!(result.snapshot.target_hp, 0);
assert_eq!(result.snapshot.player_mana, 12);
assert_eq!(result.outcome, CombatOutcome::Victory);
assert_eq!(result.damage_taken, 0);
}
#[test]
fn spar_mode_keeps_hp_floor_at_one() {
let snapshot = build_battle_state_snapshot(BattleStateInput {
battle_state_id: "battle_002".to_string(),
story_session_id: "storysess_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_001".to_string(),
chapter_id: Some("chapter_spar".to_string()),
target_npc_id: "npc_002".to_string(),
target_name: "卫队长".to_string(),
battle_mode: BattleMode::Spar,
player_hp: 5,
player_max_hp: 5,
player_mana: 10,
player_max_mana: 10,
target_hp: 3,
target_max_hp: 3,
experience_reward: 0,
reward_items: vec![],
created_at_micros: 10,
});
let result = resolve_combat_action(
snapshot,
ResolveCombatActionInput {
battle_state_id: "battle_002".to_string(),
function_id: "battle_attack_basic".to_string(),
action_text: "普通攻击".to_string(),
base_damage: 5,
mana_cost: 0,
heal: 0,
mana_restore: 0,
counter_multiplier_basis_points: 10_000,
updated_at_micros: 20,
},
)
.expect("spar attack should succeed");
assert_eq!(result.snapshot.target_hp, 1);
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
assert_eq!(result.outcome, CombatOutcome::SparComplete);
}
#[test]
fn resolve_rejects_unsupported_function() {
let error = resolve_combat_action(
build_fight_snapshot(),
ResolveCombatActionInput {
battle_state_id: "battle_001".to_string(),
function_id: "inventory_use".to_string(),
action_text: "使用物品".to_string(),
base_damage: 0,
mana_cost: 0,
heal: 0,
mana_restore: 0,
counter_multiplier_basis_points: 7_200,
updated_at_micros: 20,
},
)
.expect_err("inventory_use should be deferred for now");
assert_eq!(error, CombatFieldError::UnsupportedFunctionId);
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "module-custom-world"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
spacetimedb = { workspace = true, optional = true }

View File

@@ -1,6 +1,6 @@
# module-custom-world 独立模块 package 占位说明
# module-custom-world 独立模块 package 说明
日期:`2026-04-20`
日期:`2026-04-21`
## 1. package 职责
@@ -14,7 +14,41 @@
## 2. 当前阶段说明
当前提交仅完成目录占位不提前进入问答流、agent 流、世界编译与资产绑定实现
当前阶段已经不再是单纯目录占位,而是先把 `M5` 首批 `custom world / agent` 类型契约与字段校验固定下来,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表
当前已落地:
1. 真实 `Cargo.toml` crate scaffold
2. `CustomWorldPublicationStatus``CustomWorldThemeMode``CustomWorldGenerationMode`
3. `CustomWorldSessionStatus``RpgAgentStage`
4. `RpgAgentMessageRole``RpgAgentMessageKind`
5. `RpgAgentOperationType``RpgAgentOperationStatus`
6. `RpgAgentDraftCardKind``RpgAgentDraftCardStatus`
7. `CustomWorldRoleAssetStatus`
8. 首批表字段校验函数与最小单测
9. `published profile compile` 输入输出 contract
10. `publish_world` 串联输入输出 contract
当前 crate 仍然只承接:
1. 共享枚举与类型口径
2. 字段校验与字符串归一化
3. published profile compile 的最小编译摘要 contract
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
当前阶段明确不提前进入:
1. 旧问答流 reducer 编排
2. RPG 创作 Agent 编排
3. publish gate blocker 规则迁移
4. 资产绑定与图片生成副作用
当前设计依据:
1. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
2. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)
4. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
后续与本 package 直接相关的任务包括:
@@ -26,5 +60,6 @@
## 3. 边界约束
1. `module-custom-world` 负责世界状态真相、agent 状态与模块级编排,不把整个会话重新塞回单大 JSON 体。
2. 外部 LLM、图片生成、OSS 写入等副作用通过平台适配和应用层完成,状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。
3. 前端兼容 REST 与 SSE 由 `apps/api-server` 暴露,但自定义世界主链状态不能再次分散到本地 session store 或前端临时状态中。
2. 外部 LLM、图片生成、OSS 写入等副作用通过平台适配和应用层完成,状态最终回写到 `spacetime-module` 聚合的状态模型中。
3. 前端兼容 REST 与 SSE 由 `api-server` 暴露,但自定义世界主链状态不能再次分散到本地 session store 或前端临时状态中。
4. `custom_world_asset_link` 本轮不冻结,等待 `asset_object / asset_entity_binding / M6 assets` 的槽位规则稳定后再接。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[package]
name = "module-inventory"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
spacetimedb = { workspace = true, optional = true }

Some files were not shown because too many files have changed in this diff Show More