用户明明默写了 20 多个单词,为什么任务栏还停在 4/20?
一次"计数没坏,却看起来卡死"的排查实录:任务进度条停在 4/20 的背后,是五层互相掩盖的线索。
用户明明默写了 20 多个单词,为什么任务栏还停在 4/20?
一次"计数没坏,却看起来卡死"的排查实录:任务进度条停在 4/20 的背后,是五层互相掩盖的线索。
问题现象
安卓用户 ᗜ֊ᗜ 反馈:自己明明默写了 20 多个单词,首页任务栏里的"默写复习"却一直停在 4/20,怎么都不涨。
我的第一反应是"计数丢了"。结果拉数据一看,结论完全相反——计数一直在涨,只是用户看到的永远是旧值。
排查过程
第一步:服务端计数其实是对的
从生产库看这位用户的 DailyTaskCompletion:
| taskType | currentValue | targetValue | updatedAt |
|---|---|---|---|
| COMPLETE_REVIEWS | 17 | 20 | 09:50:58 |
而词库表里今天所有 dictation 答题数 sum(correctCount + incorrectCount) 恰好也是 17。两边完全对得上——每次 /api/dictation/update 都正确 +1 了,FLASHCARD_INTERACT=15/15、REACH_ACCURACY=81/80 也都正常。计数链路是活的。
那问题出在哪?
第二步:updatedAt 冻住了
任务行的 updatedAt 永远停在创建时刻 09:50:58,哪怕 currentValue 已经从 4 涨到 17。
去翻 GameService.reportTaskProgress:
await prisma.dailyTaskCompletion.upsert({
where: { userId_date_task_type: {...} },
update: {
currentValue: newValue, // ← 绝对值覆盖,不是原子自增
isCompleted,
completedAt: ...,
// ← 没有 updatedAt: new Date()
},
...
})
两个问题叠加:
update分支没写updatedAt,所以任务行的时间戳永远冻结在创建时刻。任何"看时间戳判断有没有更新"的方式都会被骗。- 读改写不是原子的:先
findUnique读currentValue,再计算newValue覆盖写回。并发场景下两个请求都读到4,都算出5,一起写回5——一次 +1 就丢了。移动端快速答题时这就是真实风险。
第三步:上报失败被静默吞掉
后端:gameService.reportTaskProgress(...).catch(() => {})——异常全吞。
前端(dictation/page.tsx):
if (!res.ok && process.env.NODE_ENV === 'development') {
console.error('Dictation update failed:', ...)
}
生产环境只打印到 dev 才有的 console,WebView 里网络抖动导致上报失败时,用户完全无感知,任务栏自然不动。
第四步:任务栏组件不刷新
DailyTaskCard 只在挂载 / refreshKey 变化时拉数据。安卓 WebView 从默写页返回首页时走的是 bfcache(后退缓存),组件根本不重挂载,useEffect 不重跑——显示的还是旧快照 4/20。
讽刺的是,同页的 FullscreenFlashcard 早就专门加了 pageshow 处理 bfcache,唯独任务卡漏了。
修复
reportTaskProgress:upsertupdate分支补updatedAt: new Date();- 计数改为原子
{ increment: value },用增量后的实际值判断是否达标,再配合条件updateMany(isCompleted: false)保证"首次达标"只发一次学力奖励,并发不重复发; DailyTaskCard增加pageshow+visibilitychange重拉;- 默写上报失败时
toast.error提示并允许重试。
版本 1.13.1,构建 → deploy-full.sh → 按部署 checklist 验证 BUILD_ID / HTTP 200 / chunks / sidecar,全部通过。
为什么之前没发现?
五个掩盖层,层层抹掉线索:
- 计数没坏——不存在能告警的"数据丢失",它是"对的",只是展示旧值;
updatedAt冻结——任何按时间戳的检查都会误判成卡死;- 异常被吞——无日志、无提示,无从发现;
- 零测试覆盖——
src/__tests__里没有GameService/reportTaskProgress的任何用例; - 只在 Android WebView 复现——服务端永远对,客户端从默写页返回才显示旧值,纯服务端监控天然看不见。
经验
- 时间戳要写就写对:upsert 的
update分支漏掉updatedAt,会让"卡死"假象骗过所有人。 - 计数一律原子化:
increment比"读出来+1再写回"安全得多,且不增加查询量。 - bfcache 面前,
useEffect会骗你:移动端凡是"返回页面要刷新数据"的组件,都要处理pageshow。 - 用户报告"没反应",先怀疑是展示层,再怀疑是数据层。这次数据层一分没丢。