Files
SoundGame/web/server/README.md
T
xsl 566241e05d feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)
配对码与认领(B):
- POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏
- 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化
- api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22

键盘语音双套资产(A,路线 a):
- 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原)
- 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条)
- check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成)
- voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引
- mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动

回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12)
同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
2026-09-10 00:43:23 +08:00

194 lines
11 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.
# SoundGame Web 服务器(web/server/server.js)
Node 零依赖(仅用内置模块)的静态文件 + 用户系统 API 服务器。
P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity.md` 方案 B 与
`03-data-sync.md`):首次访问 0 输入 0 点击自动建匿名档案(自动名「玩家一/二/…」),
认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、
服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。
P1 增补**配对码跨设备**(`01 §4.2` 旅程 3):老设备在「档案管理 → 转移到新设备」
生成 6 位配对码(逐位朗读),新设备输入即可把本机进度按桶 LWW 合并进老档案;
token 180 天滑动过期,到期后大厅引导「重新登录或新建」。
## 启动
```bash
# 推荐:用项目自带 node
tools/node/bin/node web/server/server.js
# 或用封装脚本(打印局域网地址)
bash web/serve.sh
# 自定义端口(默认 8000)/ 数据目录(默认 web/server/data/,测试用)
PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
```
- 静态根目录:`web/`(`/` 即游戏大厅 `index.html`);`web/server/` 目录一律 404(内含 token 与 PIN 哈希)
- 数据目录:自动创建;检测到 v1 旧文件(`users.json`/`sessions.json`)时启动即单向迁移(原文件保留不动)
## ⚠ 安全说明
家庭局域网级认证(比之前加固了一档,但仍**请勿暴露公网**):
- PIN 以 **scrypt 哈希**(N=16384, r=8, node:crypto 内置)存储,不再明文;
- token **180 天滑动过期**、可 logout 吊销、惰性清扫,不再无限增长;
- 匿名建档端点有 per-IP 频控(默认 10 个/天,`SG_DEVICE_LIMIT` 覆盖);
- 配对码:**仅存内存不落盘、不进日志**;10 分钟单次有效、同档案同时仅 1 个有效码、
`pair/start` per-IP 频控(默认 20 个/天,`SG_PAIR_LIMIT` 覆盖)、`pair/join`
**连错 5 次封锁同 IP 10 分钟**(10^6 码空间 × 5 次 → 单窗口猜中概率 ≤ 5×10⁻⁶);
- 仍是 HTTP 明文传输、无 TLS、无登录失败退避——威胁模型是「家庭 WiFi 可信 +
防家人恶作剧」。公网开放 API 的前置清单见 `docs/deploy.md`「以后若要开放用户系统」。
## API
所有 API 请求/响应均为 JSON(`Content-Type: application/json`)。
错误响应统一为 `{ "error": "描述" }` + 相应 HTTP 状态码。
需要鉴权的端点用请求头 `Authorization: Bearer <token>`。
### POST /api/device —— 匿名自动建档(核心)
无参(或带已固化设备的旧 token 重附着)。
- 请求:`{}` 或 `{ "token": "<本机已有的 token>" }`
- 成功(新建):`201 { "ok": true, "profileId": "p_a1b2c3d4e5f6", "token": "<48 位随机 hex>", "name": "玩家一", "claimed": false }`
- `name` 由服务器按档案序号自动命名:玩家一、玩家二、玩家三……(可听词表)
- `token` 即设备身份,客户端存 localStorage,之后一切鉴权用它
- 成功(重附着):`200 { ..., "attached": true }`——幂等,不新建档案、不占频控额度
- 失败:`429`(同 IP 当日新建数超限)
大厅(`web/index.html`)首访静默调用本端点建档;纯静态公网形态(无 API)自动降级为
本地档案(`web/profiles.js` 同一套逻辑,token 为 null)。
### GET /api/profile —— 当前档案信息
- 请求头:Bearer
- 成功:`200 { "profileId": "p_…", "name": "玩家一", "claimed": false, "createdAt": "…" }`
### PUT /api/profile —— 改名
- 请求头:Bearer;请求:`{ "name": "爸爸" }`(1~24 位字母/数字/中文/`_`/`-`)
- 成功:`200 { "ok": true, "name": "爸爸" }`
- 失败:`400`(格式非法)/ `401` / `409`(该名字已被其他**已认领**档案占用;匿名档案重名不限制)
### POST /api/claim —— 认领(命名 + 设 PIN)/ 换设备登录(合并)
携带当前设备 token(Bearer 或 body.token)。请求:`{ "name"?, "pin"? }`,
`pin` 为 4~6 位数字。三种语义:
1. **只设 PIN**(`{pin}`):当前档案原地升级为可跨设备登录(`claimed: true`)。
2. **命名(±PIN)**(`{name, pin?}`,且该名字尚无已认领档案):当前匿名档案原地
命名(+设 PIN),进度零迁移——Firebase「匿名账号升级不换 UID」模式。
3. **登录/合并**(`{name, pin}`,且该名字已有已认领档案):验 PIN 后把**当前匿名
档案**的进度按桶 LWW(updatedAt 新者胜,同值比 rev)合并进目标档案,当前 token
改绑目标档案,匿名档案删除。返回 `{ "ok": true, "merged": true, "mergedGames": 3, "profileId": "…", "name": "爸爸", "claimed": true }`。
- 失败:`400`(格式)/ `401`(PIN 缺失或不正确)/ `409`(当前档案已设 PIN,需先建新匿名档案)
### POST /api/login —— 旧客户端兼容
- 请求:`{ "username": "uuu", "pin": "0858" }`
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu", "claimed": true }`
- v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。
大厅在 token 180 天过期后的「名字 + PIN 重新登录」路径也走本端点(无需有效 token)。
### POST /api/register —— 旧客户端兼容(建议改用 device + claim)
- 请求:`{ "username": "ming", "pin": "1234" }` → `201 { "ok": true, "username": "ming", "profileId": "…", "token": "…" }`
- 直接创建已认领档案(不合并当前匿名档案的进度)。
### POST /api/logout —— 吊销当前 token
- 请求头:Bearer;成功:`200 { "ok": true }`
### POST /api/pair/start —— 生成配对码(老设备出示,P1)
- 请求头:Bearer(匿名 / 已认领档案均可)
- 请求:`{}`
- 成功:`200 { "ok": true, "code": "483920", "expiresAt": "2026-09-09T12:10:00Z" }`
- `code`:6 位数字,**10 分钟有效、单次使用**;同档案同时仅 1 个有效码,
重新生成会立即使旧码作废(单码作废 + 重生成即调用本端点)
- 码仅存服务器内存:进程重启即全部失效(老设备再点一次「生成配对码」即可)
- 失败:`401` / `429`(同 IP 当日生成数超 `SG_PAIR_LIMIT`,默认 20)
### POST /api/pair/join —— 配对码转入(新设备,P1)
- 请求:`{ "code": "483920" }`;请求头 Bearer 可选(新设备当前匿名档案 token)。
token 无效/过期时按「无档案设备」处理,不报 401。
- 成功(带 token,与 claim-merge 同语义——**新设备当前档案按桶 LWW 并入码对应的
老档案**,方向裁定:本地新进度并入老档案):
`200 { "ok": true, "merged": true, "mergedGames": 2, "profileId": "p_…", "name": "玩家一", "token": "<原 token,已改绑>", "claimed": false }`
- 成功(无 token,全新浏览器):直接为老档案签发新 token(`merged: false`)
- 成功(joiner 已是该档案):幂等返回当前 token(`merged: false`),码照常作废
- 成功即**作废该码**(单次使用),并发重复 join 靠同步删除保证原子
- 失败:
- `400`(码格式非法,不计入连错)
- `404`(码不存在 / 已过期 / 已用过——统一文案不泄露状态;**计入连错**)
- `409`(joiner 当前档案已设 PIN:请先「+ 新玩家」建匿名档案再转入;不消费码)
- `429`(同 IP 连错 5 次 → 封锁 10 分钟)
- 日志脱敏:配对码明文不进日志(与 PIN 同待遇)
### GET /api/progress —— 读进度
- 单桶(旧形状,游戏兼容,如 radio 直接读 `data`):
`GET /api/progress?game=rpg` → `200 { "game": "rpg", "data": { … }, "updatedAt": "…", "rev": 2 }`
(无存档时 `data` 为 `null`)
- 全桶(大厅用,一次拉回替代旧的 8 次串行 GET):
`GET /api/progress` → `200 { "profileId": "…", "name": "…", "games": { "rpg": { "updatedAt": "…", "rev": 2, "data": { … } }, … } }`
- 失败:`401` / `400`(game 名非法)
### PUT /api/progress —— 写进度(baseUpdatedAt LWW 守卫)
- 请求:`{ "game": "rpg", "data": { … }, "baseUpdatedAt": "<上次已知服务器桶 updatedAt>" }`
- `game`:1~32 位字母/数字/`_`/`-`;大厅约定 `_settings` 桶存用户设置(玩家级)
- `data`:任意 JSON 值原样透传(宽容收);大厅卡片展示 `data.summary`(客户端约定)
- `baseUpdatedAt` **可省略**:视为 legacy 客户端无条件放行(服务器 console 警告)——
各游戏存量 PUT 不带此字段,兼容不破
- 成功:`200 { "ok": true, "game": "rpg", "updatedAt": "<服务器时间>", "rev": 3 }`
- `updatedAt` 由**服务器时钟**打点(不信任客户端墙钟)
- `409`(LWW 冲突,携带的 baseUpdatedAt 落后于服务器桶):
`{ "error": "…", "game": "rpg", "server": { "updatedAt": "…", "rev": 4, "data": { … } } }`
——客户端应比较 `data.updatedAt` 择优后带新基线重试(大厅已实现)
## 数据格式
```
web/server/data/
├── profiles.json { "seq": 3, "profiles": { "<pid>": { "name", "pinHash", "createdAt" } } }
├── tokens.json { "<token>": { "profileId", "createdAt", "lastSeen", "expiresAt" } }
├── profiles/
│ └── <pid>.json { "id": "<pid>", "games": { "<game>": { "updatedAt", "rev", "data" } } }
├── users.json (v1 遗留,迁移源,保留不动)
├── sessions.json (v1 遗留,迁移源,保留不动)
└── uuu.json (v1 遗留进度,已迁至 profiles/uuu.json,保留不动)
```
- `pinHash`:`null`(匿名档案)或 `"scrypt$16384$8$1$<salt-hex>$<hash-hex>"`
- 一档案一进度文件,按游戏分桶;`rev` 单调递增,作 updatedAt 同值的破平局保险
- v1 → v2 启动迁移(单向,幂等跳过):`users.json` → `profiles.json`(pid=旧用户名,
PIN 立即哈希)、`sessions.json` → `tokens.json`(补 180 天过期)、
`<username>.json` → `profiles/<username>.json`(各桶补 `updatedAt`/`rev`)
所有写入按文件串行排队(Promise 链),tmp + rename 原子落盘,并发请求不会写坏文件。
**单实例约束**:两个 server.js 进程共享同一 data/ 会绕过写队列。
## 静态服务
- MIME:`.ogg audio/ogg`、`.m4a audio/mp4`、`.js text/javascript`、
`.html text/html`、`.png image/png`、`.json application/json` 等;
- 目录自动解析 `index.html`;
- 支持简化版 HTTP Range(单段 `bytes=start-end` / `start-` / `-suffix`),
返回 206 + `Content-Range`,供音频元素拖动进度;
- 路径穿越防护:请求路径限制在 `web/` 内;`web/server/` 目录(token/PIN 哈希/进度数据)一律 404。
## 测试
```bash
tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶纯函数单测(零依赖)
```
覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H6)、
配对码全流程(I1:start 规约/join 桶级 LWW 合并/单次作废/无 token 转入/幂等/日志脱敏)、
配对码防护(I2:join 5 连错封锁、start per-IP 频控)、token 过期 401 路径(J1)。