Files
SoundGame/web/server/server.js
T
SoundGame Dev cc0f8c008d HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构
- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
2026-08-29 21:55:44 +08:00

311 lines
10 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 零依赖,仅内置模块)。
*
* 功能:
* 1. 静态托管 web/ 根目录(正确 MIME,支持简化版 Range 请求供音频拖动)。
* 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} 写进度(同上)
*
* 数据落盘(data/ 目录自动创建):
* data/users.json { username: pin }
* data/sessions.json { token: username }
* data/<username>.json { <game>: <data> } 进度,一用户一文件
* 并发写入:按文件串行 Promise 队列。
*
* 启动:node web/server/server.js (端口 env PORT,默认 8000)
*
* ⚠ 安全说明:这是家庭局域网玩具级认证——PIN 明文存储、token 不过期、
* 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 = 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 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}$/;
// ---------------- 状态 ----------------
/** @type {Record<string, string>} username -> pin */
let users = {};
/** @type {Record<string, string>} token -> username */
let sessions = {};
/** 按文件串行的写入队列: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);
});
}
function progressFile(username) {
// username 已被 USERNAME_RE 限制为安全字符,可直接作文件名
return path.join(DATA_DIR, `${username}.json`);
}
// ---------------- 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);
});
}
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;
}
// ---------------- API ----------------
async function handleApi(req, res, url) {
const route = `${req.method} ${url.pathname}`;
if (route === 'POST /api/register') {
const { username, pin } = JSON.parse(await readBody(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) {
return sendJson(res, 409, { error: '用户名已被注册' });
}
users[username] = pin;
await writeJson(USERS_FILE, users);
return sendJson(res, 201, { ok: true, username });
}
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 });
}
if (route === 'GET /api/progress') {
const username = authUser(req);
if (!username) 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 });
}
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));
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);
});
return sendJson(res, 200, { ok: true, game });
}
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;
}
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);
}
// ---------------- 启动 ----------------
async function main() {
await fsp.mkdir(DATA_DIR, { recursive: true });
users = await readJson(USERS_FILE, {});
sessions = await readJson(SESSIONS_FILE, {});
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}`);
});
}
main().catch((e) => {
console.error('[server] 启动失败:', e);
process.exit(1);
});