Files
SoundGame/web/server/server.js
T
xsl 566241e05d 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 在途)
2026-09-10 00:43:23 +08:00

938 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* server.js — SoundGame 用户系统 + 静态文件服务器(Node 零依赖,仅内置模块)。
*
* 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/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)
* - 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?}
* 写桶。baseUpdatedAt 与服务器桶不符 → 409 + 服务器桶
* (LWW 冲突,客户端择优重试);缺省视为 legacy 放行
* (console 警告——各游戏存量 PUT 不带此字段)。
*
* 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)
*
* 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 +
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)、
* /api/pair/* per-IP 频控 + join 5 连错封锁 + 配对码日志脱敏。
* HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。
*/
const http = require('node:http');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
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 = 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 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 内不重复落盘)
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
};
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}$/;
// ---------------- 状态 ----------------
/** @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();
function enqueueWrite(file, fn) {
const tail = writeQueues.get(file) || Promise.resolve();
const next = tail.then(fn, fn); // 前序失败不阻塞后续
writeQueues.set(file, next.catch(() => {}));
return next;
}
async function readJson(file, fallback) {
try {
return JSON.parse(await fsp.readFile(file, 'utf8'));
} catch (_) {
return fallback;
}
}
/** 队列化原子写(tmp + rename)。 */
function writeJson(file, obj) {
return enqueueWrite(file, async () => {
const tmp = file + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(obj, null, 2));
await fsp.rename(tmp, file);
});
}
// ---------------- 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;
}
// ---------------- 跨档案合并原语(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 = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
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 = clientIp(req);
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 工具 ----------------
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
});
res.end(body);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > 1024 * 1024) { // 1MB 上限
reject(new Error('body too large'));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
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 ----------------
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;
const mergedGames = await mergeGamesInto(targetPid, srcPid);
tokens[auth.token].profileId = targetPid; // token 改绑目标档案
await persistTokens();
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);
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, claimed: true,
});
}
// ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ----
if (route === 'POST /api/register') {
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 (findClaimedByName(username)) {
return sendJson(res, 409, { error: '用户名已被注册' });
}
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 });
}
// ---- 登出(吊销当前 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 auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const game = url.searchParams.get('game');
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 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 字段' });
}
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];
});
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 端点' });
}
// ---------------- 静态文件 ----------------
async function serveStatic(req, res, url) {
let pathname;
try {
pathname = decodeURIComponent(url.pathname);
} catch (_) {
return sendJson(res, 400, { error: '非法 URL' });
}
let filePath = path.normalize(path.join(ROOT, pathname));
if (!filePath.startsWith(ROOT + path.sep) && filePath !== ROOT) {
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 {
stat = await fsp.stat(filePath);
if (stat.isDirectory()) {
filePath = path.join(filePath, 'index.html');
stat = await fsp.stat(filePath);
}
} catch (_) {
res.writeHead(404).end('Not Found');
return;
}
const mime = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
const headers = {
'Content-Type': mime,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
};
// 简化版 Range:仅支持单个 bytes=start-end / start- / -suffix
const range = req.headers.range;
if (range) {
const m = range.match(/^bytes=(\d*)-(\d*)$/);
if (m && (m[1] !== '' || m[2] !== '')) {
let start, end;
if (m[1] === '') { // 后缀范围:最后 N 字节
const n = Number(m[2]);
start = Math.max(stat.size - n, 0);
end = stat.size - 1;
} else {
start = Number(m[1]);
end = m[2] === '' ? stat.size - 1 : Math.min(Number(m[2]), stat.size - 1);
}
if (start > end || start >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end();
return;
}
res.writeHead(206, {
...headers,
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Content-Length': end - start + 1,
});
if (req.method === 'HEAD') return res.end();
fs.createReadStream(filePath, { start, end }).pipe(res);
return;
}
}
res.writeHead(200, { ...headers, 'Content-Length': stat.size });
if (req.method === 'HEAD') return res.end();
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 });
await migrate();
const removed = cleanupTokens();
if (removed) {
console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
await persistTokens();
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (url.pathname.startsWith('/api/')) {
return await handleApi(req, res, url);
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405).end('Method Not Allowed');
return;
}
return await serveStatic(req, res, url);
} catch (e) {
if (e instanceof SyntaxError) {
return sendJson(res, 400, { error: '请求体不是合法 JSON' });
}
console.error('[server] 未处理异常:', e);
if (!res.headersSent) sendJson(res, 500, { error: '服务器内部错误' });
else res.end();
}
});
server.listen(PORT, () => {
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}`);
});
}
main().catch((e) => {
console.error('[server] 启动失败:', e);
process.exit(1);
});