From 74b97c610fbb6a7f35555708a53b68ea36028171 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 31 Aug 2026 08:55:36 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E5=8D=95):=20=E3=80=8C=E9=80=89?= =?UTF-8?q?=E5=81=9A=E3=80=8D=E7=BB=88=E4=BA=8E=E4=BD=9C=E6=95=B0=E4=BA=86?= =?UTF-8?q?=EF=BC=8C=E5=88=86=E6=AF=8D=E5=8F=AA=E7=AE=97=E5=BF=85=E5=81=9A?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D --- apps/api/package.json | 2 +- apps/api/src/main.ts | 10 +- apps/api/src/routes/problemset.ts | 17 +- .../src/scripts/backfill-problemset-badges.ts | 100 ----------- apps/api/src/scripts/backfill-problemsets.ts | 166 ++++++++++++++++++ apps/api/src/services/problemset.ts | 43 +++-- 6 files changed, 214 insertions(+), 124 deletions(-) delete mode 100644 apps/api/src/scripts/backfill-problemset-badges.ts create mode 100644 apps/api/src/scripts/backfill-problemsets.ts diff --git a/apps/api/package.json b/apps/api/package.json index ab5cf32..6e40088 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,7 @@ "worker": "bun src/main.ts worker", "build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api", "seed:dev": "bun src/scripts/seed-dev.ts", - "backfill:badges": "bun src/main.ts backfill-badges", + "backfill:problemsets": "bun src/main.ts backfill-problemsets", "typecheck": "tsc --noEmit", "check:routes": "bun src/scripts/check-route-shadowing.ts", "db:pull": "drizzle-kit pull", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 294f2d4..40eddca 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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) } diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index 2484b84..629e23b 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -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() @@ -206,8 +206,11 @@ async function recomputeProgress( progress: typeof schema.problemsetProgress.$inferSelect, detail: Record, ) { - 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) => ({ diff --git a/apps/api/src/scripts/backfill-problemset-badges.ts b/apps/api/src/scripts/backfill-problemset-badges.ts deleted file mode 100644 index 9b1a2ce..0000000 --- a/apps/api/src/scripts/backfill-problemset-badges.ts +++ /dev/null @@ -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 -} diff --git a/apps/api/src/scripts/backfill-problemsets.ts b/apps/api/src/scripts/backfill-problemsets.ts new file mode 100644 index 0000000..4200ca9 --- /dev/null +++ b/apps/api/src/scripts/backfill-problemsets.ts @@ -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 +} diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts index a8e21d4..6cd0136 100644 --- a/apps/api/src/services/problemset.ts +++ b/apps/api/src/services/problemset.ts @@ -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 +type ProblemLink = { problemId: number; score: number; isRequired: boolean } +type BadgeCheck = Pick /** * 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。 @@ -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)),