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