fix(成就): 后台补发成就后重算已解锁数并接着判「奖杯收藏家」;recount 订正存量

rescanAchievement 只插 user_achievement、加 unlock_count,不重算
achievement_unlocked_count,也不做元成就的第二轮判定(旧 rescan_achievement
原样如此)。判题结算只在「这次有新解锁」时才重算,所以被补发的人计数会一直停在
旧值。2026-09-07 一次补发之后 269 人少算,其中 10 人实际够 15 个却没拿到
「奖杯收藏家」。

- 新增 refreshUnlockedCount:一条 SQL 按 user_achievement 重算,只 jsonb_set
  这一个键、只写值变了的行。rescanAchievement 补发非白金成就后调用它,再补发元成就。
- recount 同时核对已解锁数与元成就漏发,--apply 先改计数再补发,复核同一份口径。

用 09-14 生产备份实跑:recount 订正 269 人、补发 10 条,复核通过、重跑无差异,
其余指标 0 行被动;模拟调低阈值补发 1504 条,「奖杯收藏家」随之 65 → 95,
与预先算出的跨线人数一致。

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:38 -06:00
parent f6995c841b
commit 3921c496cb
3 changed files with 111 additions and 6 deletions

View File

@@ -13,7 +13,7 @@
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列重算回与 submission 一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
* oj2-api fix-achievement-hours # 订正「夜猫子」「早起的鸟儿」的历史误发,默认只读预演
*
* 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用

View File

@@ -3,6 +3,7 @@ import { eq, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus, isAccepted } from "../judge/status"
import { objectValue } from "../routes/helpers"
import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../services/achievements"
/**
* 把反范式的计数列重算回与 submission 表一致。
@@ -16,6 +17,11 @@ import { objectValue } from "../routes/helpers"
* problem.submission_number / accepted_number / statistic_info
* user_profile.submission_number / accepted_number / acm_problems_status
*
* 外加 `user_stat.metrics.achievement_unlocked_count`(已解锁的非白金成就数,是
* user_achievement 的副本)以及它连带的「奖杯收藏家」:计数改对之后,达标却没发的
* 走 `rescanAchievement` 补发backfilled、推通知。已知漂移来源是后台补发成就 ——
* 2026-09-07 一次补发后 269 人少算、10 人漏发,`rescanAchievement` 已修,这里订存量。
*
* **不管**的acm_contest_rank比赛榜有自己的一套罚时累计重算要连带 submission_info
* 里每题的尝试次数口径复杂单独一件事、achievement.unlock_count0010 之后
* user_achievement 随成就级联,漂不了)、题单进度与奖章(走 backfill-problemsets
@@ -151,6 +157,51 @@ type Plan = {
diffs: Diff[]
problemFixes: { id: number; value: ProblemExpected }[]
profileFixes: { id: number; value: ProfileExpected & { merged: Record<string, unknown> } }[]
/** achievement_unlocked_count 不对的用户 */
unlockedCountFixes: number[]
/** 按正确计数已达标、却没持有元成就的 (用户, 元成就) */
metaGrants: { userId: number; achievementId: number }[]
}
/**
* 已解锁数与元成就的差异。口径和 `refreshUnlockedCount` / 判题结算一致;
* 元成就只看有 user_stat 的用户 —— `rescanAchievement` 也只扫这些人。
*/
async function unlockedCountPlan(plan: Plan) {
const [rows, metas] = await Promise.all([
db.execute<{ user_id: number; counter: unknown; actual: number }>(sql`
select s.user_id, s.metrics -> 'achievement_unlocked_count' as counter, coalesce(c.value, 0) as actual
from user_stat s
left join (
select ua.user_id, count(*)::int as value
from user_achievement ua
join achievement a on a.id = ua.achievement_id
where a.rarity <> 'platinum'
group by ua.user_id
) c on c.user_id = s.user_id
`),
metaAchievements(),
])
const holders = metas.length
? await db.select({ userId: schema.userAchievement.userId, achievementId: schema.userAchievement.achievementId })
.from(schema.userAchievement)
.where(sql`${schema.userAchievement.achievementId} in ${metas.map((meta) => meta.id)}`)
: []
const held = new Set(holders.map((row) => `${row.userId}:${row.achievementId}`))
for (const row of rows) {
const label = `用户 ${row.user_id}`
if (row.counter !== row.actual) {
plan.diffs.push({ label, field: "achievement_unlocked_count", before: row.counter ?? null, after: row.actual })
plan.unlockedCountFixes.push(row.user_id)
}
for (const meta of metas) {
const met = meta.operator === "gte" ? row.actual >= meta.threshold : row.actual <= meta.threshold
if (!met || held.has(`${row.user_id}:${meta.id}`)) continue
plan.diffs.push({ label, field: `成就「${meta.name}`, before: "未发", after: "补发" })
plan.metaGrants.push({ userId: row.user_id, achievementId: meta.id })
}
}
}
/** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */
@@ -174,7 +225,7 @@ async function computePlan(): Promise<Plan> {
expectedProfiles(),
])
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [] }
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [], unlockedCountFixes: [], metaGrants: [] }
for (const problem of problems) {
const want = expectedProblem.get(problem.id) ?? {
@@ -230,11 +281,12 @@ async function computePlan(): Promise<Plan> {
plan.profileFixes.push({ id: profile.id, value: { ...want, merged } })
}
}
await unlockedCountPlan(plan)
return plan
}
function report(plan: Plan) {
console.log(`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} 人):`)
console.log(`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} / 已解锁数 ${plan.unlockedCountFixes.length} 人 / 元成就补发 ${plan.metaGrants.length}`)
for (const diff of plan.diffs.slice(0, 40)) {
console.log(` ${diff.label} ${diff.field}: ${JSON.stringify(diff.before)}${JSON.stringify(diff.after)}`)
}
@@ -245,7 +297,7 @@ function report(plan: Plan) {
export async function recount(options: { apply: boolean }) {
const plan = await computePlan()
if (plan.diffs.length === 0) {
console.log("计数列与 submission 一致,没有要订正的。")
console.log("计数列与 submission / user_achievement 一致,没有要订正的。")
return 0
}
report(plan)
@@ -271,13 +323,19 @@ export async function recount(options: { apply: boolean }) {
}).where(eq(schema.userProfile.id, fix.id))
}
})
console.log(`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人,复核中……`)
// 先改计数、再补发rescanAchievement 读的是 metrics 里的计数。
// 补发幂等(唯一键 + 冲突忽略),重跑不会重复发
const recounted = await refreshUnlockedCount(plan.unlockedCountFixes)
if (plan.metaGrants.length) {
for (const meta of await metaAchievements()) await rescanAchievement(meta.id)
}
console.log(`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人、已解锁数 ${recounted.length} 人,补发元成就 ${plan.metaGrants.length} 条,复核中……`)
// 复核跑的是同一份 computePlan。这里还剩差异说明口径本身有问题不是数据脏
// 必须让部署脚本看见非零退出码,而不是打一行字了事。
const after = await computePlan()
if (after.diffs.length === 0) {
console.log("复核通过:计数列与 submission 一致")
console.log("复核通过:计数列与 submission / user_achievement 一致")
return 0
}
console.error(`复核未通过,仍有 ${after.diffs.length} 处差异:`)

