// 回响星核 / Echo Nexus — 云排行榜 mini-service // P2/#4:把信标本机排行榜升级为云排行榜。 // 端口固定 3030;Caddy 通过 ?XTransformPort=3030 转发。 // 内存存储(重启清空),Hono + Bun 原生。 // // API: // GET /api/leaderboard → { entries: BeaconScoreEntry[], total } // POST /api/leaderboard body { entry } → { entries, total, rank } // GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore } // GET / → 健康检查 // // 所有响应自动 CORS 开放(hono/cors),OPTIONS 预检 hono 自动处理。 import { Hono } from "hono"; import { cors } from "hono/cors"; // --------------------------------------------------------------------------- // 类型(与前端 BeaconScoreEntry 一致,但这里宽松化以接受历史数据) // --------------------------------------------------------------------------- interface BeaconScoreEntry { timestamp: number; dateKey: string; challenge: string; difficulty: string; progress: number; score: number; durationSec: number; isWeekly?: boolean; isTimed?: boolean; } // --------------------------------------------------------------------------- // 内存存储 // --------------------------------------------------------------------------- const entries: BeaconScoreEntry[] = []; const MAX_ENTRIES = 1000; const TOP_N = 100; // 简单防刷:同 dateKey+challenge 10 秒内只接受 1 次提交 const lastSubmitAt = new Map(); const RATE_LIMIT_MS = 10_000; function sortEntries() { entries.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; // 同分情况下,用时短者靠前 if (a.durationSec !== b.durationSec) return a.durationSec - b.durationSec; // 仍相同,按时间戳升序(先提交靠前) return a.timestamp - b.timestamp; }); while (entries.length > MAX_ENTRIES) entries.pop(); } function findRank(entry: BeaconScoreEntry): number { const idx = entries.findIndex( (e) => e.timestamp === entry.timestamp && e.dateKey === entry.dateKey && e.score === entry.score ); return idx >= 0 ? idx + 1 : -1; } // --------------------------------------------------------------------------- // Hono 实例 // --------------------------------------------------------------------------- const app = new Hono(); // 全局 CORS 开放(前端跨端口访问) app.use( "*", cors({ origin: "*", allowMethods: ["GET", "POST", "OPTIONS"], allowHeaders: ["Content-Type", "X-Requested-With"], exposeHeaders: ["Content-Length"], maxAge: 600, }) ); // 健康检查 app.get("/", (c) => c.json({ service: "echo-nexus-leaderboard", version: "v0.8.2", ok: true, uptime: Math.floor(process.uptime?.() ?? 0), }) ); // GET /api/leaderboard — 返回 Top100 app.get("/api/leaderboard", (c) => { sortEntries(); const top = entries.slice(0, TOP_N); return c.json({ entries: top, total: entries.length }); }); // POST /api/leaderboard — 插入一条记录,返回新榜 + 排名 app.post("/api/leaderboard", async (c) => { let body: { entry?: unknown }; try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON body" }, 400); } if (!body || !body.entry || typeof body.entry !== "object") { return c.json({ error: "Missing entry in body" }, 400); } const e = body.entry as Partial; if ( typeof e.timestamp !== "number" || typeof e.dateKey !== "string" || typeof e.challenge !== "string" || typeof e.difficulty !== "string" || typeof e.score !== "number" ) { return c.json({ error: "Entry schema mismatch" }, 400); } // 防刷:同 dateKey+challenge 10 秒内只接受 1 次 const key = `${e.dateKey}|${e.challenge}`; const now = Date.now(); const last = lastSubmitAt.get(key) ?? 0; if (now - last < RATE_LIMIT_MS) { return c.json( { error: "Rate limited", retryAfterMs: RATE_LIMIT_MS - (now - last), rank: -1, }, 429 ); } lastSubmitAt.set(key, now); // 规范化 entry(去多余字段,补默认值) const entry: BeaconScoreEntry = { timestamp: e.timestamp, dateKey: e.dateKey, challenge: e.challenge, difficulty: e.difficulty, progress: typeof e.progress === "number" ? e.progress : 0, score: Math.max(0, Math.floor(e.score)), durationSec: typeof e.durationSec === "number" ? e.durationSec : 0, isWeekly: e.isWeekly === true, isTimed: e.isTimed === true, }; entries.push(entry); sortEntries(); const rank = findRank(entry); return c.json({ entries: entries.slice(0, TOP_N), total: entries.length, rank, }); }); // GET /api/leaderboard/stats — 全局统计 app.get("/api/leaderboard/stats", (c) => { sortEntries(); const uniqueDateKeys = new Set(entries.map((e) => e.dateKey)); return c.json({ totalSubmissions: entries.length, uniquePlayers: uniqueDateKeys.size, topScore: entries.length > 0 ? entries[0].score : 0, }); }); // --------------------------------------------------------------------------- // 启动 // --------------------------------------------------------------------------- const PORT = 3030; console.log(`[leaderboard-service] listening on :${PORT}`); const server = { port: PORT, fetch: app.fetch, }; export default server;