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:
+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) ----
|
||||
|
||||
Reference in New Issue
Block a user