- Add the event results page with schedule, rankings, Battle bracket, athletes, records, bilingual labels, and navigation entry. - Add the read/write competition data service, a complete Excel example, empty public state, and validation smoke tests. - Handle Furion statusCode/data responses from the production API and retain static fallback for the public page. - Document Docker sidecar deployment beside test_eventapi, persistent storage, private admin access, Nginx routing, and OSS asset rewriting. Verification: - node server/competition-api.smoke.mjs - node scripts/competition-response-test.mjs - node scripts/battle-layout-test.mjs - npm run build -- --base=https://assets.example.test/static/test/ - npm run rewrite:oss -- https://assets.example.test/static/test/ - Production /api/competition/state returned HTTP 200 after the sidecar started. Deployment note: publish the updated YD-WEB static build before public results can consume the unwrapped API response. The event remains unpublished until an administrator saves and publishes data.
145 lines
8.2 KiB
JavaScript
145 lines
8.2 KiB
JavaScript
import { createServer } from 'node:http';
|
|
import { readFile, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
import { isIP } from 'node:net';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const dataPath = resolve(process.env.COMPETITION_DATA_PATH || resolve(root, 'server/data/competition-state.json'));
|
|
const seedPath = resolve(root, 'public/competition-state.json');
|
|
const allowedOrigins = new Set((process.env.COMPETITION_ALLOWED_ORIGINS || '').split(',').map((item) => item.trim()).filter(Boolean));
|
|
const adminAllowedIps = new Set(['127.0.0.1', '::1', ...(process.env.COMPETITION_ADMIN_ALLOWED_IPS || '').split(',').map((item) => item.trim()).filter(Boolean)]);
|
|
const port = Number(process.env.COMPETITION_PORT || 8787);
|
|
const host = process.env.COMPETITION_HOST || '127.0.0.1';
|
|
const maxBody = 8 * 1024 * 1024;
|
|
const clients = new Set();
|
|
let writeQueue = Promise.resolve();
|
|
|
|
if ([...adminAllowedIps].some((address) => !isIP(address))) {
|
|
console.error('COMPETITION_ADMIN_ALLOWED_IPS 只能包含逗号分隔的 IP 地址');
|
|
process.exit(1);
|
|
}
|
|
|
|
function cors(request, response) {
|
|
const origin = request.headers.origin;
|
|
if (!origin || !allowedOrigins.has(origin)) return;
|
|
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
response.setHeader('Vary', 'Origin');
|
|
response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
response.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
|
|
}
|
|
|
|
function send(response, status, value) {
|
|
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
response.end(JSON.stringify(value));
|
|
}
|
|
|
|
function adminIpAllowed(request) {
|
|
const address = request.socket.remoteAddress?.replace(/^::ffff:/, '');
|
|
return adminAllowedIps.has(address);
|
|
}
|
|
|
|
async function state() {
|
|
try { return JSON.parse(await readFile(dataPath, 'utf8')); }
|
|
catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
return JSON.parse(await readFile(seedPath, 'utf8'));
|
|
}
|
|
}
|
|
|
|
function validText(value, limit = 300) {
|
|
return typeof value === 'string' && value.length <= limit;
|
|
}
|
|
|
|
function validate(input) {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return '数据必须是 JSON 对象';
|
|
if (typeof input.published !== 'boolean') return 'published 必须是布尔值';
|
|
const c = input.competition;
|
|
if (!c || ['nameZh', 'nameEn', 'dateZh', 'dateEn', 'venueZh', 'venueEn'].some((key) => !validText(c[key]))) return '赛事名称、日期、场地字段无效';
|
|
if (input.published && !c.nameZh.trim()) return '发布前必须填写赛事中文名称';
|
|
const n = input.notice;
|
|
if (!n || typeof n.enabled !== 'boolean' || !validText(n.textZh, 1000) || !validText(n.textEn, 1000)) return '公告字段无效';
|
|
const s = input.schedule;
|
|
if (!s || !validText(s.currentId, 100) || !validText(s.callingId, 100) || !Number.isInteger(s.upcomingCount) || s.upcomingCount < 0 || s.upcomingCount > 20 || !Array.isArray(s.items) || s.items.length > 1000) return '赛程字段无效';
|
|
const ids = new Set();
|
|
for (const item of s.items) {
|
|
if (!item || !validText(item.id, 100) || !item.id || ids.has(item.id) || ['time', 'nameZh', 'nameEn', 'group', 'gender', 'venueZh', 'venueEn'].some((key) => !validText(item[key] ?? ''))) return '赛程含重复编号或无效字段';
|
|
ids.add(item.id);
|
|
}
|
|
if ((s.currentId && !ids.has(s.currentId)) || (s.callingId && !ids.has(s.callingId))) return '当前/检录场次必须存在于赛程中';
|
|
if (s.upcomingIds != null && (!Array.isArray(s.upcomingIds) || s.upcomingIds.some((id) => !ids.has(id)))) return '即将开始场次必须存在于赛程中';
|
|
if (!input.results || !['overall', 'challenge', 'battle'].every((key) => Array.isArray(input.results[key]) && input.results[key].length <= 1000)) return '成绩数据格式无效';
|
|
if (!Array.isArray(input.players) || input.players.length > 10000 || !Array.isArray(input.records) || input.records.length > 1000) return '选手或纪录数据格式无效';
|
|
if (input.players.some((player) => !player || !validText(player.id, 100) || !validText(player.nameZh || '') || !validText(player.nameEn || ''))) return '选手编号或姓名字段无效';
|
|
if (input.records.some((record) => !record || !validText(record.id, 100) || !validText(record.eventZh || '') || !validText(record.nameZh || ''))) return '纪录字段无效';
|
|
if (input.results.overall.some((bucket) => !bucket || !validText(bucket.key, 100) || !Array.isArray(bucket.entries) || bucket.entries.length > 10000 || bucket.entries.some((entry) => !entry || !validText(entry.playerId, 100)))) return '全能赛成绩格式无效';
|
|
if (input.results.challenge.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rankings) || !Array.isArray(event.qualifier) || [event.rankings, event.qualifier].some((list) => list.length > 10000 || list.some((entry) => !entry || !validText(entry.playerId, 100))))) return '挑战赛成绩格式无效';
|
|
if (input.results.battle.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rounds) || event.rounds.length > 20 || event.rounds.some((round) => !round || !Array.isArray(round.matches) || round.matches.length > 100 || round.matches.some((match) => !match || !validText(match.id, 100))))) return 'Battle 对阵格式无效';
|
|
return '';
|
|
}
|
|
|
|
async function readBody(request) {
|
|
let total = 0;
|
|
const chunks = [];
|
|
for await (const chunk of request) {
|
|
total += chunk.length;
|
|
if (total > maxBody) {
|
|
const error = new Error('数据超过 8 MB');
|
|
error.status = 413;
|
|
throw error;
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
catch { const error = new Error('JSON 格式错误'); error.status = 400; throw error; }
|
|
}
|
|
|
|
function broadcast() {
|
|
for (const client of clients) client.write(`event: update\ndata: ${Date.now()}\n\n`);
|
|
}
|
|
|
|
const server = createServer(async (request, response) => {
|
|
cors(request, response);
|
|
if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return; }
|
|
const path = new URL(request.url, 'http://localhost').pathname;
|
|
try {
|
|
if (path === '/api/competition/ping' && request.method === 'GET') return send(response, 200, { ok: true, version: 1, mode: 'service' });
|
|
if (path === '/api/competition/state' && request.method === 'GET') {
|
|
const current = await state();
|
|
if (current.published) return send(response, 200, current);
|
|
const empty = JSON.parse(await readFile(seedPath, 'utf8'));
|
|
return send(response, 200, empty);
|
|
}
|
|
if (path === '/api/competition/stream' && request.method === 'GET') {
|
|
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' });
|
|
response.write(': connected\n\n');
|
|
clients.add(response);
|
|
request.on('close', () => clients.delete(response));
|
|
return;
|
|
}
|
|
if (path === '/api/competition/admin' && ['GET', 'PUT'].includes(request.method)) {
|
|
if (!adminIpAllowed(request)) return send(response, 403, { error: '管理接口仅允许指定内网地址访问' });
|
|
if (request.method === 'GET') return send(response, 200, await state());
|
|
const next = await readBody(request);
|
|
const problem = validate(next);
|
|
if (problem) return send(response, 400, { error: problem });
|
|
await (writeQueue = writeQueue.catch(() => {}).then(async () => {
|
|
await mkdir(dirname(dataPath), { recursive: true });
|
|
const temp = `${dataPath}.${process.pid}.tmp`;
|
|
await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
await rename(temp, dataPath);
|
|
}));
|
|
broadcast();
|
|
return send(response, 200, { ok: true });
|
|
}
|
|
return send(response, 404, { error: '接口不存在' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return send(response, error.status || 500, { error: error.status ? error.message : '服务器处理失败' });
|
|
}
|
|
});
|
|
|
|
const heartbeat = setInterval(() => { for (const client of clients) client.write(': heartbeat\n\n'); }, 25000);
|
|
server.on('close', () => clearInterval(heartbeat));
|
|
server.listen(port, host, () => console.log(`Competition API listening on ${host}:${port}`));
|