# M3:runtime settings Axum + SpacetimeDB 落地设计 日期:`2026-04-21` 关联任务: - [../../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) 关联现状: - [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.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. 文档目的 `02_M3_RUNTIME_PROFILE.md` 已经冻结了 M3 的任务范围,但还没有把首批可编码切片细化到表字段、procedure、Axum facade、兼容错误格式和测试策略。 本文件只解决 M3 第一批最小纵向切片: 1. `GET /api/runtime/settings` 2. `PUT /api/runtime/settings` 以及其在 Rust 重写中的完整落位: 1. `module-runtime` 的字段约束与 DTO 2. `crates/spacetime-module` 的 `runtime_setting` 表与 procedure 3. `crates/spacetime-client` 的 procedure 调用封装 4. `crates/api-server` 的兼容 facade 与响应 contract 本文件不新增 checklist,不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足可以直接编码的技术口径。 ## 2. 为什么先做 runtime settings 在 M3 范围内,`runtime settings` 是当前最适合先迁移的纵向切片: 1. 读写模型最小,只依赖 `user_id + music_volume + platform_theme`。 2. 旧 Node 逻辑没有跨表聚合、副作用和复杂 projection。 3. 前端 contract 清晰,兼容路径只有一条,不涉及 `/api/profile/*` 双路径。 4. 可以先把 `Axum -> JWT -> SpacetimeDB procedure -> 标准 envelope` 主链跑通,为后续 `browse history / snapshot / save archive / dashboard` 复用。 ## 3. 旧实现冻结口径 当前 Node 侧 `runtime settings` 行为来自: - `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` - `server-node/src/repositories/runtimeRepository.ts` 冻结行为如下: ### 3.1 路由 - `GET /api/runtime/settings` - `PUT /api/runtime/settings` 两条接口都要求 JWT。 ### 3.2 请求体 `PUT /api/runtime/settings` 请求体: ```json { "musicVolume": 0.42, "platformTheme": "light" } ``` 校验规则: 1. `musicVolume` 必须在 `0 ~ 1`。 2. `platformTheme` 只接受 `light | dark`。 ### 3.3 默认值 默认值来自 `packages/shared/src/contracts/runtime.ts`: 1. `DEFAULT_MUSIC_VOLUME = 0.42` 2. `DEFAULT_PLATFORM_THEME = "light"` 当用户从未写入过设置时,读取接口必须返回默认值,而不是 `404` 或 `null`。 ### 3.4 归一化规则 旧 Node 写入时会做以下归一化: 1. `musicVolume` 强制 clamp 到 `0 ~ 1` 2. `platformTheme` 如果不是 `dark`,统一回退到 `light` Rust 重写阶段仍保持同样语义,避免前端产生行为漂移。 ## 4. Rust 落位决议 ### 4.1 crate 分工 本切片固定按以下边界落位: 1. `crates/module-runtime` - 定义 `RuntimeSettings` 领域 DTO、默认值、字段校验与归一化规则。 2. `crates/spacetime-module` - 定义 `runtime_setting` 表。 - 提供 `upsert_runtime_setting_and_return` procedure。 3. `crates/spacetime-client` - 提供 `get_runtime_settings`、`put_runtime_settings` 调用封装。 4. `crates/api-server` - 提供 `GET/PUT /api/runtime/settings`。 - 保持当前 envelope / 错误格式 / 请求头兼容。 ### 4.2 身份边界 当前阶段前端仍只访问 Axum,不直连 SpacetimeDB。 因此: 1. 用户身份仍由 Axum 侧 JWT middleware 校验。 2. Axum 从已校验的 access token claims 中取 `user_id`。 3. `user_id` 作为 procedure 入参写入 `runtime_setting`。 注意: 1. 这不是最终的 SpacetimeDB 原生身份透传形态。 2. 在 M3 首批切片里,先以 Axum 作为唯一鉴权边界,保证与当前前端 contract 一致。 ## 5. SpacetimeDB 表设计 ### 5.1 表名 `runtime_setting` ### 5.2 字段 | 字段 | 类型 | 说明 | | --- | --- | --- | | `user_id` | `String` | 主键,绑定平台用户 | | `music_volume` | `f32` | 音量,持久化归一化后的值 | | `platform_theme` | `RuntimePlatformTheme` | 平台主题枚举 | | `created_at` | `Timestamp` | 首次创建时间 | | `updated_at` | `Timestamp` | 最近更新时间 | ### 5.3 设计决议 1. 每个用户只保留一行设置,不做历史版本表。 2. `user_id` 直接作为主键,避免再引入无业务价值的自增 ID。 3. `platform_theme` 固定为枚举,不把 `light/dark` 继续散落成字符串字面量。 4. 首批阶段不把设置拆成多行 KV 表,避免简单需求被过度抽象。 ## 6. Procedure 设计 ### 6.1 不单独暴露 reducer 给 Axum 本切片优先提供 procedure,而不是让 Axum 直接调 reducer + 再查询表。 原因: 1. 当前 `spacetime-client` 已经以 procedure 返回结果的模式承接资产链。 2. 设置接口需要同步返回最终写入结果,procedure 可减少一次额外查询。 3. 当前 `runtime_setting` 不需要客户端订阅,private table + procedure 更直接。 ### 6.2 Procedure 列表 1. `get_runtime_setting_or_default` 2. `upsert_runtime_setting_and_return` 返回 DTO 固定为: ```text RuntimeSettingSnapshot { user_id music_volume platform_theme created_at_micros updated_at_micros } ``` 如果用户还没有设置记录: 1. `get_runtime_setting_or_default` 返回默认值快照。 2. 但不强制立即插入表,避免纯读取请求制造无意义写入。 ## 7. Axum facade 设计 ### 7.1 GET /api/runtime/settings 行为: 1. 走 `require_bearer_auth`。 2. 从 `claims.user_id` 取用户 ID。 3. 调 `spacetime_client.get_runtime_settings(user_id)`。 4. 返回: ```json { "musicVolume": 0.42, "platformTheme": "light" } ``` ### 7.2 PUT /api/runtime/settings 行为: 1. 走 `require_bearer_auth`。 2. 使用 Axum `Json` + `serde` 解析请求。 3. 在 `module-runtime` 内做归一化。 4. 调 `spacetime_client.put_runtime_settings(user_id, payload)`。 5. 返回归一化后的最终值。 ### 7.3 错误映射 1. 请求体解析失败:`400 BAD_REQUEST` 2. 字段校验失败:`400 BAD_REQUEST` 3. SpacetimeDB 调用失败:`502 BAD_GATEWAY` 4. JWT 缺失或失效:沿用现有 `401 UNAUTHORIZED` 错误 `details.provider` 固定为: 1. `runtime-settings`:本地字段归一化或 DTO 构建失败 2. `spacetimedb`:procedure 调用失败 ## 8. 首批测试策略 本切片测试分两层: ### 8.1 必跑测试 1. `module-runtime` - 默认值 - clamp 规则 - theme 归一化 2. `api-server` - 未登录返回 `401` - 请求 envelope 打开时返回标准 `ok/data/error/meta` - JSON 结构与字段名兼容 ### 8.2 可选联调测试 补一条 `#[ignore]` 的集成测试: 1. 需要本地 SpacetimeDB 已启动 2. 需要当前 `spacetime-module` 已发布 3. 验证 `PUT -> GET` 能往返一致 原因: 1. 当前仓库已有资产链的 `#[ignore]` 集成测试模式。 2. 在未稳定建立测试 harness 前,不强制把 SpacetimeDB 作为默认单测前置条件。 ## 9. 后续扩展顺序 `runtime settings` 完成后,M3 后续能力按以下顺序推进: 1. `user_browse_history` 2. `runtime_snapshot` 3. `profile_save_archive` 4. `profile_dashboard_state + profile_wallet_ledger + profile_played_world` 顺序原因: 1. `browse_history` 仍是单表为主,只带去重与排序规则。 2. `snapshot` 和 `save_archive` 依赖兼容聚合策略,复杂度更高。 3. `dashboard / play-stats / wallet-ledger` 依赖 projection,更适合放在 snapshot 规则固定后收口。 ## 10. 本文完成定义 当以下条件成立时,本设计文档视为完成: 1. `runtime settings` 的字段、默认值、归一化规则、procedure 与 Axum facade 已书面冻结。 2. 后续编码无需再猜测: - 表字段名 - 主键策略 - 默认值来源 - Axum 与 SpacetimeDB 的职责边界 3. 可以直接据此开始 `module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 编码。