配对码与认领(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 在途)
938 lines
37 KiB
JavaScript
938 lines
37 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|