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 文档
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# SoundGame Web 服务器(web/server/server.js)
|
||||
|
||||
Node 零依赖(仅用内置模块)的静态文件 + 用户系统 API 服务器。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 推荐:用项目自带 node
|
||||
tools/node/bin/node web/server/server.js
|
||||
|
||||
# 或用封装脚本(打印局域网地址)
|
||||
bash web/serve.sh
|
||||
|
||||
# 自定义端口(默认 8000)
|
||||
PORT=8001 tools/node/bin/node web/server/server.js
|
||||
```
|
||||
|
||||
- 静态根目录:`web/`(`/` 即游戏大厅 `index.html`)
|
||||
- 数据目录:`web/server/data/`(首次启动自动创建)
|
||||
|
||||
## ⚠ 安全说明
|
||||
|
||||
这是**家庭局域网玩具级认证**,请勿暴露到公网:
|
||||
|
||||
- PIN 以**明文**存储在 `data/users.json`;
|
||||
- token 永不过期,明文存于 `data/sessions.json`;
|
||||
- HTTP 明文传输,无 TLS、无防爆破、无速率限制。
|
||||
|
||||
用途仅限于同一 WiFi 下的家庭成员各自存档。要公网部署请换正经认证方案。
|
||||
|
||||
## API
|
||||
|
||||
所有 API 请求/响应均为 JSON(`Content-Type: application/json`)。
|
||||
错误响应统一为 `{ "error": "描述" }` + 相应 HTTP 状态码。
|
||||
|
||||
### POST /api/register
|
||||
|
||||
注册新用户。
|
||||
|
||||
- 请求:`{ "username": "ming", "pin": "1234" }`
|
||||
- `username`:1~24 位字母/数字/中文/`_`/`-`
|
||||
- `pin`:4~6 位数字
|
||||
- 成功:`201 { "ok": true, "username": "ming" }`
|
||||
- 失败:`400`(格式非法)/ `409 { "error": "用户名已被注册" }`
|
||||
|
||||
### POST /api/login
|
||||
|
||||
登录,返回 token。
|
||||
|
||||
- 请求:`{ "username": "ming", "pin": "1234" }`
|
||||
- 成功:`200 { "ok": true, "token": "<48 位随机 hex>", "username": "ming" }`
|
||||
- 失败:`401 { "error": "用户名或 PIN 错误" }`
|
||||
|
||||
token 存内存并落盘 `data/sessions.json`(服务器重启后会话仍有效)。
|
||||
|
||||
### GET /api/progress?game=xxx
|
||||
|
||||
读取某游戏的进度数据。
|
||||
|
||||
- 请求头:`Authorization: Bearer <token>`
|
||||
- 成功:`200 { "game": "escape", "data": { ... } }`(无存档时 `data` 为 `null`)
|
||||
- 失败:`401`(token 缺失/无效)/ `400`(缺少 game 参数)
|
||||
|
||||
### PUT /api/progress
|
||||
|
||||
写入某游戏的进度数据(覆盖该 game 键)。
|
||||
|
||||
- 请求头:`Authorization: Bearer <token>`
|
||||
- 请求:`{ "game": "escape", "data": { "summary": "已逃脱 2 次 / 最快 3:25", ... } }`
|
||||
- `game`:1~32 位字母/数字/`_`/`-`;大厅约定用 `_settings` 存用户设置
|
||||
- `data`:任意 JSON 值;大厅卡片会展示 `data.summary` 字符串(若存在)
|
||||
- 成功:`200 { "ok": true, "game": "escape" }`
|
||||
- 失败:`401` / `400`
|
||||
|
||||
## 数据格式
|
||||
|
||||
```
|
||||
web/server/data/
|
||||
├── users.json { "<username>": "<pin>" }
|
||||
├── sessions.json { "<token>": "<username>" }
|
||||
└── <username>.json { "<game>": <data>, ... } 一用户一文件
|
||||
```
|
||||
|
||||
所有写入按文件串行排队(Promise 链),tmp + rename 原子落盘,并发请求不会写坏文件。
|
||||
|
||||
## 静态服务
|
||||
|
||||
- MIME:`.ogg audio/ogg`、`.m4a audio/mp4`、`.js text/javascript`、
|
||||
`.html text/html`、`.png image/png`、`.json application/json` 等;
|
||||
- 目录自动解析 `index.html`;
|
||||
- 支持简化版 HTTP Range(单段 `bytes=start-end` / `start-` / `-suffix`),
|
||||
返回 206 + `Content-Range`,供音频元素拖动进度;
|
||||
- 路径穿越防护:请求路径限制在 `web/` 内。
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user