From 9d9e104df615ed3ad0b71f9b895b7b247e86cf56 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 1 Sep 2026 00:01:38 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E5=8D=95):=20=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E8=AE=B0=E8=B4=A6=E6=8C=AA=E5=88=B0=E5=88=A4=E9=A2=98=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E8=B7=AF=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9D=A0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端记账是这条链上最松的一环:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只认路由参数里那一个题单。于是 · 从普通题库入口做出同一道题 → 不计进度 · 网络一抖、页面提前关掉 → 进度静默丢失 · 一道题同时在两个已加入的题单里 → 只有进去的那个记上 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账 —— 2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度就是它跑的。 改成判题落库之后由 judge/run.ts 记账(recordSolvedProblem),记进该用户**所有**已加入 且包含这道题的题单。位置在最后那条 publishSubmissionUpdate("finished") 之前,所以前端 收到「判完了」时进度已经落库,跳回题单页看到的就是新数据。前端那次回调删掉。 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积; 更要紧的是这条口径必须和补账工具一致,否则补账工具会永远「发现」差异。 实跑(本地起 api + worker 真判一次):提交时**完全没带 problemSetId**,判完之后 progress 变成 1/1 100% 已完成、10 分、complete_time 落下、all_problems 奖章发出、 problemset_submission 也记上了。 ## 补账那半 backfill-problemsets 前面加一道「按实际 AC 补进度」,移植自旧栈的 fix_problemset_progress,口径和 recordSolvedProblem 逐条对齐(非比赛提交、 ACCEPTED 或 AST_CHECK_FAILED、取最早那次)。补录的格子先并进 detail 再重算, 所以预演里的奖章名单是照着「补完账又重算过」的进度算的,和 --apply 的结果一致。 在生产快照上实跑: 合计:补录 10 道题,进度 497 条要重算(完成 +23 / -0),奖章补发 56 条、收回 0 条 已订正 11 个题单 → 复核通过 user_badge 1180 → 1236,已完成 621 → 644,problemset_submission 7735 → 7745 「未完成但有完成时间」仍是 4 条;重复跑幂等 补录的 10 道分布在题单 4/5/6/15/16(1/1/1/4/3),和离线独立算的数字逐个对上 —— 其中题单 15 那位 AC 了全部 12 题却一直显示未完成。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D --- apps/api/src/judge/run.ts | 42 +++- apps/api/src/scripts/backfill-problemsets.ts | 195 ++++++++++++++---- apps/api/src/services/problemset.ts | 85 ++++++++ .../src/oj/problem/components/SubmitCode.vue | 14 +- 4 files changed, 281 insertions(+), 55 deletions(-) diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 409f23b..2897ef1 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -6,7 +6,8 @@ import { and, eq, inArray } from "drizzle-orm" import { config } from "../config" import { db, schema } from "../db" import { publishAchievementNotification } from "../events" -import { updateAchievementsForSubmission } from "../services/achievements" +import { updateAchievementsForProblemSet, updateAchievementsForSubmission } from "../services/achievements" +import { recordSolvedProblem } from "../services/problemset" import { checkAst, type AstRule } from "./ast" import { publishSubmissionUpdate } from "./events" import type { JudgeJobData } from "./job" @@ -446,6 +447,45 @@ export async function judgeSubmission(job: JudgeJobData) { ) if (!saved) return + // 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress, + // 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。 + // 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时 + // 进度已经落库,跳回题单页看到的就是新数据。 + // 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。 + if (row.submission.contestId === null && isAccepted(result)) { + try { + const { updated, earned } = await recordSolvedProblem( + row.submission.userId, + row.problem.id, + row.submission.id, + row.submission.createTime, + ) + if (earned.length > 0) { + await publishAchievementNotification(row.submission.userId, earned.map((badge) => ({ + id: badge.id, + name: badge.name, + description: badge.description, + icon: badge.icon, + rarity: "bronze", + kind: "badge", + }))) + } + if (updated > 0) { + const unlocked = await updateAchievementsForProblemSet(row.submission.userId) + await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({ + id: achievement.id, + name: achievement.name, + description: achievement.description, + icon: achievement.icon, + rarity: achievement.rarity, + kind: "achievement", + }))) + } + } catch (error) { + console.error(`Failed to record problem set progress for ${row.submission.id}`, error) + } + } + try { const unlocked = await updateAchievementsForSubmission(row.submission.id) await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({ diff --git a/apps/api/src/scripts/backfill-problemsets.ts b/apps/api/src/scripts/backfill-problemsets.ts index 4200ca9..74ceec8 100644 --- a/apps/api/src/scripts/backfill-problemsets.ts +++ b/apps/api/src/scripts/backfill-problemsets.ts @@ -1,22 +1,25 @@ -import { eq } from "drizzle-orm" +import { and, eq, inArray, isNull, sql } from "drizzle-orm" import { db, schema } from "../db" +import { JudgeStatus } from "../judge/status" import { objectValue } from "../routes/helpers" import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } from "../services/problemset" /** - * 把题单的进度和奖章订正到与当前规则一致。 + * 把题单的进度和奖章订正到与当前规则一致。三笔历史欠账,一趟结清: * - * 两笔历史欠账: + * 1. **进度漏记**。判题这一路记账(services/problemset.ts 的 recordSolvedProblem)是后来才有的, + * 在那之前靠前端 AC 之后回调,只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度, + * 网络一抖就静默丢失。这里按实际 AC 记录补回来 —— 移植自旧栈的管理命令 + * `problemset/management/commands/fix_problemset_progress.py`。 + * 2. **奖章漏发**。奖章原本只在学生做出一道题那一刻发,进度从别的路径变了就没人回头判过达标。 + * 生产快照里 53 条应发未发、涉及 30 名学生 —— 其中 23 条正是上面那个管理命令留下的: + * 它补进度,而旧栈的信号只挂在 ProblemSetProblem 和 ProblemSetBadge 上、不挂 Progress。 + * 3. **算法改过**。分母只算必做题(选做不再卡完成)、空题单不再算完成、total_score 跟着分值走。 + * 已有的行要跑一遍才会按新规则重算。 * - * 1. 奖章原本只在「学生做出一道题」那一刻发(`PUT /problem-set-progress`),进度从别的路径 - * 变了 —— 后台加减题目、旧栈的 fix_problemset_progress 批量补进度 —— 就没人回头判过达标。 - * 生产快照里 53 条应发未发、涉及 30 名学生。 - * 2. 进度的算法后来改了:分母只算必做题(选做题不再卡完成),空题单不再算完成, - * total_score 跟着题目分值走。已有的行要跑一遍才会按新规则重算。 - * - * 两件事一起做,因为它们是同一笔账:进度一变,奖章达标面就跟着变, - * 所以落库走的是 resyncProgress —— 它重算进度之后会顺手重算这份题单的全部奖章。 + * 三件事必须一趟做完,因为它们首尾相接:补进度 → 完成状态变 → 奖章达标面变。 + * 落库走 resyncProgress,它重算进度之后会顺手重算该题单的全部奖章。 * * 默认只读,把差异打出来;确认无误再加 --apply 落库。 * 只要预演里出现「收回」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke —— @@ -27,9 +30,62 @@ import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } fr * * 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 */ +const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] + +type ProblemLink = { problemId: number; score: number; isRequired: boolean } + +async function loadSet(problemsetId: number) { + 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, problemsetId)), + db.select().from(schema.problemsetProgress) + .where(eq(schema.problemsetProgress.problemsetId, problemsetId)), + db.select().from(schema.problemsetBadge) + .where(eq(schema.problemsetBadge.problemsetId, problemsetId)), + ]) + return { links, progresses, badges } +} + +/** + * 找出「这个题单里的题,学生其实早就 AC 了,可进度里没记」的那些格子。 + * + * 口径必须和 recordSolvedProblem 一模一样(非比赛提交、ACCEPTED 或 AST_CHECK_FAILED、 + * 取最早那次),否则补账工具会永远「发现」差异。题单里的题必定是非比赛题,所以 + * isNull(contestId) 实际上不会过滤掉任何东西,写上是为了两边字面一致。 + */ +async function recoverable(links: ProblemLink[], progresses: (typeof schema.problemsetProgress.$inferSelect)[]) { + const gaps: { userId: number; problemId: number }[] = [] + for (const progress of progresses) { + const detail = objectValue(progress.progressDetail) + for (const link of links) { + if (!(String(link.problemId) in detail)) gaps.push({ userId: progress.userId, problemId: link.problemId }) + } + } + if (gaps.length === 0) return new Map() + const rows = await db.select({ + userId: schema.submission.userId, + problemId: schema.submission.problemId, + solvedAt: sql`min(${schema.submission.createTime})::text`, + }).from(schema.submission).where(and( + inArray(schema.submission.userId, [...new Set(gaps.map((g) => g.userId))]), + inArray(schema.submission.problemId, [...new Set(gaps.map((g) => g.problemId))]), + isNull(schema.submission.contestId), + inArray(schema.submission.result, ACCEPTED), + )).groupBy(schema.submission.userId, schema.submission.problemId) + const solved = new Map(rows.map((row) => [`${row.userId}:${row.problemId}`, row.solvedAt])) + const found = new Map() + for (const gap of gaps) { + const key = `${gap.userId}:${gap.problemId}` + const at = solved.get(key) + if (at) found.set(key, at) + } + return found +} + 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) @@ -41,23 +97,21 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke 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 { links, progresses, badges } = await loadSet(set.id) + const found = await recoverable(links, progresses) + const scoreByProblem = new Map(links.map((link) => [link.problemId, link.score])) + + // 把补回来的格子先并进 detail,再按新规则重算 —— 奖章的差异要照着「补完账又重算过」 + // 的进度看,否则预演报出来的名单和 --apply 之后的结果对不上 + const next = progresses.map((row) => { + const detail = objectValue(row.progressDetail) + for (const link of links) { + const at = found.get(`${row.userId}:${link.problemId}`) + if (at) detail[String(link.problemId)] = { score: scoreByProblem.get(link.problemId) ?? 0, submit_time: at } + } + return { ...row, ...computeProgress(detail, links, row.completeTime, now) } + }) + const changed = next.filter((row, i) => { const was = progresses[i]! return was.totalProblemsCount !== row.totalProblemsCount || @@ -72,9 +126,15 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke 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 }) + report.push({ + set, links, found, scoreByProblem, + changed: changed.length, newlyCompleted, uncompleted, badgeDiffs, + recovered: found.size, + recoveredUsers: new Set([...found.keys()].map((key) => key.split(":")[0]!)).size, + }) } + const recovered = report.reduce((n, r) => n + r.recovered, 0) 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) @@ -84,6 +144,9 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke console.log(`共 ${sets.length} 个题单\n`) for (const r of report) { const lines = [] + if (r.recovered) { + lines.push(` 补录:${r.recovered} 道题已 AC 但进度里没记(${r.recoveredUsers} 名学生)`) + } if (r.changed) { lines.push(` 进度:${r.changed} 条要重算` + (r.newlyCompleted ? `,其中 ${r.newlyCompleted} 条未完成 → 已完成` : "") + @@ -101,10 +164,10 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke for (const line of lines) console.log(line) } } - console.log(`\n合计:进度 ${progressRows} 条要重算(完成 +${completedGain} / -${completedLoss}),` + - `奖章补发 ${missing} 条、收回 ${extra} 条`) + console.log(`\n合计:补录 ${recovered} 道题,进度 ${progressRows} 条要重算` + + `(完成 +${completedGain} / -${completedLoss}),奖章补发 ${missing} 条、收回 ${extra} 条`) - if (progressRows === 0 && missing === 0 && extra === 0) { + if (recovered === 0 && progressRows === 0 && missing === 0 && extra === 0) { console.log("题单数据与当前规则一致,无需订正") return 0 } @@ -120,8 +183,53 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke let touched = 0 for (const r of report) { - if (!r.changed && !r.badgeDiffs.some((d) => d.missing.length || d.extra.length)) continue - // resyncProgress 重算进度之后会把这份题单的奖章一并重算,两笔账一次结清 + const hasBadgeDrift = r.badgeDiffs.some((d) => d.missing.length || d.extra.length) + if (!r.recovered && !r.changed && !hasBadgeDrift) continue + // 补录的格子先写进 detail,resyncProgress 是照着库里的 detail 重算的 + if (r.recovered) { + await db.transaction(async (tx) => { + const rows = await tx.select().from(schema.problemsetProgress) + .where(eq(schema.problemsetProgress.problemsetId, r.set.id)) + for (const row of rows) { + const detail = objectValue(row.progressDetail) + let dirty = false + for (const link of r.links) { + const at = r.found.get(`${row.userId}:${link.problemId}`) + if (!at || String(link.problemId) in detail) continue + detail[String(link.problemId)] = { score: r.scoreByProblem.get(link.problemId) ?? 0, submit_time: at } + dirty = true + const [existing] = await tx.select({ id: schema.problemsetSubmission.id }) + .from(schema.problemsetSubmission).where(and( + eq(schema.problemsetSubmission.problemsetId, r.set.id), + eq(schema.problemsetSubmission.userId, row.userId), + eq(schema.problemsetSubmission.problemId, link.problemId), + )).limit(1) + if (!existing) { + const [submission] = await tx.select({ id: schema.submission.id }).from(schema.submission) + .where(and( + eq(schema.submission.userId, row.userId), + eq(schema.submission.problemId, link.problemId), + isNull(schema.submission.contestId), + inArray(schema.submission.result, ACCEPTED), + )).orderBy(schema.submission.createTime).limit(1) + if (submission) { + await tx.insert(schema.problemsetSubmission).values({ + problemsetId: r.set.id, + userId: row.userId, + submissionId: submission.id, + problemId: link.problemId, + }) + } + } + } + if (dirty) { + await tx.update(schema.problemsetProgress).set({ progressDetail: detail }) + .where(eq(schema.problemsetProgress.id, row.id)) + } + } + }) + } + // 重算进度,顺带重算这份题单的全部奖章 await resyncProgress(r.set.id) touched += 1 } @@ -135,15 +243,12 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke 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)), - ]) + const { links, progresses, badges } = await loadSet(set.id) + const found = await recoverable(links, progresses) + if (found.size) { + remaining += found.size + console.error(` 仍有可补录的进度:题单${set.id} ${found.size} 条`) + } for (const row of progresses) { const next = computeProgress(objectValue(row.progressDetail), links, row.completeTime, now) if (row.isCompleted !== next.isCompleted || row.totalScore !== next.totalScore || diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts index 6cd0136..71bcfd4 100644 --- a/apps/api/src/services/problemset.ts +++ b/apps/api/src/services/problemset.ts @@ -210,3 +210,88 @@ export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { u held: have.size, } } + +/** + * 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。 + * + * 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调 + * 路由参数里那一个题单。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失; + * 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次 + * 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。 + * + * 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。 + * + * 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。 + * 更要紧的是这条规则必须和补账那条(scripts/backfill-problemsets.ts)一致 —— + * 两边口径不一样的话,补账工具会永远「发现」差异。 + */ +export async function recordSolvedProblem( + userId: number, + problemId: number, + submissionId: string, + solvedAt: string, +) { + const joined = await db + .select({ problemsetId: schema.problemsetProgress.problemsetId }) + .from(schema.problemsetProgress) + .innerJoin(schema.problemsetProblem, and( + eq(schema.problemsetProblem.problemsetId, schema.problemsetProgress.problemsetId), + eq(schema.problemsetProblem.problemId, problemId), + )) + .where(eq(schema.problemsetProgress.userId, userId)) + const earned: BadgeRow[] = [] + let updated = 0 + for (const { problemsetId } of joined) { + const hits = await db.transaction(async (tx) => { + const [progress] = await tx.select().from(schema.problemsetProgress).where(and( + eq(schema.problemsetProgress.problemsetId, problemsetId), + eq(schema.problemsetProgress.userId, userId), + )).for("update").limit(1) + if (!progress) return [] + + // 提交记录先补上,即使这道题早就记过 —— 老数据里有记了进度没记提交的行 + const [existing] = await tx.select({ id: schema.problemsetSubmission.id }) + .from(schema.problemsetSubmission).where(and( + eq(schema.problemsetSubmission.problemsetId, problemsetId), + eq(schema.problemsetSubmission.userId, userId), + eq(schema.problemsetSubmission.problemId, problemId), + )).limit(1) + if (!existing) { + await tx.insert(schema.problemsetSubmission) + .values({ problemsetId, userId, submissionId, problemId }) + } + + const detail = objectValue(progress.progressDetail) + if (String(problemId) in detail) return [] + 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, problemsetId)) + const link = links.find((item) => item.problemId === problemId) + if (!link) return [] + detail[String(problemId)] = { score: link.score, submit_time: solvedAt } + const update = computeProgress(detail, links, progress.completeTime) + await tx.update(schema.problemsetProgress).set(update) + .where(eq(schema.problemsetProgress.id, progress.id)) + updated += 1 + + const badges = await tx.select().from(schema.problemsetBadge) + .where(eq(schema.problemsetBadge.problemsetId, problemsetId)) + const eligible = badges.filter((badge) => eligibleForBadge(badge, { ...progress, ...update })) + if (eligible.length === 0) return [] + // 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的 + const inserted = await tx.insert(schema.userBadge).values(eligible.map((badge) => ({ + userId, + badgeId: badge.id, + earnedTime: new Date().toISOString(), + }))).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] }) + .returning({ badgeId: schema.userBadge.badgeId }) + const ids = new Set(inserted.map((row) => row.badgeId)) + return eligible.filter((badge) => ids.has(badge.id)) + }) + earned.push(...hits) + } + return { updated, earned } +} diff --git a/apps/web/src/oj/problem/components/SubmitCode.vue b/apps/web/src/oj/problem/components/SubmitCode.vue index fd6f82b..31af6d1 100644 --- a/apps/web/src/oj/problem/components/SubmitCode.vue +++ b/apps/web/src/oj/problem/components/SubmitCode.vue @@ -1,7 +1,7 @@