Files
SoundGame/research/platform/01-identity.md
T
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

261 lines
27 KiB
Markdown
Raw 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.
# 平台调研 01 · 无感多用户身份系统
> 课题:多个玩家(家庭 WiFi 手机/PC + 未来公网 game.xiangsilian.com)可区分、可保留各自进度与设置,且**绝不能强迫注册**。
> 调研日期:2026-09-09。方法:本仓库代码审计 + 业界模式检索(来源链接见 §3.6)。
---
## 0. 结论速览
**主推:方案 B「自动匿名档案 + 设备档案切换器 + 配对码跨设备」**——首次访问 0 输入 0 点击即自动建档(服务器静默发 token,玩家无感知),同设备多玩家用 Netflix 式档案切换,跨设备/找回用「旧设备出示 6 位配对码 → 新设备输入」;纯静态公网形态自动降级为方案 A(纯本地档案)。PIN 降级为**可选的"认领"升级项**(防兄弟恶作剧),passkey 留给公网化之后再评估(LAN HTTP 下技术上不可用,见 §3.3)。
三个待产品拍板的决策点见文末 §6。
---
## 1. 现状审计
### 1.1 服务器(web/server/server.js,310 行)
| 项 | 现状 | 评估 |
|---|---|---|
| 注册/登录 | `POST /api/register {username, pin}` / `POST /api/login`,PIN 4~6 位数字 | **PIN 明文存 users.json**;用户名可中文(`USERNAME_RE`) |
| Token | 登录返回 24 字节随机 hex,内存 + sessions.json | **永不过期、无登出端点**——客户端"退出"只是删 localStorage,token 在服务器侧永久有效 |
| 进度 API | `GET/PUT /api/progress`,Bearer token,`data` 自由格式但须带 `summary`(大厅卡片显示) | 接口形状健康,可复用 |
| 并发 | 按文件串行 Promise 队列,tmp+rename 原子写 | 健康,可复用 |
| 防护 | 无速率限制、无 IP 封禁、HTTP 明文 | HANDOVER §2.2 已自我标注"玩具级" |
数据模型(web/server/data/ 实测):
```
users.json { "uuu": "0858" } // username -> pin 明文
sessions.json { "<48位hex>": "uuu" } // token -> username,无过期时间
uuu.json { "_settings": {voice, speed, bgm, guide} } // 一用户一文件 {<game>: data}
```
### 1.2 客户端(web/index.html + 各游戏)
- **登录态**:localStorage `soundgame.auth` = `{username, token}`,大厅与 7 个游戏各自直读此 key。
- **游客态今天就能玩**:不登录 → 全部 8 款游戏可玩,进度写各游戏自己的 localStorage key,设置写 `soundgame.settings`。服务器同步仅在登录时进行(PUT 静默跳过)。**即"本地优先 + 可选账号"架构今天已经成立,只是"账号"这一半摩擦大(要起名+想 PIN+输两遍)且公网不可用。**
- **公网形态**(docs/deploy.md):Nginx 纯静态、**不跑任何 API**;大厅检测 `https://*.xiangsilian.com` 即隐藏整个用户面板(index.html `PUBLIC_STATIC`)→ 公网访客全部是孤立的 localStorage 游客。deploy.md 已写明公网开放 API 的前置(127.0.0.1 绑定、只反代 /api/、limit_req、PIN 哈希、token 过期)。
localStorage key 全量清单(多档案方案的现实约束,见 §4):
| key | 归属 | 说明 |
|---|---|---|
| `soundgame.auth` | 大厅+7游戏 | 登录 token |
| `soundgame.settings` | engine/settings.js | 四项全局设置 |
| `soundgame.progress.rpg` / `.dungeon` / `.radio` | 各游戏 | 存档(命名不统一) |
| `soundgame.rhythm.progress` | rhythm | 存档 |
| `soundgame.simon.best` / `soundgame.space.best` / `soundgame.escape.stats` | 各游戏 | 最高分/统计 |
| `soundgame.rpg.tutIntroSeen` / `soundgame.escape.guideDone` / `soundgame.space.drillDone` | 各游戏 | 一次性教学标记 |
| (storyline 无本地 key) | — | 无本地持久化 |
### 1.3 审计发现的关键缺口(新方案必须解决)
1. **跨设备恢复只做了 1/8**:只有 radio 在"本地无存档 + 已登录"时 GET 服务器存档(radio/game.js:325-342);rpg/dungeon/rhythm/escape/simon/space 只 PUT 不 GET——**已登录用户换设备,7 款游戏进度照样丢**(服务器上有副本却不拉回)。
2. **设置是"设备级"而非"玩家级"**:一台手机一个 `soundgame.settings`;家里视障玩家 2.2x 语速与普通玩家 1.0x 共用一部手机时互相覆盖(虽然登录后会拉 `_settings`,但游客态无档案可分)。
3. **大厅无读屏适配**:index.html **零 aria 属性**;登录表单是 JS 动态生成、仅 placeholder 无 `<label>`(读屏不友好);设置面板倒是用了正规 `<label for>+<select>`。卡片是真实 `<a>`,读屏可列出。→ 身份 UI 重做时必须补 ARIA 与可听化。
4. **"退出"不失效 token**、sessions.json 无清理,长期只增不减。
### 1.4 语音技术栈对身份 UI 的约束(重要)
全仓库**无 speechSynthesis 使用**——所有语音都是离线 TTS 预生成音频资产(Qwen3-TTS 流水线),且设计规范禁止数字碎片拼接(数字按档位整句预生成)。推论:**任意自定义玩家名无法被"说出来"**,档案名要么从预生成词表选、要么用预生成的"玩家一/二/三",要么为大厅引入浏览器 SpeechSynthesis(与整体录音质感不一致,需产品拍板,见 §6-D3)。
---
## 2. 业界模式调研发现
### 2.1 模式总表
| 模式 | 代表 | 首访摩擦 | 进度保留 | 换设备 | 启示 |
|---|---|---|---|---|---|
| **匿名设备账号**(首访自动建档,后可升级) | Firebase Anonymous Auth、Unity Authentication、腾讯 CloudBase 匿名登录 | 0 | 云端 | 需升级/绑定 | 业界标准做法:UID 服务端生成、`linkWithCredential` 升级**同 UID 数据不迁移**;官方警告匿名账号**丢失凭证即孤儿数据**,建议 30 天不活跃清理;升级提示放在"账号开始有价值的那一刻" |
| **本地优先 + 可选账号链接** | Wordle(原版→NYT)、Poki | 0 | localStorage/cookie | 登录后云同步 | Wordle 原版全 localStorage;迁移 NYT 时靠"旧域名最后访问一次"携带本地数据;后加免费 NYT 账号才跨设备。Poki FAQ 明说:无账号进度存浏览器 cookie,建账号可跨设备。**SoundGame 现状就是这个模式的前半段** |
| **档案切换器**(一设备多档案) | Netflix、Nintendo Switch、Jellyfin/Emby 的诉求贴 | 选头像 1 击 | 每档案独立 | 档案级 | 家庭共设备场景的标准解;Switch 儿童档可选 PIN;自托管社区(Jellyfin #7059)反复索要同款 |
| **Passkey 无用户名一键建档** | web.dev/Auth0/FIDO | 1 击+生物识别 | 云端 | **iCloud/Google 密钥链自动同步** | 理论上最优雅;但见 §2.3 硬约束 |
| **QR/配对码跨设备** | Jackbox(房间码+QR)、Google TV、WhatsApp Web | — | — | 扫码/输码 | "已有身份的设备给新设备发短期票据"是无邮箱场景的换设备通行做法 |
| **纯游客不留进度** | chess.com Guest Play | 0 | **不留**(无 ELO 无历史) | 不支持 | 反面教材:我们的核心诉求就是"游客也要留进度",不采用 |
### 2.2 无邮箱无手机号的"找回"业界处理
- **passkey 多设备同步**:丢单台设备无忧(iCloud Keychain / Google Password Manager 同步);但**全家桶全丢**时,苹果恢复 iCloud 钥匙串本身可能要短信验证——没有邮箱/手机号时基本无解(sixcolors、Authsignal、r/Passkeys 讨论)。
- **恢复码**:通行替代(一次性长码,抄在纸上)。对家庭游戏场景等价于"配对码的长期版"。
- **业界现实**:小休闲产品(Wordle 时代、Poki 游客)干脆**不支持找回**,丢就丢;有价值的账号才给恢复通道。
- **对本项目的翻译**:找回 = 「另一台仍登录的设备出示配对码」(Jackbox/WhatsApp 模式),加一层**可选 PIN 认领**兜底"旧设备已刷机"的情况;不做邮箱/手机号。
### 2.3 Passkey 的硬约束(决定性发现)
WebAuthn 要求 **Secure Context(HTTPS 或 localhost)**(W3C WebAuthn L3、MDN)。`http://192.168.x.x:8000` 这种**局域网 IP + HTTP 不满足**,`navigator.credentials` 直接为 null(Corbado、StackOverflow #55971593、mkcert #210)。家庭 WiFi 主路径(serve.sh 打印的局域网地址)**天然用不了 passkey**;变通(ngrok/mkcert 全家设备装 CA)对家庭用户不现实。另:本项目服务器零依赖原则下,WebAuthn 服务端验证(COSE/ES256)要么引入 `@simplewebauthn/server`(破例)要么手搓密码学(危险)。→ **passkey 只能作为公网 HTTPS 域名下的可选增强,且建议推迟。**
### 2.4 双形态(公网+内网)的安全底线
不需要银行级,但要"防邻居蹭网互删进度":
| 层 | 局域网形态(现状 HTTP) | 公网形态(未来 HTTPS) |
|---|---|---|
| 传输 | HTTP 明文 = 同 WiFi 者可嗅 token。**威胁模型只能假设家庭 WiFi 可信**(WPA2/3 密码不外传即可);要更强就得走公网域名(家庭内网也可访问 https://game.xiangsilian.com) | TLS,嗅探关闭 |
| 认证 | 匿名档案 token + 可选 PIN 认领(防"家人恶作剧互删"级别) | 同左 + 登录/配对端点限速 |
| Token | 加 TTL(滑动续期,如 180 天)+ 登出可吊销 | 同左 |
| PIN(若设置) | scrypt 哈希存储(node:crypto 自带,不破零依赖) | 同左 + limit_req(deploy.md 已规划) |
| 服务边界 | 服务器只绑 LAN | 绑 127.0.0.1,Nginx 只反代 /api/(deploy.md「以后若要开放用户系统」原文照做) |
deploy.md 已写的前置清单与本方案完全兼容——本方案把其中"PIN 改哈希、token 过期"消化进档案系统本身。
### 2.5 中文社区实践补充
前端圈通行做法与本报告方案一致:首次访问生成 UUID 存 localStorage(可 Cookie 双保险)作设备身份 → 服务器据此签发 token → 同一设备 ID 对应唯一匿名账号(腾讯 CloudBase 匿名登录文档明确"同一设备只能生成一个匿名用户、可后续绑定升级")。浏览器指纹(FingerprintJS)可作 localStorage 被清后的兜底,但隐私争议大且家庭场景收益低,**不建议引入**。
### 2.6 来源链接
- Firebase 匿名认证最佳实践:<https://firebase.blog/posts/2023/07/best-practices-for-anonymous-authentication/>
- Unity Authentication 匿名登录:<https://docs.unity.com/en-us/authentication/approaches-to-authentication>
- 腾讯 CloudBase 匿名登录(同设备唯一匿名账号):<https://docs.cloudbase.net/http-api/auth/auth-sign-in-anonymously>
- 前端设备唯一标识实践(掘金):<https://juejin.cn/post/7640043551352635402>;CSDN:<https://blog.csdn.net/weixin_43242942/article/details/139881190>
- Wordle 本地存储与 NYT 迁移:<https://www.reddit.com/r/wordle/comments/1cszd9v/>(含 localStorage 实证); NYT 关联统计报道见 CNET
- Poki FAQ(无账号=cookie 存进度):<https://poki.com/en/c/faq>;隐私声明:<https://poki.com/en/c/privacy-statement>
- chess.com Guest Play:<https://www.chess.com/forum/view/site-feedback/playing-as-a-guest>
- Netflix 档案切换:<https://help.netflix.com/en/node/322532753336036>;Jellyfin 多档案切换诉求:<https://github.com/jellyfin/jellyfin-web/discussions/7059>;UX 分析:<https://medium.com/@mariajennings/reflection-point-patterns-and-flows-78acd5ab234c>
- WebAuthn 去用户名化(discoverable credentials):<https://stackoverflow.com/questions/73562080/>;web.dev 注册指南:<https://web.dev/articles/passkey-registration>
- WebAuthn Secure Context 约束:W3C <https://www.w3.org/TR/webauthn-3/>;MDN <https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create>;LAN IP 不可用实证 <https://www.corbado.com/blog/test-passkeys-localhost-ngrok>、<https://stackoverflow.com/questions/55971593/>;mkcert 局域网讨论 <https://github.com/FiloSottile/mkcert/issues/210>
- Passkey 找回/全丢设备:<https://www.authsignal.com/blog/articles/what-happens-when-your-passkey-device-is-lost-understanding-recovery-and-device-sync>、<https://sixcolors.com/link/2022/06/how-do-you-recover-passkeys-if-you-lose-all-your-devices/>、<https://www.reddit.com/r/Passkeys/comments/1faz0gt/>、Apple <https://support.apple.com/en-us/102195>
- Jackbox 房间码/QR 第二屏:<https://www.jackboxgames.com/how-to-play>、<https://www.jackboxgames.com/blog/how-to-play-party-pack-nine-remotely>;TV QR 登录模式 <https://www.cosmiclearn.com/googletv/tv-authentication.php>;配对码设计 <https://softwarewitchcraft.com/posts/designing-pairing-codes-tradeoffs-mistakes-simple-approach-that-works/>
- QR 多端登录(streaming/passkey 场景):<https://mojoauth.com/blog/multi-device-login-for-streaming-implementing-passkeys-across-ios-android-web-and-tv>
---
## 3. 候选方案矩阵
维度说明:★ 越多越好(工作量列反着看:★ 越少越好=工作量越小)。"语音适配"= 视障用户可听、可手势操作的成本。
| 方案 | 首访摩擦 | 同设备多玩家 | 跨设备同玩家 | 隐私与安全 | 实现工作量(文件级) | 语音适配 |
|---|---|---|---|---|---|---|
| **A. 纯本地档案**(localStorage 多命名空间档案,无服务器身份) | ★★★★★ 0 击(自动"玩家一") | ★★★★★ | ✗(只能整档导出/导入码) | ★★★★★ 无攻击面 | ★(新 engine/profile.js ~150 行 + index.html 改面板;游戏零改动若走"换档=换 key 快照") | ★★★★(无配对旅程要念) |
| **B. 自动匿名档案 + 切换器 + 配对码**(首访静默 POST 建档拿 token;Netflix 式切换;跨设备=6 位配对码/QR;可选 PIN 认领) | ★★★★★ 0 输入 0 击 | ★★★★★ | ★★★★(配对码:需旧设备在手;PIN 认领兜底) | ★★★(token TTL+吊销、scrypt PIN;LAN HTTP 仍明文=家庭可信假设) | ★★(server.js +~180 行含配对/迁移;engine/profile.js ~250 行;index.html 面板重做;**8 个游戏零改动**,见 §4.3 换档走服务器倒灌) | ★★★(切换/配对语音线要新增台词资产) |
| **C. 改良 PIN**(自动建档但随机发 PIN 一次展示;保留现登录 UI 为跨设备主路径) | ★★★(首访弹出"抄下你的 PIN"本身是打断+认知负担) | ★★★(PIN 是档案口令,家人共用即互看) | ★★★★(名+PIN 哪都能登) | ★★★(须配 scrypt+限速,否则弱于 B) | ★★(server 改动小于 B,无配对子系统) | ★★★(输 PIN 数字键盘可听化) |
| **D. Passkey 一键建档**(WebAuthn discoverable credential,无用户名无密码) | ★★★★(1 击+生物识别,但要系统弹窗理解成本) | ★★★★(每档案一个 passkey) | ★★★★★(iCloud/Google 同步白送) | ★★★★★ | ★★★★★(服务端 COSE/ES256 验证:引依赖或手搓密码学;**LAN HTTP 不可用,见 §2.3**) | ★★★(系统弹窗有系统级旁白) |
| **E. QR/配对码**(独立小方案,只做"身份搬运") | — | — | ★★★★ | ★★★★(短期单次码) | ★(+~60 行) | ★★(QR 不可听,须配数字码朗读) |
关键否决/保留理由:
- **D 否决为主方案**:局域网 HTTP 私有 IP 下 WebAuthn API 直接不存在(§2.3),而家庭 WiFi 恰是主场景;零依赖约束下服务端实现成本最高。留作公网化之后的可选"认领"升级项。
- **C 不推荐但保留 API 兼容**:PIN 从"人人必经"降级为"想要跨设备找回的人可选设置",现有 username+PIN 端点在迁移窗口保留。
- **E 不是独立方案**,是 B 的跨设备旅程构件(A 若只做本地,也可后补导出码)。
---
## 4. 推荐方案(主推 B,A 为其离线/公网静态降级形态)
### 4.1 总体形态
```
┌─ 首访:0 输入 → 静默 POST /api/profile → token + "玩家一"
局域网(serve.sh)──┤─ 切换:大厅档案面板(本地保存该设备所有 {id,name,token})
└─ 跨设备:旧设备"出示配对码"(6 位数字,10 分钟单次)→ 新设备输入 → 拉全部进度
公网(Nginx)────── API 未开放期:自动降级为方案 A(纯本地档案,localStorage 快照式换档)
API 开放期(deploy.md 前置完成后):与局域网同一套 B
可选升级 ────────── "认领":给档案设 PIN(scrypt 哈希),用于名+PIN 跨设备登录(旧设备不在手时兜底)
```
离线/无 API 时 B→A 的降级是**同一份 profile.js 代码的两种存储后端**(服务器或 localStorage 快照),不是两套系统——现状 index.html 的 `api()` 已有"服务器不可达静默降级"基因,延续即可。
### 4.2 三个旅程(语音 + 手势描述)
本仓库手势语言:上/下/左/右滑动、单击(约 300ms 延迟)、双击;全项目 8 个统一 earcon(engine/guide.js 运行时合成)。大厅新增语音资产需走 TTS 流水线(台词表加进 tools/gen_voice_dual.py,男女双声)。
**旅程 1 · 首次进入(0 感知建档)**
- 打开大厅,无任何弹窗/表单。页面加载时后台静默建档(服务器形态拿 token;无服务器则纯本地)。
- 首次完成任一游戏存档后,大厅顶部档案区轻声播一句(可被任何触摸打断,遵守引擎语音链规则):「已创建玩家档案:玩家一。你可以在大厅顶部切换或重命名。」同时卡片刷新出进度摘要。不打断优先级高于这句闲聊的任何游戏语音。
- 视障路径:大厅是真实 DOM 按钮,TalkBack/VoiceOver 逐个可读;补 `aria-live` 播报上述句子;卡片本身已可读(真实 `<a>`)。
**旅程 2 · 切换玩家(同设备,2 击)**
- 大厅顶部常驻「玩家:〔玩家一〕」按钮(真实 `<button>` + `aria-haspopup`)。单击 → 展开档案列表(每行一个 `<button role="menuitemradio">`:玩家一✓ / 玩家二 / +新玩家 / ⚙ 重命名·配对·设PIN)。
- 单击某档案 → 播确认 earcon(guide 词表的 success 音)→ 语音「已切换到玩家二」→ 下方游戏卡进度摘要刷新。
- 切换的数据搬运(详见 4.3):旧档案本地进度全部 PUT 上服务器 → 新档案全部进度 GET 倒灌回**原有** localStorage key → 游戏代码零改动。离线(纯本地后端)则等价地在 localStorage 快照间搬运。
- 听觉化档案名:预生成「玩家一~玩家九」音 + 每档案一个签名 earcon(8 音词表外允许第 9 种"档案身份音",或复用音高变体),任意汉字名不朗读只显示(决策点 D3)。
**旅程 3 · 换设备(配对码)**
- 旧设备:档案面板 →「换设备 / 配对」→ 显示 6 位数字配对码 + QR(QR 给明眼人扫码用,同 LAN 下直开新设备页)。**语音路径**:逐位朗读数字(需预生成 0-9 单数字音——设计规范禁"数字碎片拼接"是对整句台词的;配对码逐位播报是系统级新场景,需设计侧裁一条豁免或用「第 x 位是 y」整句预生成,见决策点 D3 附注)。
- 新设备:大厅档案面板 →「我是老玩家」→ 数字小键盘(大按键、无 IME)逐位输入,单击选键、输入完自动校验;错误播 error earcon +「配对码不正确,请重试」。
- 成功 → 成功 earcon +「欢迎回来,玩家二」→ 新设备拿到 token,全部进度从服务器倒灌本地(顺带修复审计缺口 1:跨设备拉回从此对所有 8 款游戏生效,因为倒灌发生在大厅层而非各游戏内)。
- 兜底:旧设备已丢失且档案设过 PIN →「用玩家名+PIN 登录」(保留现有 login UI 为这条低频路径);没设 PIN 也没旧设备 → 接受丢失、重新开始(与 Wordle/Poki 游客同水位,写入产品预期管理文案)。
### 4.3 服务器数据模型变更(users.json v2 + tokens + pairings)
```
data/users.json(v2,启动时自动从 v1 迁移)
{
"u_a1b2c3": {
"id": "u_a1b2c3",
"name": "玩家一",
"kind": "device", // device=匿名档案 | claimed=已设PIN
"pinHash": null, // claimed: "scrypt$N$r$p$salt$hash"(node:crypto 自带)
"createdAt": "…", "lastSeenAt": "…"
},
"uuu": { "id": "uuu", "name": "uuu", "kind": "claimed", "pinHash": "scrypt$…(由明文 0858 迁移时哈希)", … }
}
data/tokens.json { "<hex>": { "uid": "u_a1b2c3", "exp": 1893456000, "lastUsedAt": … } } // 滑动 180 天
data/pairings.json { "483920": { "uid": "u_a1b2c3", "exp": …, "used": false } } // 10 分钟单次
data/<uid>.json { "<game>": data } // 结构不变;老用户 uid=旧用户名,文件零迁移
```
API 增删(server.js 内保持零依赖):
| 端点 | 说明 |
|---|---|
| `POST /api/profile`(新) | 无参或 `{name?}` → 建匿名档案 + 返回 `{token, profile}`。配简单 per-IP 频控(如 10 个/天)防刷 |
| `GET /api/profile`(新) | 当前 token 的档案信息 |
| `PUT /api/profile`(新) | 改名 |
| `POST /api/profile/pin`(新) | 设置/修改 PIN(kind→claimed,scrypt) |
| `POST /api/pair/start`(新) | 已登录 → 生成 6 位配对码(10 分钟单次) |
| `POST /api/pair/redeem`(新) | `{code}` → 新 token(配对码换登录) |
| `POST /api/logout`(新) | 吊销当前 token |
| `GET /api/progress` / `PUT /api/progress` | **不变** |
| `POST /api/register` / `POST /api/login` | 保留一个迁移窗口(等价于 claimed 档案),之后降级为内部专用 |
| 顺带修复 | sessions.json → tokens.json 迁移时全部给 180 天滑动过期;写入时惰性清理过期项 |
### 4.4 向后兼容与迁移(现有用户 = data/ 里的 1 个真实账号 "uuu")
1. server.js 启动检测 users.json 为 v1(值是字符串)→ 一次性转 v2:uid 沿用旧用户名(`uuu.json` 进度文件零迁移),PIN 明文即刻转 scrypt,sessions token 全量搬入 tokens.json 并加 exp。
2. 客户端 `soundgame.auth` 旧结构 `{username, token}` 在 profile.js 初始化时识别 → 服务器换发(或直接沿用,token 仍有效)→ 包成单档案的档案表。老用户无感。
3. 游客的 localStorage 进度:首次进入新档案系统时,把现有 `soundgame.progress.*` 等本地 key **认领进首个自动档案**(数据不丢,升级为档案数据)——这是 Firebase `linkWithCredential`「UID 不变、数据原地升级」思想(§2.1)。
4. 大厅 `PUBLIC_STATIC` 分支保留:公网无 API 期 → 档案面板仍在但走纯本地后端(比今天"整个面板藏掉"更好)。
### 4.5 文件级改动清单
| 文件 | 改动 | 规模估计 |
|---|---|---|
| `web/server/server.js` | 新端点(§4.3 表)、scrypt、token TTL/吊销、配对码、v1→v2 启动迁移、per-IP 简单频控 | +~180 行 |
| `web/engine/profile.js`(新) | 档案表(本地 {id,name,token} 列表)、激活档案、换档搬运(flush 本地全部 key→PUT;GET→倒灌)、双后端(服务器/纯本地快照)、配对 UI 逻辑 | ~250 行 |
| `web/index.html` | 登录面板 → 档案面板(列表+新玩家+重命名+配对+设PIN+名PIN登录折叠项);补 aria(动态表单加 label、aria-live 播报区);`PUBLIC_STATIC` 分支改为本地档案模式 | ~150 行重写 |
| 8 个 `web/*/game.js` | **零改动**(换档搬运在大厅层完成;radio 自带的 GET 逻辑与新机制共存不冲突) | 0 |
| `web/engine/settings.js` | 零改动(`_settings` 本来就走 progress API,档案化后自动变成玩家级设置) | 0 |
| `web/serve.sh` / `web/server/README.md` / `HANDOVER.md` §2.2、§6 | 文档更新(玩具级警告改写为实际水位) | 小 |
| `tools/gen_voice_dual.py` 台词表 | 新增大厅语音(sys_profile_created / switched / pair_code 逐位数字 / welcome_back 等,男女双声)+ 走一轮 TTS 流水线 | 若干条 |
| `docs/deploy.md` | 「以后若要开放用户系统」小节更新为本方案公网路径 | 小 |
### 4.6 风险与缓解
| 风险 | 等级 | 缓解 |
|---|---|---|
| localStorage 被清/换浏览器 → 匿名档案孤儿(业界公认,Firebase 官方警告同款) | 中 | 引导时机学 Firebase:在"第一次想跨设备/第二次设 PIN 机会点"提示设 PIN 或配对,而非首访打断;服务器对 180 天不活跃 device 档案做归档清理(防 users.json 无限膨胀) |
| LAN HTTP 明文下 token 可被同 WiFi 者嗅探 → "邻居删进度" | 中 | 威胁模型写明:家庭 WiFi 密码可信即安全;要更强走 https://game.xiangsilian.com(内网也能访问公网域名)。认领 PIN 防的是"家人恶作剧"级别 |
| 换档搬运期间断网/杀页面 → 两档案数据交错 | 低 | 搬运顺序固定:先全部 flush 完成再切换;失败即中止切换并播 error;纯本地后端快照为原子 JSON |
| 一次性教学标记(tutIntroSeen 等 3 个 key)跨档案泄漏(换了档案教学不再重播) | 低 | 可接受(教学只教一次本就是产品取向);彻底修需游戏改动或 key 前缀化,列为后续可选项 |
| 配对码被念出来的体验(视障用户逐位听 6 位数字) | 中 | 数字音预生成 + "重复播报"手势(双击重听);或改 4 位词表码(每词一个预生成音)——设计侧裁决 |
| 任意汉字名无法朗读,视障家人无法"听出"谁在玩 | 中 | 每档案绑定签名 earcon + 固定编号音(玩家一~九可听),自定义名只作视觉显示(决策点 D3) |
| 匿名档案被脚本批量刷(公网开放后) | 低 | per-IP 频控 + limit_req(Nginx 层,deploy.md 已规划);家庭场景几乎无此风险 |
---
## 5. 备选方案说明
- **若决定完全不上服务器身份**(公网永远纯静态):直接做方案 A。损失:跨设备、玩家级设置云同步、家庭共用 PC 时的云端备份;得到:零攻击面、实现最省。A 是 B 的真子集(profile.js 双后端中的本地后端),先做 A 再补 B 不返工——这也是把 A 设计为 B 的降级形态的原因。
- **若公网化后想上 passkey(D)**:在 claimed 档案上加第二凭据即可(passkey 与 PIN 并存),客户端改动集中在大厅,服务端需引入 WebAuthn 验证依赖或手搓(建议届时引入 `@simplewebauthn/server` 并为服务器破一次零依赖例,或用 Nginx+auth 子服务隔离依赖)。届时 B 的配对码仍是兜底通道,两者不冲突。
## 6. 三个关键设计决策点(需产品拍板)
- **D1 · 档案名怎么起**:首访完全 0 感知(自动"玩家一",事后改名)还是首访点一次"起个名字/用默认"?(推荐前者:改名入口在档案面板,随时可改;起名不是注册,不该挡在玩游戏前面。)
- **D2 · 跨设备找回的底线**:只配对码(旧设备必须在手)够不够?是否同时保留"名+PIN 登录"作为旧设备丢失时的兜底(推荐保留,PIN 变为可选认领项);还是彻底极简、丢了就丢(Wordle 同水位)?
- **D3 · 视障玩家的"名字可听化"**:自定义汉字名不朗读(只显示+签名 earcon+编号音)够不够?还是引入浏览器 SpeechSynthesis 念任意名(与全预生成语音的质感不一致、移动端质量参差)?附带:配对码逐位播报需要设计侧给"数字单音"或"第 x 位是 y"整句一个豁免裁定。