用户说"第一天的闪卡更好玩"——一次把 bug 当需求改的复盘
两条用户反馈:一条暴露了默写成绩里隐藏许久的累加 bug,另一条竟是怀念一个被我修掉的并发 bug。把两件事放在一起复盘,顺带讲清套壳应用"热更 vs 发版"的边界。
用户说"第一天的闪卡更好玩"——一次把 bug 当需求改的复盘
收到一位用户的语音反馈(转文字),原话大意:
"第一天用的时候,闪卡 15 个词加了学力,20 个词又加了一次。第二天 15 个词就完成今日任务了,后面不加了。如果能像第一天那样,上面有个进度条,到 20 词又能加就好了,我觉得每天 50 个词会更好玩。"
"默写那里我设置一组 10 个,它确实每组都是 10 个,但查成绩的时候第一组算 10、第二组算 20、第三组算 30,我没默写的那 10 个它直接判定为错了。"
两条反馈,指向两个完全不同性质的问题:一个是我一直没发现的真 bug,另一个是用户喜欢上了我之前修掉的 bug 行为。
问题一:默写成绩"多出来的词全判错"
现象
用户设置一组 10 个词,实际每轮也确实只默写 10 个。但点「新的一组」再来一轮后,结算页显示"共测试 20 个单词",正确率分母变成了 20——那 10 个根本没让他默写的词被算进了错误里。第三组变成 30,错得越来越多。
根因
dictation/page.tsx 里「新的一组」是这么写的:
const restartQuiz = () => {
...
setTotalWordsTested(prev => prev + words.length) // 累加已测试单词数
setWords([])
setScore({ correct: 0, total: 0 }) // ← 但分数每轮重置
...
}
分数(score)每轮都清零重算,但"测试总数"(totalWordsTested)却在跨组累加:10 → 20 → 30。结算页用这个累计数当分母:
共测试 {totalWordsTested} 个单词,答对 {score.correct} 个。
<DictationResultCharts total={totalWordsTested || words.length} ... />
于是第二组的成绩变成"答对 X / 20",凭空多出 10 个"错误"。百分比那个大字倒是对的(用的 words.length),所以用户看到的是一张大数字和图表互相矛盾的结算页——大数字 70 分,图表却显示错了一大半。
修复
把「新的一组」的计数改成按当前组单词数重置,和 startTest 保持一致:
setTotalWordsTested(words.length) // 每组独立评分:不再跨组累加
一行,各组回归"10 个里对几个"的正确语义。
问题二:闪卡任务"多加一次学力"其实是个 bug
最讽刺的部分
用户怀念的"第一天 15 词加一次、20 词又加一次",是我一个多月前刚修掉的并发竞态 bug(v1.13.1)。
原实现是"读旧值 → 加 1 → 写回":
const newValue = (existing?.currentValue ?? 0) + value
const isCompleted = newValue >= config.targetValue
...
if (isCompleted) {
await this.addPower(userId, config.powerReward, `TASK:${taskType}`)
}
用户快速点「认识/不认识」时,两次请求同时读到 currentValue = 14,各自算出 15,都通过 isCompleted 判断,都发了一次奖励——所以在 15 词附近"多加了一次"。修复后改为原子 { increment: value } + 条件 updateMany,同一任务一天只能"首次达标"发一次奖,行为正确了,但也变得不好玩了:15 词达标,任务完成,后面无论再点多少都不再奖励。
把"想要的行为"做成正式功能
用户其实提出了一个挺合理的诉求:别在 15 词就停,让闪卡成为一个有里程碑、有进度条、能持续获得反馈的长期任务。他说"每天 50 词会更好玩"。
于是把闪卡任务从"单目标 15 词"改造成"里程碑式":
- 目标:每天 50 词
- 里程碑:累计 15 / 30 / 50 词,每达标一段发 +20 学力
- 进度条按当前段显示:达标后刷新进入下一段(15 → 30 → 50),持续有正反馈
数据层给 DailyTaskCompletion 加了一列 milestoneIndex,标记"下一个待发奖的段"。发奖逻辑沿用之前并发安全的套路,但改成逐段抢占:
// 每段一个"已领奖"标记(milestoneIndex),条件不匹配的那次 count=0,不会重复发
const claimed = await prisma.dailyTaskCompletion.updateMany({
where: { userId, date: today, taskType, milestoneIndex, currentValue: { gte: milestone.target } },
data: { milestoneIndex: { increment: 1 }, updatedAt: new Date() },
})
if (claimed.count > 0) {
await this.addPower(userId, milestone.powerReward, `TASK:${taskType}:M${milestoneIndex + 1}`)
}
奖励额度高了,每日学力上限也得跟着放开:DAILY_POWER_CAP 100 → 160,不然又会重现"到点了怎么不加了"。
顺带一提:这次根本不用重新打包
这两处改动全是 web 侧代码(React 组件 + API + Prisma schema),而安卓 App 是 WebView 套壳、桌面端是 Electron 加载同一个站点。所以跑一遍 deploy-full.sh 热更部署即可,迁移也在部署脚本里自动执行——用户不用重新下载安装包。
那上次(FileProvider 分享图片报"获取资源失败")为什么要重装 APK?因为那是原生 Java 代码(AndroidManifest.xml 的 authority),WebView 无法热更原生层,只能打新包。套壳应用有个清晰的边界:壳内的代码热更,壳本身的代码只能发版。
经验
- 用户记住的"旧体验"可能是 bug 的副产物。 别急着把"回到过去"当需求实现,先搞清楚用户真正想要的是什么样的反馈节奏——他们要的是"持续有奖励的进度条",而不是"复现一次重复发奖"。
- 计数类状态:分组就独立,跨组别累加。
score重置了、totalWordsTested却累加,这种"一半重置一半累加"的状态最容易造成图表自相矛盾。 - 发奖的原子性要一直守住。 里程碑化之后,我复用了
milestoneIndex当"已领奖"标记 + 条件updateMany,保证并发下每段只发一次——这是上次踩过updatedAt冻结 / 读改写丢计数的坑之后,最不该放松的一环。 - 套壳应用的更新边界要想清楚。 原生层改动 → 发新包;壳内页面 → 热更。想清楚了,部署时就不会白白给用户制造一次"又要下载"的负担。
收到这样具体的用户反馈是挺开心的事——它同时修掉了一个隐藏 bug,又给玩法设计提了个好建议。