From 3921c496cba6727585ab94f39234ff060d6108ef Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 14 Sep 2026 05:55:38 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E6=88=90=E5=B0=B1):=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=A1=A5=E5=8F=91=E6=88=90=E5=B0=B1=E5=90=8E=E9=87=8D=E7=AE=97?= =?UTF-8?q?=E5=B7=B2=E8=A7=A3=E9=94=81=E6=95=B0=E5=B9=B6=E6=8E=A5=E7=9D=80?= =?UTF-8?q?=E5=88=A4=E3=80=8C=E5=A5=96=E6=9D=AF=E6=94=B6=E8=97=8F=E5=AE=B6?= =?UTF-8?q?=E3=80=8D=EF=BC=9Brecount=20=E8=AE=A2=E6=AD=A3=E5=AD=98?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd --- apps/api/src/main.ts | 2 +- apps/api/src/scripts/recount.ts | 68 +++++++++++++++++++++++++-- apps/api/src/services/achievements.ts | 47 ++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5d83285..df9ba50 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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:这几个模块都有导入即执行的副作用 diff --git a/apps/api/src/scripts/recount.ts b/apps/api/src/scripts/recount.ts index afaa211..d068a4e 100644 --- a/apps/api/src/scripts/recount.ts +++ b/apps/api/src/scripts/recount.ts @@ -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 } }[] + /** 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 { 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.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} 处差异:`) diff --git a/apps/api/src/services/achievements.ts b/apps/api/src/services/achievements.ts index 7b547b5..3d82ddb 100644 --- a/apps/api/src/services/achievements.ts +++ b/apps/api/src/services/achievements.ts @@ -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