Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -4,7 +4,30 @@
## 文档列表
- [SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md](./SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md):记录 `WP-BF Big Fish` 运行态从前端本地规则切到 Rust 领域真相源、SpacetimeDB run 表、API facade 和前端新接口接入的关闭口径。
- [SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md](./SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md):记录 `WP-PF platform side effects` 平台副作用收口,统一 LLM、OSS、SMS、微信平台错误分类与 API 映射,并将微信 OAuth provider 下沉到 `platform-auth`
- [SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md](./SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` Adapter/API 收口,将 checkpoint、profile/save archive meta、充值/邀请/兑换/钱包等剩余纯规则迁入 `module-runtime`,移除 `/api/runtime/profile/*` 旧兼容挂载并对齐前端 `/api/profile/*` 请求路径。
- [SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md):冻结 `WP-SC Spacetime Client` 本次基础设施重构边界,明确只收口 `spacetime-client` 的 typed facade、错误映射和 row snapshot mapper不预判尚未由 `WP-ST` 稳定的表、reducer、procedure 或 row shape。
- [SERVER_RS_DDD_WP_RT_APPLICATION_RECORD_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_RT_APPLICATION_RECORD_REFACTOR_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` 的应用记录投影拆分切片,将 settings、browse history、profile/save 等 `build_runtime_*_record` 迁入 `module-runtime/src/application.rs`,不改回包字段语义。
- [SERVER_RS_DDD_WP_RT_COMMANDS_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_RT_COMMANDS_REFACTOR_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` 的命令构造拆分切片,将 settings、browse history、profile/save 等 `build_runtime_*_input` 和写入归一化函数迁入 `module-runtime/src/commands.rs`,不改校验语义。
- [SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md):记录 `WP-AI AI Task``module-ai` 内部子模块拆分,将 domain、commands、application 与行为测试继续拆到职责更细的子文件,同时保持 `module_ai::*` 公开导出、SpacetimeDB schema、BFF route 和前端契约不变。
- [SERVER_RS_DDD_WP_AI_TASK_BFF_CLOSURE_2026-04-29.md](./SERVER_RS_DDD_WP_AI_TASK_BFF_CLOSURE_2026-04-29.md):记录 `WP-AI AI Task` BFF 收口与关闭口径,补齐 AI task mutation route 鉴权和 SpacetimeDB 未发布错误 envelope 的定向验证不改表结构、LLM provider、SSE 或前端消费。
- [SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md):记录 `WP-CW Custom World` 基础领域枚举归位切片,将 Custom World / RPG Agent 基础枚举、进度常量和字符串口径迁入 `module-custom-world/src/domain.rs`,不改 SpacetimeDB、API 或前端行为。
- [SERVER_RS_DDD_WP_RPG_STORY_DOMAIN_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_RPG_STORY_DOMAIN_SPLIT_2026-04-29.md):记录 `WP-RPG Gameplay 域``module-story` 领域拆分收口,将 story session 领域模型、命令、事件、应用映射和错误层从 `lib.rs` 拆入 DDD 骨架文件,并修正 README 不再指向旧 `/api/runtime/story/*` 兼容链路。
- [SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md):记录 `WP-PZ Puzzle` 领域类型与规则拆分切片,将 Agent/作品/运行态领域类型、写入命令、应用规则、字段错误和最小领域事件归位到 `module-puzzle` 的 DDD 骨架文件,不改 SpacetimeDB、API 或前端行为。
- [SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md):记录 `WP-PZ Puzzle` 基础领域常量与枚举归位切片,将 Puzzle Agent、发布状态、运行态状态、ID 前缀、标签数量和洗牌次数口径迁入 `module-puzzle/src/domain.rs`,不改 SpacetimeDB、API 或前端行为。
- [SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md):记录 `WP-RPG Gameplay 域` 的 combat 基础领域常量与枚举归位切片,将战斗 ID 前缀、版本、伤害、切磋保底生命、旧攻击 function 列表和基础枚举迁入 `module-combat/src/domain.rs`,不改 SpacetimeDB、API 或前端行为。
- [SERVER_RS_DDD_WP_ST_AUTH_ADAPTER_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_ST_AUTH_ADAPTER_SPLIT_2026-04-29.md):记录 `WP-ST` Auth SpacetimeDB adapter 目录化切片将认证表、procedure 和快照 JSON mapper 拆入 `auth/` 子模块,不改 schema、procedure 签名或绑定形状。
- [SERVER_RS_DDD_WP_RS_COMPAT_RESIDUE_AUDIT_2026-04-29.md](./SERVER_RS_DDD_WP_RS_COMPAT_RESIDUE_AUDIT_2026-04-29.md):记录 `WP-RS Runtime Story 去兼容层` 的 compat 残留审计切片,清理 `module-runtime-story` 运行代码注释口径,并冻结仍需等待新写接口的前端和 contract 残留。
- [SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md):记录 `WP-AS Assets` 资产对象类型归位切片,将领域快照、命令 DTO、应用返回 DTO 和字段错误拆入 `module-assets` 的 DDD 骨架文件,不改 SpacetimeDB、API、OSS 或前端行为。
- [SERVER_RS_DDD_WP_RT_ERROR_LAYER_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_RT_ERROR_LAYER_REFACTOR_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` 的错误层拆分切片,将 settings、browse history、profile/save 三组字段错误和中文错误文案迁入 `module-runtime/src/errors.rs`,不改校验语义。
- [SERVER_RS_DDD_WP_RT_DOMAIN_SNAPSHOT_RECORD_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_RT_DOMAIN_SNAPSHOT_RECORD_REFACTOR_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` 的 snapshot、profile、wallet、played world 与 save archive 领域快照和记录类型拆分切片,只移动纯类型和枚举方法,不改 SpacetimeDB、API 或前端接线。
- [SERVER_RS_DDD_WP_RT_RUNTIME_SETTINGS_DOMAIN_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_RT_RUNTIME_SETTINGS_DOMAIN_REFACTOR_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` 的 runtime settings 领域值对象拆分切片,将默认设置、平台主题和值对象迁入 `module-runtime/src/domain.rs`,不改 SpacetimeDB、API 或前端接线。
- [SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md):记录 `WP-A Auth` DDD 分层收口,将账号、会话、验证码、微信 state/绑定规则、命令输入、应用返回、领域错误和领域事件归位到 `module-auth` 骨架,并核查 API、platform 与 SpacetimeDB adapter 边界。
- [SERVER_RS_DDD_WP_ST_CUSTOM_WORLD_ROOT_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_ST_CUSTOM_WORLD_ROOT_SPLIT_2026-04-29.md):记录 `WP-ST` Custom World SpacetimeDB adapter 从根入口迁入 `custom_world/mod.rs` 的边界、无 schema 变更口径和验收命令。
- [SERVER_RS_DDD_WP_FE_S_RPG_RUNTIME_STORY_CLIENT_MIGRATION_2026-04-29.md](./SERVER_RS_DDD_WP_FE_S_RPG_RUNTIME_STORY_CLIENT_MIGRATION_2026-04-29.md):记录 `WP-FE-S` RPG runtime story client 读取侧迁到 `storySessionId` scoped runtime projection并补齐 story session 新主链 `begin/continue/state/projection` API client 的边界、旧写接口暂留原因和后续依赖。
- [SERVER_RS_DDD_WP_FE_H_RPG_RUNTIME_STORY_HOOKS_PROJECTION_2026-04-29.md](./SERVER_RS_DDD_WP_FE_H_RPG_RUNTIME_STORY_HOOKS_PROJECTION_2026-04-29.md):记录 `WP-FE-H` RPG runtime story 读取侧 hooks 接线切片,将 option catalog 与继续游戏刷新显式接入 `getRpgStoryRuntimeProjection`,写接口和组件层仍等待后续收口。
- [SERVER_RS_DDD_WP_FE_C_RPG_RUNTIME_SHELL_TEST_FIXTURE_2026-04-29.md](./SERVER_RS_DDD_WP_FE_C_RPG_RUNTIME_SHELL_TEST_FIXTURE_2026-04-29.md):记录 `WP-FE-C` RPG runtime shell 组件测试夹具接线切片,将组件测试 mock 对齐当前 hooks 暴露的 UI 对象形状,不触碰未稳定写接口。
- [SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_PROGRESS_2026-04-29.md](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_PROGRESS_2026-04-29.md):记录 `G1 契约与路由矩阵` 已完成的本地进度、验证结果、单 owner 边界和下一批并行任务入口。
- [SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md):冻结 `server-rs` DDD G1 契约与路由矩阵,明确新旧 HTTP 路由去留、DTO 删除/保留/重命名、页面到 query/result DTO 映射、breaking change、API 错误 envelope 和共享契约单 owner 边界。
- [SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md](./SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md):记录 `WP-API api-server BFF` 启动切片,先收口旧 runtime story 兼容路由挂载、错误 envelope 回归和后续依赖,不越过 `spacetime-client` 接线边界。

View File

@@ -43,13 +43,13 @@ G1 单 owner 文件范围:
| Runtime settings/save | `GET/PUT /api/runtime/settings``GET/PUT/DELETE /api/runtime/save/snapshot` | 保留 | `RuntimeSettingsResponse``PutRuntimeSettingsRequest``SavedGameSnapshotResponse``PutSavedGameSnapshotRequest` | WP-RT |
| RPG 作品库 | `GET /api/runtime/custom-world-library``GET/PUT/DELETE /api/runtime/custom-world-library/{profile_id}``POST /publish``POST /unpublish``GET /api/runtime/custom-world-gallery``GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}``GET /api/runtime/custom-world-gallery/by-code/{code}` | 收敛 | 命名后续改为 RPG creation/work route family删除 `custom-world` 旧泛名歧义 | WP-CW、WP-FE |
| RPG Agent | `POST /api/runtime/custom-world/agent/sessions``GET/DELETE /sessions/{session_id}``GET /result-view``GET /works``GET /cards/{card_id}``POST /messages``POST /messages/stream``POST /actions``GET /operations/{operation_id}` | 收敛 | DTO 重命名为 `RpgAgent*`Rust 当前 `CustomWorldAgent*` 后续物理重命名 | WP-CW、WP-FE、WP-DEL |
| Big Fish Agent/Works | `POST /api/runtime/big-fish/agent/sessions``GET /sessions/{session_id}``POST /messages``POST /messages/stream``POST /actions``GET /works``DELETE /works/{session_id}``GET /gallery``POST /sessions/{session_id}/play``POST /works/{session_id}/play` | 保留 | `BigFish*` DTO`sessions/{id}/play``works/{id}/play` 后续二选一保留 | WP-BF |
| Big Fish Agent/Works/Runtime | `POST /api/runtime/big-fish/agent/sessions``GET /sessions/{session_id}``POST /messages``POST /messages/stream``POST /actions``GET /works``DELETE /works/{session_id}``GET /gallery``POST /sessions/{session_id}/play``POST /works/{session_id}/play``POST /sessions/{session_id}/runs``GET /runs/{run_id}``POST /runs/{run_id}/input` | 保留 | `BigFish*` DTO运行态正式使用 `BigFishRunResponse``SubmitBigFishInputRequest``sessions/{id}/play``works/{id}/play` 后续二选一保留 | WP-BF |
| Puzzle Agent/Works/Runtime | `POST /api/runtime/puzzle/agent/sessions``GET /sessions/{session_id}``POST /messages``POST /messages/stream``POST /actions``GET /works``GET/PUT/DELETE /works/{profile_id}``GET /gallery``GET /gallery/{profile_id}``POST /runs``POST /runs/local-next-level``GET /runs/{run_id}``POST /runs/{run_id}/swap``POST /runs/{run_id}/drag``POST /runs/{run_id}/next-level``POST /runs/{run_id}/leaderboard` | 保留 | `PuzzleAgent*``PuzzleWork*``PuzzleRun*` DTO | WP-PZ |
| RPG profile/asset generation | `POST /api/runtime/custom-world/profile``POST /api/custom-world/entity``POST /api/runtime/custom-world/entity``POST /api/custom-world/scene-npc``POST /api/runtime/custom-world/scene-npc``POST /api/custom-world/scene-image``POST /api/custom-world/cover-image``POST /api/runtime/custom-world/cover-image``POST /api/custom-world/cover-upload``POST /api/runtime/custom-world/cover-upload` | 重命名 | 去掉非 runtime 前缀旧入口;统一到 RPG creation asset/profile route family | WP-CW、WP-AS、WP-DEL |
| Profile | `GET/POST/DELETE /api/runtime/profile/browse-history``GET/POST/DELETE /api/profile/browse-history``GET /dashboard``GET /wallet-ledger``GET /recharge-center``POST /recharge/orders``GET /referrals/invite-center``POST /referrals/redeem-code``POST /redeem-codes/redeem``GET /play-stats``GET /save-archives``POST /save-archives/{world_key}` | 重命名 | 保留 `/api/runtime/profile/*` 主链,删除 `/api/profile/*` 镜像入口 | WP-RT、WP-FE、WP-DEL |
| Profile | `GET/POST/DELETE /api/profile/browse-history``GET /api/profile/dashboard``GET /api/profile/wallet-ledger``GET /api/profile/recharge-center``POST /api/profile/recharge/orders``GET /api/profile/referrals/invite-center``POST /api/profile/referrals/redeem-code``POST /api/profile/redeem-codes/redeem``GET /api/profile/play-stats``GET /api/profile/save-archives``POST /api/profile/save-archives/{world_key}`;旧 `GET/POST/DELETE /api/runtime/profile/*` 已取消挂载 | 重命名 | 保留 `/api/profile/*` 主链,删除 `/api/runtime/profile/*` 旧兼容入口 | WP-RT、WP-FE、WP-DEL |
| Runtime inventory | `GET /api/runtime/sessions/{runtime_session_id}/inventory` | 保留 | `RuntimeInventoryStateResponse` | WP-RPG、WP-RT |
| Runtime story 旧层 | `POST /api/runtime/story/sessions``POST /api/runtime/story/state/resolve``GET /api/runtime/story/state/{session_id}``POST /api/runtime/story/actions/resolve``POST /api/runtime/story/initial``POST /api/runtime/story/continue` | 已删除 | 已从 `api-server` 取消挂载并删除 `api-server/src/runtime_story*` 兼容实现;后续前端迁移到 `GET/POST /api/story/*` 和 session scoped story/chat facade | WP-RS、WP-FE、WP-DEL |
| Story/Game facade | `POST /api/story/sessions``GET /api/story/sessions/{story_session_id}/state``POST /api/story/sessions/continue``POST /api/story/battles``GET /api/story/battles/{battle_state_id}``POST /api/story/npc/battle``POST /api/story/battles/resolve` | 保留 | `BeginStorySession*``ContinueStory*`battle/npc command/result DTO 后续补齐到 `shared-contracts` | WP-RPG、WP-RS |
| Story/Game facade | `POST /api/story/sessions``GET /api/story/sessions/{story_session_id}/state``GET /api/story/sessions/{story_session_id}/runtime-projection``POST /api/story/sessions/continue``POST /api/story/battles``GET /api/story/battles/{battle_state_id}``POST /api/story/npc/battle``POST /api/story/battles/resolve` | 保留 | `BeginStorySession*``ContinueStory*``StoryRuntimeProjection*``CreateStoryBattle*``StoryBattleState*``ResolveStoryBattle*` DTO 补齐到 `shared-contracts` | WP-RPG、WP-RS |
## 3. DTO 冻结清单
@@ -62,13 +62,15 @@ G1 单 owner 文件范围:
| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse``AuthUserPayload``PublicUserSummaryPayload``PublicUserSearchResponse``PasswordEntry*``PasswordChange*``PasswordReset*``AuthMeResponse``AuthSessionsResponse``RefreshSessionResponse``Logout*``Phone*``Wechat*` |
| `shared-contracts/src/ai.rs` | `CreateAiTaskRequest``AppendAiTextChunkRequest``CompleteAiStageRequest``AttachAiResultReferenceRequest``FailAiTaskRequest``AiTask*Payload``AiTaskMutationResponse``AiTaskAcceptedResponse` |
| `shared-contracts/src/assets.rs` | Direct upload、read url、asset object、asset binding、asset history、character visual/animation、workflow cache、role asset workflow 相关 DTO |
| `shared-contracts/src/big_fish*.rs` | `CreateBigFishSessionRequest``SendBigFishMessageRequest``ExecuteBigFishActionRequest``RecordBigFishPlayRequest``BigFish*Response``BigFishWorksResponse` |
| `shared-contracts/src/creation_agent_document_input.rs` | `ParseCreationAgentDocumentInputRequest/Response``CreationAgentDocumentInputPayload` |
| `shared-contracts/src/big_fish*.rs` | `CreateBigFishSessionRequest``SendBigFishMessageRequest``ExecuteBigFishActionRequest``RecordBigFishPlayRequest``SubmitBigFishInputRequest``BigFish*Response``BigFishRunResponse``BigFishWorksResponse` |
| `shared-contracts/src/puzzle_*.rs` | `CreatePuzzleAgentSessionRequest``SendPuzzleAgentMessageRequest``ExecutePuzzleAgentActionRequest``PuzzleAgent*Response``PuzzleWork*``PuzzleRun*``PuzzleGallery*` |
| `shared-contracts/src/runtime.rs` | runtime settings/save/profile/browse history/custom world library/agent/result view/inventory 现有 DTO 在迁移窗口保留 |
| `shared-contracts/src/story.rs` | `BeginStorySessionRequest``ContinueStoryRequest``StorySessionPayload``StoryEventPayload``StorySessionMutationResponse``StorySessionStateResponse` |
| `shared-contracts/src/story.rs` | `BeginStorySessionRequest``ContinueStoryRequest``StorySessionPayload``StoryEventPayload``StorySessionMutationResponse``StorySessionStateResponse``StoryRuntimeProjectionRequest/Response``CreateStoryBattleRequest``CreateStoryNpcBattleRequest``StoryBattleStateResponse``ResolveStoryBattleRequest/Response` |
| `packages/shared/src/contracts/runtime.ts` | `RuntimeSettings``SavedGameSnapshot*`、profile、browse history、library/gallery DTO迁移窗口继续作为前端消费主入口 |
| `packages/shared/src/contracts/creationAgentDocumentInput.ts` | `ParseCreationAgentDocumentInputRequest/Response``CreationAgentDocumentInputPayload` |
| `packages/shared/src/contracts/rpgAgent*.ts` | RPG Agent、draft、anchors、result view、work summary DTO |
| `packages/shared/src/contracts/bigFish*.ts` | Big Fish Agent、runtime、本地作品列表 DTO |
| `packages/shared/src/contracts/bigFish*.ts` | Big Fish Agent、backend runtime run/input、本地作品列表 DTO |
| `packages/shared/src/contracts/puzzle*.ts` | Puzzle Agent、work、gallery、runtime DTO |
### 3.2 重命名
@@ -80,7 +82,7 @@ G1 单 owner 文件范围:
| Rust `GenerateCustomWorldProfile*` | `GenerateRpgCreationProfile*` | 去掉泛化 custom world 命名,明确 RPG 创作 profile。 |
| TS `AuthEntry*` | `PasswordEntry*` 或统一后端 `AuthPasswordEntry*` | 需要在 WP-A 中二选一收口,避免 entry 与 phone/wechat 登录语义混杂。 |
| `RuntimeStory*` view model | `RpgRuntimeStory*` 或拆到 `Story*``Battle*``Inventory*` | 旧聚合大 DTO 后续拆分为 story session、battle、inventory、npc interaction 投影。 |
| Profile 镜像 DTO | `RuntimeProfile*` | `/api/profile/*` 镜像删除,契约命名跟随 `/api/runtime/profile/*`。 |
| Profile 旧兼容入口 DTO | `RuntimeProfile*` | `/api/runtime/profile/*` 旧兼容入口已删除,契约命名可继续保留 RuntimeProfile 领域语义HTTP 主链固定为 `/api/profile/*`。 |
### 3.3 删除
@@ -93,7 +95,7 @@ G1 单 owner 文件范围:
| Rust/TS `RuntimeStoryActionRequest/Response` 旧总入口形态 | `POST /api/runtime/story/actions/resolve` 删除后 | story/battle/npc/inventory 分命令 result DTO |
| TS `StoryRequestPayload``PlainTextPromptRequest``PlainTextResponse` | runtime chat 不再由前端传 prompt 后 | 后端 session scoped chat/story command |
| TS `CreateCustomWorldSessionRequest``AnswerCustomWorldSessionQuestionRequest``CustomWorldSessionRecord` 等旧问答生成 DTO | 确认无前端运行引用后 | RPG Agent session DTO |
| `/api/profile/*` 镜像 DTO 别名 | 前端全量迁到 `/api/runtime/profile/*` 后 | Runtime profile DTO |
| `/api/runtime/profile/*` 旧兼容 DTO 别名 | 前端全量迁到 `/api/profile/*` 后 | Runtime profile DTO |
## 4. 页面/功能到 query/result DTO 映射
@@ -111,23 +113,23 @@ G1 单 owner 文件范围:
| RPG Agent 工作区 | route param `session_id` / `operation_id` | `CreateCustomWorldAgentSessionRequest``SendCustomWorldAgentMessageRequest``ExecuteCustomWorldAgentActionRequest` | `CustomWorldAgentSessionResponse``CustomWorldAgentOperationResponse``CustomWorldCreationResultViewResponse`,后续重命名 `RpgAgent*` |
| RPG 结果页 | route param `session_id` | section patch/action request | `CustomWorldCreationResultViewResponse``CustomWorldAgentCardDetailResponse` |
| RPG 资产工坊 | route param `character_id``GetReadUrlQuery` | `CharacterVisualGenerateRequest``CharacterAnimationGenerateRequest``CharacterWorkflowCacheSaveRequest``CharacterRoleAssetWorkflowResolveRequest` | `CharacterVisualGenerateResponse``CharacterAnimationGenerateResponse``CharacterWorkflowCacheGetResponse``CharacterRoleAssetWorkflowResponse` |
| Big Fish Agent | route param `session_id` | `CreateBigFishSessionRequest``SendBigFishMessageRequest``ExecuteBigFishActionRequest` | `BigFishSessionResponse``BigFishActionResponse` |
| Big Fish Agent/Runtime | route param `session_id` / `run_id` | `CreateBigFishSessionRequest``SendBigFishMessageRequest``ExecuteBigFishActionRequest``SubmitBigFishInputRequest` | `BigFishSessionResponse``BigFishActionResponse``BigFishRunResponse` |
| Big Fish 广场/作品 | bearer token 或公开 gallery query | `RecordBigFishPlayRequest` | `BigFishWorksResponse``BigFishGalleryResponse``BigFishSessionResponse` |
| Puzzle Agent | route param `session_id` | `CreatePuzzleAgentSessionRequest``SendPuzzleAgentMessageRequest``ExecutePuzzleAgentActionRequest` | `PuzzleAgentSessionResponse``PuzzleAgentActionResponse` |
| Puzzle 作品/广场 | route param `profile_id` | `PutPuzzleWorkRequest` | `PuzzleWorksResponse``PuzzleWorkDetailResponse``PuzzleGalleryResponse``PuzzleGalleryDetailResponse` |
| Puzzle 运行态 | route param `run_id` | `StartPuzzleRunRequest``AdvanceLocalPuzzleNextLevelRequest``SwapPuzzlePiecesRequest``DragPuzzlePieceRequest``SubmitPuzzleLeaderboardRequest` | `PuzzleRunResponse` |
| Runtime 设置与存档 | bearer token | `PutRuntimeSettingsRequest``PutSavedGameSnapshotRequest``PutRuntimeSaveCheckpointRequest` | `RuntimeSettingsResponse``SavedGameSnapshotResponse``BasicOkResponse` |
| 个人中心 | bearer token | `CreateProfileRechargeOrderRequest``RedeemProfileReferralInviteCodeRequest``RedeemProfileRewardCodeRequest``PlatformBrowseHistoryUpsertRequest` | `ProfileDashboardSummaryResponse``ProfileWalletLedgerResponse``ProfileRechargeCenterResponse``ProfileReferralInviteCenterResponse``ProfilePlayStatsResponse``ProfileSaveArchiveListResponse``PlatformBrowseHistoryResponse` |
| RPG Story 运行态 | route param `story_session_id``battle_state_id` | `BeginStorySessionRequest``ContinueStoryRequest`battle/npc 命令 DTO 后续补齐 | `StorySessionMutationResponse``StorySessionStateResponse``RuntimeInventoryStateResponse` |
| RPG Story 运行态 | route param `story_session_id``battle_state_id` | `BeginStorySessionRequest``ContinueStoryRequest``CreateStoryBattleRequest``CreateStoryNpcBattleRequest``ResolveStoryBattleRequest` | `StorySessionMutationResponse``StorySessionStateResponse``StoryRuntimeProjectionResponse``StoryBattleStateResponse``ResolveStoryBattleResponse``RuntimeInventoryStateResponse` |
| Runtime chat/NPC 私聊 | route param `runtime_session_id``story_session_id` | 后续新增 session scoped chat command | 后续新增 chat turn result`rpgRuntimeChat.ts` DTO 只作为迁移参考 |
## 5. Breaking change 清单
1. 删除兼容层是本轮默认策略。旧 `/api/runtime/story/*``/_internal/auth/*``/generated-*``/api/profile/*` 镜像入口在对应前端迁移完成后物理删除。
1. 删除兼容层是本轮默认策略。旧 `/api/runtime/story/*``/_internal/auth/*``/generated-*``/api/runtime/profile/*` 兼容入口在对应前端迁移完成后物理删除。
2. Runtime story/chat 不再接受前端拼装的 `worldType``character``monsters``history``context`、prompt 文本作为正式真相。正式命令必须以 `runtimeSessionId``storySessionId``battleStateId` 等后端 session id 为索引。
3. `CustomWorld*` 作为 RPG 主链命名将被重命名为 `RpgCreation*``RpgAgent*`。前端可同步修改,不保留旧命名适配层。
4. `/api/custom-world/*` 非 runtime 前缀旧入口删除,统一进入 RPG creation route family 或 asset route family。
5. `/api/profile/*` 镜像入口删除,统一使用 `/api/runtime/profile/*`
5. `/api/runtime/profile/*` 兼容入口删除,统一使用 `/api/profile/*`
6. 资产读取不再依赖 `/generated-*` 静态代理作为正式 contract统一走 asset object、read url 或后端投影里的正式 URL 字段。
7. LLM 代理不得作为玩法 prompt 透传入口。玩法 prompt 由 `api-server`/`platform-llm` 内部编排,前端只提交用户动作和展示态输入。
8. API 错误体统一为 `ApiErrorEnvelope`。旧 `{ error, meta }` 只允许在已列入删除计划的旧接口中短期存在。

View File

@@ -40,6 +40,16 @@ npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE
结果通过4 个文档文件 UTF-8 编码检查正常。
## 4.1 持续巡检记录
2026-04-29 本轮持续巡检已补齐迁移后漂移:
1. `Profile` 路由矩阵从旧 `/api/runtime/profile/*` 主链修正为当前 `/api/profile/*` 主链,并明确 `/api/runtime/profile/*` 已作为旧兼容入口取消挂载。
2. `Big Fish Agent/Works` 路由矩阵补齐后端真相源运行态接口:`POST /api/runtime/big-fish/sessions/{session_id}/runs``GET /api/runtime/big-fish/runs/{run_id}``POST /api/runtime/big-fish/runs/{run_id}/input`
3. `Story/Game facade` 补齐 `GET /api/story/sessions/{story_session_id}/runtime-projection`,并将 story runtime projection、story battle command/result DTO 标记为已进入 `shared-contracts/src/story.rs`
4. DTO 保留清单补齐 `shared-contracts/src/creation_agent_document_input.rs``packages/shared/src/contracts/creationAgentDocumentInput.ts`
5. `packages/shared/src/index.ts` 已导出 `creationAgentDocumentInput` 类型,避免前端继续绕过共享契约包入口深路径引用。
## 5. 后续入口
下一步可以按全局清单进入第 1 批领域规则并行任务:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
# server-rs DDD WP-AI 内部子模块拆分记录2026-04-29
## 1. 背景
`WP-AI AI Task` 已完成领域层、SpacetimeDB adapter、spacetime-client facade 与 BFF route 闭环。当前继续推进 DDD 收口时,`module-ai` 虽然已经从首轮 `lib.rs` 大文件拆成 `domain / commands / application / events / errors`,但 `domain.rs``commands.rs``application.rs` 仍承载多类职责,后续继续演进阶段规则、任务结果聚合或 store 实现时容易重新堆成大文件。
本次只做 `module-ai` crate 内部子模块拆分,保持 `module_ai::*` 对外公开导出不变,不改变 SpacetimeDB table / reducer / procedure / event table不改变 HTTP DTO、route 或前端调用。
## 2. 本次拆分范围
允许修改:
1. `server-rs/crates/module-ai/src/domain.rs`
2. `server-rs/crates/module-ai/src/domain/*`
3. `server-rs/crates/module-ai/src/commands.rs`
4. `server-rs/crates/module-ai/src/commands/*`
5. `server-rs/crates/module-ai/src/application.rs`
6. `server-rs/crates/module-ai/src/application/*`
7. `server-rs/crates/module-ai/src/lib.rs`
8. `server-rs/crates/module-ai/src/tests.rs`
9. `server-rs/crates/module-ai/README.md`
10. 本文档、`docs/technical/README.md` 与全局任务清单进度记录
禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/**`
3. `server-rs/crates/api-server/src/**`
4. `server-rs/crates/shared-contracts/src/**`
5. `packages/shared/src/contracts/**`
6. `server-rs/crates/spacetime-module/src/migration.rs`
## 3. 拆分落点
```text
server-rs/crates/module-ai/src/
├─ application.rs
├─ application/
│ ├─ result.rs
│ ├─ service.rs
│ └─ store.rs
├─ commands.rs
├─ commands/
│ ├─ inputs.rs
│ └─ validation.rs
├─ domain.rs
├─ domain/
│ ├─ ids.rs
│ ├─ stages.rs
│ └─ types.rs
├─ errors.rs
├─ events.rs
├─ lib.rs
└─ tests.rs
```
职责说明:
1. `domain/types.rs` 只放 AI task、stage、chunk、result reference 的领域类型与快照。
2. `domain/stages.rs` 只放默认阶段蓝图、阶段 slug、阶段中文标签与终态判断。
3. `domain/ids.rs` 只放 ID 前缀、ID helper 和共享字符串归一 helper re-export。
4. `commands/inputs.rs` 只放写入输入结构。
5. `commands/validation.rs` 只放创建任务输入校验。
6. `application/result.rs` 只放面向 SpacetimeDB procedure 的轻量结果结构。
7. `application/service.rs` 只放 AI task 状态机应用服务。
8. `application/store.rs` 只放当前内存 store 与流式文本片段聚合。
9. `tests.rs` 承接原 `lib.rs` 行为测试,`lib.rs` 只保留模块声明与公开导出。
## 4. 行为不变口径
本次必须保持:
1. `module_ai::*` 公开导出兼容。
2. 默认阶段蓝图顺序不变。
3. 任务状态迁移不变。
4. 终态任务不允许继续写入阶段、文本片段、结果引用或完成状态。
5. 流式文本片段继续按 `sequence` 聚合到阶段输出和 `latest_text_output`
6. 中文错误文案不改写为英文。
7. 不新增真实 LLM provider、prompt 组装、SSE 协议或前端消费逻辑。
## 5. 验收
必须执行:
```powershell
cargo fmt -p module-ai --manifest-path server-rs/Cargo.toml --check
cargo test -p module-ai --manifest-path server-rs/Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/module-ai/README.md server-rs/crates/module-ai/src/lib.rs server-rs/crates/module-ai/src/domain.rs server-rs/crates/module-ai/src/domain/ids.rs server-rs/crates/module-ai/src/domain/stages.rs server-rs/crates/module-ai/src/domain/types.rs server-rs/crates/module-ai/src/commands.rs server-rs/crates/module-ai/src/commands/inputs.rs server-rs/crates/module-ai/src/commands/validation.rs server-rs/crates/module-ai/src/application.rs server-rs/crates/module-ai/src/application/result.rs server-rs/crates/module-ai/src/application/service.rs server-rs/crates/module-ai/src/application/store.rs server-rs/crates/module-ai/src/tests.rs
npm.cmd run api-server:maincloud
```
`api-server:maincloud` 是常驻后端启动命令,验收时以命令启动和 `GET http://127.0.0.1:3100/healthz` 探测结果记录为准。

View File

@@ -0,0 +1,73 @@
# server-rs DDD WP-AI AI Task BFF 收口记录2026-04-29
## 1. 背景
`WP-AI AI Task` 的领域层已完成 DDD 拆分,`spacetime-module/src/ai/*` 已具备 AI task 真相表、阶段表、文本片段、结果引用、事件表和最小 reducer / procedure`spacetime-client` 已提供 typed facade`api-server` 已挂载 AI task mutation route。
本次认领的目标不是新增模型供应商能力,也不是改表,而是把现有 AI task BFF 链路做闭环验证,确认前端或后续业务模块调用 AI task 写接口时不会绕过鉴权、不会在 SpacetimeDB 未发布时返回错误形态不一致的响应。
## 2. 本次完成范围
1. 补齐 `api-server/src/ai_tasks.rs` 的 AI task mutation route 定向测试。
2. 覆盖以下写接口的未登录拦截:
- `POST /api/ai/tasks/{task_id}/stages/{stage_kind}/start`
- `POST /api/ai/tasks/{task_id}/chunks`
- `POST /api/ai/tasks/{task_id}/stages/{stage_kind}/complete`
- `POST /api/ai/tasks/{task_id}/references`
- `POST /api/ai/tasks/{task_id}/complete`
- `POST /api/ai/tasks/{task_id}/fail`
- `POST /api/ai/tasks/{task_id}/cancel`
3. 覆盖上述写接口在 token 有效但 SpacetimeDB 未发布时统一返回 `502 BAD_GATEWAY`,并保持错误详情 `provider = spacetimedb`
4. 复核 `module-ai``spacetime-client``spacetime-module` 与 BFF 定向测试,确认本次不需要修改 `migration.rs`
## 3. 边界
本次未进入:
1. 真实 LLM provider 调用、prompt 组装和供应商降级策略。
2. SSE 流式输出协议。
3. AI task 订阅 projection、清理调度或前端消费 UI。
4. SpacetimeDB table、reducer、procedure、event table 的结构变更。
5. 共享契约字段调整或前端 API client 改造。
这些能力继续归入 `WP-PF platform side effects``WP-API api-server BFF``WP-FE` 和后续对应业务域工作包,不在 `module-ai` 领域核心中混入。
## 4. 验收
已执行:
```powershell
cargo fmt -p api-server --manifest-path server-rs\Cargo.toml --check
cargo test -p api-server ai_task --manifest-path server-rs\Cargo.toml
cargo test -p module-ai --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml
cargo check -p api-server --manifest-path server-rs\Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/SERVER_RS_DDD_WP_AI_TASK_BFF_CLOSURE_2026-04-29.md docs/technical/README.md server-rs/crates/api-server/src/ai_tasks.rs server-rs/crates/api-server/src/wechat_auth.rs
npm.cmd run api-server:maincloud
```
结果:
1. `api-server ai_task` 定向测试通过7 个测试全部通过。
2. `module-ai` 测试通过9 个测试全部通过。
3. `spacetime-client` 编译通过。
4. `spacetime-module` 编译通过。
5. `api-server` 编译通过,仅保留既有未使用代码 warning。
6. `npm.cmd run check:server-rs-ddd` 通过。
7. 编码检查通过5 个文件均为 UTF-8。
8. `npm.cmd run api-server:maincloud` 已启动本仓库 `server-rs/target/debug/api-server.exe``GET http://127.0.0.1:3100/healthz` 返回 `200`
9. 未执行 SpacetimeDB 发布、绑定生成或 migration 更新,原因是本次未改变 SpacetimeDB schema、reducer/procedure 签名或绑定形状。
## 5. 关闭口径
`WP-AI AI Task` 当前已完成:
1. 领域层 DDD 拆分。
2. SpacetimeDB AI task 真相表、阶段表、文本片段、结果引用和事件表 adapter。
3. `spacetime-client` typed facade。
4. `api-server` AI task mutation route 与错误 envelope。
5. BFF 鉴权和 SpacetimeDB 未发布错误形态的定向回归测试。
因此本次将工作包认领状态从 `已认领` 更新为 `已关闭`。后续真实模型调用、SSE 和前端消费不再阻塞 `WP-AI` 关闭分别由平台副作用、API 编排和前端迁移工作包继续承接。

View File

@@ -0,0 +1,58 @@
# WP-AS Assets 资产对象类型归位落地说明
## 背景
`module-assets` 已具备 `domain / commands / application / errors / events` DDD 骨架,但 `asset_object_core.rs` 仍同时承载领域模型、命令 DTO、应用返回 DTO、字段错误和纯构建函数。继续推进资产对象、实体绑定、OSS adapter 与 SpacetimeDB row mapper 时,容易把纯领域事实和外层编排混在同一个文件里。
本次作为 `WP-AS Assets` 的小切片,只在 `module-assets` crate 内做类型归位,不改变现有公开 API、SpacetimeDB 表结构、reducer/procedure、OSS 行为、api-server 路由或前端行为。
## 本次范围
允许修改:
1. `server-rs/crates/module-assets/src/domain.rs`
2. `server-rs/crates/module-assets/src/commands.rs`
3. `server-rs/crates/module-assets/src/application.rs`
4. `server-rs/crates/module-assets/src/errors.rs`
5. `server-rs/crates/module-assets/src/asset_object_core.rs`
6. `server-rs/crates/module-assets/src/lib.rs`
7. `server-rs/crates/module-assets/README.md`
8. 本文档与全局 DDD 任务清单
禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/**`
3. `server-rs/crates/api-server/src/**`
4. `server-rs/crates/platform-oss/src/**`
5. 前端 services/hooks/components
## 设计
本次按 DDD 骨架进行类型归位:
1. `domain.rs` 承接资产对象、资产历史、实体绑定的纯快照、记录、访问策略、ID 前缀和版本常量。
2. `commands.rs` 承接确认资产对象、资产历史查询、对象 upsert、实体绑定等输入 DTO。
3. `application.rs` 承接 procedure result 和确认资产对象结果等应用返回 DTO。
4. `errors.rs` 承接 `AssetObjectFieldError` 及其中文错误文案。
5. `asset_object_core.rs` 保留字段校验、输入构建、记录构建、ID 生成和可复用归一化函数。
`lib.rs` 继续按原名称导出,避免影响 `spacetime-module``api-server` 或既有测试。
## 边界说明
1. 本次不移动 `AssetObjectService`,因为它仍依赖 `platform-oss` 的对象探测和进程内仓储。
2. 本次不新增资产任务、manifest 或专业资产表。
3. 本次不修改 `migration.rs`,因为没有表结构变更。
4. 本次不改变 `bucket + object_key` 双列真相字段。
## 验收
```powershell
cargo fmt -p module-assets --manifest-path server-rs/Cargo.toml --check
cargo test -p module-assets --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md server-rs/crates/module-assets/src/domain.rs server-rs/crates/module-assets/src/commands.rs server-rs/crates/module-assets/src/application.rs server-rs/crates/module-assets/src/errors.rs server-rs/crates/module-assets/src/asset_object_core.rs server-rs/crates/module-assets/src/lib.rs server-rs/crates/module-assets/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/README.md
```
后端启动按项目约束执行 `npm.cmd run api-server:maincloud`,若命令以前台服务常驻,则以 `/healthz` 结果记录。

View File

@@ -0,0 +1,121 @@
# WP-A Auth DDD 分层收口说明
## 背景
`module-auth` 当前已经具备 `domain / commands / application / events / errors` 文件骨架,但真实认证类型、服务、内存仓、文件持久化、短信 provider、密码哈希和微信状态逻辑仍主要集中在 `src/lib.rs`。这会让后续继续迁移 Auth 领域规则时难以区分纯领域模型和外层 adapter 能力。
本次从原先的领域值对象启动切片继续推进到 `WP-A Auth` 收口:把认证上下文的纯领域事实、命令输入、应用返回、领域错误和领域事件落回 DDD 骨架文件,同时核查 `api-server / platform-auth / spacetime-module` 的职责边界。
## 本次范围
允许修改:
1. `server-rs/crates/module-auth/src/domain.rs`
2. `server-rs/crates/module-auth/src/commands.rs`
3. `server-rs/crates/module-auth/src/application.rs`
4. `server-rs/crates/module-auth/src/events.rs`
5. `server-rs/crates/module-auth/src/errors.rs`
6. `server-rs/crates/module-auth/src/lib.rs`
7. `server-rs/crates/spacetime-module/src/auth.rs`
8. `server-rs/crates/module-auth/README.md`
9. 本文档
10. 全局 DDD 任务清单进度记录
本次只核查但不改动:
1. `server-rs/crates/api-server/src/auth*.rs`
2. `server-rs/crates/api-server/src/password_entry.rs`
3. `server-rs/crates/api-server/src/phone_auth.rs`
4. `server-rs/crates/api-server/src/refresh_session.rs`
5. `server-rs/crates/api-server/src/wechat_auth.rs`
6. `server-rs/crates/platform-auth/src/**`
禁止修改:
1. 其他玩法域。
2. 前端 services / hooks / components。
3. `server-node` 或旧 PostgreSQL 实现。
4. SpacetimeDB 表结构、reducer/procedure 签名和 `migration.rs`
## 设计
本次将以下纯领域事实和规则落入 `domain.rs`
1. `AuthLoginMethod`
2. `AuthBindingStatus`
3. `AuthUser`
4. `PhoneNumberSnapshot`
5. `PhoneAuthScene`
6. `WechatIdentityProfile`
7. `WechatAuthScene`
8. `WechatAuthStateRecord`
9. `RefreshSessionClientInfo`
10. `RefreshSessionRecord`
11. `AuthStoreSnapshotRecord`
12. 密码长度、短信验证码长度、验证码 TTL、冷却、失败次数等领域常量。
13. 手机号规范化、手机号脱敏、公开叙世号规范化、验证码 key 构造等纯函数。
本次将以下写入输入落入 `commands.rs`
1. `PasswordEntryInput`
2. `ChangePasswordInput`
3. `ResetPasswordInput`
4. `SendPhoneCodeInput`
5. `PhoneLoginInput`
6. `ResolveWechatLoginInput`
7. `CreateWechatAuthStateInput`
8. `BindWechatPhoneInput`
9. `CreateRefreshSessionInput`
10. `RotateRefreshSessionInput`
11. `LogoutCurrentSessionInput`
12. `LogoutAllSessionsInput`
13. `AuthStoreSnapshotUpsertInput`
本次将以下应用返回落入 `application.rs`
1. 登录、换密、重置密码、验证码发送、手机号登录、微信登录、微信绑定、会话签发/轮换/查询/登出等 result。
2. `AuthStoreSnapshotProcedureResult`
本次将以下错误落入 `errors.rs`
1. `PasswordEntryError`
2. `PhoneAuthError`
3. `WechatAuthError`
4. `RefreshSessionError`
5. `LogoutError`
6. 错误展示文案和模块内错误映射辅助函数。
本次将领域事件落入 `events.rs`
1. `UserCreated`
2. `RefreshSessionIssued`
3. `RefreshSessionRevoked`
4. `PhoneVerified`
5. `WechatIdentityBound`
`lib.rs` 保留当前服务、进程内仓储和文件持久化实现,但不再继续拥有上述命令、结果、错误、事件和领域值对象定义;公开 API 继续通过 `pub use application::* / commands::* / domain::* / errors::* / events::*` 导出,避免影响现有 BFF 调用。
## 边界说明
1. `module-auth` 承接账号、refresh session、短信验证码状态、微信 state 和微信绑定规则,不依赖 Axum、SpacetimeDB table API、HTTP status、真实短信 SDK、微信 HTTP 或 JWT/cookie 细节。
2. `platform-auth` 继续承接短信 provider、微信 OAuth provider、密码哈希、refresh token、JWT 和 cookie 等平台副作用。
3. `api-server` 只做请求解析、鉴权、DTO 映射、session cookie/JWT 编排和平台/领域服务装配;本次核查未发现 Auth route 需要复制领域状态机。
4. `spacetime-module/src/auth.rs` 当前仍是认证快照与正式表之间的 SpacetimeDB adapter不调用短信、微信、JWT、HTTP 或文件系统;本次只修正历史乱码中文注释和错误文案,不改变表结构或 procedure 签名。
5. `InMemoryAuthStore` 和文件持久化仍保留在 `lib.rs`,作为当前 Auth 运行支撑;总纲中“仓储剥离到 adapter 或测试支撑”的更彻底物理拆分可作为后续非 WP-A 阻塞项继续推进,但本次 WP-A 已完成 DDD 骨架归位和边界核查。
## 验收
```powershell
cargo fmt -p module-auth --manifest-path server-rs/Cargo.toml --check
cargo test -p module-auth --manifest-path server-rs/Cargo.toml
cargo check -p module-auth --manifest-path server-rs/Cargo.toml --all-features
cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml
cargo test -p api-server auth --manifest-path server-rs/Cargo.toml
cargo test -p api-server wechat --manifest-path server-rs/Cargo.toml
cargo check -p api-server --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md server-rs/crates/module-auth/src/domain.rs server-rs/crates/module-auth/src/commands.rs server-rs/crates/module-auth/src/application.rs server-rs/crates/module-auth/src/errors.rs server-rs/crates/module-auth/src/events.rs server-rs/crates/module-auth/src/lib.rs server-rs/crates/module-auth/README.md server-rs/crates/spacetime-module/src/auth.rs docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/README.md
npm.cmd run api-server:maincloud
```
本次不发布 SpacetimeDB、不生成绑定、不修改 `migration.rs`原因是没有表结构、reducer、procedure 或绑定形状变更。

View File

@@ -0,0 +1,84 @@
# server-rs DDD WP-BF Big Fish 运行态后端真相源关闭记录2026-04-29
## 1. 背景
`WP-BF Big Fish` 已完成发布门禁领域化,但运行态仍由前端本地规则推进,导致前端同时承担输入、实体移动、碰撞、合成、胜负结算和快照更新。按照本轮 DDD 重构边界,前端只负责表现和提交输入,正式运行态规则必须落到 `server-rs`
本次关闭目标是把 Big Fish 从“前端本地运行态”切到“Rust 领域规则 + SpacetimeDB 运行表 + API facade + 前端 client 接入”的单主链,同时移除 Big Fish works mapper 中的旧形状兼容解析。
## 2. 本次完成范围
1. `module-big-fish` 新增运行态领域快照:
- `BigFishRunStatus`
- `BigFishVector2`
- `BigFishRuntimeEntitySnapshot`
- `BigFishRuntimeSnapshot`
2. `module-big-fish` 新增运行态应用服务:
- `start_big_fish_run`
- `submit_big_fish_input`
- `serialize_runtime_snapshot`
- `deserialize_runtime_snapshot`
3. 运行态规则已由领域层统一负责:
- 初始己方和野生实体生成。
- 输入向量归一化。
- 领队、跟随者和野生实体移动。
- 同级收编、强弱碰撞、三合一升级。
- 离屏野生实体裁剪和补刷。
- 胜利、失败和结算事件。
4. `spacetime-module/src/big_fish/runtime.rs` 新增 Big Fish 运行态 procedure
- `start_big_fish_run`
- `get_big_fish_run`
- `submit_big_fish_input`
5. 新增 `big_fish_runtime_run` 表,保存 run 快照、最后输入方向、tick、归属用户和会话来源并已同步 `migration.rs` 与表目录。
6. `spacetime-client` 新增 Big Fish runtime typed facade 和 record mapper并重新生成 Rust 绑定。
7. `api-server` 新增鉴权路由:
- `POST /api/runtime/big-fish/sessions/{session_id}/runs`
- `GET /api/runtime/big-fish/runs/{run_id}`
- `POST /api/runtime/big-fish/runs/{run_id}/input`
8. `shared-contracts` 与前端 shared contract 新增 `BigFishRunResponse`、运行态实体/坐标 DTO 和 `SubmitBigFishInputRequest`
9. 前端 Big Fish 运行态 client 改为调用后端 run/input 接口,`PlatformEntryFlowShellImpl` 不再推进本地规则。
10. 删除 `src/services/big-fish-runtime/bigFishLocalRuntime.ts``/big-fish` 调试直达页降级回平台入口,避免继续暴露本地运行态主链。
## 3. 边界说明
1. 本次不接入 `server-node`、Express 或 PostgreSQL也不为旧 Node 兼容层保留双主链。
2. `spacetime-module` 只负责表读写、授权、事务编排和调用领域服务,不在 adapter 内复制 Big Fish 玩法规则。
3. `api-server` 只负责鉴权、请求校验、DTO 映射和 `spacetime-client` facade 调用,不直接访问 SpacetimeDB table。
4. 前端只提交方向输入并渲染后端返回快照,正式实体状态、胜负和事件日志都以后端响应为准。
5. Big Fish works mapper 已移除旧 JSON 兼容解析,后续要求 SpacetimeDB procedure 返回新的 `owner_user_id``play_count` 等稳定字段。
6. `big_fish_runtime_run` 是运行态快照表,不替代创作会话、资产槽或发布门禁真相;删除作品时同步清理来源会话下的 run。
## 4. 后续边界
1. Big Fish 运行态资源展示仍复用现有 session / work 的资产槽数据;若要让公开作品 run 响应直接携带资产包,应由后续契约任务单独扩展 DTO。
2. 真实 Maincloud 发布和订阅侧 smoke 仍归 `WP-V 全链验证与发布 smoke`
3. 若后续把运行态拆为更细表,而不是整份 `snapshot_json`,必须重新同步 `migration.rs`、绑定和 `SPACETIMEDB_TABLE_CATALOG.md`
## 5. 验收
关闭前必须执行:
```powershell
cargo test -p module-big-fish --manifest-path server-rs/Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml
cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml
cargo check -p api-server --manifest-path server-rs/Cargo.toml
npm.cmd run test -- src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/README.md docs/technical/SPACETIMEDB_TABLE_CATALOG.md server-rs/crates/module-big-fish/src/application.rs server-rs/crates/module-big-fish/src/domain.rs server-rs/crates/module-big-fish/src/commands.rs server-rs/crates/module-big-fish/src/errors.rs server-rs/crates/module-big-fish/src/events.rs server-rs/crates/module-big-fish/src/lib.rs server-rs/crates/spacetime-module/src/big_fish/runtime.rs server-rs/crates/spacetime-module/src/big_fish/tables.rs server-rs/crates/spacetime-module/src/big_fish/session.rs server-rs/crates/spacetime-module/src/migration.rs server-rs/crates/spacetime-client/src/big_fish.rs server-rs/crates/spacetime-client/src/mapper.rs server-rs/crates/spacetime-client/src/lib.rs server-rs/crates/shared-contracts/src/big_fish.rs server-rs/crates/api-server/src/big_fish.rs server-rs/crates/api-server/src/app.rs packages/shared/src/contracts/bigFish.ts src/services/big-fish-runtime/bigFishRuntimeClient.ts src/services/big-fish-runtime/index.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/BigFishPlaygroundApp.tsx
npm.cmd run api-server:maincloud
```
最终执行结果:
1. `cargo test -p module-big-fish --manifest-path server-rs/Cargo.toml` 通过6 个测试通过。
2. `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 通过。
3. `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml` 通过。
4. `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过,仅保留既有 `api-server/src/prompt/rpg/runtime_chat.rs` 未使用 helper warning。
5. `npm.cmd run check:server-rs-ddd` 通过。
6. `npm.cmd run test -- src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx` 通过4 个测试通过;测试已改为真实定时等待,不再使用全局 fake timers。
7. `npm.cmd run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public code search opens a published big fish work by BF code"` 通过,确认 BF 编号搜索进入后端 run 主链。
8. `npm.cmd run check:encoding -- ...` 对本次触达文件通过,收尾补充检查对 3 个文件通过。
9. `npm.cmd run api-server:maincloud` 已按常驻服务启动;`GET http://127.0.0.1:3100/healthz` 返回 `200`,验收后已停止本次启动进程。
补充说明:完整 `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 当前单独运行仍有既有长流程用例失败,失败集中在 RPG/拼图/创作结果页路径,例如 `create tab opens compiled agent draft in result refinement page``published puzzle detail returns to the source platform tab``agent draft result test button enters current draft without publish gate`。这些失败在不并跑 Big Fish runtime 测试时也存在,未指向本次 WP-BF 运行态迁移文件,后续应由对应 RPG/Puzzle/Custom World 前端接线包继续收口。

View File

@@ -0,0 +1,41 @@
# WP-CW Custom World 基础领域枚举归位切片
## 背景
`module-custom-world/src/lib.rs` 仍直接承载 Custom World 和 RPG Agent 的基础枚举、字符串口径与进度常量。随着 `custom_world` SpacetimeDB adapter 已完成根入口瘦身,领域 crate 也需要继续把纯领域对象迁入 `domain.rs`,避免后续 profile、agent session、draft card、gallery 与 publish gate 规则继续堆回根文件。
## 本次范围
1. 认领 `WP-CW Custom World` 的基础领域枚举归位切片。
2.`MAX_PROGRESS_PERCENT` 迁入 `module-custom-world/src/domain.rs`
3. 将 Custom World / RPG Agent 基础枚举迁入 `domain.rs`
- `CustomWorldPublicationStatus`
- `CustomWorldThemeMode`
- `CustomWorldGenerationMode`
- `CustomWorldSessionStatus`
- `RpgAgentStage`
- `RpgAgentMessageRole`
- `RpgAgentMessageKind`
- `RpgAgentOperationType`
- `RpgAgentOperationStatus`
- `RpgAgentDraftCardKind`
- `RpgAgentDraftCardStatus`
- `CustomWorldRoleAssetStatus`
4. 将这些枚举的 `as_str``CustomWorldThemeMode::from_client_str` 一并迁入 `domain.rs`
5. `lib.rs` 通过 `pub use domain::*` 保持既有 `module_custom_world::*` 公开 API。
## 边界
1. 本次不改 SpacetimeDB table、reducer、procedure、row mapper 或 `migration.rs`
2. 本次不改 `api-server``spacetime-client`、platform adapter 或前端。
3. 本次不移动 profile、agent session、draft card、publish gate 的结构体和校验函数,避免把大包拆分与本切片混在一起。
4. 本次不改变任何序列化字段、枚举字符串值或中文错误文案。
## 验收
```powershell
cargo fmt -p module-custom-world --manifest-path server-rs/Cargo.toml --check
cargo test -p module-custom-world --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md server-rs/crates/module-custom-world/src/domain.rs server-rs/crates/module-custom-world/src/lib.rs docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,32 @@
# WP-FE-C RPG runtime shell 组件测试夹具接线切片
## 背景
`WP-FE-S``WP-FE-H` 已经把 RPG runtime story 读取侧收口到 `storySessionId` scoped projection client 与 hooks 网关。组件层仍不能迁移完整开局、动作结算等写接口,但 `RpgRuntimeShell` 组件测试夹具还保留旧 hook UI 形状,导致 `typecheck` 在组件测试文件中失败。
## 本次范围
1. 认领 `WP-FE-C Frontend components 接线` 中可并行的 RPG runtime shell 组件测试夹具接线切片。
2. 修正 `src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx`
- `StoryMoment` mock 使用当前 `text/options` 字段。
- `Character` mock 对齐当前角色类型,不再写入旧 `motivation/combatStyle/role/imageSrc/initialItems` 等字段。
- `npcUi/characterChatUi/inventoryUi/battleRewardUi/questUi/npcChatQuestOfferUi/goalUi` mock 对齐当前 hooks 暴露的稳定 UI 对象形状。
3. 保持 `RpgRuntimeShell` 正式组件行为不变,只消除测试夹具对旧组件接线形状的依赖。
## 边界
1. 本次不改 `src/services/**``src/hooks/**` 或任何后端接口。
2. 本次不迁移 `beginRuntimeStorySession``resolveRuntimeStoryAction` 等尚未稳定的新写接口。
3. 本次不新增 UI 文案,不改组件视觉布局。
4. 本次不删除旧 runtime story contract 或旧 client alias这些仍等待 `WP-DEL` 串行收口。
## 验收
```powershell
npm.cmd run typecheck -- --pretty false
npm.cmd run test -- src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_FE_C_RPG_RUNTIME_SHELL_TEST_FIXTURE_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx
git diff --check -- docs/technical/SERVER_RS_DDD_WP_FE_C_RPG_RUNTIME_SHELL_TEST_FIXTURE_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx
```
结果:`RpgRuntimeShell.test.tsx` 定向测试通过;编码检查和空白检查通过。`typecheck` 未全量通过,但不再报本切片文件,剩余阻塞来自既有非本切片文件 `src/data/sceneEncounterPreviews.ts``src/services/ai.ts`

View File

@@ -0,0 +1,32 @@
# WP-FE-H RPG runtime story 读取侧 hooks 接线切片
## 背景
`WP-FE-S` 已经补齐 story session 新主链 client并提供 `getRpgStoryRuntimeProjection` 读取 `/api/story/sessions/{storySessionId}/runtime-projection``WP-FE-H` 的完整 hooks 迁移仍依赖后端 session scoped 开局和动作结算写接口;但读取侧 option catalog 与继续游戏刷新已经具备稳定 projection contract可以先把 hooks 网关的读取语义收口到 projection client。
## 本次范围
1. 认领 `WP-FE-H Frontend hooks 迁移` 中可并行的 RPG runtime story 读取侧 hooks 接线切片。
2.`src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` 的读取侧从历史别名 `getRpgRuntimeStoryState` 改为显式调用 `getRpgStoryRuntimeProjection`
3. `loadServerRuntimeOptionCatalog` 继续只读取服务端 option catalog不上传本地 `GameState` 参与动作合法性解析。
4. `resumeServerRuntimeStory` 继续保持本地已水合快照主体,只同步服务端 `runtimeSessionId / storySessionId / runtimeActionVersion` 和投影故事。
5. 更新 `runtimeStoryCoordinator.test.ts` mock 命名,确保 hooks 测试明确断言读取侧走 projection client。
6. 补齐 `src/hooks/useGameFlow.customWorld.test.tsx``beginRpgRuntimeStorySession` 测试桩,避免 hooks 全量测试在 Node 环境直接 fetch 相对路径,同时保持自定义世界开局 hooks 仍消费服务端快照。
## 边界
1. 本次不迁移 `beginRuntimeStorySession`,因为新开局接口尚未返回可直接进入游戏的完整 `GameState` 快照。
2. 本次不迁移 `resolveRuntimeStoryAction`,因为完整动作结算的新 session scoped 写接口尚未稳定。
3. 本次不改 components不新增 UI 文案,不在组件层拼 API 请求。
4. 本次不改 `api-server``spacetime-client``spacetime-module` 或共享契约。
5. 本次不删除旧 runtime story contract、旧 client alias 或兼容测试;这些仍等待 `WP-DEL` 串行收口。
## 验收
```powershell
npm.cmd run test -- src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts
npm.cmd run test -- src/hooks/useGameFlow.customWorld.test.tsx
npm.cmd run test -- src/hooks
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_FE_H_RPG_RUNTIME_STORY_HOOKS_PROJECTION_2026-04-29.md src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts src/hooks/rpg-runtime-story/inventoryActions.ts src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts src/hooks/useGameFlow.customWorld.test.tsx docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
git diff --check -- docs/technical/SERVER_RS_DDD_WP_FE_H_RPG_RUNTIME_STORY_HOOKS_PROJECTION_2026-04-29.md src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts src/hooks/rpg-runtime-story/inventoryActions.ts src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts src/hooks/useGameFlow.customWorld.test.tsx docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,57 @@
# WP-FE-S RPG runtime story client 迁移记录
## 背景
`WP-API` 已提供新主链读取接口:
```text
GET /api/story/sessions/{storySessionId}/runtime-projection
```
该接口返回 `StoryRuntimeProjectionResponse`,不再返回旧 runtime story 的 `snapshot / viewModel / presentation / patches` 组合。前端读取侧必须改用 `storySessionId`,不能继续把 `runtimeSessionId` 当成 story 会话主键。
## 本轮落地边界
已落地:
1.`packages/shared/src/contracts/story.ts` 补齐前端 story session / runtime projection 契约,字段对齐 Rust `shared-contracts/src/story.rs` 的 camelCase 回包。
2.`GameState`、快照水合类型与水合逻辑中新增 `storySessionId?: string | null`
3. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 的读取侧改为:
- `getRuntimeStoryState({ storySessionId })` 请求 `/api/story/sessions/{storySessionId}/runtime-projection`
- `loadRuntimeInventoryView``StoryRuntimeProjectionResponse` 映射背包视图。
- 缺失 `storySessionId` 时直接抛出中文错误,不回退到 `runtimeSessionId`
4. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` 的读取侧改为消费新投影:
- 选项目录来自 `projection.options`
- 继续游戏时保留本地已水合快照主体,只同步 `runtimeSessionId / storySessionId / runtimeActionVersion` 和展示故事。
5. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 新增 story session 新主链 API client
- `beginStorySession` 请求 `POST /api/story/sessions`
- `continueStorySession` 请求 `POST /api/story/sessions/continue`
- `getStorySessionState` 请求 `GET /api/story/sessions/{storySessionId}/state`
- `getStoryRuntimeProjection` 请求 `GET /api/story/sessions/{storySessionId}/runtime-projection`
-`storySessionId` 做统一 trim 与空值中文错误,避免后续 hooks 继续散落路径常量和空 ID 分支。
6. `src/services/rpg-runtime/index.ts` 已导出新 client 函数与结果类型,供后续 `WP-FE-H` 迁 hook 时直接接入。
7. 为满足 `WP-FE-S``src/services` 全量验收,补回 `src/services/customWorldAgentGenerationProgress.ts` 缺失的“建立场景连接”阶段,使草稿生成进度重新对齐既有 13 步文档与测试口径。
## 未完成边界
暂未迁移:
1. `beginRuntimeStorySession` 仍调用旧 `/api/runtime/story/sessions`,因为当前新 `/api/story/sessions` 只创建 story session不返回前端开局所需的完整 `GameState` 快照。新 `beginStorySession` 已作为稳定 client 先行提供hook 是否切换等待后端开局投影组合稳定。
2. `resolveRuntimeStoryAction` 仍调用旧 `/api/runtime/story/actions/resolve`,因为当前新主链尚未提供等价的 session scoped 动作结算接口与快照回包。新 `continueStorySession` 只覆盖 story event 续写,不等价于完整 runtime action settle。
3. hooks/components 不在本轮大改;`WP-FE-H` 等后端写接口稳定后再迁。
## 后续依赖
1. `WP-API/WP-RS` 需要提供新的 story session scoped 开局接口,返回可直接进入游戏的 `GameState` 或明确的前端投影组合。
2. `WP-API/WP-RS` 需要提供新的 story session scoped 动作结算接口,返回新投影及必要的状态更新结果。
3. 上述接口稳定后,`WP-FE-S` 再删除旧 `/api/runtime/story/*` 写接口调用,`WP-FE-H` 迁 hooks最后由 `WP-DEL` 物理删除旧 runtime story contract 与 compat 测试。
## 验收命令
```powershell
npm.cmd run test -- src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts
npm.cmd run test -- src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts
npm.cmd run test -- src/services/customWorldAgentGenerationProgress.test.ts
npm.cmd run test -- src/services
npm.cmd run check:encoding -- packages/shared/src/contracts/story.ts src/services/rpg-runtime/rpgRuntimeStoryClient.ts src/services/rpg-runtime/index.ts src/services/customWorldAgentGenerationProgress.ts src/services/customWorldAgentGenerationProgress.test.ts src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts src/hooks/rpg-runtime-story/inventoryActions.ts src/persistence/runtimeSnapshot.ts src/persistence/runtimeSnapshotTypes.ts src/types/game.ts docs/technical/SERVER_RS_DDD_WP_FE_S_RPG_RUNTIME_STORY_CLIENT_MIGRATION_2026-04-29.md
```

View File

@@ -0,0 +1,69 @@
# WP-PF 平台副作用错误分类收口记录2026-04-29
## 1. 背景
`WP-PF platform side effects` 负责承载 LLM、OSS、SMS、微信等外部副作用实现。当前 `platform-llm``platform-oss``platform-auth` 已经各自有错误枚举,但 `api-server` 后续接入时仍容易重复用字符串或具体枚举分支判断 HTTP 错误类别。
第一段切片已补平台错误分类基础设施。继续收口时,需要把 `api-server` 中已接入的平台副作用统一改为消费这些稳定分类,并把微信 OAuth HTTP provider 下沉到 `platform-auth`,让 `api-server` 只保留 BFF 编排、会话签发、redirect 与错误 envelope 映射。
## 2. 范围
允许修改:
1. `server-rs/crates/platform-llm/src/lib.rs`
2. `server-rs/crates/platform-oss/src/lib.rs`
3. `server-rs/crates/platform-auth/src/lib.rs`
4. `server-rs/crates/api-server/src/platform_errors.rs`
5. `server-rs/crates/api-server/src/llm.rs`
6. `server-rs/crates/api-server/src/assets.rs`
7. 已经直接映射 OSS 错误的资产相关 BFF 模块
8. `server-rs/crates/api-server/src/state.rs`
9. `server-rs/crates/api-server/src/wechat_auth.rs`
10. `server-rs/crates/api-server/src/wechat_provider.rs`
11. 本文档
12. 全局任务清单进度记录
禁止修改:
1. `spacetime-module/src/**`
2. `spacetime-client/src/**`
3. `module-*`
4. 前端 services/hooks/components
5. SpacetimeDB table / reducer / procedure / migration
6. 玩法状态机和领域规则
## 3. 设计
每个 platform crate 暴露自己的错误分类枚举:
1. `LlmErrorKind`
2. `OssErrorKind`
3. `AuthPlatformErrorKind`
并在既有错误枚举上增加 `kind()` 方法。分类只表达 adapter 可消费的稳定错误类别,不承载业务状态机,也不直接绑定 HTTP status。
`api-server` 增加内部 `platform_errors` 模块,负责把平台错误分类映射到 HTTP status、统一 provider details 与中文 envelope。映射原则
1. `InvalidRequest` / `InvalidConfig` 等本地配置或请求错误不再散落在业务 route 中重复 match。
2. `ObjectNotFound` 稳定映射为 `404`
3. LLM 上游 `429` 保留 `429`,其他上游、网络、序列化、签名类错误映射为 `502`
4. SMS/微信 provider 错误统一走 `platform-auth` 错误分类,领域态错误仍由 `module-auth` 自己的应用错误映射。
5. `platform-*` 不绑定 HTTP status不生成 Axum response不持有玩法领域状态。
微信 OAuth provider 的 HTTP 请求、授权 URL 拼接、mock provider 和回调资料解析归入 `platform-auth`。由于 `platform-auth` 不能依赖 `module-auth`,微信 provider 输出独立的 `WechatIdentityProfile``api-server` 在 BFF 边界把它转换为 `module_auth::WechatIdentityProfile` 后再调用领域服务。
图像/视频模型直连接口仍分布在角色形象、角色动画、拼图和自定义世界资产模块中,当前属于视觉资产生成专项遗留,不在本次 WP-PF LLM/OSS/SMS/微信统一 adapter 收口内;后续若新建 image/video platform crate需要另立工作包迁移。
## 4. 验收
```powershell
cargo test -p platform-llm -p platform-oss -p platform-auth --manifest-path server-rs/Cargo.toml
cargo fmt -p platform-llm -p platform-oss -p platform-auth --manifest-path server-rs/Cargo.toml --check
cargo test -p api-server platform_errors --manifest-path server-rs/Cargo.toml
cargo test -p api-server llm --manifest-path server-rs/Cargo.toml
cargo test -p api-server wechat --manifest-path server-rs/Cargo.toml
cargo check -p api-server --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/platform-llm/src/lib.rs server-rs/crates/platform-oss/src/lib.rs server-rs/crates/platform-auth/src/lib.rs server-rs/crates/api-server/src/platform_errors.rs server-rs/crates/api-server/src/llm.rs server-rs/crates/api-server/src/state.rs server-rs/crates/api-server/src/wechat_provider.rs server-rs/crates/api-server/src/wechat_auth.rs
npm.cmd run api-server:maincloud
```

View File

@@ -0,0 +1,43 @@
# WP-PZ Puzzle 基础领域常量与枚举归位切片
## 背景
`module-puzzle/src/lib.rs` 当前仍承载 Puzzle Agent、作品发布和运行态拼图的基础枚举、ID 前缀、标签数量规则与洗牌尝试次数。随着 DDD 骨架已经具备 `domain.rs``commands.rs``application.rs``events.rs``errors.rs`,本切片先把纯领域常量和基础枚举迁入 `domain.rs`,避免后续 Agent session、work profile、runtime run 与排行榜规则继续堆回根文件。
## 本次范围
1. 认领 `WP-PZ Puzzle` 的基础领域常量与枚举归位切片。
2. 将以下常量迁入 `module-puzzle/src/domain.rs`
- `PUZZLE_AGENT_SESSION_ID_PREFIX`
- `PUZZLE_AGENT_MESSAGE_ID_PREFIX`
- `PUZZLE_PROFILE_ID_PREFIX`
- `PUZZLE_RUN_ID_PREFIX`
- `PUZZLE_MIN_TAG_COUNT`
- `PUZZLE_MAX_TAG_COUNT`
- `PUZZLE_INITIAL_SHUFFLE_ATTEMPTS`
3. 将以下基础枚举迁入 `domain.rs`
- `PuzzleAgentStage`
- `PuzzleAnchorStatus`
- `PuzzleAgentMessageRole`
- `PuzzleAgentMessageKind`
- `PuzzlePublicationStatus`
- `PuzzleRuntimeLevelStatus`
4. 将这些枚举的 `as_str` 方法一并迁入 `domain.rs`
5. `lib.rs` 通过 `pub use domain::*` 保持既有 `module_puzzle::*` 公开 API。
## 边界
1. 本次不改 SpacetimeDB table、reducer、procedure、row mapper 或 `migration.rs`
2. 本次不改 `api-server``spacetime-client`、platform adapter 或前端。
3. 本次不移动 Agent session、work profile、runtime run、leaderboard 的结构体和校验函数,避免把大包拆分与本切片混在一起。
4. 本次不改变任何序列化字段、枚举字符串值、标签数量规则、洗牌规则或中文错误文案。
## 验收
```powershell
cargo fmt -p module-puzzle --manifest-path server-rs/Cargo.toml --check
cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml
cargo check -p module-puzzle --features spacetime-types --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md server-rs/crates/module-puzzle/src/domain.rs server-rs/crates/module-puzzle/src/lib.rs server-rs/crates/module-puzzle/README.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,33 @@
# WP-PZ Puzzle 领域类型与规则拆分切片
## 背景
上一轮 `WP-PZ Puzzle` 已经把基础常量与枚举迁入 `module-puzzle/src/domain.rs`,但 `module-puzzle/src/lib.rs` 仍然承载 Agent 会话快照、作品 profile、运行态棋盘、procedure 输入、procedure 返回、字段错误与全部纯规则函数。
按照 `SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md``module-puzzle` 应继续把纯领域内容落入 `domain.rs``commands.rs``application.rs``events.rs``errors.rs`,让根入口只保留模块声明和公开导出。
## 本次范围
1. 将 Puzzle Agent、锚点包、草稿、作品 profile、运行态棋盘、排行榜等纯领域类型迁入 `module-puzzle/src/domain.rs`
2. 将 SpacetimeDB procedure/reducer 写入输入迁入 `module-puzzle/src/commands.rs`
3. 将 procedure 返回包装和拼图纯规则函数迁入 `module-puzzle/src/application.rs`
4.`PuzzleFieldError` 和中文错误文案迁入 `module-puzzle/src/errors.rs`
5.`module-puzzle/src/events.rs` 增加最小 `PuzzleDomainEvent`,先表达草稿变化、作品发布和运行态推进事实。
6. `module-puzzle/src/lib.rs` 只保留 DDD 子模块声明和 `pub use`,保持既有 `module_puzzle::*` 外部 API。
## 边界
1. 本次不改 SpacetimeDB table、reducer、procedure、row mapper 或 `migration.rs`
2. 本次不改 `api-server``spacetime-client`、platform adapter 或前端。
3. 本次不改变序列化字段、procedure 输入结构、procedure 返回 JSON 包装、标签数量规则、洗牌规则、移动/合并/拆分语义或中文错误文案。
4. 本次新增的 `PuzzleDomainEvent` 只作为领域事件落位,不接入 `spacetime-module` event table。
## 验收
```powershell
cargo fmt -p module-puzzle --manifest-path server-rs/Cargo.toml --check
cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml
cargo check -p module-puzzle --features spacetime-types --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md server-rs/crates/module-puzzle/src/lib.rs server-rs/crates/module-puzzle/src/domain.rs server-rs/crates/module-puzzle/src/commands.rs server-rs/crates/module-puzzle/src/application.rs server-rs/crates/module-puzzle/src/events.rs server-rs/crates/module-puzzle/src/errors.rs server-rs/crates/module-puzzle/README.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,41 @@
# WP-RPG Combat 基础领域常量与枚举归位切片
## 背景
`WP-RPG Gameplay 域` 覆盖 combat、inventory、npc、progression、quest、runtime-item、story 等多个玩法 crate。本次选择其中最小且可并行的 `module-combat` 切片:`module-combat/src/lib.rs` 仍直接承载战斗 ID 前缀、版本/伤害常量、旧攻击 function 列表以及基础枚举。随着 DDD 骨架已经具备 `domain.rs`,这些纯领域对象应先归位到 `domain.rs`,为后续拆分命令、错误、应用结果和跨域事件留出边界。
## 本次范围
1. 认领 `WP-RPG Gameplay 域` 的 combat 基础领域常量与枚举归位切片。
2. 将以下常量迁入 `module-combat/src/domain.rs`
- `BATTLE_STATE_ID_PREFIX`
- `INITIAL_BATTLE_VERSION`
- `BASIC_FIGHT_COUNTER_RATIO`
- `MIN_FIGHT_COUNTER_DAMAGE`
- `SPAR_MIN_HP`
- `LEGACY_ATTACK_FUNCTION_IDS`
3. 将以下基础枚举迁入 `domain.rs`
- `BattleMode`
- `BattleStatus`
- `CombatOutcome`
4. 将这些枚举的 `as_str` 方法一并迁入 `domain.rs`
5. `lib.rs` 通过 `pub use domain::*` 保持既有 `module_combat::*` 公开 API。
6. `module-combat``spacetime-types` feature 同步启用 `module-runtime-item/spacetime-types`,确保战斗快照里引用的 `RuntimeItemRewardItemSnapshot` 在 wasm 目标下具备 SpacetimeDB 类型派生。
## 边界
1. 本次不改 SpacetimeDB table、reducer、procedure、row mapper 或 `migration.rs`
2. 本次不改 `api-server``spacetime-client`、platform adapter 或前端。
3. 本次不移动 `BattleStateInput``BattleStateSnapshot``ResolveCombatActionInput``CombatFieldError` 和战斗结算函数,避免把常量枚举归位与大包拆分混在一起。
4. 本次不改变任何战斗数值、支持的 function id、枚举字符串值或中文错误文案。
5. Cargo feature 传播仅用于修复 `spacetime-types` 组合编译,不引入新依赖路径或运行时行为。
## 验收
```powershell
cargo fmt -p module-combat --manifest-path server-rs/Cargo.toml --check
cargo test -p module-combat --manifest-path server-rs/Cargo.toml
cargo check -p module-combat --features spacetime-types --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md server-rs/crates/module-combat/Cargo.toml server-rs/crates/module-combat/src/domain.rs server-rs/crates/module-combat/src/lib.rs server-rs/crates/module-combat/README.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,38 @@
# WP-RPG module-story 领域拆分收口2026-04-29
## 1. 收口目标
本切片关闭 `SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md``module-story` 的两类漂移:
1. README 仍写“目录占位”和旧 `/api/runtime/story/*` 兼容链路。
2. `domain / commands / application / events / errors` 文件仍停在“过渡落位”注释,真实规则集中在 `lib.rs`
本次只做物理分层和文档对齐,不新增 story action 写接口,不修改 SpacetimeDB 表结构,不触碰 `migration.rs`
## 2. 已完成内容
1. `src/domain.rs` 收口剧情会话领域快照、状态、ID 前缀和 `generate_story_session_id`
2. `src/commands.rs` 收口 `StorySessionInput``StoryContinueInput``StorySessionStateInput`、输入构造和字段校验。
3. `src/events.rs` 收口 `StoryEventKind``StoryEventSnapshot`、开局事件和事件 ID 生成。
4. `src/application.rs` 收口会话快照构造、续写应用服务、procedure result 类型和只读 record mapper。
5. `src/errors.rs` 收口 `StorySessionFieldError` 与中文错误文案。
6. `src/lib.rs` 只保留模块声明和 `pub use`,继续保持 `module_story::*` 公开 API。
7. `module-story/README.md` 改为当前真实边界,明确不恢复旧 `/api/runtime/story/*` 兼容路由。
## 3. 边界
1. 本切片不改变 `story_session``story_event` 的 SpacetimeDB row shape、reducer/procedure 签名或前端 DTO。
2. 本切片不接入 LLM、SSE、HTTP route 或前端 hooks。
3. 后续完整动作结算仍等待 `WP-RS/WP-ST/WP-SC/WP-API/WP-FE` 继续推进。
4. `module-story` 只承载纯 story session 规则;运行态投影、背包/NPC/战斗/任务联动继续由相邻领域模块和 adapter 编排。
## 4. 验收
已执行:
```powershell
cargo test -p module-story --manifest-path server-rs\Cargo.toml
cargo check -p module-story --manifest-path server-rs\Cargo.toml
```
结果通过8 个单元测试通过。

View File

@@ -0,0 +1,56 @@
# WP-RS Runtime Story Compat 残留审计切片
## 背景
`WP-RS Runtime Story 去兼容层` 已完成 crate 迁名、旧 HTTP compat 路由下线、runtime projection 契约/API/读取侧迁移等多轮推进。当前仍存在一些 `compat` 或旧 `/api/runtime/story/*` 命名残留,需要区分“可以立即清理的工程语义残留”和“必须等待后端写接口稳定后再删除的运行依赖”。
本次切片只处理前者,并冻结后者的交接清单。
## 本次范围
允许修改:
1. `server-rs/crates/module-runtime-story/src/**` 中只描述历史兼容阶段的注释。
2. `server-rs/crates/module-runtime-story/README.md`
3. 本文档
4. `docs/technical/README.md`
5. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
禁止修改:
1. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
2. `src/hooks/rpg-runtime-story/**`
3. `packages/shared/src/contracts/rpgRuntimeStoryState.ts`
4. `server-rs/crates/shared-contracts/src/runtime_story.rs`
5. `api-server` route 挂载和 BFF 行为
6. SpacetimeDB 表、procedure、绑定和 `migration.rs`
## 本次处理
1. 清理 `module-runtime-story` 运行代码注释中的 `compat` 定位,将 crate 口径改为 runtime story 主链纯规则收口。
2. 保留“历史 payload”相关说明因为这些字段仍是实际输入兼容范围不属于旧层命名。
3. 更新 `module-runtime-story/README.md`,明确后续要迁的是旧 `/api/runtime/story/*` 写侧能力,而不是继续扩展兼容桥。
## 残留清单
以下残留本次不删除:
1. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 仍指向 `/api/runtime/story`,原因是开局和动作写侧 session scoped 新接口尚未完成。
2. `src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts` 仍断言旧写接口路径,需等待新写接口和 service contract 稳定后由 `WP-FE-S` 修改。
3. `packages/shared/src/contracts/rpgRuntimeStoryState.ts``server-rs/crates/shared-contracts/src/runtime_story.rs` 仍保留 `RuntimeStoryActionResponse`,原因是前端旧写侧仍消费该响应形状。
4. `src/hooks/rpg-runtime-story/**` 仍保留部分旧调用面兼容注释,需等 `WP-FE-H` 在 service 迁移后统一收口。
## 后续边界
1. 后端新写接口稳定前,不删除旧前端写 client。
2. `WP-FE-S` 先迁 service再由 `WP-FE-H` 迁 hooks最后 `WP-FE-C` 接组件。
3. `WP-DEL` 只能在旧接口无运行引用后删除 `RuntimeStoryActionResponse` 等旧 contract。
## 验收
```powershell
cargo fmt -p module-runtime-story --manifest-path server-rs/Cargo.toml --check
cargo test -p module-runtime-story --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RS_COMPAT_RESIDUE_AUDIT_2026-04-29.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/module-runtime-story/README.md server-rs/crates/module-runtime-story/src/domain.rs server-rs/crates/module-runtime-story/src/commands.rs server-rs/crates/module-runtime-story/src/errors.rs server-rs/crates/module-runtime-story/src/events.rs server-rs/crates/module-runtime-story/src/core.rs server-rs/crates/module-runtime-story/src/game_state.rs server-rs/crates/module-runtime-story/src/battle.rs server-rs/crates/module-runtime-story/src/forge.rs server-rs/crates/module-runtime-story/src/forge_actions.rs server-rs/crates/module-runtime-story/src/npc_support.rs
```

View File

@@ -0,0 +1,130 @@
# WP-RT Adapter/API 收口落地说明
## 背景
`WP-RT Runtime/Profile/Save` 已完成 runtime settings、snapshot/profile/save archive 类型、错误层、命令构造和应用记录投影拆分。剩余风险集中在 Adapter/API 层仍保留部分纯规则,以及 profile 旧兼容路径继续挂载,容易让后续前端或 BFF 再走 `/api/runtime/profile/*` 旧入口。
本次收口目标是继续遵循 DDD 边界:`module-runtime` 承载 runtime/profile/save 的纯规则和字段错误,`spacetime-module` 只保留 SpacetimeDB table、事务读写和 row mapper`api-server` 只负责 HTTP/BFF 映射,前端请求层改用新的 profile API。
## 本次范围
允许修改:
1. `server-rs/crates/module-runtime/src/domain.rs`
2. `server-rs/crates/module-runtime/src/errors.rs`
3. `server-rs/crates/module-runtime/src/commands.rs`
4. `server-rs/crates/module-runtime/src/application.rs`
5. `server-rs/crates/module-runtime/src/lib.rs`
6. `server-rs/crates/spacetime-module/src/runtime/profile.rs`
7. `server-rs/crates/api-server/src/app.rs`
8. `server-rs/crates/api-server/src/runtime_save.rs`
9. `server-rs/crates/api-server/src/runtime_profile.rs`
10. `server-rs/crates/api-server/src/runtime_browse_history.rs`
11. `src/services/rpg-runtime/rpgRuntimeRequest.ts`
12. `src/services/rpg-entry/rpgProfileClient.test.ts`
13. `src/services/rpg-entry/rpgEntryClients.routing.test.ts`
14. 相关 README、技术文档和全局任务清单
禁止修改:
1. SpacetimeDB 表结构和 `migration.rs`
2. RPG story / runtime story 玩法规则
3.`server-node` / PostgreSQL 兼容逻辑
4. 非 WP-RT 并行包文件
## 设计
### 1. checkpoint 规则下沉
`api-server/src/runtime_save.rs` 不再本地维护 checkpoint 的 sessionId 校验、预览/测试态拒绝和 runtimeStats 时间水位刷新规则。
新增或迁入 `module-runtime`
1. `RuntimeSaveCheckpointInput`
2. `RuntimeSaveCheckpointSnapshotUpdate`
3. `build_runtime_save_checkpoint_input`
4. `build_runtime_save_checkpoint_update`
5. `refresh_runtime_snapshot_play_time`
6. `is_non_persistent_runtime_snapshot`
7. checkpoint 相关 `RuntimeProfileFieldError`
`api-server` 只把 HTTP payload 转为领域输入,并把领域错误映射为 runtime-save 的 API envelope。
### 2. profile/save archive 投影 meta 规则下沉
`spacetime-module/src/runtime/profile.rs` 不再本地维护 save archive/world meta 的 JSON 解析规则。
新增或迁入 `module-runtime`
1. `RuntimeProfileWorldSnapshotMeta`
2. `RuntimeProfileSaveArchiveMeta`
3. `resolve_runtime_profile_world_snapshot_meta`
4. `resolve_runtime_profile_save_archive_meta`
5. `read_runtime_json_non_negative_u64`
6. `read_runtime_json_string_field`
7. `build_runtime_builtin_world_title`
`spacetime-module` 继续负责读取 snapshot、写入 dashboard / played world / save archive 表,但 meta 判断和默认标题、摘要兜底由领域模块统一维护。
### 3. profile 剩余纯规则下沉
本次继续把留在 SpacetimeDB adapter 中的纯规则收回 `module-runtime`
1. played world、snapshot wallet ledger、save archive、recharge order、recharge wallet ledger、redeem usage、redeem ledger 等 ID 生成规则。
2. 首充叙世币奖励计算。
3. 会员购买续期时间计算。
4. 邀请码 deterministic 生成、邀请链接、每日奖励窗口和邀请人奖励上限判断。
5. 兑换码 public / unique / private 模式使用资格校验。
6. 钱包正负 delta 转换、余额溢出和余额不足校验。
`spacetime-module` 中仍保留必须依赖表状态的逻辑:查找已有邀请码、统计当天奖励次数、读取/更新 wallet dashboard、写入 wallet ledger、membership、recharge order、redeem usage 和 referral relation。
### 4. profile 旧兼容路径移除
`api-server/src/app.rs` 移除 `/api/runtime/profile/*` 旧兼容挂载,只保留 `/api/profile/*` 新主路径。
保留的新路径:
1. `/api/profile/browse-history`
2. `/api/profile/dashboard`
3. `/api/profile/wallet-ledger`
4. `/api/profile/recharge-center`
5. `/api/profile/recharge/orders`
6. `/api/profile/referrals/invite-center`
7. `/api/profile/referrals/redeem-code`
8. `/api/profile/redeem-codes/redeem`
9. `/api/profile/save-archives`
10. `/api/profile/save-archives/{world_key}`
新增 `runtime_profile_legacy_routes_are_not_mounted` 测试,确认 `/api/runtime/profile/*` 旧路径返回 `404`
### 5. 前端 profile 请求路径对齐
`src/services/rpg-runtime/rpgRuntimeRequest.ts` 对以 `/profile/` 开头的 runtime request 直接发送到 `/api/profile/*`,其他 runtime 路径继续发送到 `/api/runtime/*`
`rpgProfileClient` 与 entry routing 测试同步改为断言 `/api/profile/*`
## 边界说明
1. 本次没有新增、删除或调整 SpacetimeDB 表字段,因此不修改 `migration.rs`
2. 本次没有改 reducer/procedure 对外签名,也没有改 generated binding。
3. 本次删除的是 HTTP 兼容挂载,不保留 `/api/runtime/profile/*` fallback。
4. 本次不把 SpacetimeDB 表查询搬进 `module-runtime`;领域模块只接收纯输入并返回纯结果。
5. 本次不处理 runtime story / RPG story 规则;相关内容仍归 `WP-RS``WP-RPG`
## 验收
```powershell
cargo fmt -p module-runtime -p spacetime-module -p api-server --manifest-path server-rs/Cargo.toml --check
cargo test -p module-runtime --manifest-path server-rs/Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml
cargo test -p api-server runtime_profile --manifest-path server-rs/Cargo.toml
cargo test -p api-server runtime_browse_history --manifest-path server-rs/Cargo.toml
cargo test -p api-server runtime_snapshot --manifest-path server-rs/Cargo.toml
cargo test -p api-server profile_save_archives --manifest-path server-rs/Cargo.toml
npm.cmd run test -- src/services/rpg-entry/rpgProfileClient.test.ts src/services/rpg-entry/rpgEntryClients.routing.test.ts
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md server-rs/crates/module-runtime/src/domain.rs server-rs/crates/module-runtime/src/errors.rs server-rs/crates/module-runtime/src/commands.rs server-rs/crates/module-runtime/src/application.rs server-rs/crates/module-runtime/src/lib.rs server-rs/crates/module-runtime/README.md server-rs/crates/spacetime-module/src/runtime/profile.rs server-rs/crates/api-server/src/app.rs server-rs/crates/api-server/src/runtime_save.rs server-rs/crates/api-server/src/runtime_profile.rs server-rs/crates/api-server/src/runtime_browse_history.rs src/services/rpg-runtime/rpgRuntimeRequest.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/rpg-entry/rpgEntryClients.routing.test.ts docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
git diff --check -- docs/technical/SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md server-rs/crates/module-runtime/src/domain.rs server-rs/crates/module-runtime/src/errors.rs server-rs/crates/module-runtime/src/commands.rs server-rs/crates/module-runtime/src/application.rs server-rs/crates/module-runtime/src/lib.rs server-rs/crates/module-runtime/README.md server-rs/crates/spacetime-module/src/runtime/profile.rs server-rs/crates/api-server/src/app.rs server-rs/crates/api-server/src/runtime_save.rs server-rs/crates/api-server/src/runtime_profile.rs server-rs/crates/api-server/src/runtime_browse_history.rs src/services/rpg-runtime/rpgRuntimeRequest.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/rpg-entry/rpgEntryClients.routing.test.ts docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
npm.cmd run api-server:maincloud
```

View File

@@ -0,0 +1,47 @@
# WP-RT 应用记录投影拆分落地说明
## 背景
`module-runtime` 已完成领域类型、错误类型和命令构造拆分。根入口 `lib.rs` 仍承载大量 `build_runtime_*_record`,负责把 SpacetimeDB/procedure 快照转换为 BFF 或上层 facade 使用的记录投影。该职责更接近应用层读模型映射,应迁入 `application.rs`
## 本次范围
允许修改:
1. `server-rs/crates/module-runtime/src/application.rs`
2. `server-rs/crates/module-runtime/src/lib.rs`
3. `server-rs/crates/module-runtime/README.md`
4. `docs/technical/README.md`
5. 全局 DDD 任务清单进度记录
禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/**`
3. `server-rs/crates/api-server/src/**`
4. 前端 services/hooks/components
5. `server-rs/crates/spacetime-module/src/migration.rs`
## 设计
本次将以下记录投影函数迁入 `application.rs`
1. settings、browse history、profile dashboard、wallet ledger、recharge center、membership、referral、reward code、redeem code、played world、play stats、runtime snapshot、save archive 的 `build_runtime_*_record`
2. 记录投影专用 JSON helper`parse_optional_json_value`
`format_utc_micros` 暂留 `lib.rs`,作为跨 commands/application 复用的时间格式化工具。充值商品目录函数也暂留 `lib.rs`,因为命令构造仍需要用它校验充值商品 ID。
## 边界说明
1. 本次不改变任何记录投影字段、时间格式、JSON 解析或错误语义。
2. 本次不迁移充值商品目录和商品查找函数。
3. 本次不改 SpacetimeDB 表结构、reducer、procedure 或 API route。
## 验收
```powershell
cargo fmt -p module-runtime --manifest-path server-rs/Cargo.toml --check
cargo test -p module-runtime --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RT_APPLICATION_RECORD_REFACTOR_2026-04-29.md server-rs/crates/module-runtime/src/application.rs server-rs/crates/module-runtime/src/lib.rs server-rs/crates/module-runtime/README.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,50 @@
# WP-RT 命令构造拆分落地说明
## 背景
`module-runtime` 已将领域类型迁入 `domain.rs`,错误类型迁入 `errors.rs`。根入口 `lib.rs` 仍承载大量 `build_runtime_*_input`、浏览历史写入准备、snapshot upsert JSON 归一化、邀请码/兑换码归一化等命令构造函数。为了继续推进 DDD 分层,本次将写入命令构造与字段归一化迁入 `commands.rs`
## 本次范围
允许修改:
1. `server-rs/crates/module-runtime/src/commands.rs`
2. `server-rs/crates/module-runtime/src/lib.rs`
3. `server-rs/crates/module-runtime/README.md`
4. `docs/technical/README.md`
5. 全局 DDD 任务清单进度记录
禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/**`
3. `server-rs/crates/api-server/src/**`
4. 前端 services/hooks/components
5. `server-rs/crates/spacetime-module/src/migration.rs`
## 设计
本次将以下函数迁入 `commands.rs`
1. settings、browse history、profile dashboard、wallet、recharge、referral、reward code、redeem code、play stats、runtime snapshot、save archive 的 `build_runtime_*_input`
2. `build_runtime_browse_history_sync_input``prepare_runtime_browse_history_entries``build_runtime_browse_history_id`
3. `normalize_invite_code``normalize_redeem_code`
4. 仅服务命令构造的私有 helper`normalize_runtime_*_user_id``parse_utc_rfc3339_to_micros``normalize_bottom_tab``normalize_current_story_json`
`lib.rs` 继续通过 `pub use commands::*` 暴露原公开函数名。记录投影 builder、充值商品目录、通用时间格式化和 JSON 读取 helper 暂留 `lib.rs`,后续再拆 `application.rs`
## 边界说明
1. 本次不改变任何输入构造的校验语义。
2. 本次不移动记录投影 builder避免命令层和应用读模型混写。
3. 本次不改 SpacetimeDB 表结构、reducer、procedure 或 API route。
4. 本次不触发 `migration.rs` 更新。
## 验收
```powershell
cargo fmt -p module-runtime --manifest-path server-rs/Cargo.toml --check
cargo test -p module-runtime --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RT_COMMANDS_REFACTOR_2026-04-29.md server-rs/crates/module-runtime/src/commands.rs server-rs/crates/module-runtime/src/lib.rs server-rs/crates/module-runtime/README.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,52 @@
# WP-RT Snapshot/Profile/Save Archive 领域快照与记录类型拆分落地说明
## 背景
`module-runtime/src/lib.rs` 仍集中承载 runtime snapshot、browse history、profile dashboard、wallet ledger、recharge、referral、played world、play stats 和 save archive 的大量快照、输入、过程结果与 BFF 记录类型。上一切片已将 runtime settings 值对象迁入 `domain.rs`,本次继续把这些纯数据事实迁入领域模型文件,降低根入口体积,并为后续 `commands.rs``application.rs` 和 SpacetimeDB adapter 接线拆分留出边界。
本次只移动纯类型和类型自带的字符串格式化方法不修改构造函数、归一化函数、测试、SpacetimeDB 表结构、API route 或前端。
## 本次范围
允许修改:
1. `server-rs/crates/module-runtime/src/domain.rs`
2. `server-rs/crates/module-runtime/src/lib.rs`
3. `server-rs/crates/module-runtime/README.md`
4. `docs/technical/README.md`
5. 全局 DDD 任务清单进度记录
禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/**`
3. `server-rs/crates/api-server/src/**`
4. 前端 services/hooks/components
5. `server-rs/crates/spacetime-module/src/migration.rs`
## 设计
本次将以下类型迁入 `domain.rs`
1. runtime snapshot、runtime setting、browse history、profile dashboard、wallet ledger、recharge、reward code、redeem code、referral、played world、play stats、save archive 的 snapshot/input/procedure result。
2. `RuntimeProfileWalletLedgerSourceType``RuntimeProfileRedeemCodeMode``RuntimeProfileRechargeProductKind``RuntimeProfileMembershipStatus``RuntimeProfileMembershipTier``RuntimeProfileRechargeOrderStatus``RuntimeBrowseHistoryThemeMode` 等领域枚举及其 `as_str` / `from_client_str` 方法。
3. `RuntimeSettingsRecord``RuntimeProfileDashboardRecord``RuntimeProfileWalletLedgerEntryRecord``RuntimeProfilePlayedWorldRecord``RuntimeProfilePlayStatsRecord``RuntimeSnapshotRecord``RuntimeProfileSaveArchiveRecord` 等回包投影记录类型。
4. 与这些类型强绑定、但不携带构造逻辑的默认常量:`DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME``MAX_BROWSE_HISTORY_BATCH_SIZE``PROFILE_WALLET_LEDGER_LIST_LIMIT``PROFILE_REFERRAL_REWARD_POINTS``PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT``SAVE_SNAPSHOT_VERSION``DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT``PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK`
`lib.rs` 继续通过 `pub use domain::*` 保持原公开 API。构造函数、归一化函数、充值商品目录函数、错误枚举和测试暂留 `lib.rs`,避免在同一切片里混入命令层与错误层重排。
## 边界说明
1. 这些类型包含条件 `SpacetimeType` 派生,但不是 SpacetimeDB table本次只移动自定义类型位置不修改 table/reducer/procedure。
2. 本次不移动 `RuntimeSettingsFieldError``RuntimeBrowseHistoryFieldError``RuntimeProfileFieldError`,后续可单独迁入 `errors.rs`
3. 本次不移动 `build_runtime_*` 构造函数,后续可按 settings、browse history、profile/save 三组拆入 `commands.rs` / `application.rs`
4. 本次不触发 `migration.rs` 更新。
## 验收
```powershell
cargo fmt -p module-runtime --manifest-path server-rs/Cargo.toml --check
cargo test -p module-runtime --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RT_DOMAIN_SNAPSHOT_RECORD_REFACTOR_2026-04-29.md server-rs/crates/module-runtime/src/domain.rs server-rs/crates/module-runtime/src/lib.rs server-rs/crates/module-runtime/README.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,48 @@
# WP-RT 错误层拆分落地说明
## 背景
`module-runtime` 已将 runtime settings 值对象,以及 snapshot/profile/save archive 的快照、输入、过程结果和记录投影类型迁入 `domain.rs`。根入口 `lib.rs` 仍承载 `RuntimeSettingsFieldError``RuntimeBrowseHistoryFieldError``RuntimeProfileFieldError` 及其中文错误文案,后续继续拆 `commands.rs``application.rs` 前,需要先把错误语义归位到 `errors.rs`
## 本次范围
允许修改:
1. `server-rs/crates/module-runtime/src/errors.rs`
2. `server-rs/crates/module-runtime/src/lib.rs`
3. `server-rs/crates/module-runtime/README.md`
4. `docs/technical/README.md`
5. 全局 DDD 任务清单进度记录
禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/**`
3. `server-rs/crates/api-server/src/**`
4. 前端 services/hooks/components
5. `server-rs/crates/spacetime-module/src/migration.rs`
## 设计
本次将以下错误类型迁入 `errors.rs`
1. `RuntimeSettingsFieldError`
2. `RuntimeBrowseHistoryFieldError`
3. `RuntimeProfileFieldError`
同步迁移三组 `Display` 实现,保持中文错误文案和 `MAX_BROWSE_HISTORY_BATCH_SIZE` 上限提示不变。`lib.rs` 继续通过 `pub use errors::*` 暴露原公开 API调用点无需修改。
## 边界说明
1. 本次不调整错误枚举变体,不改任何业务校验语义。
2. 本次不移动 `build_runtime_*` 构造函数;这些函数仍在 `lib.rs` 使用错误类型。
3. 本次不改 SpacetimeDB 表结构、reducer、procedure 或 API route。
## 验收
```powershell
cargo fmt -p module-runtime --manifest-path server-rs/Cargo.toml --check
cargo test -p module-runtime --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RT_ERROR_LAYER_REFACTOR_2026-04-29.md server-rs/crates/module-runtime/src/errors.rs server-rs/crates/module-runtime/src/lib.rs server-rs/crates/module-runtime/README.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```

View File

@@ -0,0 +1,52 @@
# WP-RT Runtime Settings 领域值对象拆分落地说明
## 背景
`module-runtime` 已承接运行时设置、快照、浏览历史、资料页、钱包、充值、邀请、兑换码、游玩记录和存档等纯规则,但当前大量类型仍集中在 `src/lib.rs``WP-RT Runtime/Profile/Save` 需要逐步把纯领域事实和写入命令拆入 DDD 分层文件,避免后续 `spacetime-module``api-server` 接线时继续依赖巨型根文件。
本次只启动 `runtime settings` 这一条最小切片,不改 SpacetimeDB 表结构、不改 HTTP route、不改前端。
## 本次范围
允许修改:
1. `server-rs/crates/module-runtime/src/domain.rs`
2. `server-rs/crates/module-runtime/src/lib.rs`
3. `server-rs/crates/module-runtime/README.md`
4. 本文档
5. 全局 DDD 任务清单进度记录
禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/**`
3. `server-rs/crates/api-server/src/**`
4. 前端 services/hooks/components
## 设计
本次将以下运行时设置领域对象迁入 `domain.rs`
1. `DEFAULT_MUSIC_VOLUME`
2. `DEFAULT_PLATFORM_THEME`
3. `RuntimePlatformTheme`
4. `RuntimeSettings`
`RuntimePlatformTheme::as_str``RuntimePlatformTheme::from_client_str``RuntimeSettings::defaults``RuntimeSettings::normalized` 同步迁入 `domain.rs``lib.rs` 继续通过 `pub use domain::*` 暴露原有 API保证 `spacetime-module``spacetime-client` 和既有测试无需改调用点。
## 边界说明
1. 本次不移动 `RuntimeSettingSnapshot``RuntimeSettingGetInput``RuntimeSettingUpsertInput` 和 procedure result因为它们仍与 SpacetimeDB procedure DTO 强绑定,后续可作为 `commands.rs` / Adapter mapper 切片单独拆分。
2. 本次不移动 `RuntimeSettingsFieldError`,避免把错误 Display 与多个 profile 错误枚举混在同一切片里改动。
3. 本次不移动浏览历史、钱包、充值、邀请、兑换码、游玩记录和存档类型。
## 验收
```powershell
cargo test -p module-runtime --manifest-path server-rs/Cargo.toml
cargo fmt -p module-runtime --manifest-path server-rs/Cargo.toml --check
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_RT_RUNTIME_SETTINGS_DOMAIN_REFACTOR_2026-04-29.md server-rs/crates/module-runtime/src/domain.rs server-rs/crates/module-runtime/src/lib.rs server-rs/crates/module-runtime/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
```
本次不改后端运行接线、不改 SpacetimeDB table/reducer/procedure因此不触发 `migration.rs` 更新。

View File

@@ -77,3 +77,45 @@ npm.cmd run api-server:maincloud
```
说明:本次不改 SpacetimeDB 表、reducer、procedure不刷新生成绑定不同步 `migration.rs`
## 8. story runtime inventory source 接线切片
### 8.1 目标
本轮继续在 `WP-SC` 内认领一个可并行切片:在 `SpacetimeClient::get_story_runtime_projection_source` 中复用已稳定的 `get_runtime_inventory_state` typed facade将 SpacetimeDB 背包/装备快照折回投影所需的 `game_state.playerInventory``game_state.playerEquipment`
目标是让 `/api/story/sessions/{story_session_id}/runtime-projection` 的读取投影优先消费新的 inventory adapter 结果,而不是只依赖 runtime snapshot 中的历史 JSON 背包副本。
### 8.2 边界
本轮允许修改:
1. `server-rs/crates/spacetime-client/src/story_runtime.rs`
2. 本文档
3. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
本轮禁止修改:
1. `server-rs/crates/spacetime-module/src/**`
2. `server-rs/crates/spacetime-client/src/module_bindings/**`
3. `server-rs/crates/api-server/src/app.rs`
4. HTTP DTO、前端 services/hooks/components
5. `migration.rs`
### 8.3 实现约束
1. 只在 `spacetime-client` 中组合已存在 facade不新增 table、reducer、procedure。
2. `StoryRuntimeProjectionSource` 的输出结构保持不变,投影规则继续由 `module-runtime-story::build_story_runtime_projection` 承接。
3. inventory slot 到 runtime story JSON 的转换只做字段映射,不新增玩法规则。
4. `get_runtime_inventory_state` 若返回作用域不匹配,必须中止投影,避免把其他会话的背包装进当前 story session。
### 8.4 验收
```powershell
cargo test -p spacetime-client story_runtime --manifest-path server-rs/Cargo.toml
cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml
cargo fmt -p spacetime-client --manifest-path server-rs/Cargo.toml --check
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/spacetime-client/src/story_runtime.rs
npm.cmd run api-server:maincloud
```

View File

@@ -0,0 +1,56 @@
# WP-ST Auth Adapter 目录化切片说明
## 背景
`spacetime-module/src/auth.rs` 同时承接认证表定义、procedure 入口、事务内导入导出逻辑和 module-auth 快照 JSON mapper。随着 `WP-A Auth` 已完成 DDD 骨架归位SpacetimeDB 侧也需要把 Auth adapter 从单文件拆到上下文目录,避免后续继续堆回根文件。
本次属于 `WP-ST SpacetimeDB Adapter` 的可并行切片,只做 Auth adapter 目录化,不改变 SpacetimeDB schema、procedure 名称、procedure 入参/出参或绑定形状。
## 本次范围
允许修改:
1. `server-rs/crates/spacetime-module/src/auth.rs`
2. `server-rs/crates/spacetime-module/src/auth/mod.rs`
3. `server-rs/crates/spacetime-module/src/auth/tables.rs`
4. `server-rs/crates/spacetime-module/src/auth/procedures.rs`
5. `server-rs/crates/spacetime-module/src/auth/mapper.rs`
6. 本文档
7. `docs/technical/README.md`
8. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
禁止修改:
1. `spacetime-module/src/lib.rs`
2. `spacetime-module/src/migration.rs`
3. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
4. `spacetime-client/src/module_bindings/**`
5. `api-server/src/**`
6. 前端 services / hooks / components
## 设计
本次将原 `auth.rs` 拆成:
1. `auth/mod.rs`Auth adapter 子模块入口,继续 `pub use procedures::*``pub use tables::*`,保持根模块 `pub use auth::*` 的对外导出不变。
2. `auth/tables.rs`:保留 `AuthStoreSnapshot``UserAccount``AuthIdentity``RefreshSession` 四张表定义和索引不改字段、可见性、accessor 或索引名。
3. `auth/procedures.rs`:保留 `get_auth_store_snapshot``upsert_auth_store_snapshot``import_auth_store_snapshot``export_auth_store_snapshot_from_tables` 四个 procedure 入口,以及对应事务内读写逻辑。
4. `auth/mapper.rs`:收口 module-auth 持久化快照 JSON 结构、refresh session client info JSON 结构和 identity id 组件清理函数,仅供 Auth procedure 内部使用。
## 边界说明
1. 本次未新增 reducer现有 Auth 同步仍通过 procedure 完成。
2. 本次未改变表结构,不需要修改 `migration.rs`
3. 本次未生成 SpacetimeDB 绑定,原因是导出的表、类型和 procedure 名称未改变。
4. Auth adapter 仍只负责认证快照与正式表的导入导出,不承接短信、微信 OAuth、JWT、cookie、HTTP 或文件持久化。
## 验收
```powershell
cargo fmt -p spacetime-module --manifest-path server-rs/Cargo.toml --check
cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_ST_AUTH_ADAPTER_SPLIT_2026-04-29.md docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/spacetime-module/src/auth/mod.rs server-rs/crates/spacetime-module/src/auth/tables.rs server-rs/crates/spacetime-module/src/auth/procedures.rs server-rs/crates/spacetime-module/src/auth/mapper.rs
```
`cargo check -p spacetime-module` 被非 WP-ST 依赖 crate 当前并行改动阻断,应记录具体阻断 crate 和错误位置;本切片不越界修改其他已认领工作包。

View File

@@ -0,0 +1,30 @@
# WP-ST Custom World 根入口瘦身落地说明
## 背景
`spacetime-module/src/lib.rs` 已完成 gameplay 根入口瘦身,但仍直接承载 Custom World 的 SpacetimeDB 表、reducer、procedure 与私有事务 helper。根入口继续膨胀会让后续 `module-custom-world` 领域化、绑定生成检查和并行任务边界都难以维护。
本次迁移按 WP-ST Adapter 边界执行:根入口只负责声明模块与 re-exportCustom World 的 SpacetimeDB adapter 真正落到 `spacetime-module/src/custom_world/mod.rs`
## 落地范围
1. `lib.rs` 新增 `mod custom_world;``pub use custom_world::*;`,保留根模块对绑定生成需要的公开导出。
2. 将当前 `lib.rs` 已公开的 Custom World 表、procedure、reducer、事务 helper 与单测整体迁移到 `custom_world/mod.rs`
3. `custom_world/mod.rs``use crate::*;` 复用根模块已经公开的领域类型、SpacetimeDB 类型和 JSON helper避免复制跨模块前置导入。
4. 移除 `lib.rs` 中 Custom World 的重复实现,让根入口只承担组合职责。
## 边界
1. 不新增、删除或重命名 Custom World 表。
2. 不新增、删除或重命名当前已公开的 reducer / procedure。
3. 不修改 `CustomWorldProfile``CustomWorldGalleryEntry` 等表字段,本次不触发 `migration.rs` 更新。
4. 不启用 `custom_world` 子目录中尚未成为根入口正式导出的新过程,避免把后续行为变更混入本次根入口迁移。
5. 不修改前端、BFF、`server-node` 或 PostgreSQL 兼容逻辑。
## 验收
1. `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
2. `cargo test -p module-custom-world --manifest-path server-rs/Cargo.toml`
3. `npm.cmd run check:server-rs-ddd`
4. `npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/SERVER_RS_DDD_WP_ST_CUSTOM_WORLD_ROOT_SPLIT_2026-04-29.md server-rs/crates/spacetime-module/src/lib.rs server-rs/crates/spacetime-module/src/custom_world/mod.rs`
5. 修改后端 Rust 代码后,按项目约束运行 `npm.cmd run api-server:maincloud` 并探测 `/healthz`

View File

@@ -0,0 +1,31 @@
# WP-ST Gameplay 根入口瘦身落地说明
## 背景
`spacetime-module/src/gameplay/mod.rs` 已经承接 RPG gameplay 的 SpacetimeDB table、reducer、procedure、row mapper 和事务 helper但根入口 `spacetime-module/src/lib.rs` 仍保留同一批 gameplay 代码,导致根文件继续承担 Custom World 与 RPG gameplay 两条大链路。
本次切片属于 `WP-ST SpacetimeDB Adapter`目标是让根入口只做模块声明、re-export 和仍未拆出的 Custom World adapter不改变 SpacetimeDB schema、reducer/procedure 对外名称或业务规则。
## 落地范围
1.`spacetime-module/src/lib.rs` 接入 `mod gameplay; pub use gameplay::*;`
2. 将根入口中已迁入 `gameplay/mod.rs` 的 RPG gameplay table、reducer、procedure、row mapper 和事务 helper 删除。
3.`gameplay/mod.rs` 补齐模块内依赖导入。
4. 补齐 story session 继续推进与读取所需的内部 helper
- `continue_story_tx`
- `get_story_session_state_tx`
5. 保留根入口中的 Custom World table、procedure、reducer 和测试,本次不拆 Custom World。
## 边界
1. 本次不修改 `migration.rs`因为表名、字段、reducer/procedure 名称没有变化。
2. 本次不修改 `SPACETIMEDB_TABLE_CATALOG.md`,因为表目录没有变化。
3. 本次不修改 `api-server``spacetime-client`、前端 services/hooks/components。
4. 本次只移动 adapter 归属,不把业务规则从 `module-*` 拉回 SpacetimeDB adapter。
## 验收
1. `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
2. `npm.cmd run check:server-rs-ddd`
3. `npm.cmd run check:encoding -- server-rs/crates/spacetime-module/src/lib.rs server-rs/crates/spacetime-module/src/gameplay/mod.rs docs/technical/SERVER_RS_DDD_WP_ST_GAMEPLAY_ROOT_SPLIT_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
4. 如需联调发布,再执行当前 CLI 支持的 `spacetime build -p server-rs/crates/spacetime-module`

View File

@@ -458,7 +458,7 @@ SELECT * FROM puzzle_leaderboard_entry WHERE user_id = '<user_id>' AND profile_i
### `big_fish_creation_session`
- 作用:大鱼吃小鱼创作会话表,保存种子、阶段、锚点包、草稿、资产覆盖和发布就绪状态。
- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: BigFishCreationStage`, `anchor_pack_json: String`, `draft_json: Option<String>`, `asset_coverage_json: String`, `last_assistant_reply: Option<String>`, `publish_ready: bool`, `created_at: Timestamp`, `updated_at: Timestamp`
- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: BigFishCreationStage`, `anchor_pack_json: String`, `draft_json: Option<String>`, `asset_coverage_json: String`, `last_assistant_reply: Option<String>`, `publish_ready: bool`, `play_count: u32`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`
```sql

View File

@@ -193,3 +193,7 @@ export type BigFishRuntimeSnapshotResponse = {
eventLog: string[];
updatedAt: string;
};
export type BigFishRunResponse = {
run: BigFishRuntimeSnapshotResponse;
};

View File

@@ -0,0 +1,104 @@
import type { JsonObject } from './common';
/**
* story session 主链共享契约。
* 字段命名与 server-rs/shared-contracts/src/story.rs 的 camelCase 回包保持一致。
*/
export type BeginStorySessionRequest = {
runtimeSessionId: string;
worldProfileId: string;
initialPrompt: string;
openingSummary?: string | null;
};
export type ContinueStoryRequest = {
storySessionId: string;
narrativeText: string;
choiceFunctionId?: string | null;
};
export type StorySessionPayload = {
storySessionId: string;
runtimeSessionId: string;
actorUserId: string;
worldProfileId: string;
initialPrompt: string;
openingSummary?: string | null;
latestNarrativeText: string;
latestChoiceFunctionId?: string | null;
status: string;
version: number;
createdAt: string;
updatedAt: string;
};
export type StoryEventPayload = {
eventId: string;
storySessionId: string;
eventKind: string;
narrativeText: string;
choiceFunctionId?: string | null;
createdAt: string;
};
export type StorySessionMutationResponse = {
storySession: StorySessionPayload;
storyEvent: StoryEventPayload;
};
export type StorySessionStateResponse = {
storySession: StorySessionPayload;
storyEvents: StoryEventPayload[];
};
export type StoryRuntimeProjectionRequest = {
storySessionId: string;
clientVersion?: number;
};
export type StoryRuntimeActorProjection = {
hp: number;
maxHp: number;
mana: number;
maxMana: number;
currency: number;
currencyText: string;
};
export type StoryRuntimeInventoryProjection = {
backpackItems: JsonObject[];
equipmentSlots: JsonObject[];
forgeRecipes: JsonObject[];
};
export type StoryRuntimeOptionProjection = {
functionId: string;
actionText: string;
detailText?: string | null;
scope: string;
payload?: JsonObject | null;
enabled: boolean;
reason?: string | null;
};
export type StoryRuntimeStatusProjection = {
inBattle: boolean;
npcInteractionActive: boolean;
currentEncounterId?: string | null;
currentNpcBattleMode?: string | null;
currentNpcBattleOutcome?: string | null;
};
export type StoryRuntimeProjectionResponse = {
storySession: StorySessionPayload;
storyEvents: StoryEventPayload[];
serverVersion: number;
actor: StoryRuntimeActorProjection;
inventory: StoryRuntimeInventoryProjection;
options: StoryRuntimeOptionProjection[];
status: StoryRuntimeStatusProjection;
currentNarrativeText?: string | null;
actionResultText?: string | null;
toast?: string | null;
};

View File

@@ -2,6 +2,7 @@ export * from './assets/qwenSprite';
export * from './contracts/auth';
export type * from './contracts/bigFish';
export * from './contracts/common';
export type * from './contracts/creationAgentDocumentInput';
export type * from './contracts/customWorldAgent';
export * from './contracts/rpgAgentActions';
export * from './contracts/rpgAgentAnchors';
@@ -22,6 +23,7 @@ export * from './contracts/rpgRuntimeQuestAssist';
export * from './contracts/rpgRuntimeStoryAction';
export * from './contracts/rpgRuntimeStoryState';
export * from './contracts/runtime';
export type * from './contracts/story';
export * from './http';
export * from './llm/narrativeLanguage';
export * from './llm/parsers';

1
server-rs/Cargo.lock generated
View File

@@ -1864,6 +1864,7 @@ dependencies = [
"time",
"tokio",
"tracing",
"url",
"urlencoding",
"uuid",
]

View File

@@ -500,6 +500,7 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
@@ -639,6 +640,129 @@ mod tests {
);
}
#[tokio::test]
async fn ai_task_mutation_routes_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for route in ai_task_mutation_route_cases() {
let (status, _) = post_ai_task_route(app.clone(), route.uri, None, route.body).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{}", route.uri);
}
}
#[tokio::test]
async fn ai_task_mutation_routes_return_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
for route in ai_task_mutation_route_cases() {
let (status, payload) =
post_ai_task_route(app.clone(), route.uri, Some(&token), route.body).await;
assert_eq!(status, StatusCode::BAD_GATEWAY, "{}", route.uri);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string()),
"{}",
route.uri
);
}
}
struct AiTaskRouteCase {
uri: &'static str,
body: Option<Value>,
}
fn ai_task_mutation_route_cases() -> Vec<AiTaskRouteCase> {
vec![
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/start",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/chunks",
body: Some(json!({
"stageKind": "request_model",
"sequence": 1,
"deltaText": "你听见远处的铃声。"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/complete",
body: Some(json!({
"textOutput": "你听见远处的铃声。",
"structuredPayloadJson": "{\"scene\":\"camp\"}",
"warningMessages": []
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/references",
body: Some(json!({
"referenceKind": "story_event",
"referenceId": "storyevt_001",
"label": "营地开场"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/complete",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/fail",
body: Some(json!({
"failureMessage": "模型返回内容为空"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/cancel",
body: None,
},
]
}
async fn post_ai_task_route(
app: Router,
uri: &str,
bearer_token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut request = Request::builder()
.method("POST")
.uri(uri)
.header("x-genarrative-response-envelope", "v1");
if let Some(token) = bearer_token {
request = request.header("authorization", format!("Bearer {token}"));
}
let body = if let Some(payload) = body {
request = request.header("content-type", "application/json");
Body::from(payload.to_string())
} else {
Body::empty()
};
let response = app
.oneshot(request.body(body).expect("request should build"))
.await
.expect("request should succeed");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body).expect("response body should be valid json")
};
(status, payload)
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -33,9 +33,10 @@ use crate::{
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play,
stream_big_fish_message, submit_big_fish_message,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -652,6 +653,27 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}",
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}/input",
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
@@ -828,16 +850,6 @@ pub fn build_router(state: AppState) -> Router {
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)
@@ -848,13 +860,6 @@ pub fn build_router(state: AppState) -> Router {
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(
@@ -862,13 +867,6 @@ pub fn build_router(state: AppState) -> Router {
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(
@@ -876,13 +874,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
@@ -890,13 +881,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
@@ -904,13 +888,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
@@ -918,13 +895,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
@@ -932,13 +902,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
@@ -946,20 +909,6 @@ pub fn build_router(state: AppState) -> Router {
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/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
@@ -967,13 +916,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(

View File

@@ -23,8 +23,8 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
api_response::json_success_body, http_error::AppError, platform_errors::map_oss_error,
request_context::RequestContext, state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -377,17 +377,7 @@ fn map_confirm_asset_object_prepare_error(error: ConfirmAssetObjectPrepareError)
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(platform_oss::OssError::ObjectNotFound(_)) => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(_) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
ConfirmAssetObjectPrepareError::Oss(error) => map_oss_error(error, "aliyun-oss"),
}
}

View File

@@ -23,9 +23,10 @@ use shared_contracts::big_fish::{
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest,
SendBigFishMessageRequest,
BigFishRunResponse, BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse,
BigFishRuntimeSnapshotResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
RecordBigFishPlayRequest, SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
@@ -33,9 +34,10 @@ use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput,
BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
BigFishWorkSummaryRecord, SpacetimeClientError,
BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput,
BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishRuntimeEntityRecord,
BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, BigFishSessionCreateRecordInput,
BigFishSessionRecord, BigFishVector2Record, BigFishWorkSummaryRecord, SpacetimeClientError,
};
use tokio::time::sleep;
@@ -58,6 +60,7 @@ use crate::{
auth::AuthenticatedAccessToken,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
@@ -251,6 +254,102 @@ pub async fn record_big_fish_play(
))
}
pub async fn start_big_fish_run(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let run = state
.spacetime_client()
.start_big_fish_run(BigFishRunStartRecordInput {
run_id: build_prefixed_uuid_id("big-fish-run-"),
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn get_big_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn submit_big_fish_input(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SubmitBigFishInputRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
big_fish_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, &run_id, "runId")?;
if !payload.x.is_finite() || !payload.y.is_finite() {
return Err(big_fish_bad_request(&request_context, "input is invalid"));
}
let run = state
.spacetime_client()
.submit_big_fish_input(BigFishInputSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
x: payload.x,
y: payload.y,
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn submit_big_fish_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -799,6 +898,51 @@ fn map_big_fish_asset_coverage_response(
}
}
fn map_big_fish_run_response(run: BigFishRuntimeRunRecord) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
@@ -1570,19 +1714,7 @@ fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
}
fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn build_big_fish_level_part(level: Option<u32>) -> String {
@@ -1659,6 +1791,14 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("big_fish_runtime_run 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message) if message.contains("无权访问") => {
StatusCode::FORBIDDEN
}
SpacetimeClientError::Procedure(message)
if message.contains("不能为空")
|| message.contains("尚未编译")

View File

@@ -50,6 +50,7 @@ use crate::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::role_asset_studio::{
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
},
@@ -1639,7 +1640,9 @@ async fn load_workflow_cache(
expire_seconds: Some(60),
}) {
Ok(signed) => signed,
Err(platform_oss::OssError::ObjectNotFound(_)) => return Ok(None),
Err(error) if error.kind() == platform_oss::OssErrorKind::ObjectNotFound => {
return Ok(None);
}
Err(error) => return Err(map_character_animation_oss_error(error)),
};
let response = reqwest::Client::new()
@@ -3303,19 +3306,7 @@ fn map_character_animation_spacetime_error(error: SpacetimeClientError) -> AppEr
}
fn map_character_animation_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn character_animation_error_response(

View File

@@ -38,6 +38,7 @@ use crate::{
build_fallback_moderation_safe_character_visual_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
@@ -1335,19 +1336,7 @@ fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError
}
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn parse_json_payload(

View File

@@ -35,6 +35,7 @@ use crate::{
build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::scene_background::{
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
@@ -1016,19 +1017,7 @@ fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppErr
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {

View File

@@ -34,6 +34,11 @@ impl AppError {
self.code
}
#[cfg(test)]
pub fn status_code(&self) -> StatusCode {
self.status_code
}
pub fn message(&self) -> &str {
&self.message
}

View File

@@ -6,7 +6,7 @@ use axum::{
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
use serde_json::json;
use crate::{http_error::AppError, state::AppState};
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
@@ -183,19 +183,7 @@ fn is_invalid_path_segment(segment: &str) -> bool {
}
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn map_legacy_generated_upstream_status(

View File

@@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
response::Response,
};
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest};
use platform_llm::{LlmMessage, LlmMessageRole, LlmTextRequest};
use serde_json::Value;
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
@@ -12,7 +12,7 @@ use shared_contracts::llm::{
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
platform_errors::map_llm_error, request_context::RequestContext, state::AppState,
};
pub async fn proxy_llm_chat_completions(
@@ -74,39 +74,6 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
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))
}

View File

@@ -42,6 +42,7 @@ mod logout_all;
mod password_entry;
mod password_management;
mod phone_auth;
mod platform_errors;
mod prompt;
mod puzzle;
mod puzzle_agent_turn;

View File

@@ -1,7 +1,7 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{
@@ -21,6 +21,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -237,10 +238,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
@@ -249,7 +247,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
map_phone_auth_platform_store_error(error.to_string())
}
}
}

View File

@@ -0,0 +1,133 @@
use axum::http::{HeaderValue, StatusCode};
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
use platform_llm::{LlmError, LlmErrorKind};
use platform_oss::{OssError, OssErrorKind};
use serde_json::json;
use crate::http_error::AppError;
// API 层统一消费 platform 的稳定错误分类,避免各 route 重复 match 具体 provider 分支。
pub fn map_llm_error(error: LlmError) -> AppError {
let message = llm_error_message(&error);
let status = match error.kind() {
LlmErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
LlmErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
LlmErrorKind::Upstream
if matches!(
error,
LlmError::Upstream {
status_code: 429,
..
}
) =>
{
StatusCode::TOO_MANY_REQUESTS
}
LlmErrorKind::Timeout
| LlmErrorKind::Connectivity
| LlmErrorKind::Upstream
| LlmErrorKind::StreamUnavailable
| LlmErrorKind::EmptyResponse
| LlmErrorKind::Transport
| LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_message(message)
}
pub fn map_oss_error(error: OssError, provider: &'static str) -> AppError {
let status = oss_error_status(error.kind());
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
pub fn map_phone_auth_platform_store_error(message: String) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
let status = match error.kind() {
AuthPlatformErrorKind::Disabled
| AuthPlatformErrorKind::MissingCode
| AuthPlatformErrorKind::InvalidCallback => StatusCode::BAD_REQUEST,
AuthPlatformErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
AuthPlatformErrorKind::RequestFailed
| AuthPlatformErrorKind::DeserializeFailed
| AuthPlatformErrorKind::MissingProfile
| AuthPlatformErrorKind::Upstream => StatusCode::BAD_GATEWAY,
AuthPlatformErrorKind::InvalidClaims
| AuthPlatformErrorKind::SignFailed
| AuthPlatformErrorKind::VerifyFailed
| AuthPlatformErrorKind::CookieConfig
| AuthPlatformErrorKind::HashFailed
| AuthPlatformErrorKind::InvalidVerifyCode => StatusCode::INTERNAL_SERVER_ERROR,
};
AppError::from_status(status).with_message(error.to_string())
}
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => error.with_header("retry-after", value),
Err(_) => error,
}
}
fn oss_error_status(kind: OssErrorKind) -> StatusCode {
match kind {
OssErrorKind::InvalidConfig | OssErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
OssErrorKind::ObjectNotFound => StatusCode::NOT_FOUND,
OssErrorKind::Request | OssErrorKind::SerializePolicy | OssErrorKind::Sign => {
StatusCode::BAD_GATEWAY
}
}
}
fn llm_error_message(error: &LlmError) -> String {
match error {
LlmError::InvalidConfig(message)
| LlmError::InvalidRequest(message)
| LlmError::Transport(message)
| LlmError::Deserialize(message) => message.clone(),
LlmError::Timeout { .. }
| LlmError::Connectivity { .. }
| LlmError::Upstream { .. }
| LlmError::StreamUnavailable
| LlmError::EmptyResponse => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_oss_error_uses_stable_kind_for_not_found() {
let error = map_oss_error(
OssError::ObjectNotFound("missing object".to_string()),
"oss",
);
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
}
#[test]
fn map_llm_error_preserves_upstream_rate_limit() {
let error = map_llm_error(LlmError::Upstream {
status_code: 429,
message: "too many requests".to_string(),
});
assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn map_wechat_provider_error_keeps_provider_boundary() {
let error = map_wechat_provider_error(WechatProviderError::MissingCode);
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(error.message(), "缺少微信授权 code");
}
}

View File

@@ -70,6 +70,7 @@ use crate::{
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_oss_error,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
@@ -2942,10 +2943,7 @@ fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> Str
}
fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError {

View File

@@ -258,7 +258,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.body(Body::empty())
.expect("request should build"),
)
@@ -278,7 +278,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -324,7 +324,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -361,64 +361,6 @@ mod tests {
);
}
#[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

View File

@@ -551,12 +551,6 @@ mod tests {
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};
@@ -585,7 +579,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/dashboard")
.uri("/api/profile/dashboard")
.body(Body::empty())
.expect("request should build"),
)
@@ -603,7 +597,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/wallet-ledger")
.uri("/api/profile/wallet-ledger")
.body(Body::empty())
.expect("request should build"),
)
@@ -621,7 +615,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/play-stats")
.uri("/api/profile/play-stats")
.body(Body::empty())
.expect("request should build"),
)
@@ -706,118 +700,35 @@ mod tests {
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
async fn runtime_profile_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for uri in [
"/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/recharge-center",
"/api/runtime/profile/recharge/orders",
"/api/runtime/profile/referrals/invite-center",
"/api/runtime/profile/referrals/redeem-code",
"/api/runtime/profile/redeem-codes/redeem",
"/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
"/api/runtime/profile/save-archives",
"/api/runtime/profile/save-archives/world-1",
"/api/runtime/profile/browse-history",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.uri(uri)
.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"]
);
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
.id;
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: 2,
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

@@ -4,7 +4,10 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::format_utc_micros;
use module_runtime::{
RuntimeProfileFieldError, build_runtime_save_checkpoint_input,
build_runtime_save_checkpoint_update,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
@@ -52,26 +55,6 @@ pub async fn put_runtime_snapshot(
Json(payload): Json<PutRuntimeSaveCheckpointRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
})),
)
})?;
let bottom_tab = normalize_required_string(payload.bottom_tab.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = payload
.saved_at
@@ -90,6 +73,15 @@ pub async fn put_runtime_snapshot(
.unwrap_or(now);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let checkpoint_input = build_runtime_save_checkpoint_input(
payload.session_id,
payload.bottom_tab,
saved_at_micros,
updated_at_micros,
)
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let existing = state
.get_runtime_snapshot_record(user_id.clone())
@@ -107,16 +99,18 @@ pub async fn put_runtime_snapshot(
)
})?;
validate_checkpoint_snapshot(&request_context, &session_id, &existing.game_state)?;
let game_state = sync_runtime_snapshot_play_time(existing.game_state, updated_at_micros);
let update =
build_runtime_save_checkpoint_update(checkpoint_input, existing).map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let record = state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
existing.current_story,
updated_at_micros,
update.saved_at_micros,
update.bottom_tab,
update.game_state,
update.current_story,
update.updated_at_micros,
)
.await
.map_err(|error| {
@@ -223,132 +217,6 @@ fn build_saved_game_snapshot_response(
}
}
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
game_state
.get("runtimeMode")
.and_then(Value::as_str)
.map(str::trim),
Some("preview") | Some("test")
)
}
fn validate_checkpoint_snapshot(
request_context: &RequestContext,
session_id: &str,
game_state: &Value,
) -> Result<(), Response> {
if is_non_persistent_runtime_snapshot(game_state) {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "预览或测试运行态不能创建正式 checkpoint",
})),
));
}
let persisted_session_id =
read_string_field(game_state, "runtimeSessionId").ok_or_else(|| {
runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "服务端运行时快照缺少 runtimeSessionId无法创建 checkpoint",
})),
)
})?;
if persisted_session_id != session_id {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "checkpoint sessionId 与服务端运行时快照不一致",
"expectedSessionId": persisted_session_id,
"actualSessionId": session_id,
})),
));
}
Ok(())
}
fn sync_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
let Some(game_state_object) = game_state.as_object_mut() else {
return game_state;
};
let now_text = format_utc_micros(now_micros);
let Some(runtime_stats) = game_state_object
.get_mut("runtimeStats")
.and_then(Value::as_object_mut)
else {
game_state_object.insert(
"runtimeStats".to_string(),
json!({
"playTimeMs": 0,
"lastPlayTickAt": now_text,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0,
}),
);
return game_state;
};
let current_play_time = runtime_stats
.get("playTimeMs")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let elapsed_ms = runtime_stats
.get("lastPlayTickAt")
.and_then(Value::as_str)
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
.map(offset_datetime_to_unix_micros)
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
.unwrap_or(0.0);
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
// 中文注释checkpoint 只刷新服务端已有 runtimeStats 的时间水位,
// 不从浏览器接收任何任务、背包、战斗或剧情状态。
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
game_state
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_required_string(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
fn build_profile_save_archive_summary_response(
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
) -> ProfileSaveArchiveSummaryResponse {
@@ -395,6 +263,51 @@ fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError
}))
}
fn map_runtime_save_domain_error(error: RuntimeProfileFieldError) -> AppError {
let message = error.to_string();
match error {
RuntimeProfileFieldError::MissingCheckpointSessionId => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
}))
}
RuntimeProfileFieldError::MissingRuntimeSessionId => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::MissingBottomTab => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
}))
}
RuntimeProfileFieldError::NonPersistentRuntimeSnapshot => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::RuntimeSessionMismatch {
expected_session_id,
actual_session_id,
} => AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
"expectedSessionId": expected_session_id,
"actualSessionId": actual_session_id,
})),
_ => AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"message": message,
})),
}
}
fn runtime_save_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -584,7 +497,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/save-archives")
.uri("/api/profile/save-archives")
.body(Body::empty())
.expect("request should build"),
)
@@ -594,15 +507,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_save_archives_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/save-archives",
"/api/profile/save-archives",
)
.await;
}
#[tokio::test]
async fn resume_profile_save_archive_rejects_blank_world_key() {
let state = seed_authenticated_state().await;
@@ -613,7 +517,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/save-archives/%20%20")
.uri("/api/profile/save-archives/%20%20")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
@@ -625,68 +529,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
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_payload: Value = serde_json::from_slice(
&main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
let compat_payload: Value = serde_json::from_slice(
&compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.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

View File

@@ -14,7 +14,7 @@ use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
@@ -24,7 +24,7 @@ use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
use crate::wechat_provider::build_wechat_provider;
const ADMIN_ROLE: &str = "admin";

View File

@@ -51,6 +51,23 @@ pub async fn create_story_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -89,14 +106,29 @@ pub async fn create_story_battle(
pub async fn resolve_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let battle_state_id = payload.battle_state_id;
let current_battle = state
.spacetime_client()
.get_battle_state(battle_state_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(
&request_context,
&current_battle.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.resolve_combat_action(ResolveCombatActionInput {
battle_state_id: payload.battle_state_id,
battle_state_id,
function_id: payload.function_id,
action_text: payload.action_text,
base_damage: payload.base_damage,
@@ -128,8 +160,9 @@ 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>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_battle_state(battle_state_id)
@@ -137,6 +170,7 @@ pub async fn get_story_battle_state(
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(&request_context, &result.actor_user_id, &actor_user_id)?;
Ok(json_success_body(
Some(&request_context),
@@ -175,6 +209,23 @@ pub async fn create_story_npc_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -431,6 +482,73 @@ fn story_battles_error_response(request_context: &RequestContext, error: AppErro
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner_for_battle(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-session",
"story session 不属于当前用户,不能创建战斗",
)
}
fn require_story_battle_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-battle",
"battle state 不属于当前用户",
)
}
fn require_story_session_runtime_for_battle(
request_context: &RequestContext,
session_runtime_id: &str,
requested_runtime_id: &str,
) -> Result<(), Response> {
if session_runtime_id == requested_runtime_id {
return Ok(());
}
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-session",
"message": "runtimeSessionId 与 story session 不匹配,不能创建战斗",
})),
))
}
fn require_resource_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
provider: &'static str,
message: &'static str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// API 层只做登录用户与资源属主的边界检查;战斗结算仍由 module-combat 与 SpacetimeDB 承担。
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": provider,
"message": message,
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -442,6 +560,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -454,7 +574,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::{require_story_battle_owner, require_story_session_runtime_for_battle};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn create_story_battle_requires_authentication() {
@@ -648,6 +771,37 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_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/resolve")
.header("content-type", "application/json")
.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::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -735,6 +889,63 @@ mod tests {
);
}
#[test]
fn story_battle_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
let response = require_story_battle_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_battle_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
require_story_battle_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
#[test]
fn story_battle_runtime_guard_rejects_mismatched_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
let response =
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_other")
.expect_err("mismatched runtime session should be bad request");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn story_battle_runtime_guard_accepts_matching_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_owner")
.expect("matching runtime session should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -72,14 +72,29 @@ pub async fn begin_story_session(
pub async fn continue_story(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ContinueStoryRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = payload.story_session_id;
let current_state = state
.spacetime_client()
.get_story_session_state(story_session_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&current_state.session.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.continue_story(
payload.story_session_id,
story_session_id,
module_story::generate_story_event_id(now_micros),
payload.narrative_text,
payload.choice_function_id,
@@ -123,8 +138,9 @@ 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>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_story_session_state(story_session_id)
@@ -132,6 +148,11 @@ pub async fn get_story_session_state(
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&result.session.actor_user_id,
&actor_user_id,
)?;
Ok(json_success_body(
Some(&request_context),
@@ -204,6 +225,25 @@ fn story_sessions_error_response(request_context: &RequestContext, error: AppErr
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// 这里只做 HTTP 鉴权边界判断story session 的推进规则仍由 SpacetimeDB 领域层处理。
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "story-session",
"message": "story session 不属于当前用户",
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -215,6 +255,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -227,7 +269,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::require_story_session_owner;
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn begin_story_session_requires_authentication() {
@@ -302,6 +347,32 @@ mod tests {
);
}
#[tokio::test]
async fn continue_story_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/continue")
.header("content-type", "application/json")
.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::UNAUTHORIZED);
}
#[tokio::test]
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -457,6 +528,34 @@ mod tests {
);
}
#[test]
fn story_session_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
let response = require_story_session_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_session_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
require_story_session_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -1,13 +1,13 @@
use axum::{
Json,
extract::{Extension, Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
WechatAuthScene,
};
use platform_auth::WechatAuthScene;
use shared_contracts::auth::{
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
@@ -23,6 +23,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_wechat_provider_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -50,17 +51,20 @@ pub async fn start_wechat_login(
query.redirect_path.as_deref(),
&state.config.wechat_redirect_path,
),
scene: scene.clone(),
scene: map_wechat_scene_to_domain(&scene),
request_user_agent: user_agent.clone(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_wechat_auth_error)?;
let authorization_url = state.wechat_provider().build_authorization_url(
let authorization_url = state
.wechat_provider()
.build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)?;
)
.map_err(map_wechat_provider_error)?;
Ok(json_success_body(
Some(&request_context),
@@ -121,10 +125,12 @@ pub async fn handle_wechat_callback(
{
Ok(profile) => state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
.resolve_login(module_auth::ResolveWechatLoginInput {
profile: map_wechat_profile_to_domain(profile),
})
.await
.map_err(map_wechat_auth_error),
Err(error) => Err(error),
Err(error) => Err(map_wechat_provider_error(error)),
};
match result {
@@ -239,6 +245,24 @@ fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, App
Ok(WechatAuthScene::Desktop)
}
fn map_wechat_scene_to_domain(scene: &WechatAuthScene) -> module_auth::WechatAuthScene {
match scene {
WechatAuthScene::Desktop => module_auth::WechatAuthScene::Desktop,
WechatAuthScene::WechatInApp => module_auth::WechatAuthScene::WechatInApp,
}
}
fn map_wechat_profile_to_domain(
profile: platform_auth::WechatIdentityProfile,
) -> module_auth::WechatIdentityProfile {
module_auth::WechatIdentityProfile {
provider_uid: profile.provider_uid,
provider_union_id: profile.provider_union_id,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
}
}
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string();
@@ -340,10 +364,7 @@ fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
module_auth::PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())

View File

@@ -1,280 +1,40 @@
use module_auth::{WechatAuthScene, WechatIdentityProfile};
use reqwest::Client;
use serde::Deserialize;
use tracing::warn;
use url::Url;
use platform_auth::{
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig, WechatProvider,
};
use crate::{config::AppConfig, http_error::AppError};
use axum::http::StatusCode;
#[derive(Clone, Debug)]
pub enum WechatProvider {
Disabled,
Mock(MockWechatProvider),
Real(RealWechatProvider),
}
#[derive(Clone, Debug)]
pub struct MockWechatProvider {
mock_user_id: String,
mock_union_id: Option<String>,
mock_display_name: String,
mock_avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct RealWechatProvider {
client: Client,
app_id: String,
app_secret: String,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
}
#[derive(Debug, Deserialize)]
struct WechatAccessTokenResponse {
access_token: Option<String>,
openid: Option<String>,
unionid: Option<String>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatUserInfoResponse {
openid: Option<String>,
unionid: Option<String>,
nickname: Option<String>,
headimgurl: Option<String>,
errmsg: Option<String>,
}
use crate::config::AppConfig;
pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
if !config.wechat_auth_enabled {
return WechatProvider::Disabled;
}
if config
.wechat_auth_provider
.trim()
.eq_ignore_ascii_case("mock")
{
return WechatProvider::Mock(MockWechatProvider {
mock_user_id: config.wechat_mock_user_id.clone(),
mock_union_id: config.wechat_mock_union_id.clone(),
mock_display_name: config.wechat_mock_display_name.clone(),
mock_avatar_url: config.wechat_mock_avatar_url.clone(),
});
}
let Some(app_id) = config.wechat_app_id.clone() else {
return WechatProvider::Disabled;
};
let Some(app_secret) = config.wechat_app_secret.clone() else {
return WechatProvider::Disabled;
};
WechatProvider::Real(RealWechatProvider {
client: Client::new(),
app_id,
app_secret,
authorize_endpoint: config.wechat_authorize_endpoint.clone(),
access_token_endpoint: config.wechat_access_token_endpoint.clone(),
user_info_endpoint: config.wechat_user_info_endpoint.clone(),
})
}
impl WechatProvider {
pub fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(_) => {
let mut callback = Url::parse(callback_url).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信回调地址非法:{error}"))
})?;
callback
.query_pairs_mut()
.append_pair("mock_code", "wx-mock-code")
.append_pair("state", state);
Ok(callback.to_string())
}
Self::Real(provider) => provider.build_authorization_url(callback_url, state, scene),
}
}
pub async fn resolve_callback_profile(
&self,
code: Option<&str>,
mock_code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(provider) => Ok(provider.resolve_callback_profile(mock_code)),
Self::Real(provider) => provider.resolve_callback_profile(code).await,
}
}
}
impl MockWechatProvider {
fn resolve_callback_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile {
let provider_uid = mock_code
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(self.mock_user_id.as_str())
.to_string();
WechatIdentityProfile {
provider_uid,
provider_union_id: self.mock_union_id.clone(),
display_name: Some(self.mock_display_name.clone()),
avatar_url: self.mock_avatar_url.clone(),
}
}
}
impl RealWechatProvider {
fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
let mut url = Url::parse(match scene {
WechatAuthScene::Desktop => &self.authorize_endpoint,
WechatAuthScene::WechatInApp => "https://open.weixin.qq.com/connect/oauth2/authorize",
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信授权地址非法:{error}"))
})?;
url.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("redirect_uri", callback_url)
.append_pair("response_type", "code")
.append_pair(
"scope",
match scene {
WechatAuthScene::Desktop => "snsapi_login",
WechatAuthScene::WechatInApp => "snsapi_userinfo",
},
)
.append_pair("state", state);
Ok(format!("{url}#wechat_redirect"))
}
async fn resolve_callback_profile(
&self,
code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
let code = code
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code")
})?;
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信 access_token 地址非法:{error}"))
})?;
access_token_url
.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("secret", &self.app_secret)
.append_pair("code", code)
.append_pair("grant_type", "authorization_code");
let access_token_payload = self
.client
.get(access_token_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 请求失败")
})?
.json::<WechatAccessTokenResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 响应非法")
})?;
let access_token = access_token_payload
.access_token
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
access_token_payload
.errmsg
.unwrap_or_else(|| "缺少 access_token".to_string())
WechatProvider::new(WechatAuthConfig::new(
config.wechat_auth_enabled,
config.wechat_auth_provider.clone(),
config.wechat_app_id.clone(),
config.wechat_app_secret.clone(),
normalize_wechat_endpoint(
&config.wechat_authorize_endpoint,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_access_token_endpoint,
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_user_info_endpoint,
DEFAULT_WECHAT_USER_INFO_ENDPOINT,
),
config.wechat_mock_user_id.clone(),
config.wechat_mock_union_id.clone(),
config.wechat_mock_display_name.clone(),
config.wechat_mock_avatar_url.clone(),
))
})?;
let openid = access_token_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:缺少 openid")
})?;
}
let mut user_info_url = Url::parse(&self.user_info_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信用户信息地址非法:{error}"))
})?;
user_info_url
.query_pairs_mut()
.append_pair("access_token", &access_token)
.append_pair("openid", &openid)
.append_pair("lang", "zh_CN");
let user_info_payload = self
.client
.get(user_info_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息请求失败")
})?
.json::<WechatUserInfoResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息响应非法")
})?;
let provider_uid = user_info_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
user_info_payload
.errmsg
.unwrap_or_else(|| "缺少 openid".to_string())
))
})?;
Ok(WechatIdentityProfile {
provider_uid,
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
display_name: user_info_payload.nickname,
avatar_url: user_info_payload.headimgurl,
})
fn normalize_wechat_endpoint(value: &str, fallback: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}

