"use strict"; /* ================= API 处理器 ================= 所有 SQL 均为 prepared statement,禁止字符串拼接用户输入 */ const db = require("./db"); const auth = require("./auth"); const { vUsername, vEmail, vPassword, vInt, rateLimit, clientIp } = require("./util"); /* 图鉴/装备合法 key 白名单(与 js/data.js 保持同步:27主武+15副武+10僚机+8被动=60) 注意:敌人 key 不计入玩家图鉴与装备校验,勿加入此处 */ const CODEX_KEYS = new Set([ "vulcan", "shotgun", "needle", "flame", "shotgun2", "tesla", "railgun", "deathray", "crescent", "fission", "frost", "swarm", "bubble", "starfall", "boomerang", "singularity", "wavecannon", "holy", "thundercall", "arcshu", "volt", "dawn", "proto", "nebula", "loiter", "beegrenade", "chlorophyte", "broad", "sweep", "leech", "orbit", "sats", "pd", "slowfield", "flares", "seeker", "frostnova", "thorns", "sticky", "magnetar", "executor", "airstrike", "gunner", "gunner2", "sniperd", "beamer", "bomber", "rammer", "healer", "shieldd", "missileD", "bladeD", "fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost" ]); const CODEX_TOTAL = CODEX_KEYS.size; const RUN_MODES = new Set(["stage", "endless", "rush"]); /* 存档白名单(与 js/data.js SHIPS、js/ui.js META 保持同步) */ const SHIP_KEYS = new Set(["falcon", "ghost", "bastion", "valkyrie", "asura"]); const PASSIVE_KEYS = new Set(["fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost"]); const META_MAX = { weapon: 2, armor: 2, hpup: 2, luck: 2, wallet: 3, mag2: 2, odstart: 1 }; const DEFAULT_STATE = { coins: 0, ships: ["falcon"], ship: "falcon", m: {}, best: { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 }, rush: {}, mute: false, music: true }; const qInsUser = db.prepare("INSERT INTO users(username,email,pass_hash,created_at) VALUES(?,?,?,?)"); const qInsStats = db.prepare("INSERT OR IGNORE INTO user_stats(user_id) VALUES(?)"); const qByName = db.prepare("SELECT * FROM users WHERE username=?"); const qByEmail = db.prepare("SELECT * FROM users WHERE email=?"); const qCodex = db.prepare("SELECT entry_key FROM codex_unlocks WHERE user_id=?"); const qInsCodex = db.prepare("INSERT OR IGNORE INTO codex_unlocks(user_id,entry_key,unlocked_at) VALUES(?,?,?)"); const qStats = db.prepare("SELECT * FROM user_stats WHERE user_id=?"); const qInsRun = db.prepare("INSERT INTO runs(user_id,mode,diff,score,wave,stage,time_sec,boss_kills,win,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)"); const qUpdStats = db.prepare(`INSERT INTO user_stats(user_id,boss_kills,endless_high,runs,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET boss_kills=boss_kills+excluded.boss_kills, endless_high=MAX(endless_high,excluded.endless_high), runs=runs+1, updated_at=excluded.updated_at`); const qGetState = db.prepare("SELECT state FROM player_state WHERE user_id=?"); const qPutState = db.prepare(`INSERT INTO player_state(user_id,state,updated_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET state=excluded.state, updated_at=excluded.updated_at`); const qGetSave = db.prepare("SELECT payload, updated_at FROM run_saves WHERE user_id=?"); const qPutSave = db.prepare(`INSERT INTO run_saves(user_id,payload,updated_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at`); const qDelSave = db.prepare("DELETE FROM run_saves WHERE user_id=?"); function statsOf(userId) { const s = qStats.get(userId) || { boss_kills: 0, endless_high: 0, runs: 0 }; return { bossKills: s.boss_kills, endlessHigh: s.endless_high, runs: s.runs }; } function codexOf(userId) { return qCodex.all(userId).map((r) => r.entry_key); } function stateOf(userId) { const r = qGetState.get(userId); if (!r) return null; // null=从未同步(老账号首次登陆:客户端用本地存档初始化上传) try { return JSON.parse(r.state); } catch (e) { return null; } } /* 存档白名单校验:只留合法字段并 clamp,任何字段非法则整体拒收 */ function vState(s) { if (!s || typeof s !== "object" || Array.isArray(s)) return null; if (!vInt(s.coins, 0, 1e7)) return null; if (!Array.isArray(s.ships)) return null; const ships = [...new Set(s.ships.filter((k) => SHIP_KEYS.has(k)))].slice(0, 8); if (!ships.includes("falcon")) ships.unshift("falcon"); const ship = SHIP_KEYS.has(s.ship) ? s.ship : "falcon"; const m = {}; if (s.m && typeof s.m === "object") for (const k of Object.keys(META_MAX)) if (vInt(s.m[k], 0, META_MAX[k])) m[k] = s.m[k]; const best = { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 }; if (s.best && typeof s.best === "object") for (const k of Object.keys(best)) if (vInt(s.best[k], 0, 1e8)) best[k] = s.best[k]; const rush = {}; if (s.rush && typeof s.rush === "object") for (const k of Object.keys(s.rush).slice(0, 32)) if (/^\d{1,2}$/.test(k) && vInt(s.rush[k], 0, 8640000)) rush[k] = s.rush[k]; /* 音频开关:缺省=不静音/音乐开(老存档无此字段) */ const mute = s.mute === true; const music = s.music !== false; return { coins: s.coins, ships, ship, m, best, rush, mute, music }; } /* 无尽模式断点存档校验(波次检查点快照;装备 key 复用图鉴白名单) */ function vEquipMap(o) { if (!o || typeof o !== "object" || Array.isArray(o)) return null; const out = {}; for (const k of Object.keys(o).slice(0, 8)) if (CODEX_KEYS.has(k) && vInt(o[k], 1, 5)) out[k] = o[k]; return out; } function vLoadout(a, max, src) { if (!Array.isArray(a)) return null; return [...new Set(a.filter((k) => typeof k === "string" && src[k]))].slice(0, max); } function vRunSave(s) { if (!s || typeof s !== "object" || Array.isArray(s)) return null; if (s.mode !== "endless") return null; // 断点续玩仅限无尽模式 if (!SHIP_KEYS.has(s.ship)) return null; if (!vInt(s.diff, 0, 1)) return null; if (!vInt(s.wave, 1, 9999) || !vInt(s.score, 0, 1e8) || !vInt(s.coins, 0, 1e7)) return null; if (!vInt(s.kills, 0, 1e6) || !vInt(s.level, 1, 999) || !vInt(s.xp, 0, 1e6) || !vInt(s.xpNeed, 1, 1e9)) return null; if (!vInt(s.lives, 0, 99) || !vInt(s.maxHp, 1, 9999) || !vInt(s.hp, 0, 9999)) return null; if (!vInt(s.shield, 0, 9) || !vInt(s.od, 0, 100)) return null; if (!vInt(s.bossN, 0, 9999) || !vInt(s.bossKills, 0, 60) || !vInt(s.time, 0, 5184000)) return null; const weapons = vEquipMap(s.weapons), subs = vEquipMap(s.subs), drones = vEquipMap(s.drones); if (!weapons || !subs || !drones) return null; const loadout = vLoadout(s.loadout, 5, weapons); const subLoadout = vLoadout(s.subLoadout, 5, subs); const droneLoadout = vLoadout(s.droneLoadout, 3, drones); if (!loadout || !loadout.length || !subLoadout || !droneLoadout) return null; const u = {}; if (s.u && typeof s.u === "object") for (const k of PASSIVE_KEYS) if (vInt(s.u[k], 0, 5)) u[k] = s.u[k]; const luck = vInt(s.luck, 0, 2) ? s.luck : 0; return { v: 1, mode: "endless", diff: s.diff, ship: s.ship, wave: s.wave, score: s.score, coins: s.coins, kills: s.kills, level: s.level, xp: s.xp, xpNeed: s.xpNeed, luck, weapons, loadout, subs, subLoadout, drones, droneLoadout, u, lives: s.lives, maxHp: s.maxHp, hp: Math.min(s.hp, s.maxHp), shield: s.shield, od: s.od, bossN: s.bossN, bossKills: s.bossKills, time: s.time }; } /* ---------- GET /api/run_save(无档返回 save:null) ---------- */ function getRunSave(req, res, ctx) { const r = qGetSave.get(ctx.user.id); if (!r) return ctx.ok({ save: null }); let save = null; try { save = JSON.parse(r.payload); } catch (e) { /* 损坏按无档处理 */ } return ctx.ok({ save, updatedAt: r.updated_at }); } /* ---------- POST /api/run_save(每账号1栏位,新档覆盖旧档) ---------- */ function putRunSave(req, res, ctx, body) { if (!rateLimit("rsave:" + ctx.user.id, 40, 5 * 60000)) return ctx.fail(429, "同步太频繁"); const save = vRunSave(body && body.save); if (!save) return ctx.fail(400, "非法对局存档"); qPutSave.run(ctx.user.id, JSON.stringify(save), Date.now()); return ctx.ok({ saved: true }); } /* ---------- DELETE /api/run_save(结算/死亡后清除) ---------- */ function delRunSave(req, res, ctx) { qDelSave.run(ctx.user.id); return ctx.ok({ deleted: true }); } /* ---------- POST /api/register ---------- */ function register(req, res, ctx, body) { if (!rateLimit("reg:" + ctx.ip, 10, 5 * 60000)) return ctx.fail(429, "操作太频繁,请稍后再试"); const username = body && body.username, email = body && body.email, password = body && body.password; if (!vUsername(username)) return ctx.fail(400, "用户名需为 2-16 位(中文/英文/数字/下划线)"); if (!vEmail(email)) return ctx.fail(400, "邮箱格式不正确"); if (!vPassword(password)) return ctx.fail(400, "密码需为 8-72 位"); if (qByName.get(username)) return ctx.fail(409, "用户名已被注册"); if (qByEmail.get(email)) return ctx.fail(409, "邮箱已被注册"); const info = qInsUser.run(username.trim(), email.trim().toLowerCase(), auth.hashPassword(password), Date.now()); const uid = Number(info.lastInsertRowid); qInsStats.run(uid); const token = auth.createSession(db, uid); ctx.setCookie = auth.sessionCookie(token, ctx.secure); return ctx.ok({ user: { username, email: email.trim().toLowerCase() }, stats: statsOf(uid), codex: [], codexTotal: CODEX_TOTAL, state: DEFAULT_STATE }); } /* ---------- POST /api/login ---------- */ function login(req, res, ctx, body) { if (!rateLimit("login:" + ctx.ip, 20, 5 * 60000)) return ctx.fail(429, "尝试太频繁,请稍后再试"); const id = body && body.id, password = body && body.password; if (typeof id !== "string" || typeof password !== "string" || id.length > 64 || password.length > 72) return ctx.fail(401, "用户名或密码错误"); const u = id.includes("@") ? qByEmail.get(id.trim().toLowerCase()) : qByName.get(id.trim()); /* 统一错误信息,不泄露账号是否存在;对不存在账号也跑一次哈希,防时序探测 */ const hash = u ? u.pass_hash : "0".repeat(32) + ":" + "0".repeat(128); if (!auth.verifyPassword(password, hash) || !u) return ctx.fail(401, "用户名或密码错误"); qInsStats.run(u.id); const token = auth.createSession(db, u.id); ctx.setCookie = auth.sessionCookie(token, ctx.secure); return ctx.ok({ user: { username: u.username, email: u.email }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL, state: stateOf(u.id) }); } /* ---------- POST /api/logout ---------- */ function logout(req, res, ctx) { auth.destroySession(db, ctx.token); ctx.setCookie = auth.clearCookie(ctx.secure); return ctx.ok({ bye: true }); } /* ---------- GET /api/me(未登录返回 user:null,不产生 401 控制台噪音) ---------- */ function me(req, res, ctx) { const u = ctx.user; if (!u) return ctx.ok({ user: null }); return ctx.ok({ user: { username: u.username, email: u.email, createdAt: u.created_at }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL, state: stateOf(u.id) }); } /* ---------- GET /api/leaderboard?sort=endless|boss|codex ---------- */ const LB_COLS = { endless: "endless_high", boss: "boss_kills", codex: "codexN" }; const BOSS_TOTAL = 11; // 与 js/enemies.js BOSSES 数量同步 function leaderboard(req, res, ctx, _body, query) { const sort = (query && query.get("sort")) || ""; const bossSort = sort === "boss"; /* Boss 列一律统计 BOSS 挑战(rush)击败过的不同 Boss 数(去重,stage 0~10),不再出现累计击坠数 */ const bossSub = `(SELECT COUNT(DISTINCT r.stage) FROM runs r WHERE r.user_id=u.id AND r.mode='rush' AND r.win=1 AND r.stage BETWEEN 0 AND 10)`; const rows = db.prepare(` SELECT u.username AS username, ${bossSub} AS bossKills, s.endless_high AS endlessHigh, (SELECT COUNT(*) FROM codex_unlocks c WHERE c.user_id=u.id) AS codexN FROM users u JOIN user_stats s ON s.user_id=u.id ORDER BY ${bossSort ? "bossKills" : (LB_COLS[sort] || "endless_high")} DESC, u.id ASC LIMIT 100`).all(); return ctx.ok({ rows, codexTotal: CODEX_TOTAL, bossTotal: BOSS_TOTAL, sort }); } /* ---------- POST /api/run ---------- 结算上报:基础合理性校验(客户端游戏无法杜绝作弊,只挡低级伪造) */ function run(req, res, ctx, body) { if (!rateLimit("run:" + ctx.user.id, 20, 5 * 60000)) return ctx.fail(429, "上报太频繁"); const b = body || {}; if (!RUN_MODES.has(b.mode)) return ctx.fail(400, "非法模式"); if (!vInt(b.diff, 0, 1)) return ctx.fail(400, "非法难度"); if (!vInt(b.score, 0, 1e8)) return ctx.fail(400, "非法分数"); if (!vInt(b.wave, 0, 9999) || !vInt(b.stage, 0, 99)) return ctx.fail(400, "非法进度"); if (!vInt(b.timeSec, 0, 86400)) return ctx.fail(400, "非法时长"); if (!vInt(b.bossKills, 0, 60)) return ctx.fail(400, "非法击坠数"); const keys = Array.isArray(b.codex) ? b.codex.slice(0, 100).filter((k) => CODEX_KEYS.has(k)) : []; const uid = ctx.user.id, now = Date.now(); /* endless_high 只认本账号实际上报的无尽对局分数(不收 localStorage 历史值——浏览器级共享会串号,已踩坑) */ const endlessCand = b.mode === "endless" ? b.score : 0; db.exec("BEGIN"); try { qInsRun.run(uid, b.mode, b.diff, b.score, b.wave, b.stage, b.timeSec, b.bossKills, b.win ? 1 : 0, now); qUpdStats.run(uid, b.bossKills, endlessCand, 1, now); for (const k of keys) qInsCodex.run(uid, k, now); db.exec("COMMIT"); } catch (e) { db.exec("ROLLBACK"); throw e; } return ctx.ok({ stats: statsOf(uid), codex: codexOf(uid), codexTotal: CODEX_TOTAL }); } /* ---------- POST /api/state ---------- 存档云同步:星币/机体/选中机体/永久强化/本地纪录(防抖由客户端负责) */ function saveState(req, res, ctx, body) { if (!rateLimit("state:" + ctx.user.id, 60, 5 * 60000)) return ctx.fail(429, "同步太频繁"); const st = vState(body && body.state); if (!st) return ctx.fail(400, "非法存档数据"); qPutState.run(ctx.user.id, JSON.stringify(st), Date.now()); return ctx.ok({ saved: true }); } const ROUTES = { "POST /api/register": { fn: register, auth: false }, "POST /api/login": { fn: login, auth: false }, "POST /api/logout": { fn: logout, auth: true }, "GET /api/me": { fn: me, auth: false }, "GET /api/leaderboard": { fn: leaderboard, auth: true }, "POST /api/run": { fn: run, auth: true }, "POST /api/state": { fn: saveState, auth: true }, "GET /api/run_save": { fn: getRunSave, auth: true }, "POST /api/run_save": { fn: putRunSave, auth: true }, "DELETE /api/run_save": { fn: delRunSave, auth: true }, }; module.exports = { ROUTES, CODEX_TOTAL };