离线也能玩,联网不丢分——幂等 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 边界:顺序就是防护层次
服务端那条请求流水线,每一步的顺序本身就是防护层次:
- 同源校验:带了
Origin头就必须与请求同源,否则403;响应侧主动删掉所有access-control-allow-*头,不做 CORS 放行; - 鉴权:bearer 必须匹配
^Bearer ([a-fA-F0-9-]{36})$,否则401; - 限流:先按 IP 再按身份两层,超限统一
429 + Retry-After: 60。顺序上先消耗 IP 额度、再读 body——让一个无效的大 body 在解析之前就吃掉限流额度,把解析成本挡在门外;新建存档还走一个更严的限流(每 60 秒 2 次); - 有界 JSON:强制
Content-Type: application/json(否则415),先查Content-Length(413),再流式累加字节、一超上限立刻reader.cancel()中止,不把整个流读进内存;用TextDecoder('utf-8', {fatal: true})拒非法字节; - 错误不泄漏:非预期错误一律
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 和体积上限,而不是假装限流是全局账本。
留言