View File

@@ -16,10 +16,18 @@
当前提交已完成:
1. `module-ai``Cargo.toml`
2. DDD 分层文件:
2. DDD 分层文件与内部子模块
- `src/domain.rs`
- `src/domain/types.rs`
- `src/domain/stages.rs`
- `src/domain/ids.rs`
- `src/commands.rs`
- `src/commands/inputs.rs`
- `src/commands/validation.rs`
- `src/application.rs`
- `src/application/service.rs`
- `src/application/store.rs`
- `src/application/result.rs`
- `src/events.rs`
- `src/errors.rs`
3. 首版核心类型:
@@ -40,7 +48,7 @@
- `AiTaskFinishInput`
- `AiTaskCancelInput`
- `AiTaskFailureInput`
8. 基础单元测试
8. `src/tests.rs` 中的基础单元测试
首版详细设计见:
@@ -48,6 +56,7 @@
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)
4. [../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md)
5. [../../../docs/technical/SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md)
## 3. 当前仍未进入的范围

View File

@@ -1,393 +1,12 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
mod result;
mod service;
mod store;
use shared_kernel::normalize_required_string;
pub use result::AiTaskProcedureResult;
pub use service::AiTaskService;
pub use store::InMemoryAiTaskStore;
use crate::commands::validate_task_create_input;
use crate::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCreateInput,
AiTaskFieldError, AiTaskServiceError, AiTaskSnapshot, AiTaskStageSnapshot, AiTaskStageStatus,
AiTaskStatus, AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id,
generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskProcedureResult {
pub ok: bool,
pub task: Option<AiTaskSnapshot>,
pub text_chunk: Option<AiTextChunkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryAiTaskStore {
inner: Arc<Mutex<InMemoryAiTaskStoreState>>,
}
#[derive(Debug, Default)]
struct InMemoryAiTaskStoreState {
tasks: HashMap<String, AiTaskSnapshot>,
text_chunks: HashMap<String, Vec<AiTextChunkSnapshot>>,
}
#[derive(Clone, Debug)]
pub struct AiTaskService {
store: InMemoryAiTaskStore,
}
impl AiTaskService {
pub fn new(store: InMemoryAiTaskStore) -> Self {
Self { store }
}
pub fn create_task(
&self,
input: AiTaskCreateInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?;
let snapshot = AiTaskSnapshot {
task_id: input.task_id.clone(),
task_kind: input.task_kind,
owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(),
request_label: normalize_required_string(input.request_label).unwrap_or_default(),
source_module: normalize_required_string(input.source_module).unwrap_or_default(),
source_entity_id: normalize_optional_text(input.source_entity_id),
request_payload_json: normalize_optional_text(input.request_payload_json),
status: AiTaskStatus::Pending,
failure_message: None,
stages: input
.stages
.into_iter()
.map(|stage| AiTaskStageSnapshot {
stage_kind: stage.stage_kind,
label: normalize_required_string(stage.label).unwrap_or_default(),
detail: normalize_required_string(stage.detail).unwrap_or_default(),
order: stage.order,
status: AiTaskStageStatus::Pending,
text_output: None,
structured_payload_json: None,
warning_messages: Vec::new(),
started_at_micros: None,
completed_at_micros: None,
})
.collect(),
result_references: Vec::new(),
latest_text_output: None,
latest_structured_payload_json: None,
version: INITIAL_AI_TASK_VERSION,
created_at_micros: input.created_at_micros,
started_at_micros: None,
completed_at_micros: None,
updated_at_micros: input.created_at_micros,
};
self.store.insert_task(snapshot)
}
pub fn start_task(
&self,
task_id: &str,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn start_stage(
&self,
task_id: &str,
stage_kind: crate::AiTaskStageKind,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn append_text_chunk(
&self,
task_id: &str,
stage_kind: crate::AiTaskStageKind,
sequence: u32,
delta_text: String,
created_at_micros: i64,
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> {
if delta_text.trim().is_empty() {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingChunkText,
));
}
if sequence == 0 {
return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence));
}
let chunk = AiTextChunkSnapshot {
chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence),
task_id: normalize_required_string(task_id).unwrap_or_default(),
stage_kind,
sequence,
delta_text: normalize_required_string(delta_text).unwrap_or_default(),
created_at_micros,
};
let task = self.store.append_text_chunk(chunk.clone())?;
Ok((task, chunk))
}
pub fn complete_stage(
&self,
input: AiStageCompletionInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(&input.task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == input.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Completed;
stage.completed_at_micros = Some(input.completed_at_micros);
stage.text_output = normalize_optional_text(input.text_output.clone());
stage.structured_payload_json =
normalize_optional_text(input.structured_payload_json.clone());
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
task.latest_text_output = stage.text_output.clone();
task.latest_structured_payload_json = stage.structured_payload_json.clone();
task.updated_at_micros = input.completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn attach_result_reference(
&self,
task_id: &str,
reference_kind: AiResultReferenceKind,
reference_id: String,
label: Option<String>,
created_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(reference_id) = normalize_required_string(reference_id) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingReferenceId,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.result_references.push(AiResultReferenceSnapshot {
result_ref_id: generate_ai_result_ref_id(created_at_micros),
task_id: task.task_id.clone(),
reference_kind,
reference_id: reference_id.clone(),
label: normalize_optional_text(label.clone()),
created_at_micros,
});
task.updated_at_micros = created_at_micros;
task.version += 1;
Ok(())
})
}
pub fn complete_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Completed;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn fail_task(
&self,
task_id: &str,
failure_message: String,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(failure_message) = normalize_required_string(failure_message) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingFailureMessage,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Failed;
task.failure_message = Some(failure_message.clone());
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn cancel_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Cancelled;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.get_task(task_id)
}
}
impl InMemoryAiTaskStore {
fn insert_task(&self, task: AiTaskSnapshot) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
if state.tasks.contains_key(&task.task_id) {
return Err(AiTaskServiceError::TaskAlreadyExists);
}
state.text_chunks.insert(task.task_id.clone(), Vec::new());
state.tasks.insert(task.task_id.clone(), task.clone());
Ok(task)
}
fn update_task<F>(
&self,
task_id: &str,
mut apply: F,
) -> Result<AiTaskSnapshot, AiTaskServiceError>
where
F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>,
{
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
let task = state
.tasks
.get_mut(task_id.trim())
.ok_or(AiTaskServiceError::TaskNotFound)?;
apply(task)?;
Ok(task.clone())
}
fn append_text_chunk(
&self,
chunk: AiTextChunkSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
{
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
if stage.status == AiTaskStageStatus::Pending {
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros = Some(chunk.created_at_micros);
}
task.status = AiTaskStatus::Running;
task.started_at_micros
.get_or_insert(chunk.created_at_micros);
}
let chunks = state
.text_chunks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
chunks.push(chunk.clone());
chunks.sort_by_key(|value| value.sequence);
let aggregated_text = chunks
.iter()
.filter(|value| value.stage_kind == chunk.stage_kind)
.map(|value| value.delta_text.as_str())
.collect::<Vec<_>>()
.join("");
let normalized_output = if aggregated_text.trim().is_empty() {
None
} else {
Some(aggregated_text)
};
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.text_output = normalized_output.clone();
task.latest_text_output = normalized_output;
task.updated_at_micros = chunk.created_at_micros;
task.version += 1;
Ok(task.clone())
}
fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
state
.tasks
.get(task_id.trim())
.cloned()
.ok_or(AiTaskServiceError::TaskNotFound)
}
}
use crate::{AiTaskFieldError, AiTaskServiceError, AiTaskStatus};
fn ensure_task_is_not_terminal(status: AiTaskStatus) -> Result<(), AiTaskServiceError> {
if status.is_terminal() {

View File

@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{AiTaskSnapshot, AiTextChunkSnapshot};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskProcedureResult {
pub ok: bool,
pub task: Option<AiTaskSnapshot>,
pub text_chunk: Option<AiTextChunkSnapshot>,
pub error_message: Option<String>,
}

View File

@@ -0,0 +1,250 @@
use shared_kernel::normalize_required_string;
use crate::commands::validate_task_create_input;
use crate::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCreateInput,
AiTaskFieldError, AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStageSnapshot,
AiTaskStageStatus, AiTaskStatus, AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION,
generate_ai_result_ref_id, generate_ai_text_chunk_id, normalize_optional_text,
normalize_string_list,
};
use super::{InMemoryAiTaskStore, ensure_task_is_not_terminal};
#[derive(Clone, Debug)]
pub struct AiTaskService {
store: InMemoryAiTaskStore,
}
impl AiTaskService {
pub fn new(store: InMemoryAiTaskStore) -> Self {
Self { store }
}
pub fn create_task(
&self,
input: AiTaskCreateInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?;
let snapshot = AiTaskSnapshot {
task_id: input.task_id.clone(),
task_kind: input.task_kind,
owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(),
request_label: normalize_required_string(input.request_label).unwrap_or_default(),
source_module: normalize_required_string(input.source_module).unwrap_or_default(),
source_entity_id: normalize_optional_text(input.source_entity_id),
request_payload_json: normalize_optional_text(input.request_payload_json),
status: AiTaskStatus::Pending,
failure_message: None,
stages: input
.stages
.into_iter()
.map(|stage| AiTaskStageSnapshot {
stage_kind: stage.stage_kind,
label: normalize_required_string(stage.label).unwrap_or_default(),
detail: normalize_required_string(stage.detail).unwrap_or_default(),
order: stage.order,
status: AiTaskStageStatus::Pending,
text_output: None,
structured_payload_json: None,
warning_messages: Vec::new(),
started_at_micros: None,
completed_at_micros: None,
})
.collect(),
result_references: Vec::new(),
latest_text_output: None,
latest_structured_payload_json: None,
version: INITIAL_AI_TASK_VERSION,
created_at_micros: input.created_at_micros,
started_at_micros: None,
completed_at_micros: None,
updated_at_micros: input.created_at_micros,
};
self.store.insert_task(snapshot)
}
pub fn start_task(
&self,
task_id: &str,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn start_stage(
&self,
task_id: &str,
stage_kind: AiTaskStageKind,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn append_text_chunk(
&self,
task_id: &str,
stage_kind: AiTaskStageKind,
sequence: u32,
delta_text: String,
created_at_micros: i64,
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> {
if delta_text.trim().is_empty() {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingChunkText,
));
}
if sequence == 0 {
return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence));
}
let chunk = AiTextChunkSnapshot {
chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence),
task_id: normalize_required_string(task_id).unwrap_or_default(),
stage_kind,
sequence,
delta_text: normalize_required_string(delta_text).unwrap_or_default(),
created_at_micros,
};
let task = self.store.append_text_chunk(chunk.clone())?;
Ok((task, chunk))
}
pub fn complete_stage(
&self,
input: AiStageCompletionInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(&input.task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == input.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Completed;
stage.completed_at_micros = Some(input.completed_at_micros);
stage.text_output = normalize_optional_text(input.text_output.clone());
stage.structured_payload_json =
normalize_optional_text(input.structured_payload_json.clone());
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
task.latest_text_output = stage.text_output.clone();
task.latest_structured_payload_json = stage.structured_payload_json.clone();
task.updated_at_micros = input.completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn attach_result_reference(
&self,
task_id: &str,
reference_kind: AiResultReferenceKind,
reference_id: String,
label: Option<String>,
created_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(reference_id) = normalize_required_string(reference_id) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingReferenceId,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.result_references.push(AiResultReferenceSnapshot {
result_ref_id: generate_ai_result_ref_id(created_at_micros),
task_id: task.task_id.clone(),
reference_kind,
reference_id: reference_id.clone(),
label: normalize_optional_text(label.clone()),
created_at_micros,
});
task.updated_at_micros = created_at_micros;
task.version += 1;
Ok(())
})
}
pub fn complete_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Completed;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn fail_task(
&self,
task_id: &str,
failure_message: String,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(failure_message) = normalize_required_string(failure_message) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingFailureMessage,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Failed;
task.failure_message = Some(failure_message.clone());
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn cancel_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Cancelled;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.get_task(task_id)
}
}

