Files
OJ2/apps/api/src/scripts/fix-achievement-hours.ts
yuetsh cc51e305cf
Some checks failed
Deploy / deploy (push) Has been cancelled
refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
- TIME_ZONE / TIME_ZONE_OFFSET_MINUTES 移到 packages/contract/src/time.ts,
  前后端共用一份,不再各写一遍靠注释对齐。
- apps/api/src/time.ts 新增 localTime(列),替换散落 7 处的
  `at time zone ${TIME_ZONE_SQL}`;ac-trend 的 where 复用同一个 year 表达式。
- /problems/:displayId/yearly-ac 漏写了时区、按 UTC 切年,被会话时区兜底掩盖;
  改为按东八区切(只影响每年 12-31 北京 0–8 点的提交归年)。
- 删掉数据库连接的 TimeZone 和 Dockerfile 的 TZ:正确代码不依赖它们,
  它们只会在线上掩盖漏写处、让 dev 与线上答案不同。
- time.ts:calendarDayYearsAgo/pad 并入 shiftMonthsByCalendar,startOfCalendarDay
  并入 todayStart,localWeekday 改用 getUTCDay,删掉历史叙述注释。
- 前端 zonedParts 改为固定偏移 + getUTC*(与后端、日期选择器同一写法),
  去掉 Intl formatToParts;10 万次 299ms → 11ms。zonedYear 去掉按浏览器时区的兜底。
- 两份 CLAUDE.md 同步;n-date-picker 那条过时说明改成现用法。

验证:新旧「近两年起点」21359 个时刻 0 差异、localWeekday 0 差异、
前端固定偏移与 Intl 在 America/New_York 下 47821 个时刻 0 差异;
localTime 在 select/group by/where 复用可用;fix-achievement-hours 预演仍为 148 / 60。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 06:06:52 -06:00

338 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}