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:
@@ -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