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

26 KiB
Raw Permalink Blame History

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.51.5KB(JSON.stringify(obj, null, 2) 落盘约 3 倍)。8 游戏 + _settings 全开 ≈ **每用户 1015KB**。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 + 单一浏览器实例」上,域名迁移、换设备、清缓存都会清零。

对本项目的映射:SoundGame 的部署规划(局域网 IP → 公网域名 game.xiangsilian.com)本身就是一次「换 origin」,localStorage 进度不会跟过来。任何多设备方案的第一性结论:本地为主存没问题,但必须在用户无感的情况下把数据副本尽早送到服务器。

2.2 local-first / 离线优先的通用模式

SoundGame 现状其实已经「意外地」local-first:主存 localStorage、服务器是异步备份。缺的是三块:匿名身份的服务器对应物、合并策略、失败重试。

2.3 匿名设备账号(不强迫注册的关键)

业界标准做法(analytics/SDK 领域成熟多年):

映射到本项目:「设备 token 即档案」——首次打开大厅静默 POST /api/device,服务器建一个匿名 profile 并发一个长效 token;注册只是「给当前档案命名 + 设 PIN」,登录是「在另一台设备上认领同名档案」。用户全程无感,永不被注册墙拦住。

2.4 冲突解决:LWW 够不够

结论对本项目:按游戏分桶 + 服务器时钟裁决的桶级 LWW 完全够用。理由:单人单档案、8 款游戏互相独立(天然分桶)、家庭场景不存在真并发(一人一次玩一台设备)。唯一例外是 radio 的 endings 收集(4 结局达成集合)和 dungeon 的 meta(跨 run 的金币/遗产)——跨设备各玩一会可能丢一个结局记录,但代价是「再打一遍那个结局」,家庭玩具可接受;若要修,服务器端对这两个已知字段做并集合并即可(十行代码,见 §3.4),不必上 CRDT。

2.5 token 过期策略(家庭 vs 公网)

对本项目的取舍:局域网家庭场景长效 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):

{
  "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):

{
  "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):

{
  "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 模式
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/.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 收紧 + 反代/限流/备份)。