# M3:browse history Axum + SpacetimeDB 落地设计 日期:`2026-04-21` 关联任务: - [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md) - [./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) 关联现状: - `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` - `server-node/src/repositories/runtimeRepository.ts` - `packages/shared/src/contracts/runtime.ts` ## 1. 文档目的 `02_M3_RUNTIME_PROFILE.md` 已经把 `user_browse_history` 和 `browse history` facade 列入 M3,但还没有冻结到可直接编码的字段、去重规则、路由兼容方式和错误语义。 本文只解决 `browse history` 这一个最小闭环切片: 1. `user_browse_history` 真相表 2. `GET /api/runtime/profile/browse-history` 3. `POST /api/runtime/profile/browse-history` 4. `DELETE /api/runtime/profile/browse-history` 5. `/api/profile/browse-history` 兼容路径 本文不新建 checklist,不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足本轮编码所需的冻结口径。 ## 2. 旧实现冻结口径 当前 Node 侧行为来自: - `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` - `server-node/src/repositories/runtimeRepository.ts` 冻结行为如下。 ### 2.1 路由 主路径与兼容路径都必须保留: 1. `GET /api/runtime/profile/browse-history` 2. `POST /api/runtime/profile/browse-history` 3. `DELETE /api/runtime/profile/browse-history` 4. `GET /api/profile/browse-history` 5. `POST /api/profile/browse-history` 6. `DELETE /api/profile/browse-history` 所有路径都要求 Bearer JWT。 ### 2.2 数据字段 单条浏览记录字段与 `packages/shared/src/contracts/runtime.ts` 保持一致: 1. `ownerUserId` 2. `profileId` 3. `worldName` 4. `subtitle` 5. `summaryText` 6. `coverImageSrc` 7. `themeMode` 8. `authorDisplayName` 9. `visitedAt` ### 2.3 POST 请求体兼容 `POST` 同时支持两种形态: 1. 单条对象 2. `{ entries: [...] }` 批量对象 批量最多 `100` 条。 ### 2.4 归一化规则 旧 Node 仓储不是严格校验,而是宽松归一化: 1. `ownerUserId`、`profileId`、`worldName` 去首尾空白后必须非空,否则该条忽略。 2. `subtitle`、`summaryText`、`coverImageSrc` 去首尾空白,空串按空值处理。 3. `themeMode` 不做严格枚举校验,未知值统一回落到 `mythic`。 4. `authorDisplayName` 空值回落到 `玩家`。 5. `visitedAt` 缺失时回落到当前时间。 ### 2.5 去重与排序规则 旧 Node 仓储的关键行为必须保持: 1. 去重键:`ownerUserId + profileId` 2. 同一批写入时,先按 `visitedAt DESC` 排序,再去重,只保留最新一条 3. 表内最终查询结果按 `visitedAt DESC` 返回 ### 2.6 清空行为 `DELETE` 清空当前用户的全部浏览历史,并返回: ```json { "entries": [] } ``` ## 3. Rust 落位决议 ### 3.1 crate 分工 本切片固定按以下边界落位: 1. `crates/module-runtime` - 定义 `browse history` DTO、字段校验、去重排序与宽松归一化规则。 2. `crates/spacetime-module` - 定义 `user_browse_history` 表。 - 提供 `list / upsert / clear` 三个 procedure。 3. `crates/spacetime-client` - 提供 `list_platform_browse_history` - 提供 `upsert_platform_browse_history_entries` - 提供 `clear_platform_browse_history` 4. `crates/shared-contracts` - 冻结 Axum facade 的请求/响应 DTO。 5. `crates/api-server` - 提供双路径兼容 facade。 - 保持 envelope / 错误格式与当前 `runtime settings` 一致。 ### 3.2 身份边界 本轮仍沿用 Axum Bearer JWT 作为唯一鉴权边界: 1. `require_bearer_auth` 校验 token。 2. 从 claims 中提取 `user_id`。 3. `user_id` 作为 procedure 入参传入 SpacetimeDB。 当前阶段不把浏览历史直接暴露给前端直连订阅。 ## 4. SpacetimeDB 表设计 ### 4.1 表名 `user_browse_history` ### 4.2 字段 | 字段 | 类型 | 说明 | | --- | --- | --- | | `browse_history_id` | `String` | 主键,格式为 `user_id:owner_user_id:profile_id` | | `user_id` | `String` | 当前登录用户 | | `owner_user_id` | `String` | 被浏览世界所属用户 | | `profile_id` | `String` | 被浏览世界 profile | | `world_name` | `String` | 世界名 | | `subtitle` | `String` | 副标题 | | `summary_text` | `String` | 摘要 | | `cover_image_src` | `Option` | 封面图 | | `theme_mode` | `RuntimeBrowseHistoryThemeMode` | 主题枚举 | | `author_display_name` | `String` | 作者显示名 | | `visited_at` | `Timestamp` | 最近访问时间 | | `created_at` | `Timestamp` | 首次写入时间 | | `updated_at` | `Timestamp` | 最近更新时间 | ### 4.3 索引 至少保留以下访问路径: 1. 主键 `browse_history_id` 2. `(user_id, visited_at)` 用于当前用户按时间倒序列出 3. `(user_id, owner_user_id, profile_id)` 用于幂等 upsert ### 4.4 设计决议 1. 不额外引入自增 ID,直接把幂等键收口成主键。 2. `visited_at` 单独持久化成 `Timestamp`,避免把时间排序退回字符串比较。 3. `theme_mode` 在表内固定为枚举,但 Axum 输入仍宽松接受字符串。 ## 5. Procedure 设计 ### 5.1 procedure 列表 1. `list_platform_browse_history` 2. `upsert_platform_browse_history_and_return` 3. `clear_platform_browse_history_and_return` 统一返回: ```text RuntimeBrowseHistoryProcedureResult { ok entries error_message } ``` ### 5.2 行为约束 `list_platform_browse_history` 1. 校验 `user_id` 2. 读取当前用户所有记录 3. 按 `visited_at DESC` 返回 `upsert_platform_browse_history_and_return` 1. 校验 `user_id` 2. 接受单批最多 `100` 条 3. 先按旧 Node 规则宽松归一化 4. 先按 `visitedAt DESC` 排序,再按 `ownerUserId + profileId` 去重 5. 用 `browse_history_id` 幂等 upsert 6. 返回当前用户完整倒序列表 `clear_platform_browse_history_and_return` 1. 校验 `user_id` 2. 删除当前用户全部记录 3. 返回空列表 ## 6. Axum facade 设计 ### 6.1 双路径兼容 两组路径必须共用同一组 handler: 1. `/api/runtime/profile/browse-history` 2. `/api/profile/browse-history` 只允许路由名不同,不允许行为分叉。 ### 6.2 GET 行为: 1. Bearer JWT 校验 2. 读取 claims 中的 `user_id` 3. 调 `spacetime_client.list_platform_browse_history` 4. 返回 `PlatformBrowseHistoryResponse` ### 6.3 POST 行为: 1. Bearer JWT 校验 2. 通过 `serde(untagged)` 同时接单条和批量 shape 3. 不对 `themeMode` 做严格 400 拒绝 4. 对 `ownerUserId`、`profileId`、`worldName` 的缺失或空串按旧 Node 路由规则直接返回 `400` 5. 写入成功后返回最新完整列表 ### 6.4 DELETE 行为: 1. Bearer JWT 校验 2. 清空当前用户全部记录 3. 返回 `entries: []` ### 6.5 错误映射 1. JSON 解析失败:`400 BAD_REQUEST` 2. DTO 构建失败:`400 BAD_REQUEST` 3. SpacetimeDB 调用失败:`502 BAD_GATEWAY` 4. JWT 缺失或失效:沿用当前 `401 UNAUTHORIZED` 错误 `details.provider` 固定为: 1. `browse-history` 2. `spacetimedb` ## 7. 测试策略 ### 7.1 必跑测试 1. `module-runtime` - 宽松 theme 归一化 - `visitedAt` 默认值 - 去重与倒序逻辑 2. `api-server` - 未登录返回 `401` - 兼容路径与主路径一致 - `POST` 同时支持单条和批量 - envelope 打开时错误结构稳定 ### 7.2 可选联调测试 保留 `#[ignore]` 的本地 SpacetimeDB 集成测试: 1. `POST -> GET` 2. `DELETE -> GET` ## 8. 本文完成定义 当以下条件成立时,本设计视为完成: 1. `user_browse_history` 表字段、主键和排序规则已冻结。 2. 双路径 facade、请求 shape 和错误契约已冻结。 3. 后续编码不再需要猜测: - `themeMode` 是否严格校验 - `POST` 是否支持单条/批量双 shape - 去重时机与排序依据 ## 9. 2026-04-22 实际落地进度 1. `module-runtime` 已切换为“API 入口严格校验 + 领域层静默过滤”的旧 Node 对齐模式。 2. `api-server` 已补齐双路径 browse history handler,并补 `401`、`400`、批量 shape、兼容路径一致性测试。 3. 剩余阻塞主要在工作树内其他并行任务带来的 Rust 编译占用与跨模块联调,不属于 browse history 方案本身。