后端重写提交

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

View File

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