Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -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` 接线边界。
|
||||
|
||||
@@ -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 }` 只允许在已列入删除计划的旧接口中短期存在。
|
||||
|
||||
@@ -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
@@ -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` 探测结果记录为准。
|
||||
@@ -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 编排和前端迁移工作包继续承接。
|
||||
@@ -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` 结果记录。
|
||||
@@ -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 或绑定形状变更。
|
||||
@@ -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 前端接线包继续收口。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 个单元测试通过。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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` 更新。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 和错误位置;本切片不越界修改其他已认领工作包。
|
||||
@@ -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-export,Custom 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`。
|
||||
@@ -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`。
|
||||
@@ -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
|
||||
|
||||
@@ -193,3 +193,7 @@ export type BigFishRuntimeSnapshotResponse = {
|
||||
eventLog: string[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type BigFishRunResponse = {
|
||||
run: BigFishRuntimeSnapshotResponse;
|
||||
};
|
||||
|
||||
104
packages/shared/src/contracts/story.ts
Normal file
104
packages/shared/src/contracts/story.ts
Normal 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;
|
||||
};
|
||||
@@ -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
1
server-rs/Cargo.lock
generated
@@ -1864,6 +1864,7 @@ dependencies = [
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("尚未编译")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
server-rs/crates/api-server/src/platform_errors.rs
Normal file
133
server-rs/crates/api-server/src/platform_errors.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 当前仍未进入的范围
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
14
server-rs/crates/module-ai/src/application/result.rs
Normal file
14
server-rs/crates/module-ai/src/application/result.rs
Normal 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>,
|
||||
}
|
||||
250
server-rs/crates/module-ai/src/application/service.rs
Normal file
250
server-rs/crates/module-ai/src/application/service.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
138
server-rs/crates/module-ai/src/application/store.rs
Normal file
138
server-rs/crates/module-ai/src/application/store.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
87
server-rs/crates/module-ai/src/commands/inputs.rs
Normal file
87
server-rs/crates/module-ai/src/commands/inputs.rs
Normal 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,
|
||||
}
|
||||
40
server-rs/crates/module-ai/src/commands/validation.rs
Normal file
40
server-rs/crates/module-ai/src/commands/validation.rs
Normal 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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
41
server-rs/crates/module-ai/src/domain/ids.rs
Normal file
41
server-rs/crates/module-ai/src/domain/ids.rs
Normal 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)
|
||||
}
|
||||
77
server-rs/crates/module-ai/src/domain/stages.rs
Normal file
77
server-rs/crates/module-ai/src/domain/stages.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
124
server-rs/crates/module-ai/src/domain/types.rs
Normal file
124
server-rs/crates/module-ai/src/domain/types.rs
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
220
server-rs/crates/module-ai/src/tests.rs
Normal file
220
server-rs/crates/module-ai/src/tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
当前还已补齐:
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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` 仍保留进程内仓储和文件持久化支撑,但不再继续拥有命令、结果、错误、事件和纯领域值对象定义。
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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(¤t_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("收编"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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. 边界约束
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 直接相关的任务包括:
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
51
server-rs/crates/module-puzzle/README.md
Normal file
51
server-rs/crates/module-puzzle/README.md
Normal 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
@@ -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
Reference in New Issue
Block a user