fix(题单): 「选做」终于作数了,分母只算必做题

isRequired 一直只是卡片上的一行字:卡片写着「(选做)」,进度分母和 all_problems
奖章却照样要求做完。结果是学生按提示跳过选做题,进度条卡在 100% 以下、全通奖章也
拿不到 —— 快照里 22 个人做完了全部必做题却显示未完成(题单 5 三人、6 十人、8 两人、
11 七人)。旧栈的 update_progress 同样不区分,是一路继承下来的。

改成:分母只算必做题,选做题做了仍然计分(totalScore 把它算进去),只是不卡完成。
一道必做都没标的题单退回「全部都算必做」—— 那种题单多半是没用这个字段,而不是真的
整单选做,不兜住的话它永远完不成。

## problem_count 奖章不能跟着改

老师当初是按题单的**总题数**设阈值的:题单 5 的「一职欧拉」要 8 题,而它的必做只有
7 道。要是 problem_count 也改用只数必做的 completedProblemsCount,这枚奖章一夜之间
不可得,76 个已经拿到的人会被 recalculateBadge 收回。所以它数的是「做出的题目总数
(含选做)」,从 progress_detail 的键数来。score 类同理不受影响。

预演证实了这道闸门有效:22 人拿到完成状态,奖章补发 56 条、**收回 0 条**。

## 顺带:第三份手抄的达标逻辑

PUT /problem-set-progress 里还藏着一份 inline 的奖章判定,和 services 里那份、
补发脚本里那份是三份各写各的 —— 这次改 problem_count 的语义,漏掉任何一份都会让
学生提交时发的奖章和后台重算的结果对不上。三处统一到 eligibleForBadge。

## 工具改名并扩到进度

backfill-badges → backfill-problemsets。语义变更之后,已有的进度行要跑一遍才会按新
规则重算,否则那 22 个人得等到老师下次动题单才生效。两笔账本来也是同一笔:进度一变,
奖章达标面就跟着变,所以落库走 resyncProgress(它重算进度后会顺手重算该题单全部奖章),
预演里的奖章差异也是照着订正后的进度算的,保证预演和 --apply 的结果一致。

在生产快照上实跑:

  合计:进度 494 条要重算(完成 +22 / -0),奖章补发 56 条、收回 0 条
  已订正 10 个题单 → 复核通过:题单数据与规则一致
  user_badge 1180 → 1236,已完成 621 → 643
  「未完成但有完成时间」仍是 4 条(d7a6414 那条语义保住了)
  重复跑幂等:进度 0 条、奖章 0 条

抽查题单 6(9 必做 + 1 选做):只做必做的显示 9/9 100% 已完成、80 分;连选做一起做的
同样 9/9 已完成,但 90 分。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
2026-08-31 08:55:36 -06:00
parent d9e6a2a3f0
commit 74b97c610f
6 changed files with 214 additions and 124 deletions

View File

@@ -1,100 +0,0 @@
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。
*
* 做成 main.ts 的子命令而不是独立脚本,是因为生产镜像里只有编译好的单二进制,
* 既没有 bun 也没有源码,`bun src/scripts/...` 在那儿根本不存在。跑法对齐 migrate
*
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-badges
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-badges --apply
*
* 本机开发:
*
* bun run --cwd apps/api backfill:badges
* bun apps/api/src/main.ts backfill-badges --apply
*/
export async function backfillProblemSetBadges(options: { apply: boolean; allowRevoke: boolean }) {
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("没有任何题单奖章,无事可做")
return 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("奖章与规则一致,无需补发")
return 0
}
if (!options.apply) {
console.log("\n这是只读预演什么都没写。确认无误后加 --apply 落库。")
return 0
}
if (extraTotal > 0 && !options.allowRevoke) {
console.error(
`\n存在 ${extraTotal} 条误发。补发用的 recalculateBadge 会把它们**删掉**` +
`而 user_badge 没有别处备份、earnedTime 删了就找不回来。\n` +
`确认要连同收回一起执行,加 --allow-revoke。`,
)
return 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} 条差异`)
return remaining === 0 ? 0 : 1
}

View File

@@ -0,0 +1,166 @@
import { eq } from "drizzle-orm"
import { db, schema } from "../db"
import { objectValue } from "../routes/helpers"
import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } from "../services/problemset"
/**
* 把题单的进度和奖章订正到与当前规则一致。
*
* 两笔历史欠账:
*
* 1. 奖章原本只在「学生做出一道题」那一刻发(`PUT /problem-set-progress`),进度从别的路径
* 变了 —— 后台加减题目、旧栈的 fix_problemset_progress 批量补进度 —— 就没人回头判过达标。
* 生产快照里 53 条应发未发、涉及 30 名学生。
* 2. 进度的算法后来改了:分母只算必做题(选做题不再卡完成),空题单不再算完成,
* total_score 跟着题目分值走。已有的行要跑一遍才会按新规则重算。
*
* 两件事一起做,因为它们是同一笔账:进度一变,奖章达标面就跟着变,
* 所以落库走的是 resyncProgress —— 它重算进度之后会顺手重算这份题单的全部奖章。
*
* 默认只读,把差异打出来;确认无误再加 --apply 落库。
* 只要预演里出现「收回」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke ——
* user_badge 没有别处备份earnedTime 删了就找不回来。
*
* 做成 main.ts 的子命令而不是独立脚本,是因为生产镜像里只有编译好的单二进制,
* 既没有 bun 也没有源码。跑法对齐 migrate
*
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets --apply
*
* 本机开发bun apps/api/src/main.ts backfill-problemsets
*/
export async function backfillProblemSets(options: { apply: boolean; allowRevoke: boolean }) {
const sets = await db.select({ id: schema.problemset.id, title: schema.problemset.title })
.from(schema.problemset).orderBy(schema.problemset.id)
if (sets.length === 0) {
console.log("没有任何题单,无事可做")
return 0
}
const now = new Date().toISOString()
const report = []
for (const set of sets) {
const [links, progresses, badges] = await Promise.all([
db.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, set.id)),
db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, set.id)),
db.select().from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, set.id)),
])
// 按新规则重算一遍,但不落库 —— 奖章的差异要照着订正后的进度看,
// 否则预演里报出来的名单和 --apply 之后的结果对不上
const next = progresses.map((row) => ({
...row,
...computeProgress(objectValue(row.progressDetail), links, row.completeTime, now),
}))
const changed = next.filter((row, i) => {
const was = progresses[i]!
return was.totalProblemsCount !== row.totalProblemsCount ||
was.completedProblemsCount !== row.completedProblemsCount ||
was.totalScore !== row.totalScore ||
was.isCompleted !== row.isCompleted ||
Math.abs(was.progressPercentage - row.progressPercentage) > 0.005 ||
was.completeTime !== row.completeTime ||
JSON.stringify(objectValue(was.progressDetail)) !== JSON.stringify(row.progressDetail)
})
const newlyCompleted = next.filter((row, i) => !progresses[i]!.isCompleted && row.isCompleted).length
const uncompleted = next.filter((row, i) => progresses[i]!.isCompleted && !row.isCompleted).length
const badgeDiffs = []
for (const badge of badges) badgeDiffs.push({ badge, ...(await badgeHolderDiff(badge, next)) })
report.push({ set, changed: changed.length, newlyCompleted, uncompleted, badgeDiffs })
}
const progressRows = report.reduce((n, r) => n + r.changed, 0)
const completedGain = report.reduce((n, r) => n + r.newlyCompleted, 0)
const completedLoss = report.reduce((n, r) => n + r.uncompleted, 0)
const missing = report.reduce((n, r) => n + r.badgeDiffs.reduce((m, d) => m + d.missing.length, 0), 0)
const extra = report.reduce((n, r) => n + r.badgeDiffs.reduce((m, d) => m + d.extra.length, 0), 0)
console.log(`${sets.length} 个题单\n`)
for (const r of report) {
const lines = []
if (r.changed) {
lines.push(` 进度:${r.changed} 条要重算` +
(r.newlyCompleted ? `,其中 ${r.newlyCompleted} 条未完成 → 已完成` : "") +
(r.uncompleted ? `${r.uncompleted} 条已完成 → 未完成` : ""))
}
for (const d of r.badgeDiffs) {
if (!d.missing.length && !d.extra.length) continue
lines.push(` 奖章[${d.badge.name}] ${d.badge.conditionType}/${d.badge.conditionValue}` +
`应发 ${d.eligible} / 现有 ${d.held}` +
(d.missing.length ? ` 补发 ${d.missing.length}user ${d.missing.join(", ")}` : "") +
(d.extra.length ? ` 收回 ${d.extra.length}user ${d.extra.join(", ")}` : ""))
}
if (lines.length) {
console.log(` 题单${String(r.set.id).padStart(2)} ${r.set.title}`)
for (const line of lines) console.log(line)
}
}
console.log(`\n合计进度 ${progressRows} 条要重算(完成 +${completedGain} / -${completedLoss}` +
`奖章补发 ${missing} 条、收回 ${extra}`)
if (progressRows === 0 && missing === 0 && extra === 0) {
console.log("题单数据与当前规则一致,无需订正")
return 0
}
if (!options.apply) {
console.log("\n这是只读预演什么都没写。确认无误后加 --apply 落库。")
return 0
}
if (extra > 0 && !options.allowRevoke) {
console.error(`\n预演里有 ${extra} 条奖章要被收回,而 user_badge 没有别处备份、` +
`earnedTime 删了就找不回来。\n确认要连同收回一起执行加 --allow-revoke。`)
return 1
}
let touched = 0
for (const r of report) {
if (!r.changed && !r.badgeDiffs.some((d) => d.missing.length || d.extra.length)) continue
// resyncProgress 重算进度之后会把这份题单的奖章一并重算,两笔账一次结清
await resyncProgress(r.set.id)
touched += 1
}
// 没有参与者、只有奖章欠账的题单不会走上面那条,兜一遍
for (const r of report) {
for (const d of r.badgeDiffs) {
if (d.missing.length || d.extra.length) await recalculateBadge(d.badge)
}
}
console.log(`\n已订正 ${touched} 个题单,复核中……`)
let remaining = 0
for (const set of sets) {
const [links, progresses, badges] = await Promise.all([
db.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, set.id)),
db.select().from(schema.problemsetProgress).where(eq(schema.problemsetProgress.problemsetId, set.id)),
db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, set.id)),
])
for (const row of progresses) {
const next = computeProgress(objectValue(row.progressDetail), links, row.completeTime, now)
if (row.isCompleted !== next.isCompleted || row.totalScore !== next.totalScore ||
row.completedProblemsCount !== next.completedProblemsCount ||
row.totalProblemsCount !== next.totalProblemsCount) {
remaining += 1
console.error(` 进度仍不一致:题单${set.id} user ${row.userId}`)
}
}
for (const badge of badges) {
const after = await badgeHolderDiff(badge)
if (after.missing.length || after.extra.length) {
remaining += after.missing.length + after.extra.length
console.error(` 奖章仍不一致:题单${set.id} [${badge.name}]`, after)
}
}
}
console.log(remaining === 0 ? "复核通过:题单数据与规则一致" : `复核未通过,仍有 ${remaining} 处差异`)
return remaining === 0 ? 0 : 1
}