Cloudflare · 隐私 · KeyDo

排行榜显示你的城市,却不知道你在哪——匿名地区标签

键道 KeyDo 复盘:给匿名排行榜加城市级地区标签,前提是不收集精确位置、不接第三方 IP 定位、业务库里绝不落 IP。只读 Cloudflare 边缘注入的国家/省/市字段、逐级降级、库层用 CHECK 约束把关,外加只让完整一局进榜、稳定名次靠三段 COUNT。

键道 KeyDo 的排行榜是匿名的:没有账号,一个口令一份成绩。这次给它加了个「城市级」地区标签——榜上能看到「广东·深圳」这种归属。听起来和「匿名」矛盾,但做法的核心恰恰是:在不收集精确位置、不接第三方 IP 定位、业务库里绝不落一个字节 IP 的前提下,给出一个粗到城市级的地区标签。

之前写过排行榜的服务端底线——防有人 POST 一个 9999。这篇是另一面:地区标签的隐私,和排名的完整性

城市从哪来:只读边缘注入的字段

地区不是客户端上报的,也不是拿 IP 去查库。它只读 Cloudflare 边缘节点注入的 request.cf 字段——countryregionCodecity——这些是 Cloudflare 在反代层就填好的,我们的 Worker 直接读。而且客户端上报的任何地理字段一律拒收POST /api/score 的 schema 里出现 location / country / city / ip,直接判 INVALID_ARGUMENT。地区只能来自边缘,不能来自用户自称。

规范化是逐级降级的,产出一个可空的 location_code

export function normalizeLocation(cf) {
  const { country } = cf
  if (!/^[A-Z]{2}$/.test(country)) return null
  if (country !== 'CN') {
    return Object.hasOwn(COUNTRY_LABELS, country) ? country : null   // 海外只到国家,且要在 allowlist 里
  }
  const provinceEntry = PROVINCE_BY_REGION.get(cf.regionCode)
  if (provinceEntry === undefined) return 'CN'                       // 省未知 → 只到「中国」
  const city = normalizeCityAlias(cf.city)                          // NFKC + 小写 + 去空白
  if (city !== null) {
    const entry = CITY_BY_REGION_ALIAS.get(`${cf.regionCode} ${city}`)
    if (entry !== undefined && entry.code.startsWith(provinceEntry.numericPrefix)) {
      return `CN-${entry.code}`                                      // 命中 → 深圳 CN-440300
    }
  }
  return `CN-${cf.regionCode}`                                      // 城市没命中 → 降到省 CN-GD
}

产出只有四种形态:US(海外国家)、CN-440300(中国地级市的 6 位行政区码)、CN-GD(省)、CN(只知在中国)、或 NULL。Tor 出口、缺 cf、字段非法,一律 NULL——绝不用 header、IP 或 locale 去猜补。宁可标「未知地区」,不猜。

有个消歧细节:城市匹配用 (regionCode, 规范化城市名) 组合键,还要求命中城市的行政区码前缀等于省级前缀——这样两个同名的「泰州」能按省分开,不会张冠李戴。

IP 只当限流 key,不落库

这套设计的隐私底线是:原始 IP 只用作这一次请求的限流 key,绝不写进 D1、不进响应、不进自定义日志。 生产环境还关掉了 Workers Logs 的持久化。地区标签在 Worker 内存里从 cf 算出 location_code 就用掉,原始 city 字符串连规范化后的都不持久化。

而且文档里诚实写了残余风险,没有吹「完全不碰 IP」:Cloudflare 作为反代,基础设施层仍然会处理来源 IP,KeyDo 没法替 Cloudflare 承诺它完全不处理或保留 IP。地区标签本身的措辞也克制——它描述的是「该条最佳成绩提交时的网络出口的粗略位置」,不是用户常住地,可能受 VPN、代理、运营商出口影响。能做的是自己这层不留 IP,不能做的是替上游打包票。

库层还加了一道 CHECK 约束兜底,非法地区码根本进不了库:

ALTER TABLE scores ADD COLUMN location_code TEXT CHECK (
  location_code IS NULL
  OR location_code GLOB '[A-Z][A-Z]'
  OR location_code GLOB 'CN-[A-Z][A-Z]'
  OR location_code GLOB 'CN-[0-9][0-9][0-9][0-9][0-9][0-9]'
);

完整性:只有完整一局能进榜

地区解决了「你从哪来」,还要解决「什么成绩配上榜」。这次把 completed 这个布尔引进了结算:一局是否完整,由调用方按各模式的结束条件判定(课程内容打完 / 测速墙钟到点 / 挑战 60 秒到 / 单词雨命归零)。中途 Esc、切走、重开,传 completed: false

分支效果很讲究:

完整一局 未完成(partial)
练习时长 累加 照样累加
历史记录 创建 不创建
session 计数 +1 不动
排行榜 该模式成绩入队 只有累计时长入队

也就是说:没打完的练习时长照常计入统计(你确实练了),但不产生历史记录、不占 session、不生成竞技成绩。这挡的是一类作弊——打一半、趁着瞬时 WPM 虚高就退出,然后拿这个虚高去刷榜。练时长可以计,但榜只认完整局。

配套服务端还有两道:写榜用 ON CONFLICT DO UPDATE ... WHERE excluded.value > scores.value,只有严格更高分才替换行(地区标签也只随更优成绩更新);周榜只在客户端上报的周序等于服务端当前周时才写,否则 weeklyAccepted: false——防离线攒几天的旧周成绩联网后污染本周榜。

稳定名次:三段 COUNT,不用窗口函数

名次用一个「三段 COUNT 加一」算,而不是窗口函数,就为了名次和榜单排序严格一致

SELECT 1
  + (SELECT COUNT(*) FROM scores WHERE mode=?1 AND value > s.value)
  + (SELECT COUNT(*) FROM scores WHERE mode=?1 AND value = s.value AND updated_at < s.updated_at)
  + (SELECT COUNT(*) FROM scores WHERE mode=?1 AND value = s.value AND updated_at = s.updated_at AND user_id < s.user_id)
  AS rank

三段正好对应榜单的排序键 value DESC, updated_at ASC, user_id ASC:分高的排前,同分看谁先提交,再同比 user_id。这样「我的名次」和列表里第几行永远对得上,不会出现「显示第 5 名、但列表里数下来是第 6 个」的错位。

配套建了覆盖全部排序键的复合索引 (mode, value DESC, updated_at ASC, user_id ASC),列顺序刻意等于 ORDER BY,并用 EXPLAIN QUERY PLAN 验证过 top 50 走索引、不建临时排序树。展示时身份只暴露前 4 位(substr(user_id, 1, 4)),完整口令值绝不进响应。

复盘

  • 地区标签只信边缘、不信客户端。地理只从 Cloudflare 反代注入的 cf 字段来,客户端上报的地理字段一律拒收——用户能改的东西不能当地理事实的来源;
  • 粗到能用就好,缺了就标未知。逐级降级到城市 / 省 / 国家,命不中就 NULL,绝不用 IP、header、locale 去猜补。隐私的第一原则是「少知道」,不是「猜得准」;
  • 自己这层不留 IP,同时诚实披露上游残余风险。IP 只当限流 key、不落库不进日志;但也不吹「完全不碰 IP」——反代层的 IP 处理不归你管,就如实说,别给用户一个假的绝对承诺;
  • 时长可以宽容,榜单必须严格。没打完的练习照样计时长,但不进榜——把「鼓励练习」和「竞技公平」分开,用一个 completed 布尔切开两套记账;
  • 名次算法要和排序键逐段对齐。三段 COUNT 精确复刻 ORDER BY,名次和列表位置才不会错位;索引列顺序也照抄排序键,才走得上索引不建临时排序树。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。