This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
# 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.1 先做 projection 读链
|
||||
|
||||
本轮 profile 三接口只做:
|
||||
|
||||
1. projection 表 schema
|
||||
2. procedure 读接口
|
||||
3. Axum facade
|
||||
4. shared contract
|
||||
|
||||
不做 snapshot 写链,原因:
|
||||
|
||||
1. `runtime_snapshot` 仍未冻结最终表结构。
|
||||
2. save archive 还未把“领域表真相 + 聚合快照”方案完全落到文档。
|
||||
3. 若现在提前补写逻辑,后续大概率要因为 snapshot 方案调整而返工。
|
||||
|
||||
### 4.2 默认值必须前置兼容
|
||||
|
||||
虽然 projection 还没有 writer,但 facade 仍要先兼容旧 Node 默认值:
|
||||
|
||||
1. dashboard 返回零值
|
||||
2. wallet ledger 返回空数组
|
||||
3. play stats 返回零值 + 空数组
|
||||
|
||||
这样前端不会因为表暂时为空而收到 `404` 或 `null` 结构漂移。
|
||||
|
||||
## 5. SpacetimeDB 表设计
|
||||
|
||||
### 5.1 `profile_dashboard_state`
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `user_id` | `String` | 主键,绑定平台用户 |
|
||||
| `wallet_balance` | `u64` | 当前钱包余额 |
|
||||
| `total_play_time_ms` | `u64` | 累积游玩时长 |
|
||||
| `created_at` | `Timestamp` | projection 首次建立时间 |
|
||||
| `updated_at` | `Timestamp` | projection 最近刷新时间 |
|
||||
|
||||
设计决议:
|
||||
|
||||
1. 一名用户只保留一行 dashboard 聚合状态。
|
||||
2. `playedWorldCount` 不单独持久化,读取时直接统计 `profile_played_world`。
|
||||
3. 钱包余额与总游玩时长都固定为非负整数,不保留浮点。
|
||||
|
||||
### 5.2 `profile_wallet_ledger`
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `wallet_ledger_id` | `String` | 主键,流水 ID |
|
||||
| `user_id` | `String` | 用户 ID |
|
||||
| `amount_delta` | `i64` | 本次余额增减 |
|
||||
| `balance_after` | `u64` | 变动后的余额 |
|
||||
| `source_type` | `RuntimeProfileWalletLedgerSourceType` | 当前只冻结 `snapshot_sync` |
|
||||
| `created_at` | `Timestamp` | 流水发生时间 |
|
||||
|
||||
设计决议:
|
||||
|
||||
1. 钱包流水是 append-only,不提供 update。
|
||||
2. 本轮只冻结 `snapshot_sync` 一种来源,避免前后端散落裸字符串。
|
||||
3. 读取排序由 procedure 保证,不依赖表天然顺序。
|
||||
|
||||
### 5.3 `profile_played_world`
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `played_world_id` | `String` | 主键,固定为 `user_id:world_key` |
|
||||
| `user_id` | `String` | 用户 ID |
|
||||
| `world_key` | `String` | 世界唯一键,兼容内置世界与自定义世界 |
|
||||
| `owner_user_id` | `Option<String>` | 自定义世界作者用户 ID |
|
||||
| `profile_id` | `Option<String>` | 自定义世界 profile ID |
|
||||
| `world_type` | `Option<String>` | 内置世界类型,例如 `WUXIA` |
|
||||
| `world_title` | `String` | 世界标题 |
|
||||
| `world_subtitle` | `String` | 世界副标题 |
|
||||
| `first_played_at` | `Timestamp` | 首次游玩时间 |
|
||||
| `last_played_at` | `Timestamp` | 最近游玩时间 |
|
||||
| `last_observed_play_time_ms` | `u64` | 最近一次观测到的该世界累计游玩时长 |
|
||||
|
||||
设计决议:
|
||||
|
||||
1. 每个用户每个 `world_key` 只保留一行。
|
||||
2. `played_world_id = user_id:world_key`,避免额外自增 ID。
|
||||
3. `lastObservedPlayTimeMs` 保留在表中,为后续 snapshot sync 计算增量时长服务。
|
||||
|
||||
## 6. module-runtime DTO 设计
|
||||
|
||||
本轮在 `module-runtime` 新增以下类型族:
|
||||
|
||||
1. `RuntimeProfileDashboardSnapshot`
|
||||
2. `RuntimeProfileDashboardProcedureResult`
|
||||
3. `RuntimeProfileDashboardGetInput`
|
||||
4. `RuntimeProfileWalletLedgerEntrySnapshot`
|
||||
5. `RuntimeProfileWalletLedgerProcedureResult`
|
||||
6. `RuntimeProfileWalletLedgerListInput`
|
||||
7. `RuntimeProfilePlayedWorldSnapshot`
|
||||
8. `RuntimeProfilePlayStatsSnapshot`
|
||||
9. `RuntimeProfilePlayStatsProcedureResult`
|
||||
10. `RuntimeProfilePlayStatsGetInput`
|
||||
|
||||
同时新增 record 层 DTO,供 `spacetime-client` 返回给 Axum:
|
||||
|
||||
1. `RuntimeProfileDashboardRecord`
|
||||
2. `RuntimeProfileWalletLedgerEntryRecord`
|
||||
3. `RuntimeProfilePlayedWorldRecord`
|
||||
4. `RuntimeProfilePlayStatsRecord`
|
||||
|
||||
字段规则:
|
||||
|
||||
1. 所有时间在 snapshot 内部统一保存为 `*_micros`。
|
||||
2. record 层统一格式化成 RFC3339 字符串。
|
||||
3. `updated_at_micros` 使用 `Option<i64>`,避免继续沿用 `0` 这种弱语义占位值。
|
||||
|
||||
## 7. Procedure 设计
|
||||
|
||||
本轮只新增 3 个只读 procedure:
|
||||
|
||||
1. `get_profile_dashboard`
|
||||
2. `list_profile_wallet_ledger`
|
||||
3. `get_profile_play_stats`
|
||||
|
||||
行为要求:
|
||||
|
||||
### 7.1 `get_profile_dashboard`
|
||||
|
||||
1. 校验 `user_id` 非空。
|
||||
2. 读取 `profile_dashboard_state`。
|
||||
3. 统计当前用户 `profile_played_world` 数量。
|
||||
4. 如果 dashboard 状态不存在,返回零值快照。
|
||||
|
||||
### 7.2 `list_profile_wallet_ledger`
|
||||
|
||||
1. 校验 `user_id` 非空。
|
||||
2. 读取当前用户全部流水。
|
||||
3. 按 `created_at DESC` 排序。
|
||||
4. 截断到最近 `50` 条。
|
||||
|
||||
### 7.3 `get_profile_play_stats`
|
||||
|
||||
1. 校验 `user_id` 非空。
|
||||
2. 从 `profile_dashboard_state` 读取 `total_play_time_ms` 和 `updated_at`。
|
||||
3. 读取当前用户 `profile_played_world`。
|
||||
4. 按 `last_played_at DESC` 排序。
|
||||
5. 如果 dashboard 状态不存在,仍返回零值与空数组。
|
||||
|
||||
## 8. spacetime-client 设计
|
||||
|
||||
新增 3 个调用封装:
|
||||
|
||||
1. `get_profile_dashboard(user_id)`
|
||||
2. `list_profile_wallet_ledger(user_id)`
|
||||
3. `get_profile_play_stats(user_id)`
|
||||
|
||||
错误映射保持当前链路习惯:
|
||||
|
||||
1. 本地 DTO 构建失败 -> `SpacetimeClientError::Runtime`
|
||||
2. procedure 执行失败 -> `SpacetimeClientError::Procedure`
|
||||
|
||||
不在 client 层做默认值兜底;默认值由 `spacetime-module` procedure 保证,避免多个调用方重复实现。
|
||||
|
||||
## 9. Axum facade 设计
|
||||
|
||||
### 9.1 路由
|
||||
|
||||
本轮 Rust facade 固定暴露 6 条路由:
|
||||
|
||||
1. `/api/runtime/profile/dashboard`
|
||||
2. `/api/profile/dashboard`
|
||||
3. `/api/runtime/profile/wallet-ledger`
|
||||
4. `/api/profile/wallet-ledger`
|
||||
5. `/api/runtime/profile/play-stats`
|
||||
6. `/api/profile/play-stats`
|
||||
|
||||
全部要求 Bearer JWT。
|
||||
|
||||
### 9.2 响应结构
|
||||
|
||||
1. dashboard 直接返回 `ProfileDashboardSummaryResponse`
|
||||
2. wallet-ledger 返回 `ProfileWalletLedgerResponse`
|
||||
3. play-stats 返回 `ProfilePlayStatsResponse`
|
||||
|
||||
字段名保持 camelCase,与旧 Node contract 对齐。
|
||||
|
||||
### 9.3 错误映射
|
||||
|
||||
1. JWT 缺失或失效:沿用现有 `401`
|
||||
2. 本地 DTO 准备失败:`400`
|
||||
3. SpacetimeDB 调用失败:`502`
|
||||
|
||||
`details.provider` 规则:
|
||||
|
||||
1. 本地 DTO 错误使用当前接口自己的 provider
|
||||
2. 下游 SpacetimeDB 错误统一使用 `spacetimedb`
|
||||
|
||||
## 10. 本轮暂不处理的事项
|
||||
|
||||
以下事项在本设计中显式延后:
|
||||
|
||||
1. `runtime_snapshot` 写入时如何刷新三张 profile projection 表
|
||||
2. `profile_wallet_ledger` 的更多 `source_type`
|
||||
3. `profile_played_world` 的世界标题修复、补字段或回填历史迁移
|
||||
4. `save archive` 与 `play stats` 之间的联动
|
||||
|
||||
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。
|
||||
|
||||
## 11. 测试策略
|
||||
|
||||
### 11.1 必跑
|
||||
|
||||
1. `module-runtime`
|
||||
- `user_id` 非空校验
|
||||
- record 层时间格式化
|
||||
- wallet ledger source type 字符串格式化
|
||||
2. `shared-contracts`
|
||||
- dashboard / wallet-ledger / play-stats 的 camelCase 序列化
|
||||
3. `api-server`
|
||||
- 未登录返回 `401`
|
||||
- 6 条 facade 都已挂接
|
||||
- SpacetimeDB 未发布时返回 `502`
|
||||
- 主路径与兼容路径错误 envelope 一致
|
||||
|
||||
### 11.2 本轮不强制
|
||||
|
||||
1. 不强制本地 SpacetimeDB 联调测试
|
||||
2. 不强制 projection 写入集成测试
|
||||
|
||||
原因是这两类测试都依赖后续 `runtime_snapshot` 写链补齐。
|
||||
|
||||
## 12. 本文完成定义
|
||||
|
||||
当以下条件满足时,本设计文档视为完成:
|
||||
|
||||
1. `profile_dashboard_state / profile_wallet_ledger / profile_played_world` 字段与 ID 规则已冻结。
|
||||
2. `dashboard / wallet-ledger / play-stats` 的 procedure 名、返回结构、排序与默认值已冻结。
|
||||
3. `api/runtime/*` 与兼容 `/api/profile/*` 双路径已冻结。
|
||||
4. 可以据此直接开始 `module-runtime`、`shared-contracts`、`spacetime-module`、`spacetime-client`、`api-server` 编码。
|
||||
Reference in New Issue
Block a user