进度算法在学生路径和后台路径各写了一遍,于是各自漂了一段。后台那份 (resyncProgress)名叫「把所有参与者的进度重算一遍」,实际只更新分母和百分比: - 不碰 total_score。改题目分值时专门调了它,函数体里却没这个字段,score 类奖章 因此按陈旧分数判定。 - 不碰 is_completed。加一道题之后分母变大、百分比掉下来,人还标着「已完成」。 - 不清理 progress_detail。删掉一道题之后 least(completed, total) 只保证不超过分母, 会把没做的题算成做了 —— 3 题的题单只做出 C,删掉 C 就变成 1/2 = 50%。 - 不重算奖章。题目集一变 all_problems 的达标面就变了,没有任何地方补发。 最后一条攒下了实打实的欠账:8-07 的快照里 53 条应发未发,涉及 30 名学生,误发 0 条。 其中 23 条来自 2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度 —— 进度补了, 奖章没人回头判。 两边不再分叉的唯一办法是只留一处算法,所以抽出 services/problemset.ts: computeProgress 是纯函数,学生做出一题和后台改题目都只是调用者; eligibleForBadge / recalculateBadge / resyncProgress 一并搬过来,补发脚本才能复用 同一套判定。批量写回仍是一条 UPDATE ... FROM (VALUES ...),没退回逐行往返。 顺带修掉空题单的坑:isCompleted 加了 total > 0 前提。原来 0 === 0 也成立,老师先建 题单、学生先加入、题目后加,加入那一刻就写下 complete_time 并计进「完成题单数」成就, 而且后面补上题目也不会自愈。 一处行为变化:退回未完成时 complete_time 会清空。后台那份原来保留旧时间,学生那份 一直是清空的,统一成后者 —— 否则同一行会出现「未完成 + 有完成时间」,破坏现有数据里 「complete_time 非空 ⟺ is_completed」这条不变量。代价是加题再删题会把历史完成时间 洗成「现在」。 scripts/backfill-problemset-badges.ts 补历史欠账,默认只读预演,--apply 才落库。 只要存在误发就拒绝执行并打出名单,要连收回一起做得显式加 --allow-revoke —— recalculateBadge 的删除是真删,user_badge 的 earned_time 没有别处备份。 在生产快照上实跑:补发 1180 → 1233 条,复核全部一致,历史 earned_time 未被刷新; resyncProgress 对题单 9(7 题 / 117 人 / 100 人完成)加题后正确退回 0 人完成并收回 100 枚奖章,删题后完整恢复;改题目分值 10→20 使总分和 7460 → 8590,正好 +113×10。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
93
apps/api/src/scripts/backfill-problemset-badges.ts
Normal file
93
apps/api/src/scripts/backfill-problemset-badges.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { badgeHolderDiff, recalculateBadge } from "../services/problemset"
|
||||
|
||||
/**
|
||||
* 补发历史欠账的题单奖章。
|
||||
*
|
||||
* 奖章原本只在「学生做出一道题」那一刻发(`PUT /problem-set-progress`),进度要是从别的
|
||||
* 路径变了 —— 后台加减题目、手工批量补进度 —— 就没人回头判过达标。生产快照里因此攒下
|
||||
* 53 条应发未发、涉及 30 名学生,其中 23 条来自 2026-05-22 00:50 那次一分钟内跨 7 个题单
|
||||
* 的批量补进度。
|
||||
*
|
||||
* 默认只读,把差异打出来;确认无误再加 --apply 落库。
|
||||
* 补发用的是 recalculateBadge,它同时会**收回**已经不达标的人的奖章,所以只要存在
|
||||
* 「误发」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke。
|
||||
*
|
||||
* bun src/scripts/backfill-problemset-badges.ts
|
||||
* bun src/scripts/backfill-problemset-badges.ts --apply
|
||||
*/
|
||||
const apply = process.argv.includes("--apply")
|
||||
const allowRevoke = process.argv.includes("--allow-revoke")
|
||||
|
||||
const rows = await db
|
||||
.select({ badge: schema.problemsetBadge, title: schema.problemset.title })
|
||||
.from(schema.problemsetBadge)
|
||||
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetBadge.problemsetId))
|
||||
.orderBy(schema.problemsetBadge.problemsetId, schema.problemsetBadge.id)
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("没有任何题单奖章,无事可做")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const diffs = []
|
||||
for (const { badge, title } of rows) {
|
||||
diffs.push({ badge, title, ...(await badgeHolderDiff(badge)) })
|
||||
}
|
||||
|
||||
const missingTotal = diffs.reduce((sum, d) => sum + d.missing.length, 0)
|
||||
const extraTotal = diffs.reduce((sum, d) => sum + d.extra.length, 0)
|
||||
const affected = new Set(diffs.flatMap((d) => [...d.missing, ...d.extra]))
|
||||
|
||||
console.log(`共 ${rows.length} 枚奖章\n`)
|
||||
for (const d of diffs) {
|
||||
if (!d.missing.length && !d.extra.length) continue
|
||||
const cond = `${d.badge.conditionType}/${d.badge.conditionValue}`
|
||||
console.log(
|
||||
` 题单${String(d.badge.problemsetId).padStart(2)} ${d.title} [${d.badge.name}] ${cond}\n` +
|
||||
` 应发 ${d.eligible} / 现有 ${d.held}` +
|
||||
(d.missing.length ? ` 漏发 ${d.missing.length}:user ${d.missing.join(", ")}` : "") +
|
||||
(d.extra.length ? ` 误发 ${d.extra.length}:user ${d.extra.join(", ")}` : ""),
|
||||
)
|
||||
}
|
||||
console.log(`\n合计:漏发 ${missingTotal} 条,误发 ${extraTotal} 条,涉及 ${affected.size} 名学生`)
|
||||
|
||||
if (missingTotal === 0 && extraTotal === 0) {
|
||||
console.log("奖章与规则一致,无需补发")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log("\n这是只读预演。确认无误后加 --apply 落库。")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (extraTotal > 0 && !allowRevoke) {
|
||||
console.error(
|
||||
`\n存在 ${extraTotal} 条误发。补发用的 recalculateBadge 会把它们**删掉**,` +
|
||||
`而 user_badge 没有别处备份、earnedTime 删了就找不回来。\n` +
|
||||
`确认要连同收回一起执行,加 --allow-revoke。`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let touched = 0
|
||||
for (const d of diffs) {
|
||||
if (!d.missing.length && !d.extra.length) continue
|
||||
await recalculateBadge(d.badge)
|
||||
touched += 1
|
||||
}
|
||||
console.log(`\n已重算 ${touched} 枚奖章,复核中……`)
|
||||
|
||||
let remaining = 0
|
||||
for (const { badge, title } of rows) {
|
||||
const after = await badgeHolderDiff(badge)
|
||||
if (after.missing.length || after.extra.length) {
|
||||
remaining += after.missing.length + after.extra.length
|
||||
console.error(` 仍不一致:题单${badge.problemsetId} ${title} [${badge.name}]`, after)
|
||||
}
|
||||
}
|
||||
console.log(remaining === 0 ? "复核通过:全部奖章与规则一致" : `复核未通过,仍有 ${remaining} 条差异`)
|
||||
process.exit(remaining === 0 ? 0 : 1)
|
||||
Reference in New Issue
Block a user