# 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 端点,每次登录追加一条,**无限增长** | | `.json` | `{ : }` | 一用户一文件;`_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..<杂项>` | 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.`、`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`/`.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$$", "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**(一档案一文件,取代 `.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..'] = 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..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..`、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;`.json` → profiles/.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 收紧 + 反代/限流/备份)。