Files
echo-nexus/mini-services/leaderboard-service/index.ts
T
2026-06-24 00:41:29 +00:00

183 lines
5.3 KiB
TypeScript
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.
// 回响星核 / Echo Nexus — 云排行榜 mini-service
// P2/#4:把信标本机排行榜升级为云排行榜。
// 端口固定 3030Caddy 通过 ?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<string, number>();
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<BeaconScoreEntry>;
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;