View File

@@ -0,0 +1,138 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use crate::{
AiTaskServiceError, AiTaskSnapshot, AiTaskStageStatus, AiTaskStatus, AiTextChunkSnapshot,
};
use super::ensure_task_is_not_terminal;
#[derive(Clone, Debug, Default)]
pub struct InMemoryAiTaskStore {
inner: Arc<Mutex<InMemoryAiTaskStoreState>>,
}
#[derive(Debug, Default)]
struct InMemoryAiTaskStoreState {
tasks: HashMap<String, AiTaskSnapshot>,
text_chunks: HashMap<String, Vec<AiTextChunkSnapshot>>,
}
impl InMemoryAiTaskStore {
pub(super) fn insert_task(
&self,
task: AiTaskSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
if state.tasks.contains_key(&task.task_id) {
return Err(AiTaskServiceError::TaskAlreadyExists);
}
state.text_chunks.insert(task.task_id.clone(), Vec::new());
state.tasks.insert(task.task_id.clone(), task.clone());
Ok(task)
}
pub(super) fn update_task<F>(
&self,
task_id: &str,
mut apply: F,
) -> Result<AiTaskSnapshot, AiTaskServiceError>
where
F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>,
{
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
let task = state
.tasks
.get_mut(task_id.trim())
.ok_or(AiTaskServiceError::TaskNotFound)?;
apply(task)?;
Ok(task.clone())
}
pub(super) fn append_text_chunk(
&self,
chunk: AiTextChunkSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
{
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
if stage.status == AiTaskStageStatus::Pending {
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros = Some(chunk.created_at_micros);
}
task.status = AiTaskStatus::Running;
task.started_at_micros
.get_or_insert(chunk.created_at_micros);
}
let chunks = state
.text_chunks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
chunks.push(chunk.clone());
chunks.sort_by_key(|value| value.sequence);
let aggregated_text = chunks
.iter()
.filter(|value| value.stage_kind == chunk.stage_kind)
.map(|value| value.delta_text.as_str())
.collect::<Vec<_>>()
.join("");
let normalized_output = if aggregated_text.trim().is_empty() {
None
} else {
Some(aggregated_text)
};
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.text_output = normalized_output.clone();
task.latest_text_output = normalized_output;
task.updated_at_micros = chunk.created_at_micros;
task.version += 1;
Ok(task.clone())
}
pub(super) fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
state
.tasks
.get(task_id.trim())
.cloned()
.ok_or(AiTaskServiceError::TaskNotFound)
}
}

