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