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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 补录的格子先写进 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
|
||||
}
|
||||
@@ -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<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
|
||||
}
|
||||
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user