View File

@@ -1,125 +1,9 @@
use std::collections::HashMap;
mod inputs;
mod validation;
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{
AiResultReferenceKind, AiTaskFieldError, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind,
pub use inputs::{
AiResultReferenceInput, AiStageCompletionInput, AiTaskCancelInput, AiTaskCreateInput,
AiTaskFailureInput, AiTaskFinishInput, AiTaskStageStartInput, AiTaskStartInput,
AiTextChunkAppendInput,
};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCreateInput {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub stages: Vec<AiTaskStageBlueprint>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStartInput {
pub task_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageStartInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkAppendInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiStageCompletionInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceInput {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFinishInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCancelInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFailureInput {
pub task_id: String,
pub failure_message: String,
pub completed_at_micros: i64,
}
pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> {
if normalize_required_string(&input.task_id).is_none() {
return Err(AiTaskFieldError::MissingTaskId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(AiTaskFieldError::MissingOwnerUserId);
}
if normalize_required_string(&input.request_label).is_none() {
return Err(AiTaskFieldError::MissingRequestLabel);
}
if normalize_required_string(&input.source_module).is_none() {
return Err(AiTaskFieldError::MissingSourceModule);
}
if input.stages.is_empty() {
return Err(AiTaskFieldError::MissingStageBlueprints);
}
let mut seen = HashMap::new();
for stage in &input.stages {
if normalize_required_string(&stage.label).is_none()
|| normalize_required_string(&stage.detail).is_none()
{
return Err(AiTaskFieldError::MissingStageBlueprints);
}
if seen.insert(stage.stage_kind, true).is_some() {
return Err(AiTaskFieldError::DuplicateStageBlueprint);
}
}
Ok(())
}
pub use validation::validate_task_create_input;

View File

@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{AiResultReferenceKind, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCreateInput {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub stages: Vec<AiTaskStageBlueprint>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStartInput {
pub task_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageStartInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkAppendInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiStageCompletionInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceInput {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFinishInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCancelInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFailureInput {
pub task_id: String,
pub failure_message: String,
pub completed_at_micros: i64,
}

View File

@@ -0,0 +1,40 @@
use std::collections::HashMap;
use shared_kernel::normalize_required_string;
use crate::{AiTaskFieldError, AiTaskStageKind};
use super::inputs::AiTaskCreateInput;
pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> {
if normalize_required_string(&input.task_id).is_none() {
return Err(AiTaskFieldError::MissingTaskId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(AiTaskFieldError::MissingOwnerUserId);
}
if normalize_required_string(&input.request_label).is_none() {
return Err(AiTaskFieldError::MissingRequestLabel);
}
if normalize_required_string(&input.source_module).is_none() {
return Err(AiTaskFieldError::MissingSourceModule);
}
if input.stages.is_empty() {
return Err(AiTaskFieldError::MissingStageBlueprints);
}
let mut seen: HashMap<AiTaskStageKind, bool> = HashMap::new();
for stage in &input.stages {
if normalize_required_string(&stage.label).is_none()
|| normalize_required_string(&stage.detail).is_none()
{
return Err(AiTaskFieldError::MissingStageBlueprints);
}
if seen.insert(stage.stage_kind, true).is_some() {
return Err(AiTaskFieldError::DuplicateStageBlueprint);
}
}
Ok(())
}

View File

@@ -1,239 +1,15 @@
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string,
normalize_string_list as normalize_shared_string_list,
mod ids;
mod stages;
mod types;
pub use ids::{
AI_RESULT_REF_ID_PREFIX, AI_TASK_ID_PREFIX, AI_TASK_STAGE_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX,
INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_id,
generate_ai_task_stage_id, generate_ai_text_chunk_id, normalize_optional_text,
normalize_string_list,
};
pub use types::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiTaskKind, AiTaskSnapshot,
AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStatus, AiTaskStatus,
AiTextChunkSnapshot,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const AI_TASK_ID_PREFIX: &str = "aitask_";
pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_";
pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_";
pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_";
pub const INITIAL_AI_TASK_VERSION: u32 = 1;
// AI 编排类型与当前正式运行时主链保持一致,具体 prompt 策略留给上层模块。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskKind {
StoryGeneration,
CharacterChat,
NpcChat,
CustomWorldGeneration,
QuestIntent,
RuntimeItemIntent,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AiTaskStageKind {
PreparePrompt,
RequestModel,
RepairResponse,
NormalizeResult,
PersistResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStageStatus {
Pending,
Running,
Completed,
Skipped,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiResultReferenceKind {
StorySession,
StoryEvent,
CustomWorldProfile,
QuestRecord,
RuntimeItemRecord,
AssetObject,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageBlueprint {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageSnapshot {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
pub status: AiTaskStageStatus,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskSnapshot {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub status: AiTaskStatus,
pub failure_message: Option<String>,
pub stages: Vec<AiTaskStageSnapshot>,
pub result_references: Vec<AiResultReferenceSnapshot>,
pub latest_text_output: Option<String>,
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at_micros: i64,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkSnapshot {
pub chunk_id: String,
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceSnapshot {
pub result_ref_id: String,
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}
impl AiTaskKind {
pub fn default_stage_blueprints(self) -> Vec<AiTaskStageBlueprint> {
let ordered_kinds = match self {
Self::StoryGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
],
Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => {
vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::NormalizeResult,
]
}
Self::CustomWorldGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
AiTaskStageKind::PersistResult,
],
};
ordered_kinds
.into_iter()
.enumerate()
.map(|(index, stage_kind)| AiTaskStageBlueprint {
stage_kind,
label: stage_kind.default_label().to_string(),
detail: stage_kind.default_detail().to_string(),
order: index as u32,
})
.collect()
}
}
impl AiTaskStageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::PreparePrompt => "prepare_prompt",
Self::RequestModel => "request_model",
Self::RepairResponse => "repair_response",
Self::NormalizeResult => "normalize_result",
Self::PersistResult => "persist_result",
}
}
pub fn default_label(self) -> &'static str {
match self {
Self::PreparePrompt => "整理提示词",
Self::RequestModel => "请求模型",
Self::RepairResponse => "修复响应",
Self::NormalizeResult => "归一结果",
Self::PersistResult => "回写结果",
}
}
pub fn default_detail(self) -> &'static str {
match self {
Self::PreparePrompt => "整理输入上下文并构建本轮提示词。",
Self::RequestModel => "向上游模型发起正式推理请求。",
Self::RepairResponse => "对非严格输出做补救修复或二次编排。",
Self::NormalizeResult => "把模型输出归一成模块可消费结构。",
Self::PersistResult => "把结果引用或聚合状态回写到下游模块。",
}
}
}
impl AiTaskStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
}
pub fn generate_ai_task_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros)
}
pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String {
format!(
"{}{}_{}",
AI_TASK_STAGE_ID_PREFIX,
task_id.trim(),
stage_kind.as_str()
)
}
pub fn generate_ai_result_ref_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros)
}
pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String {
format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX)
}
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}

View File

@@ -0,0 +1,41 @@
use shared_kernel::{
build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string,
normalize_string_list as normalize_shared_string_list,
};
use super::types::AiTaskStageKind;
pub const AI_TASK_ID_PREFIX: &str = "aitask_";
pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_";
pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_";
pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_";
pub const INITIAL_AI_TASK_VERSION: u32 = 1;
pub fn generate_ai_task_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros)
}
pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String {
format!(
"{}{}_{}",
AI_TASK_STAGE_ID_PREFIX,
task_id.trim(),
stage_kind.as_str()
)
}
pub fn generate_ai_result_ref_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros)
}
pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String {
format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX)
}
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}

