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:
@@ -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
|
||||
}
|
||||
166
apps/api/src/scripts/backfill-problemsets.ts
Normal file
166
apps/api/src/scripts/backfill-problemsets.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user