View File

@@ -227,10 +227,57 @@ export async function rescanAchievement(achievementId: number) {
kind: "achievement",
}])
}
// 补发的非白金成就同样计入「已解锁数」,要和判题结算一样接着做第二轮(元成就)判定。
// 旧 `rescan_achievement` 就漏了这步OJ2 原样搬过来2026-09-07 一次补发之后
// 269 人的计数停在旧值,其中 10 人实际够了「奖杯收藏家」却一直没发 ——
// 判题结算只在「这次有新解锁」时才重算,被补发的人不再解锁新成就就永远不会自愈。
if (achievement.rarity !== "platinum" && achievement.metric !== "achievement_unlocked_count") {
await refreshUnlockedCount(unlockedUserIds)
for (const meta of await metaAchievements()) await rescanAchievement(meta.id)
}
}
return { scanned: stats.length, unlocked: unlockedUserIds.length }
}
/** 以「已解锁数」为指标的元成就(奖杯收藏家)。只取上架的,和 rescan 的口径一致 */
export function metaAchievements() {
return db.select({ id: schema.achievement.id, name: schema.achievement.name, 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")))
}
/**
* 按 `user_achievement` 重算 `achievement_unlocked_count`,返回实际改动了的用户 id。
*
* 口径和判题结算一致:已解锁的**非白金**成就数。只 `jsonb_set` 这一个键、只写值变了的行,
* 不整体覆盖 `metrics` —— 整体写回会和并发判题写的其它指标互相踩。
* 不传 `userIds` 就是全体有 `user_stat` 的用户(`recount` 存量订正用)。
*/
export async function refreshUnlockedCount(userIds?: number[]) {
if (userIds && userIds.length === 0) return []
const scope = userIds ? sql`and s.user_id in ${userIds}` : sql``
const rows = await db.execute<{ user_id: number }>(sql`
update ${schema.userStat} as target
set metrics = jsonb_set(target.metrics, '{achievement_unlocked_count}', to_jsonb(fresh.value))
from (
select s.id, coalesce(c.value, 0) as value
from ${schema.userStat} s
left join (
select ua.user_id, count(*)::int as value
from ${schema.userAchievement} ua
join ${schema.achievement} a on a.id = ua.achievement_id
where a.rarity <> 'platinum'
group by ua.user_id
) c on c.user_id = s.user_id
where true ${scope}
) fresh
where target.id = fresh.id
and (target.metrics -> 'achievement_unlocked_count') is distinct from to_jsonb(fresh.value)
returning target.user_id
`)
return rows.map((row) => row.user_id)
}
/** 同上3 个参数一行 */
const STAT_UPSERT_CHUNK = 1000