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:
@@ -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:这几个模块都有导入即执行的副作用
|
||||
|
||||
@@ -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_count(0010 之后
|
||||
* 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} 处差异:`)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user