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

@@ -12,7 +12,7 @@
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api backfill-badges # 补发历史欠账的题单奖章,默认只读预演,--apply 才落库
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演
*
* 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -35,10 +35,10 @@ switch (command) {
break
}
// 一次性的数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码
case "backfill-badges": {
const { backfillProblemSetBadges } = await import("./scripts/backfill-problemset-badges")
case "backfill-problemsets": {
const { backfillProblemSets } = await import("./scripts/backfill-problemsets")
const args = process.argv.slice(3)
process.exit(await backfillProblemSetBadges({
process.exit(await backfillProblemSets({
apply: args.includes("--apply"),
allowRevoke: args.includes("--allow-revoke"),
}))
@@ -63,6 +63,6 @@ switch (command) {
}
}
default:
console.error(`未知子命令:${command}\n可用serve | worker | migrate | backfill-badges | healthcheck | sql-child`)
console.error(`未知子命令:${command}\n可用serve | worker | migrate | backfill-problemsets | healthcheck | sql-child`)
process.exit(2)
}

View File

@@ -31,7 +31,7 @@ import { publishAchievementNotification } from "../events"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { updateAchievementsForProblemSet } from "../services/achievements"
import { computeProgress } from "../services/problemset"
import { computeProgress, eligibleForBadge } from "../services/problemset"
import { objectValue, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>()
@@ -206,8 +206,11 @@ async function recomputeProgress(
progress: typeof schema.problemsetProgress.$inferSelect,
detail: Record<string, unknown>,
) {
const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
const links = await tx.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
const update = computeProgress(detail, links, progress.completeTime)
@@ -283,11 +286,9 @@ problemsetRoutes.put("/problem-set-progress", requireAuth, async (c) => {
})
}
const badges = await tx.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, problemSet.id))
const hits = badges.filter((badge) => badge.conditionType === "all_problems"
? updated.totalProblemsCount > 0 && updated.completedProblemsCount === updated.totalProblemsCount
: badge.conditionType === "problem_count"
? updated.completedProblemsCount >= badge.conditionValue
: badge.conditionType === "score" && updated.totalScore >= badge.conditionValue)
// 判定走 services/problemset.ts 那一份 —— 这里原来是第三份手抄的达标逻辑,
// 后台重算和补发脚本各有各的,改一处规则就会漏掉另外两处
const hits = badges.filter((badge) => eligibleForBadge(badge, updated))
if (hits.length === 0) return { earned: [] as (typeof schema.problemsetBadge.$inferSelect)[] }
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
const inserted = await tx.insert(schema.userBadge).values(hits.map((badge) => ({

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
}

View File

@@ -5,8 +5,9 @@ import { objectValue } from "../routes/helpers"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number }
type BadgeCheck = Pick<ProgressRow, "completedProblemsCount" | "totalProblemsCount" | "totalScore">
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick<ProgressRow,
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail">
/**
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
@@ -34,8 +35,17 @@ export function computeProgress(
// 分值以题单当前的设置为准detail 里存的是做出那一刻的快照
kept[key] = { ...objectValue(value), score }
}
const completed = Object.keys(kept).length
const total = links.length
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
// (题单 5/6/8/11。选做题做了仍然计分totalScore 把它算进去),只是不卡完成。
//
// 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是
// 真的整单选做;不兜住的话它永远完不成。
const required = links.filter((link) => link.isRequired)
const graded = required.length ? required : links
const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
const total = graded.length
// total > 0 这个前提不能省0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
// 还会写下 complete_time、计进「完成题单数」成就而且后面补上题目也不会自愈。
const isCompleted = total > 0 && completed === total
@@ -97,13 +107,23 @@ async function writeProgress(rows: ProgressWrite[]) {
}
}
/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */
/**
* 奖章达标判定的唯一实现。学生做出一题、后台改题单、补发脚本三处都调它 ——
* 以前是三份各写一遍。
*
* problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount
* (自从分母只算必做,那个只数必做题)。老师当初是按题单的总题数设阈值的:题单 5
* 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间
* 不可得76 个已经拿到的人被 recalculateBadge 收回。
*/
export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
if (badge.conditionType === "all_problems") {
return progress.totalProblemsCount > 0 &&
progress.completedProblemsCount === progress.totalProblemsCount
}
if (badge.conditionType === "problem_count") return progress.completedProblemsCount >= badge.conditionValue
if (badge.conditionType === "problem_count") {
return Object.keys(objectValue(progress.progressDetail)).length >= badge.conditionValue
}
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
return false
}
@@ -154,8 +174,11 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
*/
export async function resyncProgress(problemsetId: number) {
const [links, progresses, badges] = await Promise.all([
db.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
db.select().from(schema.problemsetBadge)
@@ -171,9 +194,9 @@ export async function resyncProgress(problemsetId: number) {
}
/** 按奖章算出「现在应该有谁」,只读,供补发脚本先看后写 */
export async function badgeHolderDiff(badge: BadgeRow) {
export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) {
const [progresses, holders] = await Promise.all([
db.select().from(schema.problemsetProgress)
known ?? db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)),
db.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id)),