View File

@@ -0,0 +1,77 @@
use super::types::{AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStatus};
impl AiTaskKind {
pub fn default_stage_blueprints(self) -> Vec<AiTaskStageBlueprint> {
let ordered_kinds = match self {
Self::StoryGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
],
Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => {
vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::NormalizeResult,
]
}
Self::CustomWorldGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
AiTaskStageKind::PersistResult,
],
};
ordered_kinds
.into_iter()
.enumerate()
.map(|(index, stage_kind)| AiTaskStageBlueprint {
stage_kind,
label: stage_kind.default_label().to_string(),
detail: stage_kind.default_detail().to_string(),
order: index as u32,
})
.collect()
}
}
impl AiTaskStageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::PreparePrompt => "prepare_prompt",
Self::RequestModel => "request_model",
Self::RepairResponse => "repair_response",
Self::NormalizeResult => "normalize_result",
Self::PersistResult => "persist_result",
}
}
pub fn default_label(self) -> &'static str {
match self {
Self::PreparePrompt => "整理提示词",
Self::RequestModel => "请求模型",
Self::RepairResponse => "修复响应",
Self::NormalizeResult => "归一结果",
Self::PersistResult => "回写结果",
}
}
pub fn default_detail(self) -> &'static str {
match self {
Self::PreparePrompt => "整理输入上下文并构建本轮提示词。",
Self::RequestModel => "向上游模型发起正式推理请求。",
Self::RepairResponse => "对非严格输出做补救修复或二次编排。",
Self::NormalizeResult => "把模型输出归一成模块可消费结构。",
Self::PersistResult => "把结果引用或聚合状态回写到下游模块。",
}
}
}
impl AiTaskStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
}

