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 在途)
This commit is contained in:
+39
-2
@@ -7,6 +7,10 @@ P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity.
|
||||
认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、
|
||||
服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。
|
||||
|
||||
P1 增补**配对码跨设备**(`01 §4.2` 旅程 3):老设备在「档案管理 → 转移到新设备」
|
||||
生成 6 位配对码(逐位朗读),新设备输入即可把本机进度按桶 LWW 合并进老档案;
|
||||
token 180 天滑动过期,到期后大厅引导「重新登录或新建」。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
@@ -30,6 +34,9 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
|
||||
- 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`「以后若要开放用户系统」。
|
||||
|
||||
@@ -81,8 +88,9 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
|
||||
### POST /api/login —— 旧客户端兼容
|
||||
|
||||
- 请求:`{ "username": "uuu", "pin": "0858" }`
|
||||
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu" }`
|
||||
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu", "claimed": true }`
|
||||
- v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。
|
||||
大厅在 token 180 天过期后的「名字 + PIN 重新登录」路径也走本端点(无需有效 token)。
|
||||
|
||||
### POST /api/register —— 旧客户端兼容(建议改用 device + claim)
|
||||
|
||||
@@ -93,6 +101,33 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
|
||||
|
||||
- 请求头: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`):
|
||||
@@ -153,4 +188,6 @@ tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶
|
||||
```
|
||||
|
||||
覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/
|
||||
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H4)。
|
||||
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H6)、
|
||||
配对码全流程(I1:start 规约/join 桶级 LWW 合并/单次作废/无 token 转入/幂等/日志脱敏)、
|
||||
配对码防护(I2:join 5 连错封锁、start per-IP 频控)、token 过期 401 路径(J1)。
|
||||
|
||||
+200
-21
@@ -17,6 +17,14 @@
|
||||
* - POST /api/login {username, pin} 旧客户端兼容(v1 用户迁移后仍可登录)
|
||||
* - POST /api/register {username, pin} 旧客户端兼容(等价于直接建已认领档案,建议改用 claim)
|
||||
* - POST /api/logout 吊销当前 token(Bearer)
|
||||
* - POST /api/pair/start (Bearer,匿名/已认领均可)生成 6 位配对码:
|
||||
* 10 分钟有效、单次使用、同档案同时仅 1 个有效码
|
||||
* (重新生成即作废旧码)、per-IP 频控(SG_PAIR_LIMIT,默认 20/天)
|
||||
* - POST /api/pair/join {code} 新设备转入:把新设备当前档案按桶 LWW 合并进码对应
|
||||
* 的老档案并改绑 token(与 claim-merge 同语义);无 token
|
||||
* 时直接为老档案签发新 token。join 连错 5 次 per-IP 封锁
|
||||
* 10 分钟;成功后码立即作废(单次使用)。配对码仅存内存,
|
||||
* 不落盘、不进日志。
|
||||
* - GET /api/progress?game=xxx 读单桶,保持 {game, data} 形状(radio 等游戏兼容)
|
||||
* - GET /api/progress 读全部桶 {games: {...}}(大厅一次拉回,修 03 §1.2)
|
||||
* - PUT /api/progress {game, data, baseUpdatedAt?}
|
||||
@@ -34,7 +42,8 @@
|
||||
* <username>.json {game: data} → profiles/<username>.json(每桶补 updatedAt/rev)
|
||||
*
|
||||
* 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 +
|
||||
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)。
|
||||
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)、
|
||||
* /api/pair/* per-IP 频控 + join 5 连错封锁 + 配对码日志脱敏。
|
||||
* HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。
|
||||
*/
|
||||
|
||||
@@ -56,6 +65,10 @@ 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 PAIR_START_LIMIT = Number(process.env.SG_PAIR_LIMIT) || 20; // per-IP 每日配对码签发上限
|
||||
const PAIR_TTL_MS = 10 * 60 * 1000; // 配对码有效期:10 分钟
|
||||
const PAIR_MAX_FAILS = 5; // join 连错封锁阈值(码尝试错误 5 次 → 封锁,旧设备重新生成)
|
||||
const PAIR_BLOCK_MS = 10 * 60 * 1000; // join 连错封锁时长
|
||||
const TOKEN_TTL_MS = 180 * 24 * 3600 * 1000; // 180 天滑动
|
||||
const TOKEN_TOUCH_MS = 6 * 3600 * 1000; // 滑动续期节流(6h 内不重复落盘)
|
||||
|
||||
@@ -83,6 +96,7 @@ 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;
|
||||
const PAIR_CODE_RE = /^\d{6}$/;
|
||||
|
||||
// ---------------- 状态 ----------------
|
||||
|
||||
@@ -256,6 +270,101 @@ function findClaimedByName(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- 跨档案合并原语(claim-merge 与 pair/join 共用,03 §3.4) ----------------
|
||||
|
||||
/** 把 srcPid 的进度按桶 LWW 并入 targetPid,返回更新的桶数。 */
|
||||
function mergeGamesInto(targetPid, srcPid) {
|
||||
let mergedGames = 0;
|
||||
return withGamesDoc(targetPid, (dst) =>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}),
|
||||
).then(() => mergedGames);
|
||||
}
|
||||
|
||||
/** 源档案出索引 + 惰性删除其进度文件(claim-merge / pair-join 收尾共用)。 */
|
||||
async function retireProfile(pid) {
|
||||
delete profileIndex.profiles[pid];
|
||||
await persistProfileIndex();
|
||||
enqueueWrite(profileGamesFile(pid), async () => {
|
||||
await fsp.unlink(profileGamesFile(pid)).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 配对码(P1:跨设备转移,01 §4.2 旅程 3) ----------------
|
||||
// 仅存内存:10 分钟短票据,进程重启即失效(老设备重新生成即可),不落盘不进日志。
|
||||
|
||||
/** @type {Map<string, {profileId: string, createdAt: string, expiresAt: string}>} code -> entry */
|
||||
const pairings = new Map();
|
||||
/** 同档案同时仅 1 个有效码:profileId -> code */
|
||||
const profilePairCode = new Map();
|
||||
/** join 连错封锁:ip -> {fails, blockedUntil} */
|
||||
const pairJoinFails = new Map();
|
||||
/** pair/start per-IP 频控:ip -> {day, count} */
|
||||
const pairStarts = new Map();
|
||||
|
||||
function sweepPairings() {
|
||||
const now = Date.now();
|
||||
for (const [code, e] of pairings) {
|
||||
if (!profileIndex.profiles[e.profileId] || Date.parse(e.expiresAt) < now) {
|
||||
pairings.delete(code);
|
||||
if (profilePairCode.get(e.profileId) === code) profilePairCode.delete(e.profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 作废一个码(join 成功消费或重新生成顶替)。 */
|
||||
function consumePairing(code) {
|
||||
const e = pairings.get(code);
|
||||
pairings.delete(code);
|
||||
if (e && profilePairCode.get(e.profileId) === code) profilePairCode.delete(e.profileId);
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
|
||||
}
|
||||
|
||||
function allowPairStart(req) {
|
||||
const ip = clientIp(req);
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const rec = pairStarts.get(ip);
|
||||
if (!rec || rec.day !== day) {
|
||||
pairStarts.set(ip, { day, count: 1 });
|
||||
return true;
|
||||
}
|
||||
if (rec.count >= PAIR_START_LIMIT) return false;
|
||||
rec.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function pairJoinBlocked(req) {
|
||||
const rec = pairJoinFails.get(clientIp(req));
|
||||
return !!(rec && rec.blockedUntil > Date.now());
|
||||
}
|
||||
|
||||
/** join 一次失败(码不存在/已用/过期)计一次;连错 PAIR_MAX_FAILS 次封锁 PAIR_BLOCK_MS。 */
|
||||
function registerPairJoinFail(req) {
|
||||
const ip = clientIp(req);
|
||||
let rec = pairJoinFails.get(ip);
|
||||
if (!rec) {
|
||||
rec = { fails: 0, blockedUntil: 0 };
|
||||
pairJoinFails.set(ip, rec);
|
||||
}
|
||||
if (rec.blockedUntil > Date.now()) return; // 封锁期内已拒之门外,不再累计
|
||||
rec.fails += 1;
|
||||
if (rec.fails >= PAIR_MAX_FAILS) {
|
||||
rec.fails = 0;
|
||||
rec.blockedUntil = Date.now() + PAIR_BLOCK_MS;
|
||||
console.log(`[server] pair/join 连错 ${PAIR_MAX_FAILS} 次: ip=${ip} 封锁 ${PAIR_BLOCK_MS / 60000} 分钟(码作废需老设备重新生成)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 01 §4.2)。 */
|
||||
function cnNum(n) {
|
||||
const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
|
||||
@@ -271,7 +380,7 @@ function cnNum(n) {
|
||||
const deviceCreations = new Map(); // ip -> {day, count}
|
||||
|
||||
function allowDeviceCreate(req) {
|
||||
const ip = String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
|
||||
const ip = clientIp(req);
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const rec = deviceCreations.get(ip);
|
||||
if (!rec || rec.day !== day) {
|
||||
@@ -430,32 +539,100 @@ async function handleApi(req, res, url) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const mergedGames = await mergeGamesInto(targetPid, srcPid);
|
||||
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(() => {});
|
||||
});
|
||||
await retireProfile(srcPid); // 匿名源档案出索引(进度已并入,文件惰性清理)
|
||||
console.log(`[server] claim 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新)`);
|
||||
return sendJson(res, 200, {
|
||||
ok: true, merged: true, mergedGames, profileId: targetPid, name: target.name, claimed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 配对码:老设备出示(Bearer,匿名/已认领均可) ----
|
||||
if (route === 'POST /api/pair/start') {
|
||||
const auth = authProfile(req);
|
||||
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
sweepPairings();
|
||||
if (!allowPairStart(req)) {
|
||||
return sendJson(res, 429, { error: `本机今日配对码生成数已达上限(${PAIR_START_LIMIT})` });
|
||||
}
|
||||
// 同档案同时仅 1 个有效码:重新生成即作废旧码
|
||||
const old = profilePairCode.get(auth.profileId);
|
||||
if (old) consumePairing(old);
|
||||
let code;
|
||||
for (;;) {
|
||||
code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
||||
if (!pairings.has(code)) break;
|
||||
}
|
||||
pairings.set(code, {
|
||||
profileId: auth.profileId,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + PAIR_TTL_MS).toISOString(),
|
||||
});
|
||||
profilePairCode.set(auth.profileId, code);
|
||||
// 日志不落配对码明文(与 PIN 同待遇:短时凭据不进日志)
|
||||
console.log(`[server] 配对码签发: pid=${auth.profileId}(码 ****,10 分钟单次有效)`);
|
||||
return sendJson(res, 200, { ok: true, code, expiresAt: new Date(Date.now() + PAIR_TTL_MS).toISOString() });
|
||||
}
|
||||
|
||||
// ---- 配对码:新设备转入(可带当前匿名档案 token,服务器端按桶 LWW 合并进老档案) ----
|
||||
if (route === 'POST /api/pair/join') {
|
||||
const body = await parseBody(req);
|
||||
const { code } = body;
|
||||
if (typeof code !== 'string' || !PAIR_CODE_RE.test(code)) {
|
||||
return sendJson(res, 400, { error: '配对码需为 6 位数字' });
|
||||
}
|
||||
sweepPairings();
|
||||
if (pairJoinBlocked(req)) {
|
||||
return sendJson(res, 429, { error: `配对码尝试错误次数过多,请 ${PAIR_BLOCK_MS / 60000} 分钟后再试(老设备可重新生成新码)` });
|
||||
}
|
||||
const entry = pairings.get(code);
|
||||
if (!entry || !profileIndex.profiles[entry.profileId] || Date.parse(entry.expiresAt) < Date.now()) {
|
||||
consumePairing(code); // 若是过期残留则顺带清掉
|
||||
registerPairJoinFail(req);
|
||||
return sendJson(res, 404, { error: '配对码不存在或已失效(10 分钟单次有效),请在老设备重新生成' });
|
||||
}
|
||||
const targetPid = entry.profileId;
|
||||
const target = profileIndex.profiles[targetPid];
|
||||
|
||||
// joiner 身份可选:Bearer / body.token;无效或缺失一律视作无档案新设备(直发新 token)
|
||||
const auth = authProfile(req)
|
||||
|| (typeof body.token === 'string' && TOKEN_RE.test(body.token) ? authByToken(body.token.toLowerCase()) : null);
|
||||
|
||||
// joiner 已是该档案(同设备重复转入):幂等返回,消费码
|
||||
if (auth && auth.profileId === targetPid) {
|
||||
consumePairing(code);
|
||||
return sendJson(res, 200, {
|
||||
ok: true, merged: false, mergedGames: 0, profileId: targetPid, name: target.name,
|
||||
token: auth.token, claimed: !!target.pinHash,
|
||||
});
|
||||
}
|
||||
// 已设 PIN 的档案不参与合并(与 claim-merge 同防线):先建匿名档案再转入
|
||||
if (auth && auth.profile.pinHash) {
|
||||
return sendJson(res, 409, { error: '当前档案已设 PIN;请先用「+ 新玩家」建匿名档案再转入' });
|
||||
}
|
||||
|
||||
consumePairing(code); // 同步作废 = 单次使用的原子保证(后续 await 期间并发 join 即失效)
|
||||
let token;
|
||||
let mergedGames = 0;
|
||||
if (auth) {
|
||||
const srcPid = auth.profileId;
|
||||
mergedGames = await mergeGamesInto(targetPid, srcPid);
|
||||
tokens[auth.token].profileId = targetPid; // token 改绑老档案
|
||||
await persistTokens();
|
||||
await retireProfile(srcPid);
|
||||
console.log(`[server] pair 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新,码 **** 已作废)`);
|
||||
} else {
|
||||
token = issueToken(targetPid);
|
||||
console.log(`[server] pair 无档案转入: 新设备 → ${targetPid}(码 **** 已作废)`);
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
ok: true, merged: !!auth, mergedGames, profileId: targetPid, name: target.name,
|
||||
token: token || auth.token, claimed: !!target.pinHash,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 旧客户端兼容:登录(v1 用户名+PIN,映射到档案) ----
|
||||
if (route === 'POST /api/login') {
|
||||
const { username, pin } = await parseBody(req);
|
||||
@@ -469,7 +646,9 @@ async function handleApi(req, res, url) {
|
||||
await persistProfileIndex();
|
||||
}
|
||||
const token = issueToken(pid);
|
||||
return sendJson(res, 200, { ok: true, token, username: profile.name, profileId: pid, name: profile.name });
|
||||
return sendJson(res, 200, {
|
||||
ok: true, token, username: profile.name, profileId: pid, name: profile.name, claimed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ----
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
* profiles/<pid>.json(PIN 明文即刻 scrypt 哈希、旧 token 继续有效)
|
||||
* G. per-IP 匿名建档频控(429)与 /server/ 静态目录封禁(数据不外泄)
|
||||
* H. 大厅 localStorage 分桶逻辑单测(web/profiles.js 纯函数,Map 版 storage)
|
||||
* I. 配对码跨设备(P1):start(6 位/10min/同档案 1 码/频控)→ join 按桶 LWW 合并
|
||||
* + token 改绑 → 单次使用作废 + join 5 连错封锁 + 已设 PIN joiner 拒绝 + 日志不落码
|
||||
* J. token 180 天滑动过期:到期 401(大厅「档案已过期」引导的服务器侧路径)
|
||||
*
|
||||
* 运行:tools/node/bin/node web/server/test/api-smoke.mjs(或任意 node ≥18)
|
||||
* 全部通过退出码 0;任一失败抛异常退出非 0。测试数据全部写进临时目录。
|
||||
@@ -26,8 +29,8 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
BUCKETS, SETTINGS_KEY,
|
||||
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
|
||||
nextLocalProfileName, readRegistry, restoreSnapshot, setCurrentProfile,
|
||||
applyAuth, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth,
|
||||
nextLocalProfileName, readRegistry, removeProfile, restoreSnapshot, setCurrentProfile,
|
||||
snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry,
|
||||
} from '../../profiles.js';
|
||||
|
||||
@@ -165,6 +168,47 @@ test('H4. 受管桶清单覆盖 8 个可同步游戏 + _settings', () => {
|
||||
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
|
||||
});
|
||||
|
||||
test('H5. 配对码逐位中文读法(0 读「零」非字母 O;speechSynthesis 前置纯函数)', () => {
|
||||
assert.equal(digitsToCn('048920'), '零 四 八 九 二 零');
|
||||
assert.equal(digitsToCn('000000'), '零 零 零 零 零 零');
|
||||
assert.equal(digitsToCn('123456'), '一 二 三 四 五 六');
|
||||
assert.equal(digitsToCn(''), '');
|
||||
assert.equal(digitsToCn('9'), '九');
|
||||
// 自动名词表与 server.js cnNum 同源(玩家一~九可听)
|
||||
assert.equal(cnNum(1), '一');
|
||||
assert.equal(cnNum(10), '十');
|
||||
assert.equal(cnNum(21), '二十一');
|
||||
});
|
||||
|
||||
test('H6. 从本机移除:清快照/同步表/注册表项;当前档案连带 CURRENT/AUTH,非当前不动 AUTH', () => {
|
||||
const storage = mockStorage();
|
||||
writeRegistry(storage, [
|
||||
{ id: 'p_aaa', name: '玩家一', token: 't1' },
|
||||
{ id: 'p_bbb', name: '玩家二', token: 't2' },
|
||||
]);
|
||||
setCurrentProfile(storage, 'p_bbb');
|
||||
storage.setItem(snapshotKeyOf('p_aaa'), JSON.stringify({ 'soundgame.progress.rpg': '{}' }));
|
||||
storage.setItem(syncKeyOf('p_aaa'), JSON.stringify({ rpg: { updatedAt: 'x', rev: 1 } }));
|
||||
applyAuth(storage, { id: 'p_bbb', name: '玩家二', token: 't2' });
|
||||
|
||||
// 移除非当前档案:不清 CURRENT/AUTH
|
||||
let list = removeProfile(storage, 'p_aaa');
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(storage.getItem(snapshotKeyOf('p_aaa')), null);
|
||||
assert.equal(storage.getItem(syncKeyOf('p_aaa')), null);
|
||||
assert.equal(storage.getItem('soundgame.profile.current'), 'p_bbb');
|
||||
assert.ok(storage.getItem('soundgame.auth'), '非当前档案移除不应动 AUTH');
|
||||
|
||||
// 移除当前档案:连带清 CURRENT/AUTH(调用方负责新建/切换)
|
||||
storage.setItem(snapshotKeyOf('p_bbb'), '{}');
|
||||
list = removeProfile(storage, 'p_bbb');
|
||||
assert.deepEqual(list, []);
|
||||
assert.equal(storage.getItem('soundgame.profile.current'), null);
|
||||
assert.equal(storage.getItem('soundgame.auth'), null);
|
||||
assert.equal(storage.getItem(snapshotKeyOf('p_bbb')), null);
|
||||
assert.equal(getCurrentProfile(storage), null);
|
||||
});
|
||||
|
||||
function mockStorage() {
|
||||
const m = new Map();
|
||||
return {
|
||||
@@ -423,3 +467,208 @@ test('G2. per-IP 匿名建档频控', async () => {
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------- I. 配对码跨设备(P1) ----------------
|
||||
|
||||
test('I1. 配对码全流程:start(6 位/10min/同档案重生成顶替)→ join 按桶 LWW 合并 → 单次作废', async () => {
|
||||
const srv = await startServer({});
|
||||
try {
|
||||
// 老设备:建档 + rpg 进度
|
||||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const oldDev = { profileId: r.json.profileId, token: r.json.token, name: r.json.name };
|
||||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||||
token: oldDev.token,
|
||||
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
|
||||
// start:6 位数字、10 分钟有效期、需鉴权
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.json.ok);
|
||||
assert.match(r.json.code, /^\d{6}$/);
|
||||
const firstCode = r.json.code;
|
||||
const ttl = Date.parse(r.json.expiresAt) - Date.now();
|
||||
assert.ok(ttl > 9 * 60 * 1000 && ttl <= 10 * 60 * 1000, `expiresAt 应为 ~10 分钟(实际 ${ttl}ms)`);
|
||||
r = await call(srv.base, 'POST', '/api/pair/start');
|
||||
assert.equal(r.status, 401, 'pair/start 必须鉴权');
|
||||
|
||||
// 同档案重新生成:旧码立即作废(同档案同时仅 1 个有效码)
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||||
const code2 = r.json.code;
|
||||
let code = code2;
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const stranger = { token: r.json.token };
|
||||
// 被顶替的旧码 → 404(计 1 次错误尝试,未到封锁阈值不影响后续)
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: firstCode } });
|
||||
assert.equal(r.status, 404, '被重新生成顶替的旧码必须立即作废');
|
||||
// 非 6 位数字 → 400(格式校验在先,不计入连错计数)
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: '000001-not-real' } });
|
||||
assert.equal(r.status, 400, '非 6 位数字 → 400');
|
||||
|
||||
// 新设备:匿名档案 + dungeon 进度 + 更新的 rpg 进度
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const newDev = { profileId: r.json.profileId, token: r.json.token };
|
||||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||||
token: newDev.token,
|
||||
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||||
token: newDev.token,
|
||||
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
|
||||
// join:新设备档案按桶 LWW 并入老档案,token 改绑(与 claim-merge 同语义)
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: newDev.token, body: { code } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.ok, true);
|
||||
assert.equal(r.json.merged, true);
|
||||
assert.equal(r.json.profileId, oldDev.profileId, '新设备 token 应改绑到码对应的老档案');
|
||||
assert.equal(r.json.name, oldDev.name);
|
||||
assert.equal(r.json.token, newDev.token, 'join 复用新设备 token(改绑,不发新)');
|
||||
assert.ok(r.json.mergedGames >= 2, `至少 rpg+dungeon 两桶更新(实际 ${r.json.mergedGames})`);
|
||||
|
||||
// 合并结果:LWW 裁决(rpg 取新设备的第 12 级;dungeon 是新设备独有桶)
|
||||
r = await call(srv.base, 'GET', '/api/progress', { token: newDev.token });
|
||||
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:新设备 rpg 更新,应胜出');
|
||||
assert.equal(r.json.games.dungeon.data.floor, 3);
|
||||
// 老设备 token 不受影响,且看到同一份合并后的档案
|
||||
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: oldDev.token });
|
||||
assert.equal(r.json.data.floor, 3);
|
||||
|
||||
// 单次使用:同一码再用 → 失效(404)
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const third = { token: r.json.token };
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: third.token, body: { code } });
|
||||
assert.equal(r.status, 404, 'join 成功后旧码必须立即失效');
|
||||
|
||||
// 无 token 新设备(全新浏览器)join:直接为老档案签发新 token
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||||
code = r.json.code;
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { body: { code } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.merged, false);
|
||||
assert.equal(r.json.profileId, oldDev.profileId);
|
||||
assert.match(r.json.token, /^[0-9a-f]{48}$/, '无档案设备应拿到老档案的新 token');
|
||||
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: r.json.token });
|
||||
assert.equal(r.json.data.lv, 12, '无档案 join 也能拉到合并后的进度');
|
||||
|
||||
// joiner 已设 PIN(已认领档案)→ 409,防误合并
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const claimed = { token: r.json.token };
|
||||
r = await call(srv.base, 'POST', '/api/claim', { token: claimed.token, body: { pin: '1111' } });
|
||||
assert.equal(r.status, 200);
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||||
code = r.json.code;
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: claimed.token, body: { code } });
|
||||
assert.equal(r.status, 409, '已设 PIN 的档案不允许作为 join 源被合并');
|
||||
|
||||
// joiner 已是该档案(同设备重复转入)→ 幂等成功,消费码
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||||
code = r.json.code;
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: oldDev.token, body: { code } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.merged, false);
|
||||
assert.equal(r.json.token, oldDev.token);
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code } });
|
||||
assert.equal(r.status, 404, '幂等转入同样消费码(单次)');
|
||||
|
||||
// 日志不落配对码明文(与 PIN 同待遇):本会话所有生成过的码逐一比对
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||||
const logged = srv.logs.join('');
|
||||
for (const c of [code, code2, r.json.code]) {
|
||||
assert.ok(!logged.includes(c), `服务器日志不得包含配对码明文 ${c}`);
|
||||
}
|
||||
} finally {
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('I2. 配对码防护:join 5 连错封锁 10 分钟、start per-IP 频控、过期码失败计数', async () => {
|
||||
const srv = await startServer({ env: { SG_PAIR_LIMIT: '3' } });
|
||||
try {
|
||||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const dev = { token: r.json.token };
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const guesser = { token: r.json.token };
|
||||
|
||||
// 5 次错误码 → 全部 404,第 6 次起 429(封锁)
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: guesser.token, body: { code: '00000' + i } });
|
||||
assert.equal(r.status, 404, `第 ${i} 次错误码应 404`);
|
||||
}
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const legit = { token: r.json.token };
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||||
const code = r.json.code;
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: legit.token, body: { code } });
|
||||
assert.equal(r.status, 429, '连错 5 次后同 IP 一切 join 被封锁(含正确码)');
|
||||
assert.ok(r.json.error.includes('10 分钟'), '封锁提示应引导等待与重新生成');
|
||||
|
||||
// start per-IP 频控:SG_PAIR_LIMIT=3 → 第 4 次 429
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||||
assert.equal(r.status, 200); // 2
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||||
assert.equal(r.status, 200); // 3
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||||
assert.equal(r.status, 429, 'pair/start 应受 per-IP 频控');
|
||||
} finally {
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------- J. token 180 天滑动过期(大厅「档案已过期」引导的服务器侧路径) ----------------
|
||||
|
||||
test('J1. 过期 token → 401;pair/join 对过期 token 视作无档案设备(引导可自愈)', async () => {
|
||||
const dataDir = tmpDataDir();
|
||||
// 手工落一份「token 已过期」的数据:1 个档案 + 1 条 179 天前过期的 token
|
||||
const expiredToken = 'ab'.repeat(24);
|
||||
fs.mkdirSync(path.join(dataDir, 'profiles'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'profiles.json'), JSON.stringify({
|
||||
seq: 1,
|
||||
profiles: { p_dead: { name: '玩家一', pinHash: null, createdAt: '2025-01-01T00:00:00Z' } },
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(dataDir, 'tokens.json'), JSON.stringify({
|
||||
[expiredToken]: {
|
||||
profileId: 'p_dead', createdAt: '2025-01-01T00:00:00Z',
|
||||
lastSeen: '2025-01-01T00:00:00Z', expiresAt: '2025-06-01T00:00:00Z', // 早已过期
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const srv = await startServer({ dataDir });
|
||||
try {
|
||||
// 全部鉴权端点对过期 token 一律 401(大厅据此弹「档案已过期,重新登录或新建」)
|
||||
let r = await call(srv.base, 'GET', '/api/profile', { token: expiredToken });
|
||||
assert.equal(r.status, 401);
|
||||
assert.ok(r.json.error.includes('未授权'), '错误文案供大厅 isAuthExpired 判定');
|
||||
r = await call(srv.base, 'GET', '/api/progress', { token: expiredToken });
|
||||
assert.equal(r.status, 401);
|
||||
r = await call(srv.base, 'PUT', '/api/progress', { token: expiredToken, body: { game: 'rpg', data: {} } });
|
||||
assert.equal(r.status, 401);
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: expiredToken });
|
||||
assert.equal(r.status, 401);
|
||||
// 过期 token 已被惰性清扫出 tokens.json
|
||||
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
|
||||
assert.ok(!tokensDisk[expiredToken], '过期 token 应被惰性清扫');
|
||||
|
||||
// 恢复路径 A(大厅「新玩家」):匿名建档照常可用
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const helper = { token: r.json.token, profileId: r.json.profileId };
|
||||
assert.notEqual(helper.profileId, 'p_dead');
|
||||
r = await call(srv.base, 'GET', '/api/profile', { token: helper.token });
|
||||
assert.equal(r.status, 200);
|
||||
|
||||
// 恢复路径 B(配对转入):pair/join 带过期 token 不 401,视作无档案设备直发新 token
|
||||
// (服务器端对 join 的宽容 = 大厅引导文案「从其他设备转入」可以无感自愈)
|
||||
r = await call(srv.base, 'POST', '/api/pair/start', { token: helper.token });
|
||||
const code = r.json.code;
|
||||
r = await call(srv.base, 'POST', '/api/pair/join', { token: expiredToken, body: { code } });
|
||||
assert.equal(r.status, 200, 'join 对过期 token 应按无档案处理而非 401');
|
||||
assert.match(r.json.token, /^[0-9a-f]{48}$/);
|
||||
assert.equal(r.json.profileId, helper.profileId);
|
||||
} finally {
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user