feat: complete M3 runtime snapshot and profile save archive
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
# M3:runtime 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` 三组行为可并存,不互相覆盖
|
||||
Reference in New Issue
Block a user