View File

@@ -0,0 +1,124 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
// AI 编排类型只表达领域意图,具体 prompt 策略留给业务模块和平台层。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskKind {
StoryGeneration,
CharacterChat,
NpcChat,
CustomWorldGeneration,
QuestIntent,
RuntimeItemIntent,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AiTaskStageKind {
PreparePrompt,
RequestModel,
RepairResponse,
NormalizeResult,
PersistResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStageStatus {
Pending,
Running,
Completed,
Skipped,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiResultReferenceKind {
StorySession,
StoryEvent,
CustomWorldProfile,
QuestRecord,
RuntimeItemRecord,
AssetObject,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageBlueprint {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageSnapshot {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
pub status: AiTaskStageStatus,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskSnapshot {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub status: AiTaskStatus,
pub failure_message: Option<String>,
pub stages: Vec<AiTaskStageSnapshot>,
pub result_references: Vec<AiResultReferenceSnapshot>,
pub latest_text_output: Option<String>,
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at_micros: i64,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkSnapshot {
pub chunk_id: String,
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceSnapshot {
pub result_ref_id: String,
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}

View File

@@ -22,225 +22,4 @@ pub use errors::{AiTaskFieldError, AiTaskServiceError};
pub use events::AiTaskDomainEvent;
#[cfg(test)]
mod tests {
use super::*;
fn build_service() -> AiTaskService {
AiTaskService::new(InMemoryAiTaskStore::default())
}
fn build_create_input(task_kind: AiTaskKind) -> AiTaskCreateInput {
AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_000),
task_kind,
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: task_kind.default_stage_blueprints(),
created_at_micros: 1_713_680_000_000_000,
}
}
#[test]
fn default_stage_blueprints_match_story_baseline() {
let stages = AiTaskKind::StoryGeneration.default_stage_blueprints();
assert_eq!(stages.len(), 4);
assert_eq!(stages[0].stage_kind, AiTaskStageKind::PreparePrompt);
assert_eq!(stages[1].stage_kind, AiTaskStageKind::RequestModel);
assert_eq!(stages[2].stage_kind, AiTaskStageKind::RepairResponse);
assert_eq!(stages[3].stage_kind, AiTaskStageKind::NormalizeResult);
}
#[test]
fn create_task_rejects_duplicate_stage_blueprints() {
let mut input = build_create_input(AiTaskKind::StoryGeneration);
input.stages.push(AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::PreparePrompt,
label: "重复阶段".to_string(),
detail: "重复阶段".to_string(),
order: 99,
});
let error = validate_task_create_input(&input).expect_err("duplicate stages should fail");
assert_eq!(error, AiTaskFieldError::DuplicateStageBlueprint);
}
#[test]
fn generate_ai_task_stage_id_contains_task_and_stage_slug() {
let stage_id = generate_ai_task_stage_id("aitask_demo", AiTaskStageKind::NormalizeResult);
assert_eq!(stage_id, "aistage_aitask_demo_normalize_result");
}
#[test]
fn create_and_start_task_updates_status() {
let service = build_service();
let created = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let started = service
.start_task(&created.task_id, created.created_at_micros + 1)
.expect("task should start");
assert_eq!(created.status, AiTaskStatus::Pending);
assert_eq!(started.status, AiTaskStatus::Running);
assert_eq!(
started.started_at_micros,
Some(created.created_at_micros + 1)
);
assert_eq!(started.version, INITIAL_AI_TASK_VERSION + 1);
}
#[test]
fn append_text_chunk_aggregates_stream_output_by_stage() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CharacterChat))
.expect("task should create");
service
.start_stage(
&task.task_id,
AiTaskStageKind::RequestModel,
task.created_at_micros + 10,
)
.expect("stage should start");
let (after_first, _) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
1,
"".to_string(),
task.created_at_micros + 20,
)
.expect("first chunk should append");
let (after_second, second_chunk) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
2,
"好。".to_string(),
task.created_at_micros + 30,
)
.expect("second chunk should append");
assert_eq!(after_first.latest_text_output.as_deref(), Some(""));
assert_eq!(after_second.latest_text_output.as_deref(), Some("你好。"));
assert_eq!(second_chunk.sequence, 2);
}
#[test]
fn complete_stage_updates_latest_outputs() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::StoryGeneration))
.expect("task should create");
let completed = service
.complete_stage(AiStageCompletionInput {
task_id: task.task_id.clone(),
stage_kind: AiTaskStageKind::NormalizeResult,
text_output: Some("营地前的篝火重新亮了起来。".to_string()),
structured_payload_json: Some("{\"choices\":3}".to_string()),
warning_messages: vec!["使用了 fallback 选项池".to_string()],
completed_at_micros: task.created_at_micros + 50,
})
.expect("stage should complete");
let stage = completed
.stages
.iter()
.find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult)
.expect("normalize stage should exist");
assert_eq!(stage.status, AiTaskStageStatus::Completed);
assert_eq!(
completed.latest_text_output.as_deref(),
Some("营地前的篝火重新亮了起来。")
);
assert_eq!(
completed.latest_structured_payload_json.as_deref(),
Some("{\"choices\":3}")
);
assert_eq!(stage.warning_messages, vec!["使用了 fallback 选项池"]);
}
#[test]
fn attach_result_reference_appends_binding() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CustomWorldGeneration))
.expect("task should create");
let updated = service
.attach_result_reference(
&task.task_id,
AiResultReferenceKind::CustomWorldProfile,
"profile_001".to_string(),
Some("主世界档案".to_string()),
task.created_at_micros + 10,
)
.expect("reference should attach");
assert_eq!(updated.result_references.len(), 1);
assert_eq!(
updated.result_references[0].reference_kind,
AiResultReferenceKind::CustomWorldProfile
);
assert_eq!(updated.result_references[0].reference_id, "profile_001");
}
#[test]
fn fail_and_cancel_task_move_into_terminal_states() {
let service = build_service();
let first = service
.create_task(build_create_input(AiTaskKind::NpcChat))
.expect("task should create");
let failed = service
.fail_task(
&first.task_id,
"上游模型超时".to_string(),
first.created_at_micros + 10,
)
.expect("task should fail");
assert_eq!(failed.status, AiTaskStatus::Failed);
assert_eq!(failed.failure_message.as_deref(), Some("上游模型超时"));
let second = service
.create_task(AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_999),
..build_create_input(AiTaskKind::RuntimeItemIntent)
})
.expect("second task should create");
let cancelled = service
.cancel_task(&second.task_id, second.created_at_micros + 20)
.expect("task should cancel");
assert_eq!(cancelled.status, AiTaskStatus::Cancelled);
assert_eq!(
cancelled.completed_at_micros,
Some(second.created_at_micros + 20)
);
}
#[test]
fn complete_task_marks_terminal_success() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let completed = service
.complete_task(&task.task_id, task.created_at_micros + 100)
.expect("task should complete");
assert_eq!(completed.status, AiTaskStatus::Completed);
assert_eq!(
completed.completed_at_micros,
Some(task.created_at_micros + 100)
);
}
}
mod tests;

View File

@@ -0,0 +1,220 @@
use super::*;
fn build_service() -> AiTaskService {
AiTaskService::new(InMemoryAiTaskStore::default())
}
fn build_create_input(task_kind: AiTaskKind) -> AiTaskCreateInput {
AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_000),
task_kind,
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: task_kind.default_stage_blueprints(),
created_at_micros: 1_713_680_000_000_000,
}
}
#[test]
fn default_stage_blueprints_match_story_baseline() {
let stages = AiTaskKind::StoryGeneration.default_stage_blueprints();
assert_eq!(stages.len(), 4);
assert_eq!(stages[0].stage_kind, AiTaskStageKind::PreparePrompt);
assert_eq!(stages[1].stage_kind, AiTaskStageKind::RequestModel);
assert_eq!(stages[2].stage_kind, AiTaskStageKind::RepairResponse);
assert_eq!(stages[3].stage_kind, AiTaskStageKind::NormalizeResult);
}
#[test]
fn create_task_rejects_duplicate_stage_blueprints() {
let mut input = build_create_input(AiTaskKind::StoryGeneration);
input.stages.push(AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::PreparePrompt,
label: "重复阶段".to_string(),
detail: "重复阶段".to_string(),
order: 99,
});
let error = validate_task_create_input(&input).expect_err("duplicate stages should fail");
assert_eq!(error, AiTaskFieldError::DuplicateStageBlueprint);
}
#[test]
fn generate_ai_task_stage_id_contains_task_and_stage_slug() {
let stage_id = generate_ai_task_stage_id("aitask_demo", AiTaskStageKind::NormalizeResult);
assert_eq!(stage_id, "aistage_aitask_demo_normalize_result");
}
#[test]
fn create_and_start_task_updates_status() {
let service = build_service();
let created = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let started = service
.start_task(&created.task_id, created.created_at_micros + 1)
.expect("task should start");
assert_eq!(created.status, AiTaskStatus::Pending);
assert_eq!(started.status, AiTaskStatus::Running);
assert_eq!(
started.started_at_micros,
Some(created.created_at_micros + 1)
);
assert_eq!(started.version, INITIAL_AI_TASK_VERSION + 1);
}
#[test]
fn append_text_chunk_aggregates_stream_output_by_stage() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CharacterChat))
.expect("task should create");
service
.start_stage(
&task.task_id,
AiTaskStageKind::RequestModel,
task.created_at_micros + 10,
)
.expect("stage should start");
let (after_first, _) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
1,
"".to_string(),
task.created_at_micros + 20,
)
.expect("first chunk should append");
let (after_second, second_chunk) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
2,
"好。".to_string(),
task.created_at_micros + 30,
)
.expect("second chunk should append");
assert_eq!(after_first.latest_text_output.as_deref(), Some(""));
assert_eq!(after_second.latest_text_output.as_deref(), Some("你好。"));
assert_eq!(second_chunk.sequence, 2);
}
#[test]
fn complete_stage_updates_latest_outputs() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::StoryGeneration))
.expect("task should create");
let completed = service
.complete_stage(AiStageCompletionInput {
task_id: task.task_id.clone(),
stage_kind: AiTaskStageKind::NormalizeResult,
text_output: Some("营地前的篝火重新亮了起来。".to_string()),
structured_payload_json: Some("{\"choices\":3}".to_string()),
warning_messages: vec!["使用了 fallback 选项池".to_string()],
completed_at_micros: task.created_at_micros + 50,
})
.expect("stage should complete");
let stage = completed
.stages
.iter()
.find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult)
.expect("normalize stage should exist");
assert_eq!(stage.status, AiTaskStageStatus::Completed);
assert_eq!(
completed.latest_text_output.as_deref(),
Some("营地前的篝火重新亮了起来。")
);
assert_eq!(
completed.latest_structured_payload_json.as_deref(),
Some("{\"choices\":3}")
);
assert_eq!(stage.warning_messages, vec!["使用了 fallback 选项池"]);
}
#[test]
fn attach_result_reference_appends_binding() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CustomWorldGeneration))
.expect("task should create");
let updated = service
.attach_result_reference(
&task.task_id,
AiResultReferenceKind::CustomWorldProfile,
"profile_001".to_string(),
Some("主世界档案".to_string()),
task.created_at_micros + 10,
)
.expect("reference should attach");
assert_eq!(updated.result_references.len(), 1);
assert_eq!(
updated.result_references[0].reference_kind,
AiResultReferenceKind::CustomWorldProfile
);
assert_eq!(updated.result_references[0].reference_id, "profile_001");
}
#[test]
fn fail_and_cancel_task_move_into_terminal_states() {
let service = build_service();
let first = service
.create_task(build_create_input(AiTaskKind::NpcChat))
.expect("task should create");
let failed = service
.fail_task(
&first.task_id,
"上游模型超时".to_string(),
first.created_at_micros + 10,
)
.expect("task should fail");
assert_eq!(failed.status, AiTaskStatus::Failed);
assert_eq!(failed.failure_message.as_deref(), Some("上游模型超时"));
let second = service
.create_task(AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_999),
..build_create_input(AiTaskKind::RuntimeItemIntent)
})
.expect("second task should create");
let cancelled = service
.cancel_task(&second.task_id, second.created_at_micros + 20)
.expect("task should cancel");
assert_eq!(cancelled.status, AiTaskStatus::Cancelled);
assert_eq!(
cancelled.completed_at_micros,
Some(second.created_at_micros + 20)
);
}
#[test]
fn complete_task_marks_terminal_success() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let completed = service
.complete_task(&task.task_id, task.created_at_micros + 100)
.expect("task should complete");
assert_eq!(completed.status, AiTaskStatus::Completed);
assert_eq!(
completed.completed_at_micros,
Some(task.created_at_micros + 100)
);
}

View File

@@ -25,12 +25,14 @@
- `assetobj_` ID 前缀与初始版本常量
- `asset_entity_binding` 输入、快照、返回记录与字段校验 helper
- `assetbind_` ID 前缀
5. `WP-AS Assets` 资产对象类型归位已完成,领域快照、命令 DTO、应用返回 DTO 和字段错误已分别落到 DDD 骨架文件中。
当前 `asset_object` 表的字段、索引与可编码约束见:
1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
3. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
4. [../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md)
当前还已补齐:

View File

@@ -1,8 +1,45 @@
//! 资产应用编排落位
//! 资产应用编排返回类型
//!
//! 这里只组合纯校验与应用结果;对象探测、签名和持久化由 adapter 层完成。
pub use crate::asset_object_core::{
AssetEntityBindingProcedureResult, AssetHistoryListResult, AssetObjectProcedureResult,
ConfirmAssetObjectResult, build_asset_entity_binding_input, build_asset_object_upsert_input,
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::{
AssetEntityBindingSnapshot, AssetHistoryEntrySnapshot, AssetObjectRecord,
AssetObjectUpsertSnapshot,
};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListResult {
pub ok: bool,
pub entries: Vec<AssetHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingProcedureResult {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
}
pub use crate::asset_object_core::{
build_asset_entity_binding_input, build_asset_object_upsert_input,
};

View File

@@ -1,223 +1,18 @@
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;
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
// 资产对象访问策略先冻结为枚举,避免后续在 reducer、HTTP DTO 和脚本里散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetObjectFieldError {
MissingBucket,
MissingObjectKey,
MissingAssetKind,
MissingAssetObjectId,
MissingBindingId,
MissingEntityKind,
MissingEntityId,
MissingSlot,
InvalidVersion,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectInput {
pub bucket: Option<String>,
pub object_key: String,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_hash: Option<String>,
pub asset_kind: String,
pub access_policy: Option<AssetObjectAccessPolicy>,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListInput {
pub asset_kind: String,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
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 AssetHistoryListResult {
pub ok: bool,
pub entries: Vec<AssetHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingProcedureResult {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
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 AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetObjectRecord {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetEntityBindingRecord {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
use crate::{
commands::{AssetEntityBindingInput, AssetObjectUpsertInput},
domain::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION,
},
errors::AssetObjectFieldError,
};
// 资产核心对象字段需要继续保留模块自己的错误语义,但基础必填字符串归一化统一走 shared-kernel。
fn normalize_required_asset_field(
@@ -420,24 +215,6 @@ pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_optional_string(value)
}
impl fmt::Display for AssetObjectFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
}
}
}
impl Error for AssetObjectFieldError {}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,7 +1,105 @@
//! 资产写入命令落位
//! 资产写入命令。
//!
//! 用于表达确认资产对象、绑定实体槽位和查询资产历史的输入,不直接访问 OSS。
pub use crate::asset_object_core::{
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput,
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::{
AssetEntityBindingSnapshot, AssetObjectAccessPolicy, AssetObjectUpsertSnapshot,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectInput {
pub bucket: Option<String>,
pub object_key: String,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_hash: Option<String>,
pub asset_kind: String,
pub access_policy: Option<AssetObjectAccessPolicy>,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListInput {
pub asset_kind: String,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
impl From<AssetObjectUpsertInput> for AssetObjectUpsertSnapshot {
fn from(value: AssetObjectUpsertInput) -> Self {
Self {
asset_object_id: value.asset_object_id,
bucket: value.bucket,
object_key: value.object_key,
access_policy: value.access_policy,
content_type: value.content_type,
content_length: value.content_length,
content_hash: value.content_hash,
version: value.version,
source_job_id: value.source_job_id,
owner_user_id: value.owner_user_id,
profile_id: value.profile_id,
entity_id: value.entity_id,
asset_kind: value.asset_kind,
created_at_micros: value.updated_at_micros,
updated_at_micros: value.updated_at_micros,
}
}
}
impl From<AssetEntityBindingInput> for AssetEntityBindingSnapshot {
fn from(value: AssetEntityBindingInput) -> Self {
Self {
binding_id: value.binding_id,
asset_object_id: value.asset_object_id,
entity_kind: value.entity_kind,
entity_id: value.entity_id,
slot: value.slot,
asset_kind: value.asset_kind,
owner_user_id: value.owner_user_id,
profile_id: value.profile_id,
created_at_micros: value.updated_at_micros,
updated_at_micros: value.updated_at_micros,
}
}
}

View File

@@ -1,15 +1,128 @@
//! 资产领域模型落位
//! 资产领域模型。
//!
//! 当前先通过本文件承接对外领域 API 分层导出,旧实现仍留在
//! `asset_object_core.rs` 内部文件中,后续再逐段搬入本文件或 `domain/` 子目录
//! 本层只允许保留资产对象、实体绑定、访问策略、版本和业务归属等纯规则。
//! 本层只保留资产对象、实体绑定、访问策略、版本和业务归属等纯领域事实。
//! OSS 对象探测、HTTP DTO 映射和 SpacetimeDB row 写入都属于外层 adapter
pub use crate::asset_object_core::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_record,
build_asset_history_entry_record, build_asset_object_record, generate_asset_binding_id,
generate_asset_object_id, normalize_optional_value, validate_asset_entity_binding_fields,
validate_asset_object_fields,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
// 资产对象访问策略先冻结为枚举,避免 reducer、HTTP DTO 和脚本里散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
/// SpacetimeDB 写入前的资产对象快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
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 AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
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 AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
/// 面向 API 与前端展示的资产对象记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetObjectRecord {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
pub updated_at: String,
}
/// 面向 API 与前端展示的资产历史记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 面向 API 与前端展示的实体绑定记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetEntityBindingRecord {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}

View File

@@ -1,5 +1,36 @@
//! 资产领域错误落位
//! 资产领域错误。
//!
//! 字段错误和业务错误在这里收口HTTP 状态码与 SpacetimeDB 字符串错误由 adapter 映射。
pub use crate::asset_object_core::AssetObjectFieldError;
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetObjectFieldError {
MissingBucket,
MissingObjectKey,
MissingAssetKind,
MissingAssetObjectId,
MissingBindingId,
MissingEntityKind,
MissingEntityId,
MissingSlot,
InvalidVersion,
}
impl fmt::Display for AssetObjectFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
}
}
}
impl Error for AssetObjectFieldError {}

View File

@@ -23,9 +23,12 @@ pub use domain::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_record,
build_asset_history_entry_record, build_asset_object_record, generate_asset_binding_id,
generate_asset_object_id, normalize_optional_value, validate_asset_entity_binding_fields,
validate_asset_object_fields,
INITIAL_ASSET_OBJECT_VERSION,
};
pub use errors::AssetObjectFieldError;
pub use asset_object_core::{
build_asset_entity_binding_record, build_asset_history_entry_record, build_asset_object_record,
generate_asset_binding_id, generate_asset_object_id, normalize_optional_value,
validate_asset_entity_binding_fields, validate_asset_object_fields,
};

View File

@@ -18,15 +18,18 @@
1. JWT claims 设计与 `platform-auth` 落地。
2. refresh cookie 读取适配。
3. `module-auth` 真实 crate 与首版密码登录用例落地。
4. 微信登录链路暂缓执行,不进入当前连续实现顺序
4. `WP-A Auth` DDD 分层收口,账号、会话、验证码、微信 state/绑定规则、命令输入、应用返回、领域错误和领域事件已归位到 `domain / commands / application / errors / events`
5. `api-server / platform-auth / spacetime-module` 认证边界已核查:真实短信、微信 OAuth、JWT、cookie 和密码哈希仍由平台层或 BFF 装配承接SpacetimeDB 侧只保留快照与表适配。
当前连续实现优先顺序固定为
当前已覆盖的鉴权用例
1. 密码登录
2. refresh token 轮换
3. `me` 查询
4. 会话吊销
5. 手机验证码登录
6. 微信登录 state 创建/消费
7. 微信身份解析与手机号绑定
## 3. 当前已冻结文档
@@ -44,6 +47,7 @@
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
14. [../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
15. [../../../docs/technical/SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md)
## 4. 边界约束
@@ -56,3 +60,4 @@
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
9. 当前手机号验证码真实 provider 由 `platform-auth` 注入,`module-auth` 只保留冷却、TTL、失败次数和账号编排不保存验证码明文。
10. 当前 `lib.rs` 仍保留进程内仓储和文件持久化支撑,但不再继续拥有命令、结果、错误、事件和纯领域值对象定义。

View File

@@ -1,3 +1,112 @@
//! 认证应用编排过渡落位
//! 认证应用返回类型
//!
//! 这里只返回纯应用结果与领域事件;短信 provider、JWT 签发和持久化由外层 adapter 完成。
use serde::{Deserialize, Serialize};
use crate::domain::{
AuthStoreSnapshotRecord, AuthUser, RefreshSessionRecord, WechatAuthStateRecord,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicUserSearchResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordResult {
pub user: AuthUser,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeResult {
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
pub provider_out_id: Option<String>,
pub provider: String,
pub scene: String,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginResult {
pub user: AuthUser,
pub created: bool,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumeWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionResult {
pub session: RefreshSessionRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionResult {
pub session: RefreshSessionRecord,
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListActiveRefreshSessionsResult {
pub sessions: Vec<RefreshSessionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotProcedureResult {
pub ok: bool,
pub record: Option<AuthStoreSnapshotRecord>,
pub error_message: Option<String>,
}

View File

@@ -1,3 +1,92 @@
//! 认证写入命令过渡落位
//! 认证写入命令。
//!
//! 用于表达密码入口、手机号验证码、微信登录、刷新会话签发和吊销等用例输入。
use serde::{Deserialize, Serialize};
use crate::domain::{
AuthLoginMethod, PhoneAuthScene, RefreshSessionClientInfo, WechatAuthScene,
WechatIdentityProfile,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub phone_number: String,
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordInput {
pub user_id: String,
pub current_password: Option<String>,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordInput {
pub phone_number: String,
pub verify_code: String,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,
pub scene: PhoneAuthScene,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginInput {
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginInput {
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateInput {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneInput {
pub user_id: String,
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionInput {
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionInput {
pub refresh_token_hash: String,
pub next_refresh_token_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionInput {
pub user_id: String,
pub refresh_token_hash: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsInput {
pub user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotUpsertInput {
pub snapshot_json: String,
pub updated_at_micros: i64,
}

View File

@@ -1,4 +1,252 @@
//! 认证领域模型过渡落位
//! 认证领域模型。
//!
//! 后续迁移账号、刷新会话、验证码和微信绑定聚合时,只保留认证规则;
//! 文件持久化、真实短信发送、cookie 写入和 HTTP 上下文都属于领域核心
//! 这里只保留账号、登录方式、绑定状态等纯领域事实。文件持久化、真实短信发送、
//! cookie 写入、JWT 签发和 HTTP 上下文都属于外层 adapter
use serde::{Deserialize, Serialize};
use crate::errors::{PasswordEntryError, PhoneAuthError};
pub const PASSWORD_MIN_LENGTH: usize = 6;
pub const PASSWORD_MAX_LENGTH: usize = 128;
pub const SMS_CODE_LENGTH: usize = 6;
pub const SMS_CODE_TTL_MINUTES: i64 = 5;
pub const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
pub const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
/// 用户最近一次完成认证的入口类型。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthLoginMethod {
Password,
Phone,
Wechat,
}
impl AuthLoginMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Phone => "phone",
Self::Wechat => "wechat",
}
}
}
/// 账号是否已经完成必要绑定。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthBindingStatus {
Active,
PendingBindPhone,
}
impl AuthBindingStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingBindPhone => "pending_bind_phone",
}
}
}
/// 认证用户快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthUser {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
}
/// 规范化后的手机号快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneNumberSnapshot {
pub e164: String,
pub masked_national_number: String,
}
/// 手机验证码使用场景。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthScene {
Login,
BindPhone,
ChangePhone,
ResetPassword,
}
impl PhoneAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::BindPhone => "bind_phone",
Self::ChangePhone => "change_phone",
Self::ResetPassword => "reset_password",
}
}
}
/// 微信授权入口场景。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene {
Desktop,
WechatInApp,
}
impl WechatAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Desktop => "desktop",
Self::WechatInApp => "wechat_in_app",
}
}
}
/// 微信身份资料快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityProfile {
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
/// 微信授权 state 快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatAuthStateRecord {
pub wechat_state_id: String,
pub state_token: String,
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// refresh session 的客户端环境快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionClientInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<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: Option<String>,
}
/// refresh session 快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionRecord {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
/// Auth store 持久化快照记录。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotRecord {
pub snapshot_json: Option<String>,
pub updated_at_micros: Option<i64>,
}
pub fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
return Err(PasswordEntryError::InvalidPasswordLength);
}
Ok(())
}
pub fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
let verify_code = verify_code.trim();
if verify_code.len() != SMS_CODE_LENGTH
|| !verify_code
.chars()
.all(|character| character.is_ascii_digit())
{
return Err(PhoneAuthError::InvalidVerifyCode);
}
Ok(())
}
pub fn normalize_mainland_china_phone_number(
raw_phone_number: &str,
) -> Result<PhoneNumberSnapshot, PhoneAuthError> {
let digits = raw_phone_number
.trim()
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 11 || !digits.starts_with('1') {
return Err(PhoneAuthError::InvalidPhoneNumber);
}
Ok(PhoneNumberSnapshot {
e164: format!("+86{digits}"),
masked_national_number: mask_phone_number(&digits),
})
}
pub fn mask_phone_number(phone_number: &str) -> String {
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
}
pub fn build_national_phone_number(e164_phone_number: &str) -> Result<String, PhoneAuthError> {
let digits = e164_phone_number.trim().trim_start_matches('+');
if let Some(national) = digits.strip_prefix("86")
&& national.len() == 11
{
return Ok(national.to_string());
}
Err(PhoneAuthError::InvalidPhoneNumber)
}
pub fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开叙世号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
pub fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
pub fn normalize_public_user_code(input: &str) -> Result<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("SY").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return Err(PasswordEntryError::InvalidPublicUserCode);
}
Ok(format!("SY-{digits:0>8}"))
}
pub fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
format!("{}:{}", phone_number.trim(), scene.as_str())
}

View File

@@ -1,3 +1,179 @@
//! 认证领域错误过渡落位
//! 认证领域错误。
//!
//! 领域错误保持可测试、可映射,不能直接依赖 Axum、cookie 或平台 provider 错误模型。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
UserNotFound,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthError {
InvalidPhoneNumber,
InvalidVerifyCode,
VerifyCodeNotFound,
VerifyCodeExpired,
SendCoolingDown { retry_after_seconds: u64 },
VerifyAttemptsExceeded,
UserNotFound,
UserStateMismatch,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthError {
MissingProfile,
StateNotFound,
StateExpired,
StateConsumed,
UserNotFound,
MissingWechatIdentity,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshSessionError {
MissingToken,
SessionNotFound,
SessionExpired,
UserNotFound,
Store(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogoutError {
UserNotFound,
Store(String),
}
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PasswordEntryError {}
impl fmt::Display for PhoneAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidVerifyCode => f.write_str("验证码错误"),
Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"),
Self::VerifyCodeExpired => f.write_str("验证码已过期"),
Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"),
Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PhoneAuthError {}
impl fmt::Display for WechatAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfile => f.write_str("缺少微信身份信息"),
Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"),
Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"),
Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for WechatAuthError {}
impl fmt::Display for RefreshSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingToken => f.write_str("缺少刷新会话"),
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
f.write_str("当前登录态已失效,请重新登录")
}
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for RefreshSessionError {}
impl fmt::Display for LogoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for LogoutError {}
pub(crate) fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
}
}
}
pub(crate) fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError {
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
}
}
pub(crate) fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
}
pub(crate) fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
match error {
RefreshSessionError::Store(message) => LogoutError::Store(message),
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
}
}

View File

@@ -1,3 +1,27 @@
//! 认证领域事件过渡落位
//! 认证领域事件。
//!
//! 用于表达用户创建、会话签发/吊销、手机号验证通过和微信身份绑定等事实。
use crate::domain::AuthLoginMethod;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthDomainEvent {
UserCreated {
user_id: String,
login_method: AuthLoginMethod,
},
RefreshSessionIssued {
session_id: String,
user_id: String,
},
RefreshSessionRevoked {
session_id: String,
user_id: String,
},
PhoneVerified {
user_id: String,
},
WechatIdentityBound {
user_id: String,
},
}

View File

@@ -4,10 +4,15 @@ mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
use std::{
collections::HashMap,
error::Error,
fmt, fs,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
@@ -24,351 +29,6 @@ use shared_kernel::{
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
const PASSWORD_MIN_LENGTH: usize = 6;
const PASSWORD_MAX_LENGTH: usize = 128;
const SMS_CODE_LENGTH: usize = 6;
const SMS_CODE_TTL_MINUTES: i64 = 5;
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthLoginMethod {
Password,
Phone,
Wechat,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthBindingStatus {
Active,
PendingBindPhone,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthUser {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicUserSearchResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub phone_number: String,
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordInput {
pub user_id: String,
pub current_password: Option<String>,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordInput {
pub phone_number: String,
pub verify_code: String,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordResult {
pub user: AuthUser,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthScene {
Login,
BindPhone,
ChangePhone,
ResetPassword,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneNumberSnapshot {
pub e164: String,
pub masked_national_number: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,
pub scene: PhoneAuthScene,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeResult {
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
pub provider_out_id: Option<String>,
pub provider: String,
pub scene: String,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginInput {
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginResult {
pub user: AuthUser,
pub created: bool,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityProfile {
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginInput {
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene {
Desktop,
WechatInApp,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateInput {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatAuthStateRecord {
pub wechat_state_id: String,
pub state_token: String,
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumeWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneInput {
pub user_id: String,
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionInput {
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionClientInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<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: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionRecord {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionResult {
pub session: RefreshSessionRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionInput {
pub refresh_token_hash: String,
pub next_refresh_token_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionResult {
pub session: RefreshSessionRecord,
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListActiveRefreshSessionsResult {
pub sessions: Vec<RefreshSessionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionInput {
pub user_id: String,
pub refresh_token_hash: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsInput {
pub user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotRecord {
pub snapshot_json: Option<String>,
pub updated_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotUpsertInput {
pub snapshot_json: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotProcedureResult {
pub ok: bool,
pub record: Option<AuthStoreSnapshotRecord>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
UserNotFound,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthError {
InvalidPhoneNumber,
InvalidVerifyCode,
VerifyCodeNotFound,
VerifyCodeExpired,
SendCoolingDown { retry_after_seconds: u64 },
VerifyAttemptsExceeded,
UserNotFound,
UserStateMismatch,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthError {
MissingProfile,
StateNotFound,
StateExpired,
StateConsumed,
UserNotFound,
MissingWechatIdentity,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshSessionError {
MissingToken,
SessionNotFound,
SessionExpired,
UserNotFound,
Store(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogoutError {
UserNotFound,
Store(String),
}
#[derive(Clone, Debug)]
pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>,
@@ -2126,137 +1786,6 @@ impl InMemoryAuthStore {
}
}
impl AuthLoginMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Phone => "phone",
Self::Wechat => "wechat",
}
}
}
impl AuthBindingStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingBindPhone => "pending_bind_phone",
}
}
}
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PasswordEntryError {}
impl fmt::Display for PhoneAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidVerifyCode => f.write_str("验证码错误"),
Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"),
Self::VerifyCodeExpired => f.write_str("验证码已过期"),
Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"),
Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PhoneAuthError {}
impl fmt::Display for WechatAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfile => f.write_str("缺少微信身份信息"),
Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"),
Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"),
Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for WechatAuthError {}
impl fmt::Display for RefreshSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingToken => f.write_str("缺少刷新会话"),
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
f.write_str("当前登录态已失效,请重新登录")
}
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for RefreshSessionError {}
impl fmt::Display for LogoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for LogoutError {}
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
}
}
}
fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError {
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
}
}
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
}
fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError {
match error {
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
@@ -2266,25 +1795,6 @@ fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthEr
}
}
fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
match error {
RefreshSessionError::Store(message) => LogoutError::Store(message),
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
}
}
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
return Err(PasswordEntryError::InvalidPasswordLength);
}
Ok(())
}
async fn verify_stored_password_user(
existing_user: StoredPasswordUser,
password: &str,
@@ -2309,51 +1819,6 @@ async fn verify_stored_password_user(
})
}
fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
let verify_code = verify_code.trim();
if verify_code.len() != SMS_CODE_LENGTH
|| !verify_code
.chars()
.all(|character| character.is_ascii_digit())
{
return Err(PhoneAuthError::InvalidVerifyCode);
}
Ok(())
}
fn normalize_mainland_china_phone_number(
raw_phone_number: &str,
) -> Result<PhoneNumberSnapshot, PhoneAuthError> {
let digits = raw_phone_number
.trim()
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 11 || !digits.starts_with('1') {
return Err(PhoneAuthError::InvalidPhoneNumber);
}
Ok(PhoneNumberSnapshot {
e164: format!("+86{digits}"),
masked_national_number: mask_phone_number(&digits),
})
}
fn mask_phone_number(phone_number: &str) -> String {
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
}
fn build_national_phone_number(e164_phone_number: &str) -> Result<String, PhoneAuthError> {
let digits = e164_phone_number.trim().trim_start_matches('+');
if let Some(national) = digits.strip_prefix("86")
&& national.len() == 11
{
return Ok(national.to_string());
}
Err(PhoneAuthError::InvalidPhoneNumber)
}
fn build_random_password_seed() -> String {
format!(
"seed_{}_{}",
@@ -2362,34 +1827,6 @@ fn build_random_password_seed() -> String {
)
}
fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开叙世号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
pub fn normalize_public_user_code(input: &str) -> Result<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("SY").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return Err(PasswordEntryError::InvalidPublicUserCode);
}
Ok(format!("SY-{digits:0>8}"))
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value)
}
@@ -2404,10 +1841,6 @@ fn seconds_until(now: OffsetDateTime, target: OffsetDateTime) -> u64 {
u64::try_from(seconds.max(1)).unwrap_or(1)
}
fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
format!("{}:{}", phone_number.trim(), scene.as_str())
}
fn create_wechat_state_token() -> String {
new_uuid_simple_string()
}
@@ -2428,26 +1861,6 @@ fn parse_rfc3339_with_context(
.map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}")))
}
impl PhoneAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::BindPhone => "bind_phone",
Self::ChangePhone => "change_phone",
Self::ResetPassword => "reset_password",
}
}
}
impl WechatAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Desktop => "desktop",
Self::WechatInApp => "wechat_in_app",
}
}
}
#[cfg(test)]
mod tests {
use platform_auth::{

View File

@@ -5,11 +5,29 @@
use shared_kernel::normalize_required_string;
use crate::{
BigFishAssetSlotSnapshot, build_asset_coverage,
commands::EvaluateBigFishPublishReadinessCommand, domain::BigFishPublishReadiness,
errors::BigFishApplicationError, events::BigFishDomainEvent,
BIG_FISH_DEFAULT_LEVEL_COUNT, BIG_FISH_TARGET_WILD_COUNT, BigFishAssetSlotSnapshot,
build_asset_coverage,
commands::{
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
},
domain::{
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
BigFishRuntimeSnapshot, BigFishVector2,
},
errors::BigFishApplicationError,
events::BigFishDomainEvent,
};
const VIEW_WIDTH: f32 = 720.0;
const VIEW_HEIGHT: f32 = 1280.0;
const WORLD_HALF_WIDTH: f32 = 1400.0;
const WORLD_HALF_HEIGHT: f32 = 2400.0;
const DEFAULT_WILD_COUNT: usize = 28;
const LEADER_SPEED: f32 = 210.0;
const FOLLOWER_SPEED: f32 = 170.0;
const WILD_SPEED: f32 = 74.0;
const TICK_SECONDS: f32 = 0.1;
/// 发布门禁应用结果,供 adapter 持久化快照或转换成 API DTO。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EvaluateBigFishPublishReadinessResult {
@@ -17,6 +35,13 @@ pub struct EvaluateBigFishPublishReadinessResult {
pub events: Vec<BigFishDomainEvent>,
}
/// 运行态推进应用结果。
#[derive(Clone, Debug, PartialEq)]
pub struct BigFishRuntimeResult {
pub snapshot: BigFishRuntimeSnapshot,
pub events: Vec<BigFishDomainEvent>,
}
/// 评估 Big Fish 作品是否具备发布条件。
///
/// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图
@@ -51,6 +76,508 @@ pub fn evaluate_publish_readiness(
})
}
/// 开始一局 Big Fish 运行态。
///
/// 领域层生成初始实体池adapter 只负责把快照序列化并写入运行表。
pub fn start_big_fish_run(
command: StartBigFishRunCommand,
) -> Result<BigFishRuntimeResult, BigFishApplicationError> {
let run_id =
normalize_required_string(command.run_id).ok_or(BigFishApplicationError::MissingRunId)?;
let session_id = normalize_required_string(command.session_id)
.ok_or(BigFishApplicationError::MissingSessionId)?;
let owner_user_id = normalize_required_string(command.owner_user_id)
.ok_or(BigFishApplicationError::MissingOwnerUserId)?;
let win_level = command
.draft
.as_ref()
.map(|draft| draft.runtime_params.win_level)
.or(command.work_level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT)
.clamp(1, 32);
let wild_count = command
.draft
.as_ref()
.map(|draft| draft.runtime_params.spawn_target_count as usize)
.unwrap_or(BIG_FISH_TARGET_WILD_COUNT)
.max(DEFAULT_WILD_COUNT);
let leader = build_entity("owned-1".to_string(), 1, 0.0, 0.0);
let mut wild_entities = vec![
build_entity("wild-open-1".to_string(), 1, 92.0, 0.0),
build_entity("wild-open-2".to_string(), 1, -118.0, 46.0),
];
while wild_entities.len() < wild_count {
wild_entities.push(build_wild_entity(
0,
wild_entities.len() as u64,
1,
win_level,
&leader.position,
));
}
let snapshot = BigFishRuntimeSnapshot {
run_id: run_id.clone(),
session_id: session_id.clone(),
status: BigFishRunStatus::Running,
tick: 0,
player_level: 1,
win_level,
leader_entity_id: Some(leader.entity_id.clone()),
owned_entities: vec![leader.clone()],
wild_entities,
camera_center: leader.position,
last_input: BigFishVector2 { x: 0.0, y: 0.0 },
event_log: vec!["开局生成同级可收编目标".to_string()],
updated_at_micros: command.started_at_micros,
};
Ok(BigFishRuntimeResult {
snapshot,
events: vec![BigFishDomainEvent::RuntimeRunStarted {
run_id,
session_id,
owner_user_id,
occurred_at_micros: command.started_at_micros,
}],
})
}
/// 根据最新输入推进一帧运行态。
///
/// 这里是 Big Fish 运行态真相源;前端只能提交输入并渲染返回快照。
pub fn submit_big_fish_input(
command: SubmitBigFishInputCommand,
) -> Result<BigFishRuntimeResult, BigFishApplicationError> {
let owner_user_id = normalize_required_string(command.owner_user_id)
.ok_or(BigFishApplicationError::MissingOwnerUserId)?;
if !command.x.is_finite() || !command.y.is_finite() {
return Err(BigFishApplicationError::InvalidRuntimeInput);
}
let mut snapshot = command.current_snapshot;
if snapshot.status != BigFishRunStatus::Running {
return Ok(BigFishRuntimeResult {
snapshot,
events: Vec::new(),
});
}
let next_tick = snapshot.tick.saturating_add(1);
let normalized_input = normalize_vector(command.x, command.y);
let mut sorted_owned = refresh_leader(std::mem::take(&mut snapshot.owned_entities));
let Some(current_leader) = sorted_owned.first().cloned() else {
snapshot.status = BigFishRunStatus::Failed;
snapshot.event_log = tail_events(vec!["己方实体归零,本局失败".to_string()]);
snapshot.updated_at_micros = command.submitted_at_micros;
return Ok(BigFishRuntimeResult {
events: settlement_events(&snapshot, owner_user_id, command.submitted_at_micros),
snapshot,
});
};
let next_leader = move_leader(&current_leader, &normalized_input);
let mut owned_entities = vec![next_leader.clone()];
for (index, follower) in sorted_owned.drain(1..).enumerate() {
owned_entities.push(move_follower(&follower, &next_leader, index + 1));
}
let mut wild_entities = snapshot
.wild_entities
.into_iter()
.map(|entity| move_wild_entity(&entity, next_tick))
.collect::<Vec<_>>();
let mut events = snapshot.event_log;
let mut removed_wild = Vec::<String>::new();
let mut removed_owned = Vec::<String>::new();
let mut newly_owned = Vec::<BigFishRuntimeEntitySnapshot>::new();
for owned in &owned_entities {
if removed_owned.contains(&owned.entity_id) {
continue;
}
for wild in &wild_entities {
if removed_wild.contains(&wild.entity_id) {
continue;
}
if distance(owned, wild) > owned.radius + wild.radius {
continue;
}
if owned.level >= wild.level {
removed_wild.push(wild.entity_id.clone());
newly_owned.push(build_entity(
format!("owned-from-{}-{next_tick}", wild.entity_id),
wild.level,
wild.position.x,
wild.position.y,
));
events.push(format!("收编 {} 级实体", wild.level));
} else {
removed_owned.push(owned.entity_id.clone());
events.push(format!(
"{} 级己方实体被 {} 级野生实体吃掉",
owned.level, wild.level
));
}
}
}
owned_entities.retain(|entity| !removed_owned.contains(&entity.entity_id));
owned_entities.extend(newly_owned);
wild_entities.retain(|entity| !removed_wild.contains(&entity.entity_id));
let merge_result = merge_owned_entities(owned_entities, next_tick);
owned_entities = refresh_leader(merge_result.owned_entities);
events.extend(merge_result.events);
let player_level = owned_entities
.iter()
.map(|entity| entity.level)
.max()
.unwrap_or(0);
let leader = owned_entities.first().cloned();
let camera_center = leader
.as_ref()
.map(|entity| entity.position.clone())
.unwrap_or(snapshot.camera_center);
wild_entities = wild_entities
.into_iter()
.filter_map(|entity| {
let should_cull = entity.level == player_level
|| entity.level >= player_level.saturating_add(3)
|| entity.level.saturating_add(3) <= player_level;
let offscreen_seconds = if should_cull && is_offscreen(&entity, &camera_center) {
entity.offscreen_seconds + TICK_SECONDS
} else {
0.0
};
(offscreen_seconds < 3.0).then_some(BigFishRuntimeEntitySnapshot {
offscreen_seconds,
..entity
})
})
.collect();
while wild_entities.len() < DEFAULT_WILD_COUNT {
wild_entities.push(build_wild_entity(
next_tick,
wild_entities.len() as u64 + next_tick,
player_level.max(1),
snapshot.win_level,
&camera_center,
));
}
let status = if owned_entities.is_empty() {
events.push("己方实体归零,本局失败".to_string());
BigFishRunStatus::Failed
} else if player_level >= snapshot.win_level {
events.push("获得最高等级实体,通关".to_string());
BigFishRunStatus::Won
} else {
BigFishRunStatus::Running
};
let next_snapshot = BigFishRuntimeSnapshot {
run_id: snapshot.run_id,
session_id: snapshot.session_id,
status,
tick: next_tick,
player_level,
win_level: snapshot.win_level,
leader_entity_id: leader.map(|entity| entity.entity_id),
owned_entities,
wild_entities,
camera_center,
last_input: normalized_input,
event_log: tail_events(events),
updated_at_micros: command.submitted_at_micros,
};
let events = settlement_events(&next_snapshot, owner_user_id, command.submitted_at_micros);
Ok(BigFishRuntimeResult {
snapshot: next_snapshot,
events,
})
}
pub fn serialize_runtime_snapshot(
snapshot: &BigFishRuntimeSnapshot,
) -> Result<String, serde_json::Error> {
serde_json::to_string(snapshot)
}
pub fn deserialize_runtime_snapshot(
value: &str,
) -> Result<BigFishRuntimeSnapshot, serde_json::Error> {
serde_json::from_str(value)
}
fn build_entity(entity_id: String, level: u32, x: f32, y: f32) -> BigFishRuntimeEntitySnapshot {
BigFishRuntimeEntitySnapshot {
entity_id,
level,
position: BigFishVector2 { x, y },
radius: entity_radius(level),
offscreen_seconds: 0.0,
}
}
fn entity_radius(level: u32) -> f32 {
18.0 + level as f32 * 4.0
}
fn normalize_vector(x: f32, y: f32) -> BigFishVector2 {
let length = (x * x + y * y).sqrt();
if length <= 0.001 {
return BigFishVector2 { x: 0.0, y: 0.0 };
}
let capped = length.min(1.0);
BigFishVector2 {
x: (x / length) * capped,
y: (y / length) * capped,
}
}
fn distance(first: &BigFishRuntimeEntitySnapshot, second: &BigFishRuntimeEntitySnapshot) -> f32 {
let x = first.position.x - second.position.x;
let y = first.position.y - second.position.y;
(x * x + y * y).sqrt()
}
fn clamp(value: f32, min: f32, max: f32) -> f32 {
value.max(min).min(max)
}
fn spawn_level(player_level: u32, win_level: u32, index: u64) -> u32 {
if player_level <= 1 && index % 4 < 2 {
return 1;
}
let deltas = [-2_i32, -1, 1, 2];
let delta = deltas[(index as usize) % deltas.len()];
(player_level as i32 + delta).clamp(1, win_level as i32) as u32
}
fn spawn_position(center: &BigFishVector2, index: u64) -> BigFishVector2 {
let side = index % 4;
let offset = ((index * 97) % 980) as f32 - 490.0;
match side {
0 => BigFishVector2 {
x: center.x - VIEW_WIDTH * 0.72,
y: center.y + offset,
},
1 => BigFishVector2 {
x: center.x + VIEW_WIDTH * 0.72,
y: center.y + offset,
},
2 => BigFishVector2 {
x: center.x + offset,
y: center.y - VIEW_HEIGHT * 0.64,
},
_ => BigFishVector2 {
x: center.x + offset,
y: center.y + VIEW_HEIGHT * 0.64,
},
}
}
fn build_wild_entity(
tick: u64,
index: u64,
player_level: u32,
win_level: u32,
center: &BigFishVector2,
) -> BigFishRuntimeEntitySnapshot {
let level = spawn_level(player_level, win_level, index);
let position = spawn_position(center, index);
build_entity(
format!("wild-{tick}-{index}"),
level,
position.x,
position.y,
)
}
fn move_leader(
leader: &BigFishRuntimeEntitySnapshot,
input: &BigFishVector2,
) -> BigFishRuntimeEntitySnapshot {
BigFishRuntimeEntitySnapshot {
position: BigFishVector2 {
x: clamp(
leader.position.x + input.x * LEADER_SPEED * TICK_SECONDS,
-WORLD_HALF_WIDTH,
WORLD_HALF_WIDTH,
),
y: clamp(
leader.position.y + input.y * LEADER_SPEED * TICK_SECONDS,
-WORLD_HALF_HEIGHT,
WORLD_HALF_HEIGHT,
),
},
..leader.clone()
}
}
fn move_follower(
follower: &BigFishRuntimeEntitySnapshot,
leader: &BigFishRuntimeEntitySnapshot,
index: usize,
) -> BigFishRuntimeEntitySnapshot {
let slot_y = (index as f32 * 0.7).sin() * 42.0;
let target = BigFishVector2 {
x: leader.position.x - 52.0 - index as f32 * 10.0,
y: leader.position.y + slot_y,
};
let delta_x = target.x - follower.position.x;
let delta_y = target.y - follower.position.y;
let direction = normalize_vector(delta_x, delta_y);
let step = (FOLLOWER_SPEED * TICK_SECONDS).min((delta_x * delta_x + delta_y * delta_y).sqrt());
BigFishRuntimeEntitySnapshot {
position: BigFishVector2 {
x: follower.position.x + direction.x * step,
y: follower.position.y + direction.y * step,
},
..follower.clone()
}
}
fn move_wild_entity(
entity: &BigFishRuntimeEntitySnapshot,
tick: u64,
) -> BigFishRuntimeEntitySnapshot {
let phase =
tick as f32 * 0.23 + entity.level as f32 * 0.91 + entity.entity_id.len() as f32 * 0.13;
BigFishRuntimeEntitySnapshot {
position: BigFishVector2 {
x: clamp(
entity.position.x
+ phase.cos() * (WILD_SPEED + entity.level as f32 * 3.0) * TICK_SECONDS,
-WORLD_HALF_WIDTH,
WORLD_HALF_WIDTH,
),
y: clamp(
entity.position.y
+ (phase * 0.72).sin()
* (WILD_SPEED + entity.level as f32 * 3.0)
* TICK_SECONDS,
-WORLD_HALF_HEIGHT,
WORLD_HALF_HEIGHT,
),
},
..entity.clone()
}
}
#[derive(Clone, Debug)]
struct MergeOwnedEntitiesResult {
owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
events: Vec<String>,
}
fn merge_owned_entities(
mut owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
tick: u64,
) -> MergeOwnedEntitiesResult {
let mut events = Vec::new();
let mut changed = true;
while changed {
changed = false;
for level in 1..32 {
let same_level = owned_entities
.iter()
.enumerate()
.filter(|(_, entity)| entity.level == level)
.take(3)
.map(|(index, entity)| (index, entity.clone()))
.collect::<Vec<_>>();
if same_level.len() < 3 {
continue;
}
let center =
same_level
.iter()
.fold(BigFishVector2 { x: 0.0, y: 0.0 }, |acc, (_, entity)| {
BigFishVector2 {
x: acc.x + entity.position.x / 3.0,
y: acc.y + entity.position.y / 3.0,
}
});
let remove_indices = same_level
.iter()
.map(|(index, _)| *index)
.collect::<Vec<_>>();
owned_entities = owned_entities
.into_iter()
.enumerate()
.filter_map(|(index, entity)| (!remove_indices.contains(&index)).then_some(entity))
.collect();
owned_entities.push(build_entity(
format!("owned-merge-{}-{tick}", level + 1),
level + 1,
center.x,
center.y,
));
events.push(format!("3 个 {level} 级实体合成 {}", level + 1));
changed = true;
break;
}
}
MergeOwnedEntitiesResult {
owned_entities,
events,
}
}
fn is_offscreen(entity: &BigFishRuntimeEntitySnapshot, camera_center: &BigFishVector2) -> bool {
entity.position.x + entity.radius < camera_center.x - VIEW_WIDTH / 2.0
|| entity.position.x - entity.radius > camera_center.x + VIEW_WIDTH / 2.0
|| entity.position.y + entity.radius < camera_center.y - VIEW_HEIGHT / 2.0
|| entity.position.y - entity.radius > camera_center.y + VIEW_HEIGHT / 2.0
}
fn refresh_leader(
mut owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
) -> Vec<BigFishRuntimeEntitySnapshot> {
owned_entities.sort_by(|left, right| {
right
.level
.cmp(&left.level)
.then_with(|| left.entity_id.cmp(&right.entity_id))
});
owned_entities
}
fn tail_events(events: Vec<String>) -> Vec<String> {
events
.into_iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
fn settlement_events(
snapshot: &BigFishRuntimeSnapshot,
owner_user_id: String,
occurred_at_micros: i64,
) -> Vec<BigFishDomainEvent> {
if snapshot.status == BigFishRunStatus::Running {
return Vec::new();
}
vec![BigFishDomainEvent::RuntimeRunSettled {
run_id: snapshot.run_id.clone(),
session_id: snapshot.session_id.clone(),
owner_user_id,
status: snapshot.status.as_str().to_string(),
occurred_at_micros,
}]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -133,4 +660,63 @@ mod tests {
assert!(result.readiness.publish_ready);
assert!(result.readiness.blockers.is_empty());
}
#[test]
fn start_big_fish_run_builds_server_owned_initial_snapshot() {
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
let result = start_big_fish_run(StartBigFishRunCommand {
run_id: "big-fish-run-1".to_string(),
session_id: "big-fish-session-1".to_string(),
owner_user_id: "user-1".to_string(),
draft: Some(draft),
work_level_count: None,
started_at_micros: 1,
})
.expect("run");
assert_eq!(result.snapshot.status, BigFishRunStatus::Running);
assert_eq!(result.snapshot.player_level, 1);
assert_eq!(result.snapshot.win_level, 8);
assert!(!result.snapshot.wild_entities.is_empty());
assert_eq!(result.events.len(), 1);
}
#[test]
fn submit_big_fish_input_advances_and_keeps_runtime_truth_in_domain() {
let mut result = start_big_fish_run(StartBigFishRunCommand {
run_id: "big-fish-run-2".to_string(),
session_id: "big-fish-session-2".to_string(),
owner_user_id: "user-1".to_string(),
draft: None,
work_level_count: Some(3),
started_at_micros: 1,
})
.expect("run");
result.snapshot.wild_entities = vec![BigFishRuntimeEntitySnapshot {
entity_id: "wild-touching".to_string(),
level: 1,
position: BigFishVector2 { x: 10.0, y: 0.0 },
radius: 22.0,
offscreen_seconds: 0.0,
}];
let advanced = submit_big_fish_input(SubmitBigFishInputCommand {
owner_user_id: "user-1".to_string(),
x: 0.0,
y: 0.0,
submitted_at_micros: 2,
current_snapshot: result.snapshot,
})
.expect("advanced");
assert_eq!(advanced.snapshot.tick, 1);
assert!(advanced.snapshot.owned_entities.len() >= 2);
assert!(
advanced
.snapshot
.event_log
.iter()
.any(|event| event.contains("收编"))
);
}
}

View File

@@ -2,7 +2,7 @@
//!
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
use crate::BigFishGameDraft;
use crate::{BigFishGameDraft, domain::BigFishRuntimeSnapshot};
/// 评估作品是否可以发布的纯领域命令。
///
@@ -15,3 +15,24 @@ pub struct EvaluateBigFishPublishReadinessCommand {
pub draft: Option<BigFishGameDraft>,
pub evaluated_at_micros: i64,
}
/// 开始一局 Big Fish 运行态的纯领域命令。
#[derive(Clone, Debug, PartialEq)]
pub struct StartBigFishRunCommand {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub draft: Option<BigFishGameDraft>,
pub work_level_count: Option<u32>,
pub started_at_micros: i64,
}
/// 提交方向输入并推进一帧的纯领域命令。
#[derive(Clone, Debug, PartialEq)]
pub struct SubmitBigFishInputCommand {
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
pub current_snapshot: BigFishRuntimeSnapshot,
}

View File

@@ -3,6 +3,10 @@
//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则;
//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 发布门禁的领域判定结果。
///
/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。
@@ -14,3 +18,62 @@ pub struct BigFishPublishReadiness {
pub blockers: Vec<String>,
pub evaluated_at_micros: i64,
}
/// 运行态一局的状态。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishRunStatus {
Running,
Won,
Failed,
}
/// 运行态二维坐标。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishVector2 {
pub x: f32,
pub y: f32,
}
/// 运行态实体快照。
///
/// 只表达服务端结算后的事实,前端不能据此反推规则并本地裁决。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeEntitySnapshot {
pub entity_id: String,
pub level: u32,
pub position: BigFishVector2,
pub radius: f32,
pub offscreen_seconds: f32,
}
/// 运行态一局快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeSnapshot {
pub run_id: String,
pub session_id: String,
pub status: BigFishRunStatus,
pub tick: u64,
pub player_level: u32,
pub win_level: u32,
pub leader_entity_id: Option<String>,
pub owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
pub wild_entities: Vec<BigFishRuntimeEntitySnapshot>,
pub camera_center: BigFishVector2,
pub last_input: BigFishVector2,
pub event_log: Vec<String>,
pub updated_at_micros: i64,
}
impl BigFishRunStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Running => "running",
Self::Won => "won",
Self::Failed => "failed",
}
}
}

View File

@@ -11,6 +11,8 @@ use std::{error::Error, fmt};
pub enum BigFishApplicationError {
MissingSessionId,
MissingOwnerUserId,
MissingRunId,
InvalidRuntimeInput,
}
impl fmt::Display for BigFishApplicationError {
@@ -18,6 +20,8 @@ impl fmt::Display for BigFishApplicationError {
match self {
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}

View File

@@ -15,4 +15,17 @@ pub enum BigFishDomainEvent {
blockers: Vec<String>,
occurred_at_micros: i64,
},
RuntimeRunStarted {
run_id: String,
session_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RuntimeRunSettled {
run_id: String,
session_id: String,
owner_user_id: String,
status: String,
occurred_at_micros: i64,
},
}

View File

@@ -4,9 +4,18 @@ mod domain;
mod errors;
mod events;
pub use application::{EvaluateBigFishPublishReadinessResult, evaluate_publish_readiness};
pub use commands::EvaluateBigFishPublishReadinessCommand;
pub use domain::BigFishPublishReadiness;
pub use application::{
BigFishRuntimeResult, EvaluateBigFishPublishReadinessResult, deserialize_runtime_snapshot,
evaluate_publish_readiness, serialize_runtime_snapshot, start_big_fish_run,
submit_big_fish_input,
};
pub use commands::{
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
};
pub use domain::{
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
BigFishRuntimeSnapshot, BigFishVector2,
};
pub use errors::BigFishApplicationError;
pub use events::BigFishDomainEvent;
@@ -343,6 +352,40 @@ pub struct BigFishPlayRecordInput {
pub played_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunStartInput {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishInputSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError {
MissingSessionId,
@@ -352,6 +395,8 @@ pub enum BigFishFieldError {
MissingDraft,
InvalidLevel,
InvalidAssetKind,
MissingRunId,
InvalidRuntimeInput,
}
impl BigFishCreationStage {
@@ -691,6 +736,39 @@ pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(),
Ok(())
}
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
Ok(())
}
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_input_submit_input(
input: &BigFishInputSubmitInput,
) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
if !input.x.is_finite() || !input.y.is_finite() {
return Err(BigFishFieldError::InvalidRuntimeInput);
}
Ok(())
}
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
serde_json::to_string(anchor_pack)
}
@@ -903,6 +981,8 @@ impl fmt::Display for BigFishFieldError {
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}

View File

@@ -6,7 +6,7 @@ license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
spacetime-types = ["dep:spacetimedb", "module-runtime-item/spacetime-types"]
[dependencies]
module-runtime-item = { path = "../module-runtime-item", default-features = false }

View File

@@ -15,7 +15,7 @@
当前已经真实落地:
1. `BattleMode / BattleStatus / CombatOutcome`
1. `src/domain.rs` 承接战斗 ID 前缀、版本、伤害、切磋保底生命、旧攻击 function 列表和 `BattleMode / BattleStatus / CombatOutcome`
2. `BattleStateInput / BattleStateSnapshot / BattleStateQueryInput`
3. `ResolveCombatActionInput / ResolveCombatActionResult`
4. `BattleStateProcedureResult / ResolveCombatActionProcedureResult`
@@ -34,11 +34,12 @@
落地依据见:
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)
1. [../../../docs/technical/SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
4. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md)
5. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
6. [../../../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. 边界约束

View File

@@ -2,3 +2,84 @@
//!
//! 后续迁移 `BattleState` 与行动结算规则时,只保留单聚合内部状态变化;
//! 背包奖励、成长记账和任务联动由应用服务或 SpacetimeDB 事务 adapter 编排。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 战斗状态 ID 的稳定前缀,由领域层统一持有,避免应用层重复拼接规则。
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;
/// 切磋模式保底生命值,避免非生死战把玩家扣到 0。
pub const SPAR_MIN_HP: i32 = 1;
/// 旧版战斗动作 function id 白名单,仍由结算规则用于识别攻击类动作。
pub(crate) 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",
}
}
}

View File

@@ -4,8 +4,11 @@ mod domain;
mod errors;
mod events;
pub use domain::*;
use std::{error::Error, fmt};
use crate::domain::LEGACY_ATTACK_FUNCTION_IDS;
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
@@ -14,74 +17,6 @@ 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,

View File

@@ -19,7 +19,7 @@
当前已落地:
1. 真实 `Cargo.toml` crate scaffold
2. `CustomWorldPublicationStatus``CustomWorldThemeMode``CustomWorldGenerationMode`
2. `src/domain.rs` 承接 `CustomWorldPublicationStatus``CustomWorldThemeMode``CustomWorldGenerationMode`
3. `CustomWorldSessionStatus``RpgAgentStage`
4. `RpgAgentMessageRole``RpgAgentMessageKind`
5. `RpgAgentOperationType``RpgAgentOperationStatus`
@@ -31,7 +31,7 @@
当前 crate 仍然只承接:
1. 共享枚举与类型口径
1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出
2. 字段校验与字符串归一化
3. published profile compile 的最小编译摘要 contract
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
@@ -45,10 +45,11 @@
当前设计依据:
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)
1. [../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../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)
3. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
4. [../../../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)
5. [../../../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 直接相关的任务包括:

View File

@@ -2,3 +2,305 @@
//!
//! 后续迁移 profile、Agent 会话、草稿卡、发布门禁和画廊投影规则时,
//! 只保留纯领域结构LLM 推理、SSE 和 OSS 均留在外层 adapter。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const MAX_PROGRESS_PERCENT: u32 = 100;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldPublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldThemeMode {
Martial,
Arcane,
Machina,
Tide,
Rift,
Mythic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldGenerationMode {
Fast,
Full,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldSessionStatus {
Clarifying,
ReadyToGenerate,
Generating,
Completed,
GenerationError,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentStage {
CollectingIntent,
Clarifying,
FoundationReview,
ObjectRefining,
VisualRefining,
LongTailReview,
ReadyToPublish,
Published,
Error,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageKind {
Chat,
Clarification,
Summary,
Checkpoint,
Warning,
ActionResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationType {
ProcessMessage,
DraftFoundation,
UpdateDraftCard,
SyncResultProfile,
GenerateCharacters,
GenerateLandmarks,
DeleteCharacters,
DeleteLandmarks,
GenerateRoleAssets,
SyncRoleAssets,
GenerateSceneAssets,
SyncSceneAssets,
ExpandLongTail,
PublishWorld,
RevertCheckpoint,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationStatus {
Queued,
Running,
Completed,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardKind {
World,
Camp,
Faction,
Character,
Landmark,
Thread,
Chapter,
SceneChapter,
Carrier,
SidequestSeed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardStatus {
Suggested,
Confirmed,
Locked,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldRoleAssetStatus {
Missing,
VisualReady,
AnimationsReady,
Complete,
}
impl CustomWorldPublicationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl CustomWorldThemeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Martial => "martial",
Self::Arcane => "arcane",
Self::Machina => "machina",
Self::Tide => "tide",
Self::Rift => "rift",
Self::Mythic => "mythic",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"martial" => Some(Self::Martial),
"arcane" => Some(Self::Arcane),
"machina" => Some(Self::Machina),
"tide" => Some(Self::Tide),
"rift" => Some(Self::Rift),
"mythic" => Some(Self::Mythic),
_ => None,
}
}
}
impl CustomWorldGenerationMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Full => "full",
}
}
}
impl CustomWorldSessionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Clarifying => "clarifying",
Self::ReadyToGenerate => "ready_to_generate",
Self::Generating => "generating",
Self::Completed => "completed",
Self::GenerationError => "generation_error",
}
}
}
impl RpgAgentStage {
pub fn as_str(&self) -> &'static str {
match self {
Self::CollectingIntent => "collecting_intent",
Self::Clarifying => "clarifying",
Self::FoundationReview => "foundation_review",
Self::ObjectRefining => "object_refining",
Self::VisualRefining => "visual_refining",
Self::LongTailReview => "long_tail_review",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
Self::Error => "error",
}
}
}
impl RpgAgentMessageRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl RpgAgentMessageKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Clarification => "clarification",
Self::Summary => "summary",
Self::Checkpoint => "checkpoint",
Self::Warning => "warning",
Self::ActionResult => "action_result",
}
}
}
impl RpgAgentOperationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::ProcessMessage => "process_message",
Self::DraftFoundation => "draft_foundation",
Self::UpdateDraftCard => "update_draft_card",
Self::SyncResultProfile => "sync_result_profile",
Self::GenerateCharacters => "generate_characters",
Self::GenerateLandmarks => "generate_landmarks",
Self::DeleteCharacters => "delete_characters",
Self::DeleteLandmarks => "delete_landmarks",
Self::GenerateRoleAssets => "generate_role_assets",
Self::SyncRoleAssets => "sync_role_assets",
Self::GenerateSceneAssets => "generate_scene_assets",
Self::SyncSceneAssets => "sync_scene_assets",
Self::ExpandLongTail => "expand_long_tail",
Self::PublishWorld => "publish_world",
Self::RevertCheckpoint => "revert_checkpoint",
}
}
}
impl RpgAgentOperationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
}
}
}
impl RpgAgentDraftCardKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::World => "world",
Self::Camp => "camp",
Self::Faction => "faction",
Self::Character => "character",
Self::Landmark => "landmark",
Self::Thread => "thread",
Self::Chapter => "chapter",
Self::SceneChapter => "scene_chapter",
Self::Carrier => "carrier",
Self::SidequestSeed => "sidequest_seed",
}
}
}
impl RpgAgentDraftCardStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Suggested => "suggested",
Self::Confirmed => "confirmed",
Self::Locked => "locked",
Self::Warning => "warning",
}
}
}
impl CustomWorldRoleAssetStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Missing => "missing",
Self::VisualReady => "visual_ready",
Self::AnimationsReady => "animations_ready",
Self::Complete => "complete",
}
}
}

View File

@@ -4,6 +4,8 @@ mod domain;
mod errors;
mod events;
pub use domain::*;
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
@@ -11,138 +13,6 @@ use serde_json::{Map, Value};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const MAX_PROGRESS_PERCENT: u32 = 100;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldPublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldThemeMode {
Martial,
Arcane,
Machina,
Tide,
Rift,
Mythic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldGenerationMode {
Fast,
Full,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldSessionStatus {
Clarifying,
ReadyToGenerate,
Generating,
Completed,
GenerationError,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentStage {
CollectingIntent,
Clarifying,
FoundationReview,
ObjectRefining,
VisualRefining,
LongTailReview,
ReadyToPublish,
Published,
Error,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageKind {
Chat,
Clarification,
Summary,
Checkpoint,
Warning,
ActionResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationType {
ProcessMessage,
DraftFoundation,
UpdateDraftCard,
SyncResultProfile,
GenerateCharacters,
GenerateLandmarks,
DeleteCharacters,
DeleteLandmarks,
GenerateRoleAssets,
SyncRoleAssets,
GenerateSceneAssets,
SyncSceneAssets,
ExpandLongTail,
PublishWorld,
RevertCheckpoint,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationStatus {
Queued,
Running,
Completed,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardKind {
World,
Camp,
Faction,
Character,
Landmark,
Thread,
Chapter,
SceneChapter,
Carrier,
SidequestSeed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardStatus {
Suggested,
Confirmed,
Locked,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldRoleAssetStatus {
Missing,
VisualReady,
AnimationsReady,
Complete,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CustomWorldFieldError {
MissingProfileId,
@@ -688,172 +558,6 @@ pub struct CustomWorldPublishWorldResult {
pub error_message: Option<String>,
}
impl CustomWorldPublicationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl CustomWorldThemeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Martial => "martial",
Self::Arcane => "arcane",
Self::Machina => "machina",
Self::Tide => "tide",
Self::Rift => "rift",
Self::Mythic => "mythic",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"martial" => Some(Self::Martial),
"arcane" => Some(Self::Arcane),
"machina" => Some(Self::Machina),
"tide" => Some(Self::Tide),
"rift" => Some(Self::Rift),
"mythic" => Some(Self::Mythic),
_ => None,
}
}
}
impl CustomWorldGenerationMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Full => "full",
}
}
}
impl CustomWorldSessionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Clarifying => "clarifying",
Self::ReadyToGenerate => "ready_to_generate",
Self::Generating => "generating",
Self::Completed => "completed",
Self::GenerationError => "generation_error",
}
}
}
impl RpgAgentStage {
pub fn as_str(&self) -> &'static str {
match self {
Self::CollectingIntent => "collecting_intent",
Self::Clarifying => "clarifying",
Self::FoundationReview => "foundation_review",
Self::ObjectRefining => "object_refining",
Self::VisualRefining => "visual_refining",
Self::LongTailReview => "long_tail_review",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
Self::Error => "error",
}
}
}
impl RpgAgentMessageRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl RpgAgentMessageKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Clarification => "clarification",
Self::Summary => "summary",
Self::Checkpoint => "checkpoint",
Self::Warning => "warning",
Self::ActionResult => "action_result",
}
}
}
impl RpgAgentOperationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::ProcessMessage => "process_message",
Self::DraftFoundation => "draft_foundation",
Self::UpdateDraftCard => "update_draft_card",
Self::SyncResultProfile => "sync_result_profile",
Self::GenerateCharacters => "generate_characters",
Self::GenerateLandmarks => "generate_landmarks",
Self::DeleteCharacters => "delete_characters",
Self::DeleteLandmarks => "delete_landmarks",
Self::GenerateRoleAssets => "generate_role_assets",
Self::SyncRoleAssets => "sync_role_assets",
Self::GenerateSceneAssets => "generate_scene_assets",
Self::SyncSceneAssets => "sync_scene_assets",
Self::ExpandLongTail => "expand_long_tail",
Self::PublishWorld => "publish_world",
Self::RevertCheckpoint => "revert_checkpoint",
}
}
}
impl RpgAgentOperationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
}
}
}
impl RpgAgentDraftCardKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::World => "world",
Self::Camp => "camp",
Self::Faction => "faction",
Self::Character => "character",
Self::Landmark => "landmark",
Self::Thread => "thread",
Self::Chapter => "chapter",
Self::SceneChapter => "scene_chapter",
Self::Carrier => "carrier",
Self::SidequestSeed => "sidequest_seed",
}
}
}
impl RpgAgentDraftCardStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Suggested => "suggested",
Self::Confirmed => "confirmed",
Self::Locked => "locked",
Self::Warning => "warning",
}
}
}
impl CustomWorldRoleAssetStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Missing => "missing",
Self::VisualReady => "visual_ready",
Self::AnimationsReady => "animations_ready",
Self::Complete => "complete",
}
}
}
pub fn validate_custom_world_profile_fields(
profile_id: &str,
owner_user_id: &str,

View File

@@ -0,0 +1,51 @@
# module-puzzle 独立模块 package 说明
日期:`2026-04-29`
## 1. package 职责
`module-puzzle` 是拼图创作、作品 profile 与运行态规则模块 package后续负责
1. Puzzle Agent 会话、消息、锚点包与结果草稿的纯领域模型。
2. 拼图作品 profile、发布门禁、标签规则与作品列表投影。
3. 拼图运行态开局、交换、拖动、合并、拆分、过关和下一关推荐规则。
4.`spacetime-module` 的拼图表、reducer、procedure 和事件聚合对接。
## 2. 当前阶段说明
当前阶段已经不再只是目录占位,已先固定拼图领域 contract 与最小规则函数。
当前已落地:
1. `src/domain.rs` 承接 Puzzle 基础 ID 前缀、标签数量、洗牌次数常量、基础枚举、Agent session、message、anchor、result draft、work profile、runtime board/run 等领域类型。
2. `src/commands.rs` 承接 SpacetimeDB procedure/reducer 写入输入。
3. `src/application.rs` 承接 procedure 返回包装、标签归一化、草稿编译、发布覆盖、开局、交换、拖动、合并、拆分和下一关推荐的纯规则。
4. `src/errors.rs` 承接拼图字段错误与中文错误文案。
5. `src/events.rs` 承接草稿变化、作品发布和运行态推进的最小领域事件。
6. `spacetime-types` feature 下可供 SpacetimeDB 绑定复用的类型派生。
当前 crate 仍然只承接:
1. 拼图领域常量、枚举、快照类型和纯规则。
2. 字段校验、标签归一化与运行态规则。
3. 后续 `spacetime-module` 聚合表时需要复用的领域边界。
当前阶段明确不提前进入:
1. 图片生成、OSS 上传或 AI prompt 编排。
2. Axum 路由、SSE 或前端展示状态。
3. SpacetimeDB table、reducer、procedure 的直接定义。
当前设计依据:
1. [../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md)
3. [../../../docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md](../../../docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md)
4. [../../../docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](../../../docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md)
## 3. 边界约束
1. `module-puzzle` 不直接调用图片生成、OSS、HTTP、SSE 或 SpacetimeDB SDK。
2. 领域函数不依赖前端临时状态,拼图运行态真相最终由 SpacetimeDB 表承载。
3. `api-server` 负责 LLM、图片生成和请求响应映射`spacetime-module` 负责表、reducer、procedure 与事件。
4. 后续迁移必须优先复用现有 `domain.rs``commands.rs``application.rs``events.rs``errors.rs` 骨架。

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,181 @@
//! 拼图写入命令过渡落位
//! 拼图写入命令。
//!
//! 用于表达会话消息、作品更新、发布、开局、交换拼图块和过关推进等输入。
//! 命令只表达 SpacetimeDB procedure/reducer 需要写入的意图和参数,
//! 不包含 HTTP、前端展示或 SpacetimeDB table 操作。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::PuzzleAgentStage;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionCreateInput {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub welcome_message_id: String,
pub welcome_message_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentMessageSubmitInput {
pub session_id: String,
pub owner_user_id: String,
pub user_message_id: String,
pub user_message_text: String,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: PuzzleAgentStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleDraftCompileInput {
pub session_id: String,
pub owner_user_id: String,
pub compiled_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleGeneratedImagesSaveInput {
pub session_id: String,
pub owner_user_id: String,
pub candidates_json: String,
pub saved_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleSelectCoverImageInput {
pub session_id: String,
pub owner_user_id: String,
pub candidate_id: String,
pub selected_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzlePublishInput {
pub session_id: String,
pub owner_user_id: String,
pub work_id: String,
pub profile_id: String,
pub author_display_name: String,
pub level_name: Option<String>,
pub summary: Option<String>,
pub theme_tags: Option<Vec<String>>,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkGetInput {
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkDeleteInput {
pub profile_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkUpsertInput {
pub profile_id: String,
pub owner_user_id: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunStartInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunSwapInput {
pub run_id: String,
pub owner_user_id: String,
pub first_piece_id: String,
pub second_piece_id: String,
pub swapped_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunDragInput {
pub run_id: String,
pub owner_user_id: String,
pub piece_id: String,
pub target_row: u32,
pub target_col: u32,
pub dragged_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunNextLevelInput {
pub run_id: String,
pub owner_user_id: String,
pub advanced_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
pub submitted_at_micros: i64,
}

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