feat: complete M3 runtime snapshot and profile save archive

This commit is contained in:
2026-04-22 13:22:23 +08:00
parent 997a8daada
commit 209e924403
340 changed files with 9878 additions and 4429 deletions

View File

@@ -0,0 +1,274 @@
# M3runtime snapshot / save archive 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)
- [M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](./M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md)
- `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
- `server-node/src/modules/runtime/runtimeSnapshotHydration.ts`
## 1. 文档目的
M3 剩余未完成的主链集中在三组能力:
1. `runtime_snapshot`
2. `profile_save_archive`
3. “领域表真相 + 兼容聚合快照”策略
本文件补足这些能力落地到 Rust 所需的编码级语义,确保可以直接实现:
1. `GET /api/runtime/save/snapshot`
2. `PUT /api/runtime/save/snapshot`
3. `DELETE /api/runtime/save/snapshot`
4. `GET /api/runtime/profile/save-archives`
5. `GET /api/profile/save-archives`
6. `POST /api/runtime/profile/save-archives/:worldKey`
7. `POST /api/profile/save-archives/:worldKey`
## 2. 旧 Node 行为冻结
### 2.1 当前 snapshot
路由:
1. `GET /api/runtime/save/snapshot`
2. `PUT /api/runtime/save/snapshot`
3. `DELETE /api/runtime/save/snapshot`
语义:
1. `GET` 无记录时返回 `null`,不是默认空快照。
2. `PUT` 请求体要求:
- `gameState: unknown`
- `bottomTab: string`
- `currentStory?: unknown | null`
- `savedAt?: string`
3. `PUT` 会先执行 snapshot normalize再写入当前快照。
4. `DELETE` 只删除当前快照,不删除 `profile_save_archive``profile_dashboard_state``profile_wallet_ledger``profile_played_world`
### 2.2 save archive
路由:
1. `GET /api/runtime/profile/save-archives`
2. `GET /api/profile/save-archives`
3. `POST /api/runtime/profile/save-archives/:worldKey`
4. `POST /api/profile/save-archives/:worldKey`
语义:
1. save archive 是“按世界聚合”的最近一次快照,不是多版本列表。
2. `worldKey` 冻结规则:
- 内置世界:`builtin:{worldType}`
- 自定义世界:优先 `custom:{profileId}`,否则 `custom:{worldTitle}`
3. `GET list``savedAt DESC` 排序。
4. `POST resume` 找不到记录时返回 `404`
5. `POST resume` 命中后会把 archive 重新写回当前 snapshot并返回
- `entry`
- `snapshot`
6. 旧 Node 在 `resume` 时只回填当前 snapshot不再次刷新 dashboard / save archive / custom world profile projection。
## 3. 兼容聚合快照策略
### 3.1 领域表真相
M3 当前统一采用:
1. `runtime_snapshot` 承接前端兼容恢复所需的原始运行时快照。
2. `profile_dashboard_state`
3. `profile_wallet_ledger`
4. `profile_played_world`
5. `profile_save_archive`
其中:
1. 当前快照真相是 `runtime_snapshot`
2. profile 聚合真相分别是各自 projection 表。
3. `profile_save_archive` 是“按世界聚合的最近一次快照副本”,用于列表恢复入口。
### 3.2 本轮策略
本轮不把 Node 里的超大 hydration 逻辑逐字段翻译进 `module-runtime`
本轮冻结为:
1. `PUT snapshot` 仍接收任意 JSON。
2. Axum 层只做最小兼容校验:
- `bottomTab` 非空
- `savedAt` 缺失时补当前时间
3. `bottomTab` 只接受:
- `adventure`
- `character`
- `inventory`
- 其他值统一回退到 `adventure`
4. `currentStory` 若不是 JSON object则回退为 `null`
5. `gameState` 必须可序列化为 JSON如不是 object仍允许原样存储 JSON 值
6. profile projection 刷新只依赖旧 Node 已冻结的少数字段抽取:
- `gameState.playerCurrency`
- `gameState.runtimeStats.playTimeMs`
- `gameState.worldType`
- `gameState.currentScenePreset`
- `gameState.customWorldProfile`
- `gameState.storyEngineMemory.continueGameDigest`
- `currentStory.text`
这样做的原因:
1. 先保证 M3 的保存、读取、恢复主链跑通。
2. 避免把 `runtimeSnapshotHydration.ts` 的大量历史兼容逻辑一次性搬进 Rust造成 M3 范围膨胀。
3. 不阻断当前前端恢复链路,因为前端写入的主体仍是 JSON 快照。
## 4. SpacetimeDB 表设计
### 4.1 `runtime_snapshot`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键 |
| `version` | `u32` | 当前固定 `2` |
| `saved_at` | `Timestamp` | 快照保存时间 |
| `bottom_tab` | `String` | 当前底部标签 |
| `game_state_json` | `String` | 原始 gameState JSON 字符串 |
| `current_story_json` | `Option<String>` | 原始 currentStory JSON 字符串 |
| `created_at` | `Timestamp` | 首次创建时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
### 4.2 `profile_save_archive`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `archive_id` | `String` | 主键,固定为 `user_id:world_key` |
| `user_id` | `String` | 用户 ID |
| `world_key` | `String` | 世界唯一键 |
| `owner_user_id` | `Option<String>` | 当前先冻结为 `None` |
| `profile_id` | `Option<String>` | 自定义世界 profile ID |
| `world_type` | `Option<String>` | 世界类型 |
| `world_name` | `String` | 存档展示名 |
| `subtitle` | `String` | 副标题 |
| `summary_text` | `String` | 摘要 |
| `cover_image_src` | `Option<String>` | 封面 |
| `saved_at` | `Timestamp` | 最近一次保存时间 |
| `bottom_tab` | `String` | 存档时所在 tab |
| `game_state_json` | `String` | 最近一次存档快照 JSON |
| `current_story_json` | `Option<String>` | 最近一次剧情 JSON |
| `created_at` | `Timestamp` | 首次创建时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
## 5. Projection 刷新规则
### 5.1 `PUT /api/runtime/save/snapshot`
写入顺序冻结为:
1. upsert `runtime_snapshot`
2. 刷新 `profile_dashboard_state`
3. 刷新 `profile_wallet_ledger`
4. 刷新 `profile_played_world`
5. 刷新 `profile_save_archive`
### 5.2 dashboard / wallet / played world
沿用已冻结 Node 规则:
1. `playerCurrency` 映射到 `wallet_balance`
2. 钱包变化时写一条 `snapshot_sync` ledger
3. `runtimeStats.playTimeMs``profile_played_world.last_observed_play_time_ms` 做增量比较
4. `total_play_time_ms` 为累积值
### 5.3 save archive metadata
规则:
1. 若存在 `customWorldProfile`
- `worldType = "CUSTOM"`
- `worldKey = custom:{profileId or worldTitle}`
- `worldName` 优先 `customWorldProfile.name/title`
- `subtitle` 优先 `customWorldProfile.summary/settingText`
2. 若是内置世界:
- `worldKey = builtin:{worldType}`
- `worldName` 优先 `currentScenePreset.name`,否则 `worldType` 对应默认中文名
- `subtitle` 优先 `currentScenePreset.summary/description`
3. `summaryText` 优先级:
- `storyEngineMemory.continueGameDigest`
- `currentStory.text`
- `subtitle`
- `继续推进上一次保存的故事。`
4. `coverImageSrc`
- 自定义世界优先 `customWorldProfile.coverImageSrc`
- 内置世界优先 `currentScenePreset.imageSrc`
## 6. Axum facade 设计
### 6.1 `GET /api/runtime/save/snapshot`
返回:
1. 有记录时返回标准 snapshot object
2. 无记录时返回 `null`
### 6.2 `PUT /api/runtime/save/snapshot`
返回:
1. 归一化后并已持久化的 snapshot
### 6.3 `DELETE /api/runtime/save/snapshot`
返回:
```json
{
"ok": true
}
```
### 6.4 save archive list
主路径与兼容路径:
1. `GET /api/runtime/profile/save-archives`
2. `GET /api/profile/save-archives`
返回:
```json
{
"entries": []
}
```
### 6.5 save archive resume
主路径与兼容路径:
1. `POST /api/runtime/profile/save-archives/:worldKey`
2. `POST /api/profile/save-archives/:worldKey`
行为:
1. `worldKey` 为空返回 `400`
2. 找不到 archive 返回 `404`
3. 返回 `entry + snapshot`
## 7. 本轮验收口径
完成后需满足:
1. 登录用户可 `PUT/GET/DELETE` 当前 snapshot
2. `PUT snapshot` 后可以从 `save-archives` 看到按世界聚合的恢复入口
3. `POST save-archives/:worldKey` 可恢复当前 snapshot
4. `/api/runtime/profile/save-archives``/api/profile/save-archives` 返回一致
5. `profile dashboard / browse history / save archive` 三组行为可并存,不互相覆盖