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:
xsl
2026-09-10 00:43:23 +08:00
parent ede522dda1
commit 566241e05d
694 changed files with 4242 additions and 168 deletions
+251 -2
View File
@@ -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();
}
});