客户端算的成绩,凭什么信——排行榜的服务端底线
键道 KeyDo 复盘:WPM 是浏览器里算出来的,全球排行榜怎么防止有人随手 POST 一个 9999。上限校验、每人每模式一条、SQL 里判最佳。
键道 KeyDo 的成绩——WPM、得分——全是在你浏览器里算出来的。前端算完,往后端 POST 一下上榜。问题很明显:任何人打开控制台,都能手动 fetch('/api/score', ...) 提交一个 9999 WPM。 一个纯前端算分的游戏,全球排行榜怎么还能有点可信度?这篇讲后端那几道不算复杂、但必须有的底线。
后端是一个 Cloudflare Worker + D1(SQLite),整个 worker/index.js 128 行。
第一道:合法性上限,超了直接拒
前端能算出多离谱的数,后端不管;但后端知道人类不可能达到的数。于是每个模式设一个硬上限,超了直接拒绝:
// 服务端合法性上限:超出直接拒绝(基础防刷)
const VALUE_CAPS = { speed: 300, challenge: 50000, rain: 500000, time: 18_000_000 }
speed(WPM)上限 300——世界纪录打字速度也就 200 出头,300 已经是「不可能是人」的安全线。time(累计练习秒数)上限 5000 小时。提交时一票否决:
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return bad('invalid value')
if (value > VALUE_CAPS[mode]) return bad('value out of range')
这挡不住「提交一个 250 的假 WPM」——那种作弊后端无从分辨。但它挡住了最蠢也最常见的一类:随手填个天文数字霸榜。服务端校验的目标不是「杜绝一切作弊」,而是「让排行榜不至于被一个 9999 毁掉」。 对一个免费的打字小游戏,这条性价比最高的底线就够了。
每一个输入都不信
value 之外,其它字段一样全部校验,因为它们都来自不可信的客户端:
if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/i.test(id)) return bad('invalid id') // 必须是 UUID
if (!MODES.has(mode)) return bad('invalid mode') // 白名单
const safeName = String(name ?? '').trim().slice(0, 24) || '无名武者' // 截断 + 兜底
id 必须匹配 UUID 格式,mode 必须在白名单集合里,name 截到 24 字、空的给个默认。准确率也校验范围 0–100,越界就当没提交(存 null)。「前端会传合法数据」这个假设,在后端一次都不能有——前端是给用户用的,也是给攻击者用的。 所有 SQL 都走 D1 的 prepared statement 绑定参数,注入这条从根上堵死。
每人每模式只留最佳:用一条 SQL 表达
排行榜只保留每人每模式的历史最佳,不是流水账。这个「只在更高时更新」的逻辑,直接写进一条 INSERT ... ON CONFLICT 里,不用先查再判:
INSERT INTO scores (user_id, mode, name, value, accuracy, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (user_id, mode) DO UPDATE SET
value = CASE WHEN excluded.value > scores.value THEN excluded.value ELSE scores.value END,
accuracy = CASE WHEN excluded.value > scores.value THEN excluded.accuracy ELSE scores.accuracy END,
updated_at = excluded.updated_at
(user_id, mode) 上的唯一约束保证一人一模式一行;冲突时用 CASE WHEN 判断——新成绩更高才更新 value 和对应的 accuracy,否则保留旧的。把「取最大值」交给数据库用一条语句原子完成,比「先 SELECT 出旧值、在 JS 里比较、再 UPDATE」少一次往返,也没有并发下的竞态。 练习时长榜更省事:客户端每次提交累计秒数,服务端天然取 MAX,只增不减。
匿名但稳定的身份
没有登录,怎么认「每个人」?首次打开生成一个 crypto.randomUUID() 存 localStorage,就是你的身份。名字也自动生成——但用 UUID 前 8 位做确定性取名(十六进制转数字,去索引一组道场风格的前后缀),保证刷新页面不会变名,同一个 id 永远是「衔烛武者」。
排行榜展示时,只回传名字和 UUID 前 4 位做区分标签(substr(user_id, 1, 4)),完整 id 不外泄。身份还能导出成一段 KEYDO-<base64> 口令跨设备迁移——中文名先 UTF-8 编码再 base64,避开 btoa 不吃非 Latin1 的坑。匿名不等于不稳定:无需账号,但身份在本设备恒定、可跨设备迁移、且不把完整标识暴露在公开榜单上。
小结
给一个纯前端算分的游戏做一个不至于被毁掉的排行榜:
- 合法性上限:每模式一个硬上限(WPM 300 等),超了直接拒——挡住天文数字霸榜这类最蠢的作弊;
- 每个输入都不信:id 校验 UUID、mode 走白名单、name 截断兜底、SQL 全用 prepared statement;
- 最佳成绩用一条 SQL:
INSERT ... ON CONFLICT DO UPDATE+CASE WHEN原子取最大,省往返、无竞态; - 匿名但稳定的身份:UUID + 确定性取名,本地恒定、可导出迁移、榜单只露前 4 位。
一句话:客户端的数据一律不可信,但服务端也不必追求「杜绝一切作弊」——设一条「人类不可能越过」的底线,把最蠢的那类挡在门外,对一个小游戏就够了。
留言