From f6995c841b30fdaff5ffaff7f2741a632770718a Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 14 Sep 2026 05:55:27 -0600 Subject: [PATCH] =?UTF-8?q?ops(=E6=88=90=E5=B0=B1):=20=E5=8A=A0=20fix-achi?= =?UTF-8?q?evement-hours=EF=BC=8C=E8=AE=A2=E6=AD=A3=E6=97=B6=E5=8C=BA?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E6=9C=9F=E9=97=B4=E8=AF=AF=E5=8F=91=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E5=A4=9C=E7=8C=AB=E5=AD=90=E3=80=8D=E3=80=8C=E6=97=A9?= =?UTF-8?q?=E8=B5=B7=E7=9A=84=E9=B8=9F=E5=84=BF=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OJ2 上线到时区修复之间,成就的小时键按 UTC 判定:UTC 的 0–5 点 / 5–7 点是北京 的上午 9–13 点 / 下午 1–3 点,上课时间的提交被记成熬夜和早起。Django 时代的存量 本来就是东八区口径(已用生产备份做判别性核对),出问题的只有这两周的增量。 脚本按东八区重算两个小时指标(只合并这两个键,不整体覆盖 metrics)→ 撤回不达标 的 → 补发达标却没发的 → 同步 unlock_count → 校正已解锁数与「奖杯收藏家」连锁。 默认只读预演,--apply 落库后自动复核,幂等。 用 db_backup_2026_09_14_18_17_37.sql 实跑:修正 148 行、撤回 60 条(47 + 13, 50 人)、补发 0、连锁 0,其余指标 0 行被动。必须先部署时区修复再跑。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd --- CLAUDE.md | 54 +++ apps/api/package.json | 1 + apps/api/src/main.ts | 8 +- apps/api/src/scripts/fix-achievement-hours.ts | 337 ++++++++++++++++++ 4 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/scripts/fix-achievement-hours.ts diff --git a/CLAUDE.md b/CLAUDE.md index 95a789a..426e09e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,6 +237,60 @@ C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者 `apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse `normalizeDate`)实测都能吃 ISO,改动出参格式不需要动前端。 +### 存量成就的口径是东八区,别再退回 UTC + +(2026-09-14 用生产备份 `db_backup_2026_09_08_19_22_11.sql` 实测过,结论和直觉相反, +记在这里省得下次重新推。下面「那两周留下的实际后果」和脚本的跑数,又用 +`db_backup_2026_09_14_18_17_37.sql`(最新提交到北京时间 9-14 17:06)逐项复核过,一致。) + +**历史指标本来就是北京时间。** 旧栈 `OnlineJudge/achievement/metrics.py` 全程用 +`timezone.localtime(...)`,而 `settings.TIME_ZONE = "Asia/Shanghai"`,所以 +2022-04 到 OJ2 上线之间那 10 万多条提交累积出的 `user_stat.metrics` 是**东八区口径**。 +判别性核对(只在新旧口径算出不同值的用户里看存量更像哪边): + +| 指标 | 两口径不同 | 存量==UTC | 存量==东八区 | +|---|---|---|---| +| `midnight_submissions` | 1259 | 132 | 1123 | +| `early_bird_submissions` | 909 | 1 | 905 | +| `active_days` | 90 | 0 | 90 | +| `max_ac_in_one_day` | 17 | 0 | 17 | +| `max_ac_streak_days` | 40 | 0 | 40 | + +**所以修 OJ2 的时区不是「换口径」,是「把 OJ2 弄丢的口径补回来」。** 别以为改动会让 +存量数据失配 —— 失配的是 OJ2 上线后那两周,修完反而对齐了。 + +**那两周留下的实际后果**(截至 2026-09-14 备份,1508 条 OJ2 期提交、约 1500 个已结算用户): + +- **日期键没被污染**:`_active_dates` / `_ac_per_day` 一个都没偏 —— 上课时间的提交 + 在 UTC 下日期和北京是同一天。所以 5 个日期口径的成就(活跃天数、单日最多 AC、 + 连续天数)一条都没错。 +- **只有小时键被污染**:145 人 `midnight_submissions` 虚高、45 人 `early_bird_submissions` + 虚高。因为 UTC 的「凌晨 0–5 点」正好是北京的上午 9–13 点,而学生恰恰在上课时间提交。 +- **结果是 60 条误发**:47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2 + 时期的新账号(`backfilled = false`)。 +- **0 条漏发**,而且是结构性的:OJ2 窗口那 1508 条提交里,真正落在北京 0–5 点和 + 5–7 点的**都是 0 条** —— 真熬夜、真早起的人都在 Django 时代活跃过了,他们的成就 + 是当时按东八区正确发的。这个 bug 只会多给,不会少给。 + +**改数据时最大的坑:不能只删 `user_achievement`。** +`unlockAchievements()` 的判定是**纯阈值比较**(`metrics[metric] >= threshold`),不是 +「这次有没有跨过阈值」。只删行、不修 `user_stat.metrics` 的话,学生**下一次提交就把 +同一个成就原样再发一次**。必须「按东八区重算小时指标」和「对账发放」一起做。 + +现成的工具是一次性对账脚本 `apps/api/src/scripts/fix-achievement-hours.ts` +(`bun run --filter '@oj2/api' fix:achievement-hours`,默认 dry-run,`--apply` 才写): +重算两个小时指标 → 撤回不达标的 → 补发达标却没发的 → 同步 `achievement.unlock_count` +→ 校正 `achievement_unlocked_count` 与「奖杯收藏家」连锁。实测幂等,2026-09-14 那批 +跑出来是「修正 148 行 · 撤回 60 条 · 补发 0 条 · 连锁 0 条」。 + +⚠️ **顺序:先部署时区修复,再跑这个脚本。** 反过来的话,旧代码还在按 UTC 累加, +跑完马上又被写脏、成就又发回来。 + +**下次再动日历口径,照这套方法核实**:从生产备份里捞出 `submission` / `user_stat` / +`user_achievement` / `achievement` 四张表回放一遍,先用与时间无关的指标 +(`submission_count` / `accepted_count`)校准重放器(实测逐人 0 差异 / 4 人差异), +再比受影响的指标。别靠推理 —— 这次推理就得出过相反的结论。 + ## 数据库 Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表 diff --git a/apps/api/package.json b/apps/api/package.json index 1951951..1790646 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "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", "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 a7e2643..5d83285 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -14,6 +14,7 @@ * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 * oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演 * oj2-api recount # 把题目/用户的计数列重算回与 submission 一致,默认只读预演 + * oj2-api fix-achievement-hours # 订正「夜猫子」「早起的鸟儿」的历史误发,默认只读预演 * * 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用 * (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 @@ -49,6 +50,11 @@ switch (command) { 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() @@ -69,6 +75,6 @@ switch (command) { } } default: - console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-problemsets | recount | healthcheck | sql-child`) + console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-problemsets | recount | fix-achievement-hours | healthcheck | sql-child`) process.exit(2) } diff --git a/apps/api/src/scripts/fix-achievement-hours.ts b/apps/api/src/scripts/fix-achievement-hours.ts new file mode 100644 index 0000000..3387b2b --- /dev/null +++ b/apps/api/src/scripts/fix-achievement-hours.ts @@ -0,0 +1,337 @@ +import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm" + +import { db, schema } from "../db" +import { 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 ${schema.submission.createTime} at time zone ${TIME_ZONE})` + 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 +}