Files
SoundGame/web/server/README.md
T
xsl ede522dda1 feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)
用户系统(B,游戏零改动):
- POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩
- 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听)
- claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁
- PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET)
- web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4)

键盘双端(A,游戏代码零改动):
- gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault
- settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始)
- 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示
- live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言

回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过

P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
2026-09-09 23:39:23 +08:00

157 lines
8.6 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 个游戏目录零改动——分桶/倒灌发生在大厅层。
## 启动
```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` 覆盖);
- 仍是 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" }`
- v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。
### POST /api/register —— 旧客户端兼容(建议改用 device + claim)
- 请求:`{ "username": "ming", "pin": "1234" }` → `201 { "ok": true, "username": "ming", "profileId": "…", "token": "…" }`
- 直接创建已认领档案(不合并当前匿名档案的进度)。
### POST /api/logout —— 吊销当前 token
- 请求头:Bearer;成功:`200 { "ok": true }`
### 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-H4)。