From f9354b0df1edbecdf65b46e6e29173bc1023a055 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 31 Aug 2026 08:23:38 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E5=8D=95):=20=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E9=87=8D=E7=AE=97=E6=BC=8F=E4=BA=86=E5=88=86=E6=95=B0=E5=92=8C?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=8A=B6=E6=80=81=EF=BC=8C=E5=A5=96=E7=AB=A0?= =?UTF-8?q?=E6=B2=A1=E4=BA=BA=E8=A1=A5=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进度算法在学生路径和后台路径各写了一遍,于是各自漂了一段。后台那份 (resyncProgress)名叫「把所有参与者的进度重算一遍」,实际只更新分母和百分比: - 不碰 total_score。改题目分值时专门调了它,函数体里却没这个字段,score 类奖章 因此按陈旧分数判定。 - 不碰 is_completed。加一道题之后分母变大、百分比掉下来,人还标着「已完成」。 - 不清理 progress_detail。删掉一道题之后 least(completed, total) 只保证不超过分母, 会把没做的题算成做了 —— 3 题的题单只做出 C,删掉 C 就变成 1/2 = 50%。 - 不重算奖章。题目集一变 all_problems 的达标面就变了,没有任何地方补发。 最后一条攒下了实打实的欠账:8-07 的快照里 53 条应发未发,涉及 30 名学生,误发 0 条。 其中 23 条来自 2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度 —— 进度补了, 奖章没人回头判。 两边不再分叉的唯一办法是只留一处算法,所以抽出 services/problemset.ts: computeProgress 是纯函数,学生做出一题和后台改题目都只是调用者; eligibleForBadge / recalculateBadge / resyncProgress 一并搬过来,补发脚本才能复用 同一套判定。批量写回仍是一条 UPDATE ... FROM (VALUES ...),没退回逐行往返。 顺带修掉空题单的坑:isCompleted 加了 total > 0 前提。原来 0 === 0 也成立,老师先建 题单、学生先加入、题目后加,加入那一刻就写下 complete_time 并计进「完成题单数」成就, 而且后面补上题目也不会自愈。 一处行为变化:退回未完成时 complete_time 会清空。后台那份原来保留旧时间,学生那份 一直是清空的,统一成后者 —— 否则同一行会出现「未完成 + 有完成时间」,破坏现有数据里 「complete_time 非空 ⟺ is_completed」这条不变量。代价是加题再删题会把历史完成时间 洗成「现在」。 scripts/backfill-problemset-badges.ts 补历史欠账,默认只读预演,--apply 才落库。 只要存在误发就拒绝执行并打出名单,要连收回一起做得显式加 --allow-revoke —— recalculateBadge 的删除是真删,user_badge 的 earned_time 没有别处备份。 在生产快照上实跑:补发 1180 → 1233 条,复核全部一致,历史 earned_time 未被刷新; resyncProgress 对题单 9(7 题 / 117 人 / 100 人完成)加题后正确退回 0 人完成并收回 100 枚奖章,删题后完整恢复;改题目分值 10→20 使总分和 7460 → 8590,正好 +113×10。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D --- apps/api/package.json | 1 + apps/api/src/routes/admin/problemset.ts | 69 +------ apps/api/src/routes/problemset.ts | 26 +-- .../src/scripts/backfill-problemset-badges.ts | 93 +++++++++ apps/api/src/services/problemset.ts | 180 ++++++++++++++++++ 5 files changed, 280 insertions(+), 89 deletions(-) create mode 100644 apps/api/src/scripts/backfill-problemset-badges.ts create mode 100644 apps/api/src/services/problemset.ts diff --git a/apps/api/package.json b/apps/api/package.json index 1e65b23..43266d5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +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/scripts/backfill-problemset-badges.ts", "typecheck": "tsc --noEmit", "check:routes": "bun src/scripts/check-route-shadowing.ts", "db:pull": "drizzle-kit pull", diff --git a/apps/api/src/routes/admin/problemset.ts b/apps/api/src/routes/admin/problemset.ts index 4f88db8..c0b750a 100644 --- a/apps/api/src/routes/admin/problemset.ts +++ b/apps/api/src/routes/admin/problemset.ts @@ -12,19 +12,19 @@ import { updateProblemSetRequestSchema, updateProblemSetStatusRequestSchema, } from "@oj2/contract" -import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm" +import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" import { Hono } from "hono" import { requireTeacher, type AppEnv } from "../../auth/middleware" import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, success } from "../../http" +import { recalculateBadge, resyncProgress } from "../../services/problemset" import { queryInteger, sampleUser } from "../helpers" export const adminProblemSetRoutes = new Hono() type BadgeRow = typeof schema.problemsetBadge.$inferSelect -type ProgressRow = typeof schema.problemsetProgress.$inferSelect /** 对齐旧 ensure_created_by:超管放行,其余人只能碰自己建的。越权报「不存在」 */ function ownedBy(user: AuthUser, row: { createdById: number }) { @@ -305,46 +305,6 @@ async function badgesWithCount(badges: BadgeRow[]) { })) } -/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */ -function eligible(badge: BadgeRow, progress: ProgressRow) { - 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 === "score") return progress.totalScore >= badge.conditionValue - return false -} - -/** - * 重算某个奖章的获得者,对齐旧 `recalculate_user_badges`(由 post_save 信号触发)。 - * 保留已有记录的 earnedTime —— 只增删差集,不是先清空再重建, - * 否则每改一次条件所有人的获得时间都会刷新成今天。 - */ -async function recalculateBadge(badge: BadgeRow) { - const progresses = await db.select().from(schema.problemsetProgress) - .where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)) - const eligibleIds = progresses.filter((item) => eligible(badge, item)).map((item) => item.userId) - await db.transaction(async (tx) => { - await tx.delete(schema.userBadge).where(and( - eq(schema.userBadge.badgeId, badge.id), - eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined, - )) - if (!eligibleIds.length) return - const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge) - .where(eq(schema.userBadge.badgeId, badge.id)) - const have = new Set(existing.map((item) => item.userId)) - const missing = eligibleIds.filter((id) => !have.has(id)) - if (missing.length) { - await tx.insert(schema.userBadge).values(missing.map((userId) => ({ - userId, - badgeId: badge.id, - earnedTime: new Date().toISOString(), - }))) - } - }) -} - adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => { const row = await loadOwned(c, c.get("user")!) if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") @@ -408,31 +368,6 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher // ---------------------------------------------------------------- 学生进度 -/** - * 题目集或分值变动后,把所有参与者的进度重算一遍。 - * - * 旧后端不做这件事:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字, - * 进度百分比因此偏高,甚至已经「完成」的人分母变了却还标着完成。 - * 只重算分母与百分比,不碰 completeTime —— 已经完成过的事实不因加题而撤销。 - */ -async function resyncProgress(problemsetId: number) { - const [totalRow] = await db.select({ value: count() }).from(schema.problemsetProblem) - .where(eq(schema.problemsetProblem.problemsetId, problemsetId)) - const total = totalRow?.value ?? 0 - // 一条 UPDATE 把整个题单的参与者刷完。以前是先把 progress 全查出来再逐行 update, - // 一个班的题单就是几十次往返,而算出来的值只跟 total 和这一行自己的 completed 有关。 - // completed 用 least(...) 夹住,百分比按 JS 那边同样的「乘 10000 四舍五入再除 100」 - // 保留两位小数 —— 数都是非负的,numeric 的 round 和 Math.round 在这个区间一致。 - const completed = sql`least(${schema.problemsetProgress.completedProblemsCount}, ${total})` - await db.update(schema.problemsetProgress).set({ - totalProblemsCount: total, - completedProblemsCount: completed, - progressPercentage: total > 0 - ? sql`round((${completed}::numeric / ${total}) * 10000) / 100` - : sql`0`, - }).where(eq(schema.problemsetProgress.problemsetId, problemsetId)) -} - adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => { const row = await loadOwned(c, c.get("user")!) if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index 5a9e995..0bb872a 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -20,7 +20,6 @@ import { gt, ilike, inArray, - isNull, ne, or, sql, @@ -33,6 +32,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 { objectValue, queryInteger, sampleUser } from "./helpers" export const problemsetRoutes = new Hono() @@ -216,27 +216,9 @@ async function recomputeProgress( ) { const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score }) .from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId)) - const valid = new Map(links.map((link) => [String(link.problemId), link.score])) - for (const key of Object.keys(detail)) if (!valid.has(key)) delete detail[key] - let totalScore = 0 - for (const [key, value] of Object.entries(detail)) { - const score = valid.get(key) - if (score === undefined) continue - totalScore += score - detail[key] = { ...objectValue(value), score } - } - const completed = Object.keys(detail).length - const total = links.length - const isCompleted = completed === total - const update = { - progressDetail: detail, - totalProblemsCount: total, - completedProblemsCount: completed, - totalScore, - progressPercentage: total > 0 ? completed / total * 100 : 0, - isCompleted, - completeTime: isCompleted ? progress.completeTime ?? new Date().toISOString() : null, - } + // 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份, + // 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed + const update = computeProgress(detail, links, progress.completeTime) await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id)) return { ...progress, ...update } } diff --git a/apps/api/src/scripts/backfill-problemset-badges.ts b/apps/api/src/scripts/backfill-problemset-badges.ts new file mode 100644 index 0000000..80129ae --- /dev/null +++ b/apps/api/src/scripts/backfill-problemset-badges.ts @@ -0,0 +1,93 @@ +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。 + * + * bun src/scripts/backfill-problemset-badges.ts + * bun src/scripts/backfill-problemset-badges.ts --apply + */ +const apply = process.argv.includes("--apply") +const allowRevoke = process.argv.includes("--allow-revoke") + +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("没有任何题单奖章,无事可做") + process.exit(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("奖章与规则一致,无需补发") + process.exit(0) +} + +if (!apply) { + console.log("\n这是只读预演。确认无误后加 --apply 落库。") + process.exit(0) +} + +if (extraTotal > 0 && !allowRevoke) { + console.error( + `\n存在 ${extraTotal} 条误发。补发用的 recalculateBadge 会把它们**删掉**,` + + `而 user_badge 没有别处备份、earnedTime 删了就找不回来。\n` + + `确认要连同收回一起执行,加 --allow-revoke。`, + ) + process.exit(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} 条差异`) +process.exit(remaining === 0 ? 0 : 1) diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts new file mode 100644 index 0000000..83fd9c3 --- /dev/null +++ b/apps/api/src/services/problemset.ts @@ -0,0 +1,180 @@ +import { and, eq, notInArray, sql } from "drizzle-orm" + +import { db, schema } from "../db" +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 + +/** + * 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。 + * + * 以前两边各写一遍,于是各自漂了一段。后台那份(resyncProgress)只更新分母和百分比: + * - 改题目分值时调了它,可它根本不碰 total_score,score 类奖章按陈旧分数判定; + * - 不碰 is_completed,加一道题之后分母变大、百分比掉下来,人还标着「已完成」; + * - 不清理 progress_detail,删掉一道题之后 least(completed, total) 会把没做的题算成做了。 + * 两边不再分叉的唯一办法是只留一处算法,所以这里做成纯函数,两边都只是调用者。 + */ +export function computeProgress( + detail: Record, + links: ProblemLink[], + previousCompleteTime: string | null, + now = new Date().toISOString(), +) { + const scoreByProblem = new Map(links.map((link) => [String(link.problemId), link.score])) + // 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多 + const kept: Record = {} + let totalScore = 0 + for (const [key, value] of Object.entries(detail)) { + const score = scoreByProblem.get(key) + if (score === undefined) continue + totalScore += score + // 分值以题单当前的设置为准,detail 里存的是做出那一刻的快照 + kept[key] = { ...objectValue(value), score } + } + const completed = Object.keys(kept).length + const total = links.length + // total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」, + // 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。 + const isCompleted = total > 0 && completed === total + return { + progressDetail: kept, + totalProblemsCount: total, + completedProblemsCount: completed, + totalScore, + // 乘 10000 四舍五入再除 100,保留两位小数 + progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0, + isCompleted, + // 完成状态没了,complete_time 也不该留着。学生那一路本来就是这么写的, + // 后台这一路以前保留旧时间,于是同一行会出现「未完成 + 有完成时间」。 + completeTime: isCompleted ? previousCompleteTime ?? now : null, + } +} + +type ProgressWrite = ReturnType & { id: number } + +/** + * 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的题单就是上百次往返, + * 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。 + */ +async function writeProgress(rows: ProgressWrite[]) { + // 每行 8 个参数,留足余量避开 Postgres 的 65535 个绑定参数上限 + for (let start = 0; start < rows.length; start += 1000) { + const chunk = rows.slice(start, start + 1000) + const values = sql.join( + chunk.map((row) => sql`( + ${row.id}::bigint, + ${JSON.stringify(row.progressDetail)}::jsonb, + ${row.totalProblemsCount}::int, + ${row.completedProblemsCount}::int, + ${row.totalScore}::int, + ${row.progressPercentage}::double precision, + ${row.isCompleted}::boolean, + ${row.completeTime}::timestamptz + )`), + sql`, `, + ) + await db.execute(sql` + update ${schema.problemsetProgress} as pg set + progress_detail = v.detail, + total_problems_count = v.total_count, + completed_problems_count = v.completed_count, + total_score = v.total_score, + progress_percentage = v.percentage, + is_completed = v.is_completed, + complete_time = v.complete_time + from (values ${values}) as v( + id, detail, total_count, completed_count, total_score, percentage, is_completed, complete_time + ) + where pg.id = v.id + `) + } +} + +/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */ +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 === "score") return progress.totalScore >= badge.conditionValue + return false +} + +/** + * 重算某枚奖章的获得者,对齐旧 `recalculate_user_badges`(由 post_save 信号触发)。 + * 保留已有记录的 earnedTime —— 只增删差集,不是先清空再重建, + * 否则每改一次条件所有人的获得时间都会刷新成今天。 + * + * 调用方手里已经有最新的进度时把它传进来(`known`),省掉一次回表; + * 更要紧的是别用刚写完库之前的旧值去判定。 + */ +export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) { + const progresses = known ?? await db.select().from(schema.problemsetProgress) + .where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)) + const eligibleIds = progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId) + await db.transaction(async (tx) => { + await tx.delete(schema.userBadge).where(and( + eq(schema.userBadge.badgeId, badge.id), + eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined, + )) + if (!eligibleIds.length) return + const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge) + .where(eq(schema.userBadge.badgeId, badge.id)) + const have = new Set(existing.map((item) => item.userId)) + const missing = eligibleIds.filter((id) => !have.has(id)) + if (missing.length) { + await tx.insert(schema.userBadge).values(missing.map((userId) => ({ + userId, + badgeId: badge.id, + earnedTime: new Date().toISOString(), + }))) + } + }) +} + +/** + * 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。 + * + * 旧后端两件事都不做:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字, + * 进度百分比因此偏高;奖章那边更是没人回头判过,生产快照里因此攒下 53 条应发未发 + * (30 名学生,其中 23 条来自 2026-05-22 那次批量补进度)。 + */ +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().from(schema.problemsetProgress) + .where(eq(schema.problemsetProgress.problemsetId, problemsetId)), + db.select().from(schema.problemsetBadge) + .where(eq(schema.problemsetBadge.problemsetId, problemsetId)), + ]) + const now = new Date().toISOString() + const updated = progresses.map((progress) => ({ + ...progress, + ...computeProgress(objectValue(progress.progressDetail), links, progress.completeTime, now), + })) + if (updated.length) await writeProgress(updated) + for (const badge of badges) await recalculateBadge(badge, updated) +} + +/** 按奖章算出「现在应该有谁」,只读,供补发脚本先看后写 */ +export async function badgeHolderDiff(badge: BadgeRow) { + const [progresses, holders] = await Promise.all([ + 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)), + ]) + const eligible = new Set(progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId)) + const have = new Set(holders.map((item) => item.userId)) + return { + missing: [...eligible].filter((id) => !have.has(id)), + extra: [...have].filter((id) => !eligible.has(id)), + eligible: eligible.size, + held: have.size, + } +}