Files
xsl 3704cd0792 research(platform): 无感用户系统 + PC/手机双端一致 三路调研(身份/键位与语音/数据同步)+ 汇总方案
- 01-identity:推荐自动匿名档案(首访 0 交互)+ Netflix 式切换器 + 配对码跨设备;passkey 因 LAN HTTP 不可用否决;审计出 7/8 游戏跨设备只 PUT 不 GET、大厅零 aria、玩家名不可朗读三个存量问题
- 02-input-parity:方向键/WASD+空格+回车(双击即时)键位(audiogames 社区惯例同构);语音双套资产路线(153 句含操作词,306 TTS/37min);MVP ~200 行 1-2 人日
- 03-data-sync:设备 token 即档案、localStorage 按 profileId 分桶(修同设备串档)、桶级 LWW + baseUpdatedAt 守卫、claim 可选认领 + PIN scrypt;公网前 8 条清单
- 00-synthesis:需求映射 + 架构收敛 + P0/P1 分期 + 3 个待拍板决策点
2026-09-09 23:09:24 +08:00

311 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 03 · 多用户状态保留与跨设备同步 —— 数据架构调研
> 三份平台调研之三(并行:01 身份方案、02 键盘适配)。本文负责数据层:现状审计 → 业界调研 → 目标数据模型 → 冲突与离线策略 → 文件级改动清单 → 公网化分层清单。
> 红线遵守:只调研不改代码。所有行号以 2026-09-09 工作区为准。
---
## 1. 现状审计
### 1.1 服务器端(web/server/server.js,310 行,零依赖)
三个 JSON 文件,全在 `web/server/data/`:
| 文件 | 结构 | 问题 |
|---|---|---|
| `users.json` | `{ username: pin }` | **PIN 明文**(代码注释自认"玩具级") |
| `sessions.json` | `{ token: username }` | token 24 字节随机 hex,**永不过期**;无 logout 端点,每次登录追加一条,**无限增长** |
| `<username>.json` | `{ <game>: <data> }` | 一用户一文件;`_settings` 为设置保留键;data 原样存储,无包装层 |
写得好的部分(值得保留到下一版):
- `enqueueWrite`(server.js:71)按文件串行化写队列,读-改-写整体入队(PUT 分支 server.js:191),**同进程内无并发覆盖**;
- `tmp + rename` 原子写(server.js:87-93),崩溃不留半截文件;
- 请求体 1MB 上限(server.js:118)、username/game 名正则白名单、路径穿越防护(server.js:214)。
审计发现的文档-代码出入:HANDOVER §2.2 说进度 data「**必须带 summary 字符串**」,但 server.js 的 PUT 只校验 game 名格式与 data 存在(server.js:183-187),**不校验 summary**——它是客户端约定(大厅渲染时才检查 `r.data.summary` 是 string,index.html:360),服务端宽松接受任意 JSON。这本身没错(宽容收,严格显),但意味着服务器**不知道任何元信息**(没有 updatedAt、没有 rev),后面做冲突合并时无据可依。
### 1.2 客户端数据流(关键结论加粗)
**localStorage 键清单(全量):**
| 键 | 写入者 | 内容 |
|---|---|---|
| `soundgame.auth` | 大厅 index.html:225 | `{username, token}`(**单桶,无多玩家隔离**) |
| `soundgame.settings` | engine/settings.js:25 | `{voice, speed, bgm, guide}`(单桶) |
| `soundgame.progress.rpg` / `.dungeon` / `.radio` | 各 game.js `_save()` | 完整存档(含 `schema`、`updatedAt`、`summary`) |
| `soundgame.<game>.<杂项>` | rpg tutIntroSeen、escape STATS_KEY、storyline GUIDE_DONE_KEY 等 | 各游戏小标记 |
**游客态结论(直接回答调研问题 3 的后半):**
1. **游客完全能玩**。全部 8 款游戏不依赖 API:未登录时 `_save()` 只写 localStorage、跳过 fetch(rpg/game.js:1186-1196 是典型:本地必写,`auth` 不存在就 return)。公网静态站(`PUBLIC_STATIC`,index.html:371 检测 `https && *.xiangsilian.com`)就是这个模式,隐藏登录面板、纯本地。
2. **进度不会因为断网丢**:localStorage 是主存,PUT 是 fire-and-forget(`.catch(() => {})` 静默)。断网/服务器挂丢失的只是「上传」,不是进度本身。
3. **但换设备=清零**:游客进度绑死浏览器(localStorage 绑 origin+浏览器实例),且清浏览器数据即消失——这正是 Wordle 迁移 NYT 的教训(见 §2.1)。
4. 局域网场景服务器挂了(WSL2 的 Node 进程死掉):**页面都打不开**——静态文件与 API 同进程。公网场景静态由 Nginx 托管,API 404,游戏照玩。
**登录后的「搬运」——审计结论:没有搬运。** index.html 登录成功只做两件事:`pullSettings()`(**服务器设置覆盖本地**,index.html:339)和 `loadProgress()`(拉 summary 渲染卡片,index.html:354)。本地已有进度**不会上传**到服务器。游戏加载路径的不一致放大了这个问题:
| 游戏 | 加载时读哪 | 跨设备续玩 |
|---|---|---|
| radio | **本地优先**;本地无档才 fetch 服务器(radio/game.js:317-342,1.5s 超时兜底开新档) | 半支持:仅新设备方向 |
| rpg / dungeon | **只读 localStorage**(rpg/game.js:1199,dungeon/game.js:1994),从不拉服务器 | 不支持:新设备=新档 |
| simon / rhythm / space / storyline | 无进度存档(会话制) | — |
| escape | 本地 stats(次数/最佳时间) | 不支持 |
由此得出一个隐蔽的**数据分叉 bug**:设备 A 玩到第 3 章(本地+已上传),设备 B 登录同账号、本地有旧档第 2 章 → 设备 B 打开 radio/rpg 会**继续本地第 2 章**(本地优先),玩下去再 PUT,**把服务器上的第 3 章覆盖回第 2 章**。现状是 local-always-wins,连 last-write-wins 都不是。
**多玩家同设备(调研问题 4)——确认存在污染路径:**
`soundgame.auth`、`soundgame.progress.<game>`、`soundgame.settings` 全是单桶,不随账号切换。玩家 A 退出、玩家 B 登录(退出只清 auth 和 UI,**不清任何进度桶**,index.html:256-261)后:B 打开 rpg → `_loadSave()` 读到 A 的本地档 → 继续玩 A 的存档 → `_save()` 把它 PUT 到 **B 的账号**。A 的进度污染 B 的云端;反向亦然。设置同理(B 登录瞬间 `pullSettings` 用 B 的服务器设置覆盖本机,A 下次来发现设置变了)。
**save 触发时机**:rpg 在场景切换/商店/战斗结算等约 12 处调 `_save()`;radio 在进电话/抉择结算/结局(radio/game.js:678/785/891/1103)。**没有 beforeunload/pagehide 钩子,没有 sendBeacon**——关页面时最后一次事件存档之前的进度保留,之后的上传可能没发出去(本地仍有,下次玩会补传,因为下次 `_save` 会再 PUT 全量)。可接受,但有改进空间(§4.3)。
### 1.3 容量与写入竞态(调研问题 5)
- 单档实测规模:rpg/dungeon/radio 存档各约 0.5~1.5KB(`JSON.stringify(obj, null, 2)` 落盘约 3 倍)。8 游戏 + `_settings` 全开 ≈ **每用户 10~15KB**。100 用户 ≈ 1.5MB 的 JSON 文件集。**局域网家庭规模(<50 用户)完全无压力**,公网千级用户仍是 MB 量级——JSON 文件方案在这个项目体量下不是瓶颈。
- 写入竞态:两人同时玩不同游戏 → 不同用户文件,队列各自独立,无冲突;同一用户两设备同时 PUT 不同游戏 → 同一文件,`enqueueWrite` 串行化,读-改-写不互相覆盖(server.js:190-197 注释明示)。**唯一的竞态窗口在业务层**:同一账号两设备同时玩**同一游戏**,后到的 PUT 整档覆盖先到的(无 updatedAt 可比较)——即 §1.2 末尾的分叉 bug 的服务端镜像。
- 进程级:users/sessions 全量驻内存、写时落盘,崩溃最多丢队列尾部一条写;tmp+rename 保证文件系统层面原子。**单进程假设成立**(serve.sh 单实例 exec)。若未来起两个实例对着同一 data/ 目录,队列失效——公网化时要么保持单实例,要么加文件锁(§6-B6)。
---
## 2. 业界调研
### 2.1 localStorage-only 的代价:Wordle 迁移 NYT
2022 年 Wordle 从 powerlanguage.co.uk 迁到 nytimes.com,统计数据全在 localStorage、绑定旧域名,大量玩家 streak 蒸发。NYT 的补救是旧域名放一个内联脚本把 localStorage 数据搬到新域;**最终解法是登录 NYT 账号、服务端存档**。社区工程教训:纯 localStorage 把用户数据绑死在「单一 origin + 单一浏览器实例」上,域名迁移、换设备、清缓存都会清零。
- [How Wordle kept your streak alive when it migrated to the NYT's website (dev.to)](https://dev.to/timothee/how-wordle-kept-your-streak-alive-when-it-migrated-to-the-nyts-website-170b)
- [The New York Times Rushes To Fix Broken Wordle Streaks (Kotaku)](https://kotaku.com/wordle-new-york-times-streaks-broken-nyt-migration-dail-1848525236)
- [Fix My Wordle! (Reddit r/wordle)](https://www.reddit.com/r/wordle/comments/sqjxmo/fix_my_wordle/)
对本项目的映射:SoundGame 的部署规划(局域网 IP → 公网域名 game.xiangsilian.com)本身就是一次「换 origin」,localStorage 进度**不会跟过来**。任何多设备方案的第一性结论:**本地为主存没问题,但必须在用户无感的情况下把数据副本尽早送到服务器**。
### 2.2 local-first / 离线优先的通用模式
- [Ink & Switch: Local-First Software](https://www.inkandswitch.com/essay/local-first/)——奠基文章:本地为主、网络是增强;七条理想(离线可用、多设备、 longevity 等)。
- [Smashing Magazine: The Architecture of Local-First Web Development (2026-05)](https://www.smashingmagazine.com/2026/05/architecture-local-first-web-development/)——同步引擎、冲突解决、客户端迁移的工程全景。
- [Evil Martians: Cool Front-End Arts of Local-First — Storage, Sync, Conflicts](https://evilmartians.com/chronicles/cool-front-end-arts-of-local-first-storage-sync-and-conflicts)——明确指出「**记录字段级 last-write-wins 在很多场景已经够用**」;另提醒 localStorage ~5MB 上限(本项目存档 KB 级,不构成约束)。
- [Offline-First Architecture (Stackademic)](https://blog.stackademic.com/offline-first-architecture-5cb12314af75)——「软删除同步」:本地删除只有确认同步到服务器后才真正删。
- [Reddit r/gamedev: How do JS game devs handle data storage?](https://www.reddit.com/r/gamedev/comments/1gj8h4g/how_do_js_game_devs_handle_data_storage/)——浏览器游戏社区实践:localStorage/IndexedDB + 服务器备份是主流。
SoundGame 现状其实已经「意外地」local-first:主存 localStorage、服务器是异步备份。缺的是三块:**匿名身份的服务器对应物**、**合并策略**、**失败重试**。
### 2.3 匿名设备账号(不强迫注册的关键)
业界标准做法(analytics/SDK 领域成熟多年):
- [Statsig: Anonymous ID](https://www.statsig.com/glossary/anonymous-id)——首次访问生成随机 ID 存 localStorage/cookie,注册前一直用它标识用户。
- [Tell Docs: Users & Identity](https://docs.tell.rs/tracking/users-and-identity)——UUID 首次启动生成、之后随每个请求带上;**注册时与正式账号 merge**。
- [Reddit r/webdev: unique device identifier](https://www.reddit.com/r/webdev/comments/t3on6x/send_unique_device_identifier_in_js_post_call_for/)——实现共识:客户端生成 UUID v4 → localStorage → 每个请求携带。
映射到本项目:**「设备 token 即档案」**——首次打开大厅静默 `POST /api/device`,服务器建一个匿名 profile 并发一个长效 token;注册只是「给当前档案命名 + 设 PIN」,登录是「在另一台设备上认领同名档案」。用户全程无感,永不被注册墙拦住。
### 2.4 冲突解决:LWW 够不够
- [PowerSync: Custom Conflict Resolution](https://docs.powersync.com/handling-writes/custom-conflict-resolution)——策略阶梯:整体 LWW → 行级 LWW → **字段级 LWW** → CRDT。
- [OneUptime: How to Implement Last-Write-Wins](https://oneuptime.com/blog/post/2026-01-30-last-write-wins/view) 与 [ZigiOps: Conflict Resolution in Two-Way Synchronization](https://zigiwave.com/resources/conflict-resolution-bidirectional-sync)——LWW 语义与适用边界。
- [DZone: LWW vs CRDTs](https://dzone.com/articles/conflict-resolution-using-last-write-wins-vs-crdts)、[Ditto: offline-first conflict resolution](https://www.ditto.com/blog/how-to-build-robust-offline-first-apps-a-technical-guide-to-conflict-resolution-with-crdts-and-ditto)、[HN: Downsides of Offline First](https://news.ycombinator.com/item?id=28717848)——共识:**LWW 本身就是最简单的 CRDT(LWW-Register)**;纯计数/追加型状态(货币、成就集合)用 LWW 会丢增量,需要 PN-counter 或并集合并。
- [multi-leader 写冲突分析](https://programmingappliedai.substack.com/p/how-do-we-resolve-write-conflicts)——LWW 依赖时钟同步,工程上建议用服务器时间裁决而非客户端墙钟。
结论对本项目:**按游戏分桶 + 服务器时钟裁决的桶级 LWW 完全够用**。理由:单人单档案、8 款游戏互相独立(天然分桶)、家庭场景不存在真并发(一人一次玩一台设备)。唯一例外是 radio 的 `endings` 收集(4 结局达成集合)和 dungeon 的 `meta`(跨 run 的金币/遗产)——跨设备各玩一会可能丢一个结局记录,但代价是「再打一遍那个结局」,家庭玩具可接受;若要修,服务器端对这两个已知字段做**并集合并**即可(十行代码,见 §3.4),不必上 CRDT。
### 2.5 token 过期策略(家庭 vs 公网)
- [Auth0: Configure Refresh Token Expiration](https://auth0.com/docs/secure/tokens/refresh-tokens/configure-refresh-token-expiration)、[Duende: Refreshing a Token](https://docs.duendesoftware.com/identityserver/tokens/refresh/)、[CIAM Compass: Token Lifetime Best Practices](https://guptadeepak.com/ciam-compass/guides/token-lifetime-best-practices/)、[LoginRadius: Refresh Token Rotation](https://loginradius.com/blog/identity/secure-refresh-token-rotation)——共识方案是**滑动续期 + 绝对上限**的混合:活跃就续(无感),封顶强制重登(兜底)。微软 14 天滑动/90 天绝对、Atlassian 90 天不活跃即过期是常见刻度。
对本项目的取舍:局域网家庭场景**长效 token(如 180 天滑动、无绝对上限)是正确选择**——「无感」就是产品需求,爷爷奶奶不该被重新登录困扰;泄露面本来就限于家庭 WiFi。公网阶段收紧为 30~90 天滑动 + 180 天绝对上限,并加 logout 端点(现状连 logout 都没有,token 只进不出)。
---
## 3. 目标数据模型
### 3.1 设计原则
1. **游客优先**:设备 token 即档案,匿名档案与命名档案同构(数据层不区分,只有 profile 有没有 `name`/`pinHash` 的区别)。
2. **本地为主存**:游戏代码继续「先写 localStorage 再异步上传」的现行模式(改动最小),服务器是带版本意识的备份。
3. **服务器掌握时间真相**:存档包装 `{data, updatedAt}`,updatedAt 由服务器写入时打点,客户端上传时带 `baseUpdatedAt` 供 LWW 裁决——不信任客户端墙钟。
4. **按游戏分桶**:桶级 LWW,游戏间互不影响(一人玩 rpg 另一台设备玩 dungeon 不冲突)。
5. **迁移路径平滑**:`users.json`/`<username>.json` 的现有数据可脚本迁移;API 旧端点保留兼容期。
### 3.2 服务器文件结构
```
web/server/data/
├── profiles.json # 档案索引(小,常驻内存)
├── tokens.json # 设备 token → profileId(替代 sessions.json,带过期)
└── profiles/
├── p_8f3a….json # 一档案一文件(含匿名档案)
└── p_c21d….json
```
**profiles.json**(索引,取代 users.json):
```json
{
"p_8f3a1c": {
"name": "爸爸",
"pinHash": "scrypt$16384$8$1$<salt-hex>$<hash-hex>",
"createdAt": "2026-09-09T12:00:00Z",
"wasAnonymous": true,
"devices": 2
},
"p_c21d90": {
"name": null,
"pinHash": null,
"createdAt": "2026-09-09T13:20:00Z",
"wasAnonymous": true,
"devices": 1
}
}
```
PIN 用 Node 内置 `crypto.scrypt`(零依赖红线内)加盐哈希;`name: null` 即游客档案——同一结构,无需两套逻辑。
**tokens.json**(取代 sessions.json;token 仍是 24 字节随机 hex,客户端拿它当 Bearer):
```json
{
"a1b2…": {
"profileId": "p_8f3a1c",
"createdAt": "2026-09-09T12:00:00Z",
"lastSeen": "2026-09-09T20:11:00Z",
"expiresAt": "2027-03-08T20:11:00Z"
}
}
```
滑动续期:每次通过 token 鉴权时刷新 `lastSeen` 与 `expiresAt`(now + 180 天);定期清扫过期项(解决现状无限增长)。
**profiles/p_xxx.json**(一档案一文件,取代 `<username>.json`):
```json
{
"id": "p_8f3a1c",
"name": "爸爸",
"games": {
"rpg": { "updatedAt": "2026-09-09T20:03:11Z", "rev": 41, "data": { "schema": 1, "summary": "第二章·黑风寨 · Lv7 · 金币120", "…": "…" } },
"radio": { "updatedAt": "2026-09-08T22:47:02Z", "rev": 12, "data": { "schema": 1, "summary": "第3通·失恋来电 · 口碑+2 · 结局2/4", "…": "…" } },
"dungeon": { "updatedAt": "2026-09-07T21:15:40Z", "rev": 7, "data": null },
"_settings": { "updatedAt": "2026-09-09T19:58:00Z", "rev": 3, "data": { "voice": "male", "speed": 1.7, "bgm": 0.3, "guide": "standard" } }
},
"updatedAt": "2026-09-09T20:03:11Z"
}
```
- `data` 原样透传(保持现状「宽容收、严格显」),`summary` 仍是客户端约定不强制;
- `rev` 单调递增,是 updatedAt 的并列保险(同毫秒写冲突时用 rev 破平局);
- `_settings` 继续作为保留键占一个桶(迁移成本最低),不必单独开 settings 文件。
### 3.3 API 变更(增量,旧端点保留)
| 端点 | 作用 | 说明 |
|---|---|---|
| `POST /api/device` | **匿名建档**(新) | 无需认证;建 `name:null` 档案 + 发长效 token。幂等由客户端「有 token 就不重复建」保证 |
| `POST /api/claim` `{username, pin}` | **升级/登录**(新,取代直接 register+login) | 携带当前设备 token。目标用户名不存在 → 给当前档案命名+设 PIN(原地升级,进度天然保留);存在 → 验 PIN 后把当前(匿名)档案的进度**按桶 LWW 合并**进目标档案,token 改绑目标档案。这正是 [Tell/Statsig 的 anonymous→user merge 模式](https://docs.tell.rs/tracking/users-and-identity) |
| `PUT /api/progress` `{game, data, baseUpdatedAt}` | 写进度(改) | 服务器比对 `baseUpdatedAt` 与存储值:客户端落后 → 409 返回服务器档案(客户端择优重试);领先/相等 → 写入并打服务器时间。老客户端不带 baseUpdatedAt 视为「无条件写」,兼容 |
| `GET /api/progress` | 读进度(改) | 不带 `game` 参数时返回**全部桶**(现状大厅串行发 8 个 GET,index.html:357-364,改一发) |
| `POST /api/logout` | 注销 token(新) | 服务器侧删 token(现状只清客户端) |
| `DELETE /api/profile` | 删除账号(新,可延后到公网前) | roadmap §5.5 的合规要求 |
### 3.4 合并规则(claim 与 409 重试共用)
```
对每个 game 桶:
仅一边有数据 → 取有的一边
两边都有 → updatedAt 新者胜(服务器时间戳,同值比 rev)
radio.endings / dungeon.meta 两个已知收集型字段 → 对象按 key 并集(值取新)
_settings → 字段级合并没有意义,整桶 LWW 即可
```
---
## 4. 冲突与离线策略
### 4.1 写路径(游戏运行中)
```
_save()
├─ 1. localStorage['soundgame.p.<profileId>.<game>'] = data ← 主存,同步、必达
└─ 2. 入队上传 {game, data, baseUpdatedAt: 上次已知服务器 updatedAt}
├─ 成功 → 记录新 updatedAt
├─ 409 → 拉回服务器档案,与本地面包比对:
│ 服务器新(别处玩过更久)→ 仅当本地自上次同步后有新进度才回写,
│ 否则采纳服务器(现状 radio 的 _askContinue 弹「继续/重来」已是好先例)
└─ 网络失败 → 留在队列(见 4.3),下次再试
```
### 4.2 读路径(进游戏时)
radio 现状(radio/game.js:317-342)已接近正确形态,推广为全部有档游戏的统一行为:
1. 本地有档 → 直接用(离线零等待);
2. 本地无档(新设备/清过缓存)→ fetch 服务器(带超时兜底),有则用;
3. 两边都有 → 桶级 LWW。这修掉 §1.2 的分叉 bug(现状 local-always-wins 会把云端的第 3 章覆盖回第 2 章)。
### 4.3 断网/换 WiFi 与服务器宕机
- **WiFi 切换瞬间**:页面已加载、音频资产已在内存,进度写 localStorage 不受影响;在途 PUT 失败进重试队列。重试触发点:`online` 事件、`visibilitychange→visible`、下次 `_save()`、下次启动。
- **重试队列**:`localStorage['soundgame.p.<profileId>.queue']`,存未确认上传(每桶只留最新一条即可——存档是全量快照,旧上传无意义,**队列天然去重为每桶 1 条,8 桶封顶,无膨胀风险**)。
- **关页面丢尾**:`pagehide` 时对脏桶用 `navigator.sendBeacon('/api/progress', …)` 补一发(beacon 不保证送达,送达则赚);不做 beforeunload 同步存档(游戏事件存档点已够密)。
- **服务器挂**:局域网模式页面打不开是现状(静态+API 同进程),不改——家庭场景重启 serve.sh 即可;公网模式 Nginx 静态照常,游戏照玩,队列积压等恢复。
- **不引入 Service Worker/离线 PWA**:音频资产几百 MB,缓存不现实;「离线」在本项目里只需要覆盖「进度不丢」,localStorage 已做到。这是明确的不做项。
### 4.4 多玩家同设备
- localStorage 一切桶键加 profileId 段:`soundgame.p.<profileId>.<game>`、settings 同理挂桶(`_settings` 走服务器桶,本地镜像同名)。匿名档案也编 profileId → **游客多人也隔离**(现在游客连「换人」入口都没有,两人共用一台手机就是一锅粥)。
- 切换玩家 = 换 token(登录别人的账号,或大厅加「切换玩家」列出本机已知档案):切换前 flush 队列;旧档案的桶保留在本机(下次切回还在),不删除。
- 大厅提供「本机玩家」列表(localStorage 存 `soundgame.known.profiles` = [{profileId, name, token}],点击即切换)——这对「爷爷奶奶不用记 PIN」是刚需:第一次帮他们建档后,之后一键切换。
---
## 5. 文件级改动清单
按依赖顺序(不改代码,仅列改动点与规模):
| # | 文件 | 改动 | 规模 |
|---|---|---|---|
| 1 | `web/engine/progress.js`(**新建**,核心) | 统一存档模块:`save(game, data)` / `load(game)` / 桶级 LWW / 重试队列 / sendBeacon / profileId 命名空间。收编 rpg/dungeon/radio 三处重复的 `_save/_loadSave` 内联实现(三份代码几乎相同,radio/game.js:1336-1385、rpg/game.js:1165-1207、dungeon/game.js:1961-2001) | ~150 行 |
| 2 | `web/server/server.js` | §3.2/3.3:profiles/tokens 新结构、`/api/device`、`/api/claim`、PUT baseUpdatedAt 守卫、GET 全桶、logout、scrypt 哈希、滑动过期+清扫。写队列/原子写框架原样保留 | +150 行左右 |
| 3 | `web/index.html` | 启动即 `POST /api/device`(有 token 则跳过);登录/注册改走 claim(本地游客进度自动合并上云——补上现状缺失的「搬运」);`loadProgress` 改单次 GET 全桶;「本机玩家」切换 UI;`PUBLIC_STATIC` 硬编码域名检测改为「探测 /api/ 可达性」 | ~80 行改动 |
| 4 | `web/rpg/game.js`、`web/dungeon/game.js`、`web/radio/game.js` | `_save/_load` 内联块替换为 `engine/progress.js` 调用;rpg/dungeon 的 `start()` 补服务器拉档(照抄 radio 的本地优先+超时兜底模式) | 各 ~20 行净减 |
| 5 | `web/engine/settings.js` | 持久化键加 profileId 段(或改为经 progress.js 的 `_settings` 桶读写,跨设备同步顺手获得) | ~15 行 |
| 6 | `web/radio/game.js`、`web/dungeon/game.js`(可选) | endings/meta 服务器端并集合并的配套字段声明 | ~10 行 |
| 7 | `web/server/data/` 一次性迁移脚本(可放 `tools/`) | users.json → profiles.json + profiles/(老用户名档案保名保 PIN 重哈希);sessions.json → tokens.json;`<username>.json` → profiles/<id>.json 各桶补 updatedAt/rev | ~60 行 |
| 8 | `web/server/README.md`、`HANDOVER.md §2.2`、`docs/deploy.md` | 同步新 API 与安全声明(含修正「summary 服务端校验」的文档出入) | 文档 |
---
## 6. 公网化分层清单
### A. 现在就做(局域网阶段就受益,合计约 2~3 天)
1. **匿名设备档案 + claim 升级**(§3.3)——「不强迫注册」的核心;登录时游客进度自动上云。
2. **localStorage 按 profileId 分桶**(§4.4)——修多人同设备污染,现状是实打实的数据串号风险。
3. **PUT baseUpdatedAt LWW 守卫 + 读路径桶级 LWW**(§4.1/4.2)——修「本地旧档覆盖云端新档」的分叉 bug。
4. **统一存档模块 + 失败重试队列**(§5-1)——三份重复实现收编,断网补传。
5. **PIN scrypt 哈希**——`crypto.scrypt` 内置、十行级;明文 PIN 在任何阶段都没有存在的理由(含设备被盗场景)。
### B. 公网前再做(决定开放 API 那天启动,对齐 deploy.md「以后若要开放用户系统」与 roadmap §5.5)
1. **HTTPS 强制 + server.js 只监听 127.0.0.1 + Nginx 反代 `/api/`**(deploy.md 已写明套路,照做)。
2. **limit_req + 登录失败退避**——4~6 位 PIN 仅约 20 bit 熵,公网无速率限制等于没设防。
3. **token 收紧**:滑动 30~90 天 + 绝对上限 180 天 + logout/吊销端点;sessions 定期清扫。
4. **数据备份**:cron 对 `data/` 做 `tar` 快照保留 N 代(或 rclone 到对象存储);原子写只防崩溃不防磁盘/误删。
5. **隐私政策页 + 账号删除入口**(roadmap §5.5 已列:收集用户名+进度,不收集设备信息/位置)。
6. **请求体上限从 1MB 收到 32KB**、单档案 games 桶数白名单(实际 8+1 个,防滥用把服务器当免费网盘)。
7. **单实例约束写进 README** 或加进程级文件锁——两个 server.js 实例共享 data/ 会绕过写队列。
8. **基础监控**:data/ 目录体积、token 数、PUT 409 率的日级日志(零依赖下 console + logrotate 即可起步)。
### C. 明确不做(记下来防止过度设计)
- CRDT/操作变换/同步引擎(RxDB、PowerSync、ElectricSQL 等)——单人单档家庭场景,桶级 LWW 足够,引入依赖违背零依赖红线;
- Service Worker 离线 PWA——音频资产体积决定了离线只剩「进度」需求,localStorage 已覆盖;
- IndexedDB——存档 KB 级、无二进制、键值模型够用;
- 字段级 LWW——只有 `_settings` 有多字段,整桶换无感知差异。
---
## 7. 一句话总结
现状是一套「意外 local-first」的架构:本地为主存方向正确、原子写队列质量不错,但缺匿名身份的服务器对应物、缺版本意识(无 updatedAt 裁决 → 本地旧档能覆盖云端新档)、缺按玩家隔离(同设备多人串号)、缺失败重试(fire-and-forget)。目标模型「**设备 token 即档案,按游戏分桶,服务器时钟裁决的桶级 LWW**」在零依赖红线内、约 2~3 天改造量即可把「多玩家不注册 + 跨设备不丢档」做实,且为公网化预留了平滑升级路径(claim 合并 + token 收紧 + 反代/限流/备份)。