diff --git a/apps/api/package.json b/apps/api/package.json index 980bbdc..a2cb24b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,8 +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:problemsets": "bun src/main.ts backfill-problemsets", - "fix:achievement-hours": "bun src/main.ts fix-achievement-hours", + "recount": "bun --env-file=../../.env src/main.ts recount", "typecheck": "tsc --noEmit", "check:routes": "bun src/scripts/check-route-shadowing.ts", "check:ast": "bun src/scripts/check-ast-targets.ts", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index df9ba50..616dfb3 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -12,9 +12,7 @@ * oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用 * oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调 * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 - * oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演 - * oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演 - * oj2-api fix-achievement-hours # 订正「夜猫子」「早起的鸟儿」的历史误发,默认只读预演 + * oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演 * * 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用 * (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 @@ -36,25 +34,12 @@ switch (command) { await runMigrations() break } - // 一次性的数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码 - case "backfill-problemsets": { - const { backfillProblemSets } = await import("./scripts/backfill-problemsets") - const args = process.argv.slice(3) - process.exit(await backfillProblemSets({ - apply: args.includes("--apply"), - allowRevoke: args.includes("--allow-revoke"), - })) - } - // 同上,一次性的数据订正。反范式计数列被重判等操作带偏之后拿它对账。 + // 数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码。 + // 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。 case "recount": { const { recount } = await import("./scripts/recount") process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") })) } - // 同上。OJ2 上线时区丢了两周,两个小时口径的成就按 UTC 误发了一批,拿它订正。 - case "fix-achievement-hours": { - const { fixAchievementHours } = await import("./scripts/fix-achievement-hours") - process.exit(await fixAchievementHours({ apply: process.argv.slice(3).includes("--apply") })) - } case "sql-child": { const { runSqlChild } = await import("./judge/sql/child") await runSqlChild() @@ -75,6 +60,6 @@ switch (command) { } } default: - console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-problemsets | recount | fix-achievement-hours | healthcheck | sql-child`) + console.error(`未知子命令:${command}\n可用:serve | worker | migrate | recount | healthcheck | sql-child`) process.exit(2) } diff --git a/apps/api/src/scripts/backfill-problemsets.ts b/apps/api/src/scripts/backfill-problemsets.ts deleted file mode 100644 index 6ec25d2..0000000 --- a/apps/api/src/scripts/backfill-problemsets.ts +++ /dev/null @@ -1,275 +0,0 @@ -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 跟着分值走。 - * 已有的行要跑一遍才会按新规则重算。 - * - * 三件事必须一趟做完,因为它们首尾相接:补进度 → 完成状态变 → 奖章达标面变。 - * 落库走 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 - */ -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, - // 和 recoverable 上面那段同口径。不加 `::text`:OID 1184 由 db/index.ts 统一转成 - // ISO 8601 UTC,比 PG 文本更稳定(PG 文本的形状跟着会话时区走)。 - // 注:这个值写进 progress_detail.submit_time,而那个字段没有任何读取方, - // 存量里还混着 Django 的 `...+00:00`,所以形状变化只影响 backfill 自己的差异比对。 - solvedAt: sql`min(${schema.submission.createTime})`, - }).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) - 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 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 || - 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, 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) - 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.recovered) { - lines.push(` 补录:${r.recovered} 道题已 AC 但进度里没记(${r.recoveredUsers} 名学生)`) - } - 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合计:补录 ${recovered} 道题,进度 ${progressRows} 条要重算` + - `(完成 +${completedGain} / -${completedLoss}),奖章补发 ${missing} 条、收回 ${extra} 条`) - - if (recovered === 0 && 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) { - 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 - } - // 没有参与者、只有奖章欠账的题单不会走上面那条,兜一遍 - 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 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 || - 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/scripts/fix-achievement-hours.ts b/apps/api/src/scripts/fix-achievement-hours.ts deleted file mode 100644 index f55e43b..0000000 --- a/apps/api/src/scripts/fix-achievement-hours.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm" - -import { db, schema } from "../db" -import { localTime, TIME_ZONE } from "../time" - -/** - * 一次性数据对账:把「夜猫子」「早起的鸟儿」的历史发放与真实提交时间对齐。 - * - * ## 背景 - * - * 2026-08-26 OJ2 上线到 2026-09-14 修好时区之间,`services/achievements.ts` 用 - * `new Date(createTime).getHours()` 判定,而容器是 UTC —— 于是 **UTC 的「凌晨 0–5 点」 - * 其实是北京时间的上午 9–13 点**,学生在上课时间提交被记成熬夜; - * 「5–7 点」对应北京下午 1–3 点,被记成早起。 - * - * 旧 Django 栈用的是 `timezone.localtime()`(`TIME_ZONE = "Asia/Shanghai"`), - * 所以 2022–2026.08 的历史数据**本来就是东八区口径、本来就是对的**。 - * 出问题的只有 OJ2 那两周的增量。 - * - * ## 为什么不能只删 `user_achievement` - * - * `unlockAchievements()` 的判定是**纯粹的阈值比较**(`metrics[metric] >= threshold`), - * 不是「这次有没有跨过阈值」。所以只要 `user_stat.metrics.midnight_submissions` - * 还留着虚高的 13,把成就行删掉之后,学生**下一次提交就会原样再发一次**。 - * - * 必须一起做:按东八区重算小时指标写回 user_stat,再对账发放。 - * - * ## 两个方向 - * - * - **撤回**:重算后不达标却持有 → 删行 + `achievement.unlock_count` 下调。 - * - **补发**:重算后达标却没持有 → 插行(`backfilled = true`,前端显示「已获得」 - * 而不编一个解锁时间)+ 计数上调。 - * - * 2026-09-14 的实测结论:**只有撤回(60 条),补发 0 条** —— 原因是 OJ2 窗口内 - * 1508 条提交里,真正落在北京 0–5 点和 5–7 点的**都是 0 条**(真熬夜的人都在 - * Django 时代活跃过了)。补发分支留着是为了对称和以后重跑,不是为了现在有用。 - * - * ## 跑法(对齐 migrate / recount:跟着二进制走,生产镜像里没有 bun 也没有源码) - * - * # 本机开发 - * bun apps/api/src/main.ts fix-achievement-hours - * bun apps/api/src/main.ts fix-achievement-hours --apply - * - * # 服务器 / 机房(docker/ 目录下,或照 deploy.sh 用 -f 指定) - * docker compose -f docker/compose.debian.yml --env-file docker/.env \ - * run --rm oj-api oj2-api fix-achievement-hours - * # 确认无误后再加 --apply - * - * ⚠️ **必须先部署时区修复,再跑这个脚本。** 反过来的话旧代码还在按 UTC 累加, - * 跑完马上又被写脏、成就又发回来。 - * - * ⚠️ **--apply 要挑没人做题的时候跑。** 差异在事务外算、事务内写;算完到写完之间 - * 要是学生交了一发并判完,那一笔的小时计数会被覆盖。落库后的复核会报「仍有 N 处差异」 - * 并以 1 退出,不会静默 —— 见到了重跑一次即可。 - * - * 写回时**只动两个小时键**(jsonb `||` 合并),不整体覆盖 `metrics`:整体写回的是事务外 - * 读的快照,并发时会连带把 `submission_count` / `_last_ac_date` 等一起写旧,而复核只比 - * 小时指标,那种覆盖是查不出来的。 - * - * 幂等:干净状态下再跑,报 0 处差异并以 0 退出。 - */ - -/** 受时区影响的小时口径指标 —— 只有这两个 */ -const HOUR_METRICS = ["midnight_submissions", "early_bird_submissions"] as const - -interface Target { - id: number - name: string - metric: string - threshold: number - operator: string -} - -interface Plan { - metricFixes: Array<{ id: number; userId: number; midnight: number; earlyBird: number }> - revoke: Array<{ userId: number; achievementId: number; name: string; held: number; actual: number }> - grant: Array<{ userId: number; achievementId: number; name: string; actual: number }> - cascade: Array<{ userId: number; achievementId: number; name: string; before: number; after: number }> - targets: Target[] - meta: Target | undefined - touched: number[] -} - -/** - * 只读对账:算出「该改的指标 / 该撤的 / 该补的 / 连锁该撤的」。 - * 落库之后再调一次就是复核。 - */ -async function audit(): Promise { - // 用 SQL 一次算完,口径和 apps/api/src/time.ts 完全一致(东八区墙上时钟的钟点)。 - // 只统计非比赛提交 —— 和 updateAchievementsForSubmission 的 contestId !== null 提前返回对齐。 - const hour = sql`extract(hour from ${localTime(schema.submission.createTime)})` - const recomputed = await db - .select({ - userId: schema.submission.userId, - midnight: sql`count(*) filter (where ${hour} < 5)`.mapWith(Number), - earlyBird: sql`count(*) filter (where ${hour} >= 5 and ${hour} < 7)`.mapWith(Number), - }) - .from(schema.submission) - .where(isNull(schema.submission.contestId)) - .groupBy(schema.submission.userId) - const truth = new Map(recomputed.map((row) => [row.userId, row])) - const actualOf = (userId: number, metric: string) => { - const fresh = truth.get(userId) - return (metric === "midnight_submissions" ? fresh?.midnight : fresh?.earlyBird) ?? 0 - } - - // 哪些 user_stat 的小时指标要改 - const stats = await db.select().from(schema.userStat) - const statByUser = new Map(stats.map((row) => [row.userId, row])) - const metricFixes: Plan["metricFixes"] = [] - for (const row of stats) { - const fresh = truth.get(row.userId) - const corrected = { midnight_submissions: fresh?.midnight ?? 0, early_bird_submissions: fresh?.earlyBird ?? 0 } - const changed = HOUR_METRICS.filter((key) => (row.metrics as Record)[key] !== corrected[key]) - if (changed.length) { - metricFixes.push({ id: row.id, userId: row.userId, midnight: corrected.midnight_submissions, earlyBird: corrected.early_bird_submissions }) - } - } - - const targets = await db - .select({ - id: schema.achievement.id, - name: schema.achievement.name, - metric: schema.achievement.metric, - threshold: schema.achievement.threshold, - operator: schema.achievement.operator, - }) - .from(schema.achievement) - .where(and(eq(schema.achievement.visible, true), inArray(schema.achievement.metric, [...HOUR_METRICS]))) - const targetById = new Map(targets.map((item) => [item.id, item])) - const met = (target: Target, userId: number) => { - const value = actualOf(userId, target.metric) - return target.operator === "gte" ? value >= target.threshold : value <= target.threshold - } - - const grants = await db - .select({ achievementId: schema.userAchievement.achievementId, userId: schema.userAchievement.userId }) - .from(schema.userAchievement) - .where(inArray(schema.userAchievement.achievementId, targets.map((item) => item.id))) - const heldByUser = new Map>() - for (const grant of grants) { - heldByUser.set(grant.userId, (heldByUser.get(grant.userId) ?? new Set()).add(grant.achievementId)) - } - - const revoke: Plan["revoke"] = [] - for (const grant of grants) { - const target = targetById.get(grant.achievementId) - if (!target || met(target, grant.userId)) continue - revoke.push({ - userId: grant.userId, - achievementId: grant.achievementId, - name: target.name, - held: Number((statByUser.get(grant.userId)?.metrics as Record | undefined)?.[target.metric] ?? 0), - actual: actualOf(grant.userId, target.metric), - }) - } - - // 补发只针对「被成就系统结算过」的用户(有 user_stat 行)—— 从没结算过的是另一件事 - // (备份里还有 31 个有提交却没有 user_stat 行的用户),不在这个脚本的职责内。 - const grant: Plan["grant"] = [] - for (const target of targets) { - for (const userId of truth.keys()) { - if (!statByUser.has(userId)) continue - if (heldByUser.get(userId)?.has(target.id)) continue - if (!met(target, userId)) continue - grant.push({ userId, achievementId: target.id, name: target.name, actual: actualOf(userId, target.metric) }) - } - } - - // 连锁:`achievement_unlocked_count` = 「已解锁的非白金成就数」,是 奖杯收藏家 的判据。 - // 撤回会让它降、补发会让它升;降破了阈值的 奖杯收藏家 要一起撤。 - const touched = [...new Set([...revoke.map((item) => item.userId), ...grant.map((item) => item.userId)])] - const meta = ( - await db - .select({ - id: schema.achievement.id, - name: schema.achievement.name, - metric: schema.achievement.metric, - threshold: schema.achievement.threshold, - operator: schema.achievement.operator, - }) - .from(schema.achievement) - .where(and(eq(schema.achievement.visible, true), eq(schema.achievement.metric, "achievement_unlocked_count"))) - )[0] - - const cascade: Plan["cascade"] = [] - if (meta && touched.length) { - const unlocked = await db - .select({ - userId: schema.userAchievement.userId, - achievementId: schema.userAchievement.achievementId, - rarity: schema.achievement.rarity, - }) - .from(schema.userAchievement) - .innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id)) - const held = new Map>() - for (const row of unlocked) { - const list = held.get(row.userId) ?? [] - list.push({ achievementId: row.achievementId, rarity: row.rarity }) - held.set(row.userId, list) - } - for (const userId of touched) { - const mine = held.get(userId) ?? [] - const gone = new Set(revoke.filter((item) => item.userId === userId).map((item) => item.achievementId)) - const coming = new Set(grant.filter((item) => item.userId === userId).map((item) => item.achievementId)) - const before = mine.filter((item) => item.rarity !== "platinum").length - const after = mine.filter((item) => item.rarity !== "platinum" && !gone.has(item.achievementId)).length + coming.size - if (before === after) continue - const holdsMeta = mine.some((item) => item.achievementId === meta.id) - const ok = meta.operator === "gte" ? after >= meta.threshold : after <= meta.threshold - if (holdsMeta && !ok) cascade.push({ userId, achievementId: meta.id, name: meta.name, before, after }) - } - } - - return { metricFixes, revoke, grant, cascade, targets, meta, touched } -} - -function report(plan: Plan) { - console.log(`① 需要修正小时指标的 user_stat 行:${plan.metricFixes.length}`) - console.log(`② 需要【撤回】的发放:${plan.revoke.length} 条,涉及 ${new Set(plan.revoke.map((r) => r.userId)).size} 人`) - for (const target of plan.targets) { - console.log(` - ${target.name}(阈值 ${target.operator} ${target.threshold}):${plan.revoke.filter((r) => r.achievementId === target.id).length} 条`) - } - console.log(`③ 需要【补发】的发放:${plan.grant.length} 条,涉及 ${new Set(plan.grant.map((r) => r.userId)).size} 人`) - for (const target of plan.targets) { - console.log(` - ${target.name}:${plan.grant.filter((r) => r.achievementId === target.id).length} 条`) - } - console.log(`④ 连带需要撤销的「${plan.meta?.name ?? "(未配置)"}」:${plan.cascade.length} 条`) - if (plan.revoke.length <= 40) { - for (const item of plan.revoke) console.log(` [撤] user ${item.userId} ${item.name} 存量 ${item.held} → 实际 ${item.actual}`) - } - if (plan.grant.length <= 40) { - for (const item of plan.grant) console.log(` [补] user ${item.userId} ${item.name} 实际 ${item.actual}`) - } - for (const item of plan.cascade) console.log(` [撤] user ${item.userId} ${item.name} 已解锁数 ${item.before} → ${item.after}`) -} - -export async function fixAchievementHours({ apply }: { apply: boolean }): Promise { - const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge" - const database = (() => { - try { - const parsed = new URL(url) - return `${parsed.host}${parsed.pathname}` - } catch { - return "(无法解析)" - } - })() - console.log(`目标库:${database}`) - console.log(`模式:${apply ? "【真写】" : "只读预演(不改任何数据)"}`) - console.log(`时区锚点:${TIME_ZONE}\n`) - - const plan = await audit() - report(plan) - if (!plan.metricFixes.length && !plan.revoke.length && !plan.grant.length && !plan.cascade.length) { - console.log("\n复核通过:发放与真实提交时间一致,无需修改。") - return 0 - } - if (!apply) { - console.log("\n(只读预演结束。确认无误后加 --apply。)") - return 0 - } - - const { metricFixes, revoke, grant, cascade, targets, meta, touched } = plan - await db.transaction(async (tx) => { - for (const fix of metricFixes) { - // 只合并两个小时键,其余指标以库里当下的值为准(见文件头「只动两个小时键」) - await tx - .update(schema.userStat) - .set({ - metrics: sql`${schema.userStat.metrics} || jsonb_build_object('midnight_submissions', ${fix.midnight}::int, 'early_bird_submissions', ${fix.earlyBird}::int)`, - }) - .where(eq(schema.userStat.id, fix.id)) - } - for (const target of targets) { - const ids = revoke.filter((item) => item.achievementId === target.id).map((item) => item.userId) - if (ids.length) { - await tx - .delete(schema.userAchievement) - .where(and(eq(schema.userAchievement.achievementId, target.id), inArray(schema.userAchievement.userId, ids))) - await tx - .update(schema.achievement) - .set({ unlockCount: sql`greatest(${schema.achievement.unlockCount} - ${ids.length}, 0)` }) - .where(eq(schema.achievement.id, target.id)) - } - // backfilled = true:这是事后对账补的,不编一个假的解锁时间;前端因此显示「已获得」 - const rows = grant - .filter((item) => item.achievementId === target.id) - .map((item) => ({ userId: item.userId, achievementId: target.id, unlockTime: new Date().toISOString(), backfilled: true, notified: false })) - if (rows.length) { - await tx.insert(schema.userAchievement).values(rows) - await tx - .update(schema.achievement) - .set({ unlockCount: sql`${schema.achievement.unlockCount} + ${rows.length}` }) - .where(eq(schema.achievement.id, target.id)) - } - } - for (const item of cascade) { - await tx - .delete(schema.userAchievement) - .where(and(eq(schema.userAchievement.achievementId, item.achievementId), eq(schema.userAchievement.userId, item.userId))) - await tx - .update(schema.achievement) - .set({ unlockCount: sql`greatest(${schema.achievement.unlockCount} - 1, 0)` }) - .where(eq(schema.achievement.id, item.achievementId)) - } - // 最后把 achievement_unlocked_count 校正成「对账之后实际持有的非白金数」 - if (meta && touched.length) { - const fresh = await tx - .select({ userId: schema.userAchievement.userId, value: sql`count(*)`.mapWith(Number) }) - .from(schema.userAchievement) - .innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id)) - .where(and(inArray(schema.userAchievement.userId, touched), ne(schema.achievement.rarity, "platinum"))) - .groupBy(schema.userAchievement.userId) - const byUser = new Map(fresh.map((row) => [row.userId, row.value])) - for (const userId of touched) { - await tx - .update(schema.userStat) - .set({ - metrics: sql`jsonb_set(${schema.userStat.metrics}, '{achievement_unlocked_count}', ${JSON.stringify(byUser.get(userId) ?? 0)}::jsonb)`, - }) - .where(eq(schema.userStat.userId, userId)) - } - } - }) - console.log(`\n已订正指标 ${metricFixes.length} 行、撤回 ${revoke.length} 条、补发 ${grant.length} 条、连锁撤销 ${cascade.length} 条,复核中……`) - - // 复核跑的是同一份 audit。还剩差异说明口径或并发写入了,必须让调用方看见非零退出码。 - const after = await audit() - const left = after.metricFixes.length + after.revoke.length + after.grant.length + after.cascade.length - if (left === 0) { - console.log("复核通过:发放与真实提交时间一致。") - return 0 - } - console.error(`复核未通过,仍有 ${left} 处差异(多半是算完到写完之间有新提交判完,重跑一次即可):`) - report(after) - return 1 -} diff --git a/apps/api/src/scripts/recount.ts b/apps/api/src/scripts/recount.ts index d068a4e..b5ad75f 100644 --- a/apps/api/src/scripts/recount.ts +++ b/apps/api/src/scripts/recount.ts @@ -24,7 +24,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../se * * **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info * 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后 - * user_achievement 随成就级联,漂不了)、题单进度与奖章(走 backfill-problemsets)。 + * user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。 * * 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate: * diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts index 71bcfd4..70fae59 100644 --- a/apps/api/src/services/problemset.ts +++ b/apps/api/src/services/problemset.ts @@ -193,24 +193,6 @@ export async function resyncProgress(problemsetId: number) { for (const badge of badges) await recalculateBadge(badge, updated) } -/** 按奖章算出「现在应该有谁」,只读,供补发脚本先看后写 */ -export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) { - const [progresses, holders] = await Promise.all([ - 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)), - ]) - 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, - } -} - /** * 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。 * @@ -222,8 +204,6 @@ export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { u * 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。 * * 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。 - * 更要紧的是这条规则必须和补账那条(scripts/backfill-problemsets.ts)一致 —— - * 两边口径不一样的话,补账工具会永远「发现」差异。 */ export async function recordSolvedProblem( userId: number,