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 个待拍板决策点
This commit is contained in:
xsl
2026-09-09 23:09:24 +08:00
parent 1fca4998b9
commit 3704cd0792
4 changed files with 940 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
# 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 收紧 + 反代/限流/备份)。