feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)

用户系统(B,游戏零改动):
- POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩
- 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听)
- claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁
- PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET)
- web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4)

键盘双端(A,游戏代码零改动):
- gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault
- settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始)
- 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示
- live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言

回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过

P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
This commit is contained in:
xsl
2026-09-09 23:39:23 +08:00
parent 3704cd0792
commit ede522dda1
22 changed files with 2370 additions and 275 deletions
+514 -66
View File
@@ -1,25 +1,41 @@
/**
* server.js — SoundGame 用户系统 + 静态文件服务器(Node 零依赖,仅内置模块)。
*
* 功能:
* 1. 静态托管 web/ 根目录(正确 MIME,支持简化版 Range 请求供音频拖动)。
* 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/register {username, pin} 注册(用户名 + 4~6 位数字 PIN)
* - POST /api/login {username, pin} 登录,返回 token(随机 hex,
* 内存 + 落盘 data/sessions.json)
* - GET /api/progress?game=xxx 读进度(需 Authorization: Bearer token)
* - PUT /api/progress {game, data} 写进度(同上)
* - 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)
* - 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 不带此字段)。
*
* 数据落盘(data/ 目录自动创建):
* data/users.json { username: pin }
* data/sessions.json { token: username }
* data/<username>.json { <game>: <data> } 进度,一用户一文件
* 并发写入:按文件串行 Promise 队列。
* 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)
*
* 启动:node web/server/server.js (端口 env PORT,默认 8000)
*
* ⚠ 安全说明:这是家庭局域网玩具级认证——PIN 明文存储、token 不过期、
* HTTP 明文传输。请勿暴露到公网。详见 web/server/README.md。
* 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 +
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)。
* HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。
*/
const http = require('node:http');
@@ -29,10 +45,19 @@ 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 = path.join(__dirname, 'data'); // web/server/data/
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
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 TOKEN_TTL_MS = 180 * 24 * 3600 * 1000; // 180 天滑动
const TOKEN_TOUCH_MS = 6 * 3600 * 1000; // 滑动续期节流(6h 内不重复落盘)
const MIME = {
'.html': 'text/html; charset=utf-8',
@@ -57,13 +82,14 @@ 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;
// ---------------- 状态 ----------------
/** @type {Record<string, string>} username -> pin */
let users = {};
/** @type {Record<string, string>} token -> username */
let sessions = {};
/** @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();
@@ -92,9 +118,169 @@ function writeJson(file, obj) {
});
}
function progressFile(username) {
// username 已被 USERNAME_RE 限制为安全字符,可直接作文件名
return path.join(DATA_DIR, `${username}.json`);
// ---------------- 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;
}
/** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 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 = String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
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 工具 ----------------
@@ -127,11 +313,11 @@ function readBody(req) {
});
}
function authUser(req) {
const h = req.headers.authorization || '';
const m = h.match(/^Bearer\s+([0-9a-f]+)$/i);
if (!m) return null;
return sessions[m[1]] || null;
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 ----------------
@@ -139,63 +325,247 @@ function authUser(req) {
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;
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;
}
}
});
});
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(() => {});
});
console.log(`[server] claim 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新)`);
return sendJson(res, 200, {
ok: true, merged: true, mergedGames, profileId: targetPid, name: target.name, claimed: true,
});
}
// ---- 旧客户端兼容:登录(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 });
}
// ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ----
if (route === 'POST /api/register') {
const { username, pin } = JSON.parse(await readBody(req));
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 (users[username] !== undefined) {
if (findClaimedByName(username)) {
return sendJson(res, 409, { error: '用户名已被注册' });
}
users[username] = pin;
await writeJson(USERS_FILE, users);
return sendJson(res, 201, { ok: true, username });
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 });
}
if (route === 'POST /api/login') {
const { username, pin } = JSON.parse(await readBody(req));
if (typeof username !== 'string' || typeof pin !== 'string'
|| users[username] === undefined || users[username] !== pin) {
return sendJson(res, 401, { error: '用户名或 PIN 错误' });
}
const token = crypto.randomBytes(24).toString('hex');
sessions[token] = username;
await writeJson(SESSIONS_FILE, sessions);
return sendJson(res, 200, { ok: true, token, username });
// ---- 登出(吊销当前 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 username = authUser(req);
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const game = url.searchParams.get('game');
if (!game) return sendJson(res, 400, { error: '缺少 game 参数' });
const all = await readJson(progressFile(username), {});
return sendJson(res, 200, { game, data: all[game] ?? null });
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 username = authUser(req);
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { game, data } = JSON.parse(await readBody(req));
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 字段' });
}
const file = progressFile(username);
// 读-改-写整体进队列,避免并发写互相覆盖
await enqueueWrite(file, async () => {
const all = await readJson(file, {});
all[game] = data;
const tmp = file + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(all, null, 2));
await fsp.rename(tmp, file);
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];
});
return sendJson(res, 200, { ok: true, 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 端点' });
@@ -215,6 +585,12 @@ async function serveStatic(req, res, url) {
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 {
@@ -269,12 +645,83 @@ async function serveStatic(req, res, url) {
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 });
users = await readJson(USERS_FILE, {});
sessions = await readJson(SESSIONS_FILE, {});
await migrate();
const removed = cleanupTokens();
if (removed) {
console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
await persistTokens();
}
const server = http.createServer(async (req, res) => {
try {
@@ -301,6 +748,7 @@ async function main() {
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}`);
});
}