Files
Genarrative/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md
2026-04-22 12:34:49 +08:00

12 KiB
Raw Blame History

M3profile dashboard / wallet ledger / play stats Axum + SpacetimeDB 落地设计

日期:2026-04-22

关联任务:

关联现状:

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 的后续落地。

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

返回:

{
  "walletBalance": 0,
  "totalPlayTimeMs": 0,
  "playedWorldCount": 0,
  "updatedAt": null
}

语义:

  1. walletBalanceprofile_dashboard_state.wallet_balance 读取。
  2. totalPlayTimeMsprofile_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

返回:

{
  "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

返回:

{
  "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
}

语义:

  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 返回零值 + 空数组

这样前端不会因为表暂时为空而收到 404null 结构漂移。

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 层 DTOspacetime-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_msupdated_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 archiveplay 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-runtimeshared-contractsspacetime-modulespacetime-clientapi-server 编码。