Cloudflare离线KeyDo

离线也能玩,联网不丢分——幂等 outbox 加一条 Worker 边界流水线

键道 KeyDo 复盘:成绩上报原来是 fire-and-forget,离线或失败就丢;公开 API 没有体积上限、来源校验和限流。这次做了 PWA 离线、双端幂等的成绩同步 outbox、以及一条「顺序即防护层次」的 Worker 请求边界流水线。

键道 KeyDo 的成绩上报,原来就一句 submitScore(...).catch(() => {})——发出去,失败就算了。离线打的、网络抖掉的,全丢。另一头,公开的 /api 完全没有请求体上限、来源校验和限流,滥用面很大。这次整改把两头都收了:离线能玩、成绩本地暂存联网补传,且不丢不重;Worker 前面加一条边界流水线。

离线:只兜静态外壳,不缓存接口

离线用 PWA(Service Worker)实现,但有个刻意的边界:只缓存静态外壳,绝不缓存业务接口。

构建期一个 Vite 插件扫描产物,把 index.html、manifest、图标、所有 JS/CSS/字体收进一个预缓存列表,并对内容算 sha256 生成内容哈希的 cache 名 keydo-precache-<digest>——构建变了 cache 名才变,天然做版本切换。Service Worker 三段:

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE_NAME)
    try { await cache.addAll(PRECACHE_URLS) }          // 原子预缓存:一个失败就整体回滚
    catch (e) { await caches.delete(CACHE_NAME); throw e }
  })())
})
// fetch:只处理 GET/同源,且显式放行 /api(离线不缓存业务接口)
if (url.pathname === '/api' || url.pathname.startsWith('/api/')) return
if (request.mode === 'navigate') {                      // 导航请求:网络失败回退缓存的 index.html
  event.respondWith(fetch(request).catch(() => caches.match('/index.html')))
}

关键那行是 API 请求被显式跳过。离线只保证「重新打开能进四个模式」,成绩同步交给下面的 outbox——绝不把陈旧的成绩、排行当成离线数据缓存下来。缓存一个过期的排行榜比不缓存更糟。

成绩同步:双端幂等,天生不丢不重

可靠同步靠客户端和服务端两头都幂等

客户端一个可合并的 outbox。 每个身份按 模式 + 周 只存一条 outbox 条目,同 key 的多条按「分数降序」折叠成一条最优值。这是一个 per-key 的「只增最优值」结构——重复入队、重试,都收敛到「每个 key 保留更优的那条」,所以重放安全。

服务端只接受更优。 排行榜表主键是 (user_id, mode),每人每模式只有一行,写入是条件 UPSERT:

INSERT INTO scores (...) VALUES (...)
ON CONFLICT (user_id, mode) DO UPDATE SET ...
WHERE excluded.value > scores.value      -- 幂等:重复或较低的提交是 no-op

两头一叠,一条成绩重复提交多少次都是同一个结果。而且**「服务端已写、响应在网络里丢了」也没事**——客户端超时后重发,服务端那条 WHERE value > ... 让重发变成 no-op。这是幂等相对「发一次就删」的价值:不确定发没发成,那就放心重发。

跨周还有一道防重放:客户端带自己按 UTC+8 算的周序,服务端只在等于服务端当前周时才更新周榜,否则只更新累计榜、回 weeklyAccepted: false。离线攒了几天的上周成绩,联网后不会污染本周榜。

重试:分类错误 + 单飞协调 + 指数退避

不是所有失败都该重试。classifySyncError 把错误分两类:

if (code === 'NETWORK_ERROR' || code === 'INVALID_RESPONSE'
    || status === 409 || status === 429 || (status >= 500 && status <= 599))
  return { kind: 'retryable' }          // 只有这几类自动重试
if (status >= 400 && status <= 499)
  return { kind: 'blocked' }            // 其余 4xx 标记 blocked,绝不周期性重放

409(版本冲突)、429(限流)、5xx、网络错、响应格式坏——这些是「重试有意义」的;而其它 4xx(比如请求本身非法)重试一万次也没用,标 blocked,不再周期性刷。协调器是单飞(single-flight)的:同一身份同一时刻只有一轮同步在跑,250ms 内的触发合并成一次;自动重试用上限 5 分钟的指数退避,而用户手动点「重试」不受这个等待限制。还监听 online 事件,一联网自动补传。持久化的待办只存稳定错误码和重试次数,不含 bearer 或存档正文

Worker 边界:顺序就是防护层次

服务端那条请求流水线,每一步的顺序本身就是防护层次

  1. 同源校验:带了 Origin 头就必须与请求同源,否则 403;响应侧主动删掉所有 access-control-allow-* 头,不做 CORS 放行;
  2. 鉴权:bearer 必须匹配 ^Bearer ([a-fA-F0-9-]{36})$,否则 401
  3. 限流:先按 IP 再按身份两层,超限统一 429 + Retry-After: 60。顺序上先消耗 IP 额度、再读 body——让一个无效的大 body 在解析之前就吃掉限流额度,把解析成本挡在门外;新建存档还走一个更严的限流(每 60 秒 2 次);
  4. 有界 JSON:强制 Content-Type: application/json(否则 415),先查 Content-Length413),再流式累加字节、一超上限立刻 reader.cancel() 中止,不把整个流读进内存;用 TextDecoder('utf-8', {fatal: true}) 拒非法字节;
  5. 错误不泄漏:非预期错误一律 500 服务暂时不可用,内部细节带 requestId 走日志,响应体绝不含异常消息或 SQL。

有个诚实的标注:这套限流是 best-effort(边缘节点各自隔离、最终一致),不当精确的全局配额用;真正限制写放大的是 CAS + 每人每模式一行 + 100 KiB 上限那几层。限流是把明显的滥用挡在门外,不是精确记账。

附:测试要隔离

顺带一个测试卫生:HTTP 测试原来共用一个预置身份和固定分数 88,用例之间靠这个共享状态耦合。改成每个用例用自己独立的身份、自己先建一条成绩,用例之间不共享数据库状态。改完那条断言从 88 变成 91(它自己写的分)。意义是消除跨用例的顺序耦合——尤其对「幂等」「最优值」这种依赖持久状态的断言,单跑、并行跑都得稳定。

复盘

  • 离线只兜静态外壳,业务接口一律走网络。缓存一个陈旧的排行榜比不缓存更糟;SW 显式跳过 /api,成绩同步交给幂等 outbox,而不是缓存接口响应;
  • 双端幂等,不丢不重就免费。客户端 per-key 只增最优值 + 服务端 WHERE value > ... 条件写——重复提交、重放、甚至「响应丢失后重发」都收敛到同一结果。幂等让你敢放心重发;
  • 不是所有失败都该重试。把错误分成可重试(网络 / 429 / 5xx / 409)和 blocked(其它 4xx),别对一个「请求本身非法」的错误周期性刷;单飞 + 指数退避封顶 + 手动重试豁免,是一套「够用不扰民」的默认;
  • 请求边界的顺序就是防护层次。同源 → 鉴权 → 限流 → 有界解析 → 不泄漏错误,先扣限流额度再读 body、超限立刻 cancel 流,让攻击成本在最外层就被挡掉;
  • 限流是 best-effort,别当精确配额。诚实认清边缘限流的最终一致性,把「防写放大」的真正担子交给 CAS 和体积上限,而不是假装限流是全局账本。

留言

  • 加载中…

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