chore: 删掉两个跑完的一次性订正脚本,recount 留着
Some checks failed
Deploy / deploy (push) Has been cancelled

fix-achievement-hours 订正的是时区丢失那两周误发的「夜猫子」「早起的鸟儿」,
2026-09-14 已跑完(修正 148 行 · 撤回 60 条),根因修在代码里之后不会再产生;
backfill-problemsets 补的三笔题单历史欠账也已结清,进度记账现在在判题这一路。
两个子命令、两条 npm 脚本一并去掉,services/problemset.ts 的 badgeHolderDiff
只服务于补发脚本,删完 0 处引用,一起清掉。

recount 留着 —— 反范式计数列会被重判、删提交带偏,是会复发的漂移,
补一条 npm 脚本(原来只有子命令)。

docs/timezone.md 里那节改成「账已平、脚本已删」,但保住那条教训:
unlockAchievements 是纯阈值比较,只删 user_achievement 不修 metrics 的话,
学生下次提交就把同一个成就原样再发一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 08:03:57 -06:00
parent 6fdc3c588e
commit 5eee13fe81
6 changed files with 6 additions and 654 deletions

View File

@@ -11,8 +11,7 @@
"worker": "bun src/main.ts worker", "worker": "bun src/main.ts worker",
"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", "recount": "bun --env-file=../../.env src/main.ts recount",
"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

@@ -12,9 +12,7 @@
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用 * oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调 * oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演 * oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
* oj2-api fix-achievement-hours # 订正「夜猫子」「早起的鸟儿」的历史误发,默认只读预演
* *
* 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用 * 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 * Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -36,25 +34,12 @@ switch (command) {
await runMigrations() await runMigrations()
break break
} }
// 一次性的数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码 // 数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码
case "backfill-problemsets": { // 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。
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"),
}))
}
// 同上,一次性的数据订正。反范式计数列被重判等操作带偏之后拿它对账。
case "recount": { case "recount": {
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()
@@ -75,6 +60,6 @@ switch (command) {
} }
} }
default: 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) process.exit(2)
} }

View File

@@ -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<string, string>()
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<string>`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<string, string>()
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
// 补录的格子先写进 detailresyncProgress 是照着库里的 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
}

View File

@@ -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 的「凌晨 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 ${localTime(schema.submission.createTime)})`
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
}

View File

@@ -24,7 +24,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../se
* *
* **不管**的acm_contest_rank比赛榜有自己的一套罚时累计重算要连带 submission_info * **不管**的acm_contest_rank比赛榜有自己的一套罚时累计重算要连带 submission_info
* 里每题的尝试次数口径复杂单独一件事、achievement.unlock_count0010 之后 * 里每题的尝试次数口径复杂单独一件事、achievement.unlock_count0010 之后
* user_achievement 随成就级联,漂不了)、题单进度与奖章(走 backfill-problemsets * user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts
* *
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate * 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate
* *

View File

@@ -193,24 +193,6 @@ export async function resyncProgress(problemsetId: number) {
for (const badge of badges) await recalculateBadge(badge, updated) 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 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。 * 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。
* 更要紧的是这条规则必须和补账那条scripts/backfill-problemsets.ts一致 ——
* 两边口径不一样的话,补账工具会永远「发现」差异。
*/ */
export async function recordSolvedProblem( export async function recordSolvedProblem(
userId: number, userId: number,