Files
SoundGame/web/server/server.js
T
xsl ede522dda1 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)
2026-09-09 23:39:23 +08:00

759 lines
29 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)
* - 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)。
* 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 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;
// ---------------- 状态 ----------------
/** @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;
}
/** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 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 工具 ----------------
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;
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 } = 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);
});