From 05cf8011e310956c867a684892460aa02fb4b66c Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 31 Aug 2026 08:26:27 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E5=8D=95):=20=E5=A5=96=E7=AB=A0?= =?UTF-8?q?=E8=A1=A5=E5=8F=91=E6=94=B9=E6=88=90=E4=BA=8C=E8=BF=9B=E5=88=B6?= =?UTF-8?q?=E5=AD=90=E5=91=BD=E4=BB=A4=EF=BC=8C=E7=8B=AC=E7=AB=8B=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=9C=A8=E7=94=9F=E4=BA=A7=E8=B7=91=E4=B8=8D=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把补发写成 `bun src/scripts/backfill-problemset-badges.ts`,那在生产上根本执行 不了 —— api 镜像是 debian-slim + 一个编译好的 oj2-api,既没有 bun 也没有源码。 改成 main.ts 的子命令,跑法对齐 migrate(deploy.sh:197 就是这个形态): 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 脚本本身从「导入即执行」改成导出一个返回退出码的函数,main.ts 负责解析参数和 exit。 package.json 的 backfill:badges 也指向同一入口,本机和生产走的是同一条代码路径。 用编译出来的二进制在 /tmp 下实跑过全部四条路径(预演 / --apply 后自动复核 / 重复跑 幂等 / 误发时拒绝执行并退 1,加 --allow-revoke 才收回),确认动态 import 的模块进了 bundle、不依赖源码树。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D --- apps/api/package.json | 2 +- apps/api/src/main.ts | 12 +- .../src/scripts/backfill-problemset-badges.ts | 151 +++++++++--------- 3 files changed, 91 insertions(+), 74 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 43266d5..ab5cf32 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/scripts/backfill-problemset-badges.ts", + "backfill:badges": "bun src/main.ts backfill-badges", "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 5cc7bf8..294f2d4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -12,6 +12,7 @@ * oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用 * oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调 * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 + * oj2-api backfill-badges # 补发历史欠账的题单奖章,默认只读预演,--apply 才落库 * * 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用 * (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 @@ -33,6 +34,15 @@ switch (command) { await runMigrations() break } + // 一次性的数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码 + case "backfill-badges": { + const { backfillProblemSetBadges } = await import("./scripts/backfill-problemset-badges") + const args = process.argv.slice(3) + process.exit(await backfillProblemSetBadges({ + apply: args.includes("--apply"), + allowRevoke: args.includes("--allow-revoke"), + })) + } case "sql-child": { const { runSqlChild } = await import("./judge/sql/child") await runSqlChild() @@ -53,6 +63,6 @@ switch (command) { } } default: - console.error(`未知子命令:${command}\n可用:serve | worker | migrate | healthcheck | sql-child`) + console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-badges | healthcheck | sql-child`) process.exit(2) } diff --git a/apps/api/src/scripts/backfill-problemset-badges.ts b/apps/api/src/scripts/backfill-problemset-badges.ts index 80129ae..9b1a2ce 100644 --- a/apps/api/src/scripts/backfill-problemset-badges.ts +++ b/apps/api/src/scripts/backfill-problemset-badges.ts @@ -15,79 +15,86 @@ import { badgeHolderDiff, recalculateBadge } from "../services/problemset" * 补发用的是 recalculateBadge,它同时会**收回**已经不达标的人的奖章,所以只要存在 * 「误发」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke。 * - * bun src/scripts/backfill-problemset-badges.ts - * bun src/scripts/backfill-problemset-badges.ts --apply + * 做成 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 */ -const apply = process.argv.includes("--apply") -const allowRevoke = process.argv.includes("--allow-revoke") +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) -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) + 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 } -console.log(remaining === 0 ? "复核通过:全部奖章与规则一致" : `复核未通过,仍有 ${remaining} 条差异`) -process.exit(remaining === 0 ? 0 : 1)