ops(成就): 加 fix-achievement-hours,订正时区丢失期间误发的「夜猫子」「早起的鸟儿」

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
2026-09-14 05:55:27 -06:00
parent 4c0c38445c
commit f6995c841b
4 changed files with 399 additions and 1 deletions

View File

@@ -237,6 +237,60 @@ C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者
`apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse `apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse
`normalizeDate`)实测都能吃 ISO改动出参格式不需要动前端。 `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 的「凌晨 05 点」正好是北京的上午 913 点,而学生恰恰在上课时间提交。
- **结果是 60 条误发**47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2
时期的新账号(`backfilled = false`)。
- **0 条漏发**而且是结构性的OJ2 窗口那 1508 条提交里,真正落在北京 05 点和
57 点的**都是 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 建的表 Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表

View File

@@ -12,6 +12,7 @@
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api", "build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
"seed:dev": "bun src/scripts/seed-dev.ts", "seed:dev": "bun src/scripts/seed-dev.ts",
"backfill:problemsets": "bun src/main.ts backfill-problemsets", "backfill:problemsets": "bun src/main.ts backfill-problemsets",
"fix:achievement-hours": "bun src/main.ts fix-achievement-hours",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"check:routes": "bun src/scripts/check-route-shadowing.ts", "check:routes": "bun src/scripts/check-route-shadowing.ts",
"check:ast": "bun src/scripts/check-ast-targets.ts", "check:ast": "bun src/scripts/check-ast-targets.ts",

View File

@@ -14,6 +14,7 @@
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演 * oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列重算回与 submission 一致,默认只读预演 * oj2-api recount # 把题目/用户的计数列重算回与 submission 一致,默认只读预演
* oj2-api fix-achievement-hours # 订正「夜猫子」「早起的鸟儿」的历史误发,默认只读预演
* *
* 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用 * 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 * Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -49,6 +50,11 @@ switch (command) {
const { recount } = await import("./scripts/recount") const { recount } = await import("./scripts/recount")
process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") })) 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": { case "sql-child": {
const { runSqlChild } = await import("./judge/sql/child") const { runSqlChild } = await import("./judge/sql/child")
await runSqlChild() await runSqlChild()
@@ -69,6 +75,6 @@ switch (command) {
} }
} }
default: 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) process.exit(2)
} }

View File

@@ -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 的「凌晨 05 点」
* 其实是北京时间的上午 913 点**,学生在上课时间提交被记成熬夜;
* 「57 点」对应北京下午 13 点,被记成早起。
*
* 旧 Django 栈用的是 `timezone.localtime()``TIME_ZONE = "Asia/Shanghai"`
* 所以 20222026.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 条提交里,真正落在北京 05 点和 57 点的**都是 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<Plan> {
// 用 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<number>`count(*) filter (where ${hour} < 5)`.mapWith(Number),
earlyBird: sql<number>`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<string, unknown>)[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<number, Set<number>>()
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<string, unknown> | 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<number, Array<{ achievementId: number; rarity: string }>>()
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<number> {
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<number>`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
}