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)
This commit is contained in:
xsl
2026-09-09 23:39:23 +08:00
parent 3704cd0792
commit ede522dda1
22 changed files with 2370 additions and 275 deletions
+103 -41
View File
@@ -2,6 +2,11 @@
Node 零依赖(仅用内置模块)的静态文件 + 用户系统 API 服务器。
P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity.md` 方案 B 与
`03-data-sync.md`):首次访问 0 输入 0 点击自动建匿名档案(自动名「玩家一/二/…」),
认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、
服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。
## 启动
```bash
@@ -11,78 +16,126 @@ tools/node/bin/node web/server/server.js
# 或用封装脚本(打印局域网地址)
bash web/serve.sh
# 自定义端口(默认 8000)
PORT=8001 tools/node/bin/node web/server/server.js
# 自定义端口(默认 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/data/`(首次启动自动创建)
- 静态根目录:`web/`(`/` 即游戏大厅 `index.html`);`web/server/` 目录一律 404(内含 token 与 PIN 哈希)
- 数据目录:自动创建;检测到 v1 旧文件(`users.json`/`sessions.json`)时启动即单向迁移(原文件保留不动)
## ⚠ 安全说明
这是**家庭局域网玩具级认证**,请勿暴露到公网:
家庭局域网级认证(比之前加固了一档,但仍**请勿暴露公网**):
- PIN 以**明文**存储在 `data/users.json`;
- token 永不过期,明文存于 `data/sessions.json`;
- HTTP 明文传输,无 TLS、无防爆破、无速率限制。
用途仅限于同一 WiFi 下的家庭成员各自存档。公网 `game.xiangsilian.com` **不跑本服务**,
只托管静态文件(见 `docs/deploy.md`)。要公网开用户系统请换正经认证方案。
- 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/register
### POST /api/device —— 匿名自动建档(核心)
注册新用户。
无参(或带已固化设备的旧 token 重附着)。
- 请求:`{ "username": "ming", "pin": "1234" }`
- `username`:1~24 位字母/数字/中文/`_`/`-`
- `pin`:4~6 位数字
- 成功:`201 { "ok": true, "username": "ming" }`
- 失败:`400`(格式非法)/ `409 { "error": "用户名已被注册" }`
- 请求:`{}` 或 `{ "token": "<本机已有的 token>" }`
- 成功(新建):`201 { "ok": true, "profileId": "p_a1b2c3d4e5f6", "token": "<48 位随机 hex>", "name": "玩家一", "claimed": false }`
- `name` 由服务器按档案序号自动命名:玩家一、玩家二、玩家三……(可听词表)
- `token` 即设备身份,客户端存 localStorage,之后一切鉴权用它
- 成功(重附着):`200 { ..., "attached": true }`——幂等,不新建档案、不占频控额度
- 失败:`429`(同 IP 当日新建数超限)
### POST /api/login
大厅(`web/index.html`)首访静默调用本端点建档;纯静态公网形态(无 API)自动降级为
本地档案(`web/profiles.js` 同一套逻辑,token 为 null)。
登录,返回 token。
### GET /api/profile —— 当前档案信息
- 请求:`{ "username": "ming", "pin": "1234" }`
- 成功:`200 { "ok": true, "token": "<48 位随机 hex>", "username": "ming" }`
- 失败:`401 { "error": "用户名或 PIN 错误" }`
- 请求头:Bearer
- 成功:`200 { "profileId": "p_…", "name": "玩家一", "claimed": false, "createdAt": "…" }`
token 存内存并落盘 `data/sessions.json`(服务器重启后会话仍有效)。
### PUT /api/profile —— 改名
### GET /api/progress?game=xxx
- 请求头:Bearer;请求:`{ "name": "爸爸" }`(1~24 位字母/数字/中文/`_`/`-`)
- 成功:`200 { "ok": true, "name": "爸爸" }`
- 失败:`400`(格式非法)/ `401` / `409`(该名字已被其他**已认领**档案占用;匿名档案重名不限制)
读取某游戏的进度数据。
### POST /api/claim —— 认领(命名 + 设 PIN)/ 换设备登录(合并)
- 请求头:`Authorization: Bearer <token>`
- 成功:`200 { "game": "escape", "data": { ... } }`(无存档时 `data` 为 `null`)
- 失败:`401`(token 缺失/无效)/ `400`(缺少 game 参数)
携带当前设备 token(Bearer 或 body.token)。请求:`{ "name"?, "pin"? }`,
`pin` 为 4~6 位数字。三种语义:
### PUT /api/progress
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 }`。
写入某游戏的进度数据(覆盖该 game 键)。
- 失败:`400`(格式)/ `401`(PIN 缺失或不正确)/ `409`(当前档案已设 PIN,需先建新匿名档案)
- 请求头:`Authorization: Bearer <token>`
- 请求:`{ "game": "escape", "data": { "summary": "已逃脱 2 次 / 最快 3:25", ... } }`
- `game`:1~32 位字母/数字/`_`/`-`;大厅约定用 `_settings` 存用户设置
- `data`:任意 JSON 值;大厅卡片会展示 `data.summary` 字符串(若存在)
- 成功:`200 { "ok": true, "game": "escape" }`
- 失败:`401` / `400`
### 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/
├── users.json { "<username>": "<pin>" }
├── sessions.json { "<token>": "<username>" }
└── <username>.json { "<game>": <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/ 会绕过写队列。
## 静态服务
@@ -91,4 +144,13 @@ web/server/data/
- 目录自动解析 `index.html`;
- 支持简化版 HTTP Range(单段 `bytes=start-end` / `start-` / `-suffix`),
返回 206 + `Content-Range`,供音频元素拖动进度;
- 路径穿越防护:请求路径限制在 `web/` 内。
- 路径穿越防护:请求路径限制在 `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)。
+514 -66
View File
@@ -1,25 +1,41 @@
/**
* server.js — SoundGame 用户系统 + 静态文件服务器(Node 零依赖,仅内置模块)。
*
* 功能:
* 1. 静态托管 web/ 根目录(正确 MIME,支持简化版 Range 请求供音频拖动)。
* P0-B 无感用户系统(research/platform/01-identity.md 方案 B + 03-data-sync.md §3):
*
* 1. 静态托管 web/ 根目录(正确 MIME,简化版 Range 供音频拖动)。
* web/server/ 目录(含 data/ 的 token 与 PIN 哈希)一律 404,不对外提供。
*
* 2. JSON API:
* - POST /api/register {username, pin} 注册(用户名 + 4~6 位数字 PIN)
* - POST /api/login {username, pin} 登录,返回 token(随机 hex,
* 内存 + 落盘 data/sessions.json)
* - GET /api/progress?game=xxx 读进度(需 Authorization: Bearer token)
* - PUT /api/progress {game, data} 写进度(同上)
* - POST /api/device {token?} 匿名自动建档 → {profileId, token, name}(自动名
* 「玩家一/二/…」);带有效 token 时为重附着(幂等)
* - GET /api/profile 当前档案信息(Bearer)
* - PUT /api/profile {name} 改名(Bearer)
* - POST /api/claim {name?, pin?} 认领:给当前档案命名和/或设 PIN(scrypt 哈希)。
* 若名字+PIN 已有档案 → 验 PIN 后把当前匿名档案的
* 进度按桶 LWW 合并进目标档案并改绑 token(换设备登录)
* - POST /api/login {username, pin} 旧客户端兼容(v1 用户迁移后仍可登录)
* - POST /api/register {username, pin} 旧客户端兼容(等价于直接建已认领档案,建议改用 claim)
* - POST /api/logout 吊销当前 token(Bearer)
* - GET /api/progress?game=xxx 读单桶,保持 {game, data} 形状(radio 等游戏兼容)
* - GET /api/progress 读全部桶 {games: {...}}(大厅一次拉回,修 03 §1.2)
* - PUT /api/progress {game, data, baseUpdatedAt?}
* 写桶。baseUpdatedAt 与服务器桶不符 → 409 + 服务器桶
* (LWW 冲突,客户端择优重试);缺省视为 legacy 放行
* (console 警告——各游戏存量 PUT 不带此字段)。
*
* 数据落盘(data/ 目录自动创建):
* data/users.json { username: pin }
* data/sessions.json { token: username }
* data/<username>.json { <game>: <data> } 进度,一用户一文件
* 并发写入:按文件串行 Promise 队列。
* 3. 数据落盘(data/ 目录自动创建;env SG_DATA_DIR 可覆盖,供测试):
* data/profiles.json { seq, profiles: { <pid>: {name, pinHash, createdAt} } }
* data/tokens.json { <token>: {profileId, createdAt, lastSeen, expiresAt} }
* data/profiles/<pid>.json { id, games: { <game>: {updatedAt, rev, data} } }(桶级包装)
* v1 兼容迁移(启动时单向,原文件保留不动):
* users.json {username: pin明文} → profiles.json(PIN 立即 scrypt 哈希,pid=旧用户名)
* sessions.json {token: username} → tokens.json(补 180 天滑动过期)
* <username>.json {game: data} → profiles/<username>.json(每桶补 updatedAt/rev)
*
* 启动:node web/server/server.js (端口 env PORT,默认 8000)
*
* ⚠ 安全说明:这是家庭局域网玩具级认证——PIN 明文存储、token 不过期、
* HTTP 明文传输。请勿暴露到公网。详见 web/server/README.md。
* 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 +
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)。
* HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。
*/
const http = require('node:http');
@@ -29,10 +45,19 @@ const path = require('node:path');
const crypto = require('node:crypto');
const PORT = Number(process.env.PORT) || 8000;
const ROOT = path.resolve(__dirname, '..'); // web/
const DATA_DIR = path.join(__dirname, 'data'); // web/server/data/
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
const ROOT = path.resolve(__dirname, '..'); // web/
const DATA_DIR = process.env.SG_DATA_DIR
? path.resolve(process.env.SG_DATA_DIR)
: path.join(__dirname, 'data'); // web/server/data/
const PROFILES_DIR = path.join(DATA_DIR, 'profiles');
const PROFILES_FILE = path.join(DATA_DIR, 'profiles.json');
const TOKENS_FILE = path.join(DATA_DIR, 'tokens.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json'); // v1 兼容
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); // v1 兼容
const DEVICE_LIMIT = Number(process.env.SG_DEVICE_LIMIT) || 10; // per-IP 每日匿名建档上限
const TOKEN_TTL_MS = 180 * 24 * 3600 * 1000; // 180 天滑动
const TOKEN_TOUCH_MS = 6 * 3600 * 1000; // 滑动续期节流(6h 内不重复落盘)
const MIME = {
'.html': 'text/html; charset=utf-8',
@@ -57,13 +82,14 @@ const MIME = {
const USERNAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/;
const PIN_RE = /^\d{4,6}$/;
const TOKEN_RE = /^[0-9a-f]{16,128}$/i;
// ---------------- 状态 ----------------
/** @type {Record<string, string>} username -> pin */
let users = {};
/** @type {Record<string, string>} token -> username */
let sessions = {};
/** @type {{ seq: number, profiles: Record<string, {name: string, pinHash: string|null, createdAt: string}> }} */
let profileIndex = { seq: 0, profiles: {} };
/** @type {Record<string, {profileId: string, createdAt: string, lastSeen: string, expiresAt: string}>} */
let tokens = {};
/** 按文件串行的写入队列:file -> Promise 链尾 */
const writeQueues = new Map();
@@ -92,9 +118,169 @@ function writeJson(file, obj) {
});
}
function progressFile(username) {
// username 已被 USERNAME_RE 限制为安全字符,可直接作文件名
return path.join(DATA_DIR, `${username}.json`);
// ---------------- PIN:scrypt 哈希(node:crypto 内置,零依赖) ----------------
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
function hashPin(pin) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(pin, salt, SCRYPT.keylen, SCRYPT);
return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('hex')}$${hash.toString('hex')}`;
}
/** 恒时比较;v1 明文 PIN(不以 scrypt$ 开头)直接比对(调用方负责透明升级为哈希)。 */
function verifyPin(pin, stored) {
if (typeof stored !== 'string' || !stored || typeof pin !== 'string') return false;
if (!stored.startsWith('scrypt$')) return stored === pin;
const parts = stored.split('$');
if (parts.length !== 6) return false;
try {
const [, N, r, p, saltHex, hashHex] = parts;
const expected = Buffer.from(hashHex, 'hex');
const actual = crypto.scryptSync(pin, Buffer.from(saltHex, 'hex'), expected.length, {
N: Number(N), r: Number(r), p: Number(p),
});
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
} catch (_) {
return false;
}
}
// ---------------- 档案 / token ----------------
/** pid 兼容两种形态:新生 p_<hex> 与迁移档案(pid=旧用户名,已被 USERNAME_RE 限制为安全文件名字符)。 */
function profileGamesFile(pid) {
if (typeof pid !== 'string' || !USERNAME_RE.test(pid)) throw new Error('bad pid');
return path.join(PROFILES_DIR, `${pid}.json`);
}
async function loadGamesDoc(pid) {
const doc = await readJson(profileGamesFile(pid), null);
if (doc && typeof doc === 'object' && doc.games && typeof doc.games === 'object') return doc;
return { id: pid, games: {} };
}
/** 读-改-写整体进队列(fn 收到当前 doc,返回 null 跳过落盘)。 */
function withGamesDoc(pid, fn) {
const file = profileGamesFile(pid);
return enqueueWrite(file, async () => {
const doc = await loadGamesDoc(pid);
const mutated = await fn(doc);
if (mutated === false) return null;
const tmp = file + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(doc, null, 2));
await fsp.rename(tmp, file);
return doc;
});
}
function persistProfileIndex() {
return writeJson(PROFILES_FILE, profileIndex);
}
function cleanupTokens() {
const now = Date.now();
let removed = 0;
for (const [tok, entry] of Object.entries(tokens)) {
if (!entry || !profileIndex.profiles[entry.profileId] || Date.parse(entry.expiresAt) < now) {
delete tokens[tok];
removed += 1;
}
}
return removed;
}
function persistTokens() {
const removed = cleanupTokens();
if (removed) console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
return writeJson(TOKENS_FILE, tokens);
}
function issueToken(profileId) {
const token = crypto.randomBytes(24).toString('hex');
const now = new Date().toISOString();
tokens[token] = {
profileId,
createdAt: now,
lastSeen: now,
expiresAt: new Date(Date.now() + TOKEN_TTL_MS).toISOString(),
};
persistTokens();
return token;
}
/** Bearer 鉴权 + 滑动续期(节流落盘)。返回 {token, profileId, profile} 或 null。 */
function authProfile(req) {
const h = req.headers.authorization || '';
const m = h.match(/^Bearer\s+([0-9a-f]+)$/i);
if (!m) return null;
const token = m[1].toLowerCase();
return authByToken(token);
}
function authByToken(token) {
const entry = tokens[token];
if (!entry || !profileIndex.profiles[entry.profileId]) return null;
if (Date.parse(entry.expiresAt) < Date.now()) {
delete tokens[token];
persistTokens();
return null;
}
if (Date.now() - Date.parse(entry.lastSeen) > TOKEN_TOUCH_MS) {
entry.lastSeen = new Date().toISOString();
entry.expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
persistTokens();
}
return { token, profileId: entry.profileId, profile: profileIndex.profiles[entry.profileId] };
}
function newProfileId() {
for (;;) {
const pid = 'p_' + crypto.randomBytes(6).toString('hex');
if (!profileIndex.profiles[pid]) return pid;
}
}
/** 桶级 LWW 裁决(03 §3.4):updatedAt 新者胜,同值比 rev。 */
function newerBucket(a, b) {
if (!a) return b;
if (!b) return a;
if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b;
return (a.rev || 0) >= (b.rev || 0) ? a : b;
}
function findClaimedByName(name) {
for (const [pid, p] of Object.entries(profileIndex.profiles)) {
if (p.pinHash && p.name === name) return pid;
}
return null;
}
/** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 01 §4.2)。 */
function cnNum(n) {
const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
if (!Number.isInteger(n) || n <= 0 || n > 99) return String(n);
if (n < 10) return d[n];
const t = Math.floor(n / 10);
const u = n % 10;
return (t > 1 ? d[t] : '') + '十' + (u ? d[u] : '');
}
// ---------------- per-IP 频控(仅匿名建档) ----------------
const deviceCreations = new Map(); // ip -> {day, count}
function allowDeviceCreate(req) {
const ip = String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
const day = new Date().toISOString().slice(0, 10);
const rec = deviceCreations.get(ip);
if (!rec || rec.day !== day) {
deviceCreations.set(ip, { day, count: 1 });
return true;
}
if (rec.count >= DEVICE_LIMIT) return false;
rec.count += 1;
return true;
}
// ---------------- HTTP 工具 ----------------
@@ -127,11 +313,11 @@ function readBody(req) {
});
}
function authUser(req) {
const h = req.headers.authorization || '';
const m = h.match(/^Bearer\s+([0-9a-f]+)$/i);
if (!m) return null;
return sessions[m[1]] || null;
async function parseBody(req) {
const raw = await readBody(req);
if (!raw) return {};
const obj = JSON.parse(raw);
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
}
// ---------------- API ----------------
@@ -139,63 +325,247 @@ function authUser(req) {
async function handleApi(req, res, url) {
const route = `${req.method} ${url.pathname}`;
// ---- 匿名自动建档 / 重附着 ----
if (route === 'POST /api/device') {
const body = await parseBody(req);
if (body.token !== undefined) {
if (typeof body.token !== 'string' || !TOKEN_RE.test(body.token)) {
return sendJson(res, 400, { error: 'token 格式非法' });
}
const auth = authByToken(body.token.toLowerCase());
if (auth) {
return sendJson(res, 200, {
ok: true, attached: true, profileId: auth.profileId, token: auth.token,
name: auth.profile.name, claimed: !!auth.profile.pinHash,
});
}
// 失效 token:视作无参,重新建档(下方统一处理)
}
if (!allowDeviceCreate(req)) {
return sendJson(res, 429, { error: `本机今日匿名档案创建数已达上限(${DEVICE_LIMIT})` });
}
const pid = newProfileId();
profileIndex.seq += 1;
const profile = {
name: '玩家' + cnNum(profileIndex.seq),
pinHash: null,
createdAt: new Date().toISOString(),
};
profileIndex.profiles[pid] = profile;
await persistProfileIndex();
const token = issueToken(pid);
return sendJson(res, 201, { ok: true, profileId: pid, token, name: profile.name, claimed: false });
}
// ---- 档案信息 / 改名 ----
if (route === 'GET /api/profile') {
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const p = auth.profile;
return sendJson(res, 200, {
profileId: auth.profileId, name: p.name, claimed: !!p.pinHash, createdAt: p.createdAt,
});
}
if (route === 'PUT /api/profile') {
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { name } = await parseBody(req);
if (typeof name !== 'string' || !USERNAME_RE.test(name)) {
return sendJson(res, 400, { error: '名字需为 1~24 位字母/数字/中文/_/-' });
}
const holder = findClaimedByName(name);
if (holder && holder !== auth.profileId) {
return sendJson(res, 409, { error: '该名字已被其他已认领档案使用' });
}
auth.profile.name = name;
await persistProfileIndex();
return sendJson(res, 200, { ok: true, name });
}
// ---- 认领:命名 / 设 PIN / 名字+PIN 登录(合并) ----
if (route === 'POST /api/claim') {
const body = await parseBody(req);
const auth = authProfile(req) || (typeof body.token === 'string' && TOKEN_RE.test(body.token)
? authByToken(body.token.toLowerCase())
: null);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { name, pin } = body;
if (pin !== undefined && pin !== null && (typeof pin !== 'string' || !PIN_RE.test(pin))) {
return sendJson(res, 400, { error: 'PIN 需为 4~6 位数字' });
}
const profile = auth.profile;
// 只设 PIN(不改名):当前档案升级为可跨设备登录
if (name === undefined || name === null || name === '') {
if (!pin) return sendJson(res, 400, { error: '缺少 name 或 pin' });
profile.pinHash = hashPin(pin);
await persistProfileIndex();
return sendJson(res, 200, { ok: true, profileId: auth.profileId, name: profile.name, claimed: true });
}
if (typeof name !== 'string' || !USERNAME_RE.test(name)) {
return sendJson(res, 400, { error: '名字需为 1~24 位字母/数字/中文/_/-' });
}
const targetPid = findClaimedByName(name);
// 目标不存在(或就是自己):原地命名 + 设 PIN,进度零迁移
if (!targetPid || targetPid === auth.profileId) {
profile.name = name;
if (pin) profile.pinHash = hashPin(pin);
await persistProfileIndex();
return sendJson(res, 200, { ok: true, profileId: auth.profileId, name, claimed: !!profile.pinHash });
}
// 目标已存在:验 PIN → 当前匿名档案按桶 LWW 合并进目标 → token 改绑
if (profile.pinHash) {
return sendJson(res, 409, { error: '当前档案已设 PIN;请先用「新玩家」建匿名档案再登录' });
}
if (!pin) return sendJson(res, 401, { error: `「${name}」已设 PIN,请输入 PIN 登录` });
const target = profileIndex.profiles[targetPid];
if (!verifyPin(pin, target.pinHash)) {
return sendJson(res, 401, { error: 'PIN 不正确' });
}
if (!target.pinHash.startsWith('scrypt$')) {
target.pinHash = hashPin(pin); // v1 明文透明升级(迁移漏网兜底)
}
const srcPid = auth.profileId;
let mergedGames = 0;
await withGamesDoc(targetPid, (dst) => {
// 源/目标两文件各自串行;合并按桶 LWW(newerBucket),重复执行幂等
return loadGamesDoc(srcPid).then((src) => {
for (const [game, bucket] of Object.entries(src.games)) {
const cur = dst.games[game];
if (!cur || newerBucket(cur, bucket) !== cur) {
dst.games[game] = bucket;
mergedGames += 1;
}
}
});
});
tokens[auth.token].profileId = targetPid; // token 改绑目标档案
await persistTokens();
delete profileIndex.profiles[srcPid]; // 匿名源档案出索引(其 games 文件成孤儿,惰性清理见下)
await persistProfileIndex();
enqueueWrite(profileGamesFile(srcPid), async () => {
await fsp.unlink(profileGamesFile(srcPid)).catch(() => {});
});
console.log(`[server] claim 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新)`);
return sendJson(res, 200, {
ok: true, merged: true, mergedGames, profileId: targetPid, name: target.name, claimed: true,
});
}
// ---- 旧客户端兼容:登录(v1 用户名+PIN,映射到档案) ----
if (route === 'POST /api/login') {
const { username, pin } = await parseBody(req);
const pid = typeof username === 'string' ? findClaimedByName(username) : null;
if (!pid || typeof pin !== 'string' || !verifyPin(pin, profileIndex.profiles[pid].pinHash)) {
return sendJson(res, 401, { error: '用户名或 PIN 错误' });
}
const profile = profileIndex.profiles[pid];
if (!profile.pinHash.startsWith('scrypt$')) {
profile.pinHash = hashPin(pin); // 明文透明升级
await persistProfileIndex();
}
const token = issueToken(pid);
return sendJson(res, 200, { ok: true, token, username: profile.name, profileId: pid, name: profile.name });
}
// ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ----
if (route === 'POST /api/register') {
const { username, pin } = JSON.parse(await readBody(req));
const { username, pin } = await parseBody(req);
if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
return sendJson(res, 400, { error: '用户名需为 1~24 位字母/数字/中文/_/-' });
}
if (typeof pin !== 'string' || !PIN_RE.test(pin)) {
return sendJson(res, 400, { error: 'PIN 需为 4~6 位数字' });
}
if (users[username] !== undefined) {
if (findClaimedByName(username)) {
return sendJson(res, 409, { error: '用户名已被注册' });
}
users[username] = pin;
await writeJson(USERS_FILE, users);
return sendJson(res, 201, { ok: true, username });
const pid = newProfileId();
profileIndex.profiles[pid] = { name: username, pinHash: hashPin(pin), createdAt: new Date().toISOString() };
profileIndex.seq += 1;
await persistProfileIndex();
const token = issueToken(pid);
return sendJson(res, 201, { ok: true, username, profileId: pid, token });
}
if (route === 'POST /api/login') {
const { username, pin } = JSON.parse(await readBody(req));
if (typeof username !== 'string' || typeof pin !== 'string'
|| users[username] === undefined || users[username] !== pin) {
return sendJson(res, 401, { error: '用户名或 PIN 错误' });
}
const token = crypto.randomBytes(24).toString('hex');
sessions[token] = username;
await writeJson(SESSIONS_FILE, sessions);
return sendJson(res, 200, { ok: true, token, username });
// ---- 登出(吊销当前 token) ----
if (route === 'POST /api/logout') {
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
delete tokens[auth.token];
await persistTokens();
return sendJson(res, 200, { ok: true });
}
// ---- 进度:读单桶 / 读全桶 ----
if (route === 'GET /api/progress') {
const username = authUser(req);
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const game = url.searchParams.get('game');
if (!game) return sendJson(res, 400, { error: '缺少 game 参数' });
const all = await readJson(progressFile(username), {});
return sendJson(res, 200, { game, data: all[game] ?? null });
const doc = await loadGamesDoc(auth.profileId);
if (!game) {
// 全桶一次拉回(大厅用;03 §3.3——替代旧的 8 次串行 GET)
const games = {};
for (const [g, bucket] of Object.entries(doc.games)) {
games[g] = { updatedAt: bucket.updatedAt, rev: bucket.rev, data: bucket.data };
}
return sendJson(res, 200, { profileId: auth.profileId, name: auth.profile.name, games });
}
if (!/^[A-Za-z0-9_\-]{1,32}$/.test(game)) {
return sendJson(res, 400, { error: 'game 需为 1~32 位字母/数字/_/-' });
}
const bucket = doc.games[game];
// 形状兼容旧客户端:{game, data}(radio 直接读 j.data)
return sendJson(res, 200, {
game,
data: bucket ? bucket.data : null,
updatedAt: bucket ? bucket.updatedAt : null,
rev: bucket ? bucket.rev : 0,
});
}
// ---- 进度:写桶(baseUpdatedAt LWW 守卫) ----
if (route === 'PUT /api/progress') {
const username = authUser(req);
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { game, data } = JSON.parse(await readBody(req));
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { game, data, baseUpdatedAt } = await parseBody(req);
if (typeof game !== 'string' || !/^[A-Za-z0-9_\-]{1,32}$/.test(game)) {
return sendJson(res, 400, { error: 'game 需为 1~32 位字母/数字/_/-' });
}
if (data === undefined) {
return sendJson(res, 400, { error: '缺少 data 字段' });
}
const file = progressFile(username);
// 读-改-写整体进队列,避免并发写互相覆盖
await enqueueWrite(file, async () => {
const all = await readJson(file, {});
all[game] = data;
const tmp = file + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(all, null, 2));
await fsp.rename(tmp, file);
if (baseUpdatedAt !== undefined && typeof baseUpdatedAt !== 'string') {
return sendJson(res, 400, { error: 'baseUpdatedAt 需为字符串(上次已知服务器桶 updatedAt)' });
}
const pid = auth.profileId;
let conflict = null;
let written = null;
await withGamesDoc(pid, (doc) => {
const bucket = doc.games[game];
if (baseUpdatedAt !== undefined && bucket && bucket.updatedAt !== baseUpdatedAt) {
conflict = bucket; // 409 在队列外返回(响应不能在队列回调里写两次)
return false;
}
if (baseUpdatedAt === undefined) {
console.warn(`[server] legacy PUT(缺 baseUpdatedAt,放行): pid=${pid} game=${game}`);
}
const now = new Date().toISOString(); // 服务器时钟裁决(03 §3.1)
doc.games[game] = { updatedAt: now, rev: (bucket ? bucket.rev || 0 : 0) + 1, data };
written = doc.games[game];
});
return sendJson(res, 200, { ok: true, game });
if (conflict) {
return sendJson(res, 409, {
error: '进度已被其他设备更新(LWW 冲突),请择优后重试',
game,
server: { updatedAt: conflict.updatedAt, rev: conflict.rev, data: conflict.data },
});
}
return sendJson(res, 200, { ok: true, game, updatedAt: written.updatedAt, rev: written.rev });
}
return sendJson(res, 404, { error: '未知 API 端点' });
@@ -215,6 +585,12 @@ async function serveStatic(req, res, url) {
res.writeHead(403).end('Forbidden');
return;
}
// web/server/(token / PIN 哈希 / 进度数据)一律不对外提供(规范化后判定,防穿越变体)
const serverDir = path.join(ROOT, 'server') + path.sep;
if (filePath.startsWith(serverDir)) {
res.writeHead(404).end('Not Found');
return;
}
let stat;
try {
@@ -269,12 +645,83 @@ async function serveStatic(req, res, url) {
fs.createReadStream(filePath).pipe(res);
}
// ---------------- v1 → v2 迁移(启动时,单向,原文件保留) ----------------
async function migrate() {
await fsp.mkdir(PROFILES_DIR, { recursive: true });
const now = new Date().toISOString();
// users.json(v1 {username: pin明文})→ profiles.json(pid=旧用户名,PIN 立即哈希)
if (!fs.existsSync(PROFILES_FILE)) {
const idx = { seq: 0, profiles: {} };
const oldUsers = await readJson(USERS_FILE, null);
let migrated = 0;
if (oldUsers && typeof oldUsers === 'object') {
for (const [name, pin] of Object.entries(oldUsers)) {
if (typeof name !== 'string' || !USERNAME_RE.test(name)) continue;
idx.profiles[name] = {
name,
pinHash: typeof pin === 'string' && pin.startsWith('scrypt$') ? pin : hashPin(String(pin)),
createdAt: now,
};
idx.seq += 1;
// 旧进度文件 <username>.json(扁平 {game: data})→ profiles/<username>.json 桶包装
const oldProg = await readJson(path.join(DATA_DIR, `${name}.json`), null);
const games = {};
if (oldProg && typeof oldProg === 'object') {
for (const [g, d] of Object.entries(oldProg)) {
if (typeof g === 'string' && /^[A-Za-z0-9_\-]{1,32}$/.test(g)) {
games[g] = { updatedAt: now, rev: 1, data: d };
}
}
}
await writeJson(profileGamesFile(name), { id: name, games });
migrated += 1;
}
}
await writeJson(PROFILES_FILE, idx);
if (migrated > 0) {
console.log(`[server] v1 迁移: users.json → profiles.json(${migrated} 个档案,PIN 已 scrypt 哈希;原文件保留不动)`);
}
}
profileIndex = await readJson(PROFILES_FILE, { seq: 0, profiles: {} });
if (!profileIndex || typeof profileIndex !== 'object' || !profileIndex.profiles) {
profileIndex = { seq: 0, profiles: {} };
}
profileIndex.seq = Number(profileIndex.seq) || 0;
// sessions.json(v1 {token: username})→ tokens.json(补 180 天滑动过期)
if (!fs.existsSync(TOKENS_FILE)) {
const migratedTokens = {};
const oldSessions = await readJson(SESSIONS_FILE, null);
let n = 0;
if (oldSessions && typeof oldSessions === 'object') {
for (const [tok, user] of Object.entries(oldSessions)) {
if (typeof tok === 'string' && TOKEN_RE.test(tok) && profileIndex.profiles[user]) {
migratedTokens[tok.toLowerCase()] = {
profileId: user, createdAt: now, lastSeen: now,
expiresAt: new Date(Date.now() + TOKEN_TTL_MS).toISOString(),
};
n += 1;
}
}
}
await writeJson(TOKENS_FILE, migratedTokens);
if (n > 0) console.log(`[server] v1 迁移: sessions.json → tokens.json(${n} 条 token 补 180 天过期;原文件保留不动)`);
}
tokens = await readJson(TOKENS_FILE, {});
}
// ---------------- 启动 ----------------
async function main() {
await fsp.mkdir(DATA_DIR, { recursive: true });
users = await readJson(USERS_FILE, {});
sessions = await readJson(SESSIONS_FILE, {});
await migrate();
const removed = cleanupTokens();
if (removed) {
console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
await persistTokens();
}
const server = http.createServer(async (req, res) => {
try {
@@ -301,6 +748,7 @@ async function main() {
console.log(`[server] SoundGame 服务器已启动: http://localhost:${PORT}/`);
console.log(`[server] 静态根目录: ${ROOT}`);
console.log(`[server] 数据目录: ${DATA_DIR}`);
console.log(`[server] 档案数: ${Object.keys(profileIndex.profiles).length},token 数: ${Object.keys(tokens).length}`);
});
}
+425
View File
@@ -0,0 +1,425 @@
/**
* api-smoke.mjs — 无感用户系统(P0-B)零依赖冒烟测试。
*
* 覆盖(完成标准第 1 条):
* A. POST /api/device 匿名建档(自动名玩家一/二…)与 token 重附着
* B. 进度 PUT-GET-LWW:legacy 放行、baseUpdatedAt 守卫 409、全桶 GET
* C. claim 命名/设 PIN、改名、PIN-only 认领
* D. claim-merge(换设备登录):旧档案与新设备档案按桶 LWW 合并、token 改绑
* E. 旧客户端 login/register 兼容 + logout 吊销
* F. v1 迁移:users.json/sessions.json/<username>.json → profiles.json/tokens.json/
* profiles/<pid>.json(PIN 明文即刻 scrypt 哈希、旧 token 继续有效)
* G. per-IP 匿名建档频控(429)与 /server/ 静态目录封禁(数据不外泄)
* H. 大厅 localStorage 分桶逻辑单测(web/profiles.js 纯函数,Map 版 storage)
*
* 运行:tools/node/bin/node web/server/test/api-smoke.mjs(或任意 node ≥18)
* 全部通过退出码 0;任一失败抛异常退出非 0。测试数据全部写进临时目录。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BUCKETS, SETTINGS_KEY,
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, restoreSnapshot, setCurrentProfile,
snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry,
} from '../../profiles.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVER = path.join(HERE, '..', 'server.js');
const NODE = process.execPath;
// ---------------- 服务器实例管理 ----------------
function tmpDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'soundgame-smoke-'));
}
async function startServer({ dataDir = tmpDataDir(), env = {} } = {}) {
const port = 20000 + Math.floor(Math.random() * 20000);
const child = spawn(NODE, [SERVER], {
env: { ...process.env, PORT: String(port), SG_DATA_DIR: dataDir, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
const logs = [];
child.stdout.on('data', (c) => logs.push(String(c)));
child.stderr.on('data', (c) => logs.push(String(c)));
const base = `http://127.0.0.1:${port}`;
const deadline = Date.now() + 15000;
for (;;) {
if (child.exitCode !== null) throw new Error(`服务器提前退出:\n${logs.join('')}`);
if (Date.now() > deadline) throw new Error('服务器启动超时');
try {
const r = await fetch(base + '/');
if (r.ok) break;
} catch (_) { /* 尚未监听 */ }
await new Promise((r) => setTimeout(r, 100));
}
return {
base,
dataDir,
logs,
stop() {
return new Promise((resolve) => {
child.on('exit', resolve);
child.kill('SIGTERM');
setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} resolve(); }, 2000);
});
},
};
}
async function call(base, method, apiPath, { token, body } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const resp = await fetch(base + apiPath, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await resp.json().catch(() => ({}));
return { status: resp.status, json };
}
// ---------------- H. 大厅分桶逻辑单测(纯函数,无需服务器) ----------------
test('H1. 快照回灌/倒灌:玩家一/玩家二进度互不污染', () => {
const storage = mockStorage();
// 玩家一在线:rpg 进度 + 设置
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
storage.setItem(SETTINGS_KEY, JSON.stringify({ voice: 'male', speed: 1.7 }));
storage.setItem('soundgame.rpg.tutIntroSeen', '1'); // 教学标记:不入桶(跨档案共享,01 §4.6)
writeRegistry(storage, [
{ id: 'p_aaa', name: '玩家一', token: 't1' },
{ id: 'p_bbb', name: '玩家二', token: 't2' },
]);
setCurrentProfile(storage, 'p_aaa');
// 切走:回灌玩家一
stashSnapshot(storage, 'p_aaa');
assert.equal(storage.getItem('soundgame.progress.rpg'), JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
assert.ok(!JSON.parse(storage.getItem(snapshotKeyOf('p_aaa'))).hasOwnProperty('soundgame.rpg.tutIntroSeen'), '教学标记不应入快照');
// 切入玩家二:倒灌前先清空活动 key → 无残留串档
restoreSnapshot(storage, 'p_bbb');
assert.equal(storage.getItem('soundgame.progress.rpg'), null, '切到空档案必须清掉玩家一的 rpg 存档');
assert.equal(storage.getItem(SETTINGS_KEY), null, '设置桶同样隔离');
assert.equal(storage.getItem('soundgame.rpg.tutIntroSeen'), '1', '教学标记保留(跨档案共享是既定行为)');
// 玩家二游玩后切回玩家一:进度原样回归
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T11:00:00Z', summary: '新手', lv: 1 }));
stashSnapshot(storage, 'p_bbb');
restoreSnapshot(storage, 'p_aaa');
assert.deepEqual(JSON.parse(storage.getItem('soundgame.progress.rpg')), { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 });
});
test('H2. decideBucket 桶级 LWW 裁决', () => {
const newer = { updatedAt: '2026-09-09T12:00:00Z', rev: 3, data: { updatedAt: '2026-09-09T12:00:00Z', summary: 'new' } };
const older = { updatedAt: '2026-09-09T11:00:00Z', rev: 2, data: { updatedAt: '2026-09-09T11:00:00Z', summary: 'old' } };
// 服务器缺 → 本地上云
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', null), 'push-local');
assert.equal(decideBucket(null, null), 'keep');
// 本地缺 → 服务器倒灌(修「7/8 游戏只 PUT 不 GET」由大厅统一做)
assert.equal(decideBucket(null, older), 'take-server');
// 两边都有:data.updatedAt 新者胜
assert.equal(decideBucket('{"updatedAt":"2026-09-09T12:00:00Z"}', older), 'push-local');
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', older), 'take-server');
assert.equal(decideBucket('{"updatedAt":"2026-09-09T11:00:00Z"}', older), 'keep');
// 无时间戳(settings / simon 最高分):内容等价 keep,否则服务器胜(对齐旧「登录拉设置」行为)
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'male' } }), 'keep');
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'female' } }), 'take-server');
assert.equal(decideBucket('5', { updatedAt: 'x', rev: 1, data: 5 }), 'keep'); // simon.best 标量
// 本地损坏 → 服务器兜底
assert.equal(decideBucket('{broken', older), 'take-server');
});
test('H3. 注册表 / 旧登录态收养 / 本地自动名 / 游戏侧 auth 视图', () => {
const storage = mockStorage();
assert.deepEqual(readRegistry(storage), []);
assert.equal(getCurrentProfile(storage), null);
assert.equal(nextLocalProfileName([]), '玩家一');
assert.equal(nextLocalProfileName([{ id: 'a', name: 'x' }, { id: 'b', name: 'y' }]), '玩家三');
// v1 旧登录态识别
storage.setItem('soundgame.auth', JSON.stringify({ username: 'uuu', token: 'ab'.repeat(24) }));
const legacy = migrateLegacyAuth(storage);
assert.equal(legacy.username, 'uuu');
assert.equal(migrateLegacyAuth(mockStorage()), null);
// 有 token → 游戏兼容视图 {username, token};无 token(纯本地档案)→ 移除
applyAuth(storage, { id: 'p1', name: '玩家一', token: 'tk' });
assert.deepEqual(JSON.parse(storage.getItem('soundgame.auth')), { username: '玩家一', token: 'tk' });
applyAuth(storage, { id: 'L1', name: '玩家二', token: null });
assert.equal(storage.getItem('soundgame.auth'), null);
});
test('H4. 受管桶清单覆盖 8 个可同步游戏 + _settings', () => {
const games = BUCKETS.map((b) => b.game).sort();
assert.deepEqual(games, ['_settings', 'dungeon', 'escape', 'radio', 'rhythm', 'rpg', 'simon', 'space']);
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
});
function mockStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
_map: m,
};
}
// ---------------- A-G. API 冒烟(共享一个服务器实例的顺序场景) ----------------
test('A-G. API 全链路(device → progress LWW → claim → 合并 → 兼容 → 防护)', async () => {
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '100' } });
try {
// ---- A. 匿名建档 ----
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 201);
const A = { profileId: r.json.profileId, token: r.json.token };
assert.match(A.profileId, /^p_[0-9a-f]{12}$/);
assert.match(A.token, /^[0-9a-f]{48}$/);
assert.equal(r.json.name, '玩家一');
assert.equal(r.json.claimed, false);
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const B = { profileId: r.json.profileId, token: r.json.token };
assert.equal(r.json.name, '玩家二');
// token 重附着(幂等,不新建档案、不消耗 seq)
r = await call(srv.base, 'POST', '/api/device', { body: { token: A.token } });
assert.equal(r.status, 200);
assert.equal(r.json.attached, true);
assert.equal(r.json.profileId, A.profileId);
r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.json.name, '玩家三');
// ---- B. 进度 PUT-GET-LWW ----
const rpgSave = { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 };
// legacy PUT(无 baseUpdatedAt,存量游戏路径)→ 放行
r = await call(srv.base, 'PUT', '/api/progress', { token: A.token, body: { game: 'rpg', data: rpgSave } });
assert.equal(r.status, 200);
assert.ok(r.json.updatedAt && r.json.rev === 1);
// 带 baseUpdatedAt 的守卫写
r = await call(srv.base, 'PUT', '/api/progress', {
token: A.token,
body: { game: 'rpg', data: { ...rpgSave, lv: 8 }, baseUpdatedAt: r.json.updatedAt },
});
assert.equal(r.status, 200);
assert.equal(r.json.rev, 2);
const aRpgAt = r.json.updatedAt;
// 过期基线 → 409 + 服务器桶(客户端择优重试用)
r = await call(srv.base, 'PUT', '/api/progress', {
token: A.token,
body: { game: 'rpg', data: { ...rpgSave, lv: 1 }, baseUpdatedAt: '2000-01-01T00:00:00.000Z' },
});
assert.equal(r.status, 409);
assert.equal(r.json.server.data.lv, 8, '409 应携带服务器当前桶');
assert.equal(r.json.server.updatedAt, aRpgAt);
// 单桶 GET:形状兼容旧客户端(radio 直接读 j.data)
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: A.token });
assert.equal(r.status, 200);
assert.equal(r.json.game, 'rpg');
assert.equal(r.json.data.lv, 8);
assert.equal(r.json.updatedAt, aRpgAt);
// 全桶 GET(大厅一次拉回)
r = await call(srv.base, 'GET', '/api/progress', { token: A.token });
assert.equal(r.status, 200);
assert.ok(r.json.games.rpg && r.json.games.rpg.data.lv === 8);
assert.ok(r.json.games._settings === undefined || r.json.games._settings.data !== undefined);
// 未写过 radio → GET 单桶 data 为 null(radio 新设备开局路径)
r = await call(srv.base, 'GET', '/api/progress?game=radio', { token: A.token });
assert.equal(r.json.data, null);
// 未授权
r = await call(srv.base, 'GET', '/api/progress?game=rpg');
assert.equal(r.status, 401);
// ---- C. claim:命名 + 设 PIN ----
r = await call(srv.base, 'POST', '/api/claim', { token: A.token, body: { name: '爸爸', pin: '1234' } });
assert.equal(r.status, 200);
assert.equal(r.json.profileId, A.profileId);
assert.equal(r.json.name, '爸爸');
assert.equal(r.json.claimed, true);
r = await call(srv.base, 'GET', '/api/profile', { token: A.token });
assert.equal(r.json.name, '爸爸');
assert.equal(r.json.claimed, true);
// 改名(PUT profile)
r = await call(srv.base, 'PUT', '/api/profile', { token: A.token, body: { name: '妈妈' } });
assert.equal(r.status, 200);
// 已认领名字被抢注 → 409
r = await call(srv.base, 'PUT', '/api/profile', { token: B.token, body: { name: '妈妈' } });
assert.equal(r.status, 409);
// PIN-only 认领(给当前档案补设 PIN,不改名)
r = await call(srv.base, 'POST', '/api/claim', { token: B.token, body: { pin: '5678' } });
assert.equal(r.status, 200);
r = await call(srv.base, 'GET', '/api/profile', { token: B.token });
assert.equal(r.json.claimed, true);
// ---- D. claim-merge:换设备登录,按桶 LWW 合并 ----
// 设备 C(新设备匿名档案):dungeon 桶 + 比妈妈更新的 rpg 桶
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const C = { profileId: r.json.profileId, token: r.json.token };
r = await call(srv.base, 'PUT', '/api/progress', {
token: C.token,
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
});
assert.equal(r.status, 200);
const cDungeonAt = r.json.updatedAt;
await new Promise((res) => setTimeout(res, 20)); // 服务器时钟至少推进
const cRpg = { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 };
r = await call(srv.base, 'PUT', '/api/progress', { token: C.token, body: { game: 'rpg', data: cRpg } });
assert.equal(r.status, 200);
// 用「妈妈 + 1234」在设备 C 登录 → 匿名档案 C 合并进妈妈的档案,token 改绑
r = await call(srv.base, 'POST', '/api/claim', { token: C.token, body: { name: '妈妈', pin: '1234' } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, true);
assert.equal(r.json.profileId, A.profileId, 'C 的 token 应改绑到妈妈的档案');
const CTOKEN = C.token; // token 不变,指向已变
r = await call(srv.base, 'GET', '/api/progress', { token: CTOKEN });
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:C 的 rpg 更新,应胜出');
assert.equal(r.json.games.dungeon.data.floor, 3, '合并应带入 C 独有的 dungeon 桶');
// 旧 token(妈妈原设备 A)不受影响,且两端看到同一档案
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: A.token });
assert.equal(r.json.data.floor, 3);
// 错误 PIN → 401
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const D = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈', pin: '0000' } });
assert.equal(r.status, 401);
// 无 PIN 直接抢已认领名 → 401
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈' } });
assert.equal(r.status, 401);
// ---- E. 旧客户端兼容:register / login / logout ----
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
assert.equal(r.status, 201);
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
assert.equal(r.status, 409);
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '9999' } });
assert.equal(r.status, 401);
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '4321' } });
assert.equal(r.status, 200);
const oldToken = r.json.token;
assert.equal(r.json.username, '老王');
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
assert.equal(r.json.name, '老王');
// logout 吊销
r = await call(srv.base, 'POST', '/api/logout', { token: oldToken });
assert.equal(r.status, 200);
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
assert.equal(r.status, 401);
// ---- G1. /server/ 数据目录静态封禁 ----
let resp = await fetch(srv.base + '/server/data/profiles.json');
assert.equal(resp.status, 404, 'profiles.json 不应可被静态读取');
resp = await fetch(srv.base + '/x/../server/data/tokens.json');
assert.ok(resp.status === 404 || resp.status === 403, '穿越变体也应被拦');
resp = await fetch(srv.base + '/');
assert.equal(resp.status, 200, '大厅不受影响');
// 落盘检查:PIN 哈希而非明文
const idx = JSON.parse(fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8'));
for (const p of Object.values(idx.profiles)) {
assert.ok(p.pinHash === null || p.pinHash.startsWith('scrypt$'), 'PIN 必须以 scrypt$ 哈希存储');
}
assert.ok(!fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8').includes('"1234"'));
} finally {
await srv.stop();
}
});
test('F. v1 迁移:users.json/sessions.json/<username>.json → 档案分桶(旧用户无感)', async () => {
const dataDir = tmpDataDir();
const oldToken = '7c20d0f3534a31893157529e1f71c7e2c28e26fd07c8d8fc'; // 现网实测形状
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ uuu: '0858' }, null, 2));
fs.writeFileSync(path.join(dataDir, 'sessions.json'), JSON.stringify({ [oldToken]: 'uuu' }, null, 2));
fs.writeFileSync(path.join(dataDir, 'uuu.json'), JSON.stringify({
_settings: { voice: 'male', speed: 1, bgm: 1, guide: 'full' },
rpg: { schema: 1, summary: '第一章·Lv3', lv: 3 },
}, null, 2));
const srv = await startServer({ dataDir });
try {
// 旧 token 继续有效(迁移进 tokens.json,补 180 天过期)
let r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: oldToken });
assert.equal(r.status, 200);
assert.equal(r.json.data.lv, 3, '旧进度数据应完整保留(桶包装、data 透传)');
r = await call(srv.base, 'GET', '/api/progress?game=_settings', { token: oldToken });
assert.equal(r.json.data.voice, 'male');
// 旧客户端 用户名+PIN 登录兼容(映射到档案)
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0000' } });
assert.equal(r.status, 401);
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0858' } });
assert.equal(r.status, 200);
assert.equal(r.json.profileId, 'uuu', 'pid 沿用旧用户名(进度文件零改名)');
const t2 = r.json.token;
r = await call(srv.base, 'GET', '/api/progress', { token: t2 });
assert.equal(r.json.games.rpg.data.lv, 3);
// 新设备用 claim 登录旧档案 → 合并路径可用(匿名档案进度并入 uuu)
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const dev = r.json;
r = await call(srv.base, 'PUT', '/api/progress', {
token: dev.token,
body: { game: 'dungeon', data: { schema: 1, summary: '第 2 层', floor: 2 } },
});
assert.equal(r.status, 200);
r = await call(srv.base, 'POST', '/api/claim', { token: dev.token, body: { name: 'uuu', pin: '0858' } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, true);
r = await call(srv.base, 'GET', '/api/progress', { token: dev.token });
assert.equal(r.json.games.rpg.data.lv, 3, 'uuu 旧进度在合并后保留');
assert.equal(r.json.games.dungeon.data.floor, 2, '新设备进度并入');
// 迁移落盘断言:PIN 已哈希、原 v1 文件保留不动
const idxRaw = fs.readFileSync(path.join(dataDir, 'profiles.json'), 'utf8');
assert.ok(idxRaw.includes('scrypt$'), '迁移后 PIN 应为 scrypt 哈希');
assert.ok(!idxRaw.includes('0858'), '明文 PIN 不应出现在 profiles.json');
assert.equal(JSON.parse(fs.readFileSync(path.join(dataDir, 'users.json'), 'utf8')).uuu, '0858', 'v1 原文件保留');
assert.ok(fs.existsSync(path.join(dataDir, 'profiles', 'uuu.json')), '进度迁至 profiles/uuu.json');
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
assert.ok(tokensDisk[oldToken] && tokensDisk[oldToken].profileId === 'uuu');
assert.ok(Date.parse(tokensDisk[oldToken].expiresAt) > Date.now(), '旧 token 补 180 天过期');
} finally {
await srv.stop();
}
});
test('G2. per-IP 匿名建档频控', async () => {
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '2' } });
try {
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 201);
const firstToken = r.json.token;
r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 201);
r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 429);
// 重附着(带有效 token)不受频控影响
r = await call(srv.base, 'POST', '/api/device', { body: { token: firstToken } });
assert.equal(r.status, 200);
assert.equal(r.json.attached, true);
} finally {
await srv.stop();
}
});