Files
OJ2/apps/api/src/services/achievements.ts
T
xuyueandClaude Opus 5.5 85108df9d8 feat(成就): 下架三个拿不到的成就、理顺稀有度和图标;接口多给本班几人有和「本班」卡片
- 0032:下架六边形战士(判题机只剩 3 种语言)、坚持三天/一周(按天算基本拿不到);
  不撞南墙改白银、屡败屡战改黄金;百发百中换 💯、屡败屡战换 💪,不再和别的撞图标;
  代码行数只算做对的、空行不算,并按新口径重算所有人;已经拿到的一个不收回
- 奖杯收藏家只数上架的成就(判题结算、补发重算、0032 存量一起改)
- GET /achievements:每个成就带全站几人有、本班几人有;看自己时带「本班」(最近拿到、成就最多前五、自己第几);
  隐藏成就同一串拿到一档就整串亮出来;连续几周给「现在连着几周」而不是历史最长
- 删掉只有旧成就页在用的 /achievements/summary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:26:37 +08:00

553 lines
22 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,
count,
countDistinct,
eq,
inArray,
isNotNull,
isNull,
ne,
notInArray,
sql,
} from "drizzle-orm"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { calendarDay, dayNumber, localHour, localTime, weekStart } from "../time"
import { findMetric } from "./achievement-metrics"
import { isAccepted, JudgeStatus } from "../judge/status"
import { asRecord } from "../routes/helpers"
function numberMetric(metrics: Record<string, unknown>, key: string) {
const value = metrics[key]
return typeof value === "number" ? value : 0
}
/**
* 「连续几周都有新通过」:`week` 是这次新通过所在那周的周一(东八区日历日)。
* 和上一次新通过同一周不变;正好下一周就接上;隔了一周以上从 1 重来。
*
* 比「坚持三天」那段多一道判断:这次的周比记下的还早(重判了一条很早以前的提交)
* 就什么都不动 —— 否则会把「当前连续」错误地重置成 1、还把 `_last_ac_week` 拨回过去。
*/
function nextWeekStreak(value: Record<string, unknown>, week: string) {
const last = typeof value._last_ac_week === "string" ? value._last_ac_week : null
if (last && week <= last) return {}
const current =
last && dayNumber(week) - dayNumber(last) === 7
? numberMetric(value, "_current_ac_week_streak") + 1
: 1
return {
_last_ac_week: week,
_current_ac_week_streak: current,
max_ac_week_streak: Math.max(numberMetric(value, "max_ac_week_streak"), current),
}
}
async function unlockAchievements(
userId: number,
metrics: Record<string, unknown>,
onlyMeta = false,
) {
const unlocked = await db
.select({ id: schema.userAchievement.achievementId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, userId))
const filters = [eq(schema.achievement.visible, true)]
if (unlocked.length)
filters.push(
notInArray(
schema.achievement.id,
unlocked.map((row) => row.id),
),
)
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count"))
const candidates = await db
.select()
.from(schema.achievement)
.where(and(...filters))
const hits = candidates.filter((achievement) => {
const value = metrics[achievement.metric]
if (typeof value !== "number") return false
return achievement.operator === "gte"
? value >= achievement.threshold
: value <= achievement.threshold
})
if (hits.length === 0) return []
// 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。
// 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。
const inserted = await db
.insert(schema.userAchievement)
.values(
hits.map((achievement) => ({
userId,
achievementId: achievement.id,
unlockTime: new Date().toISOString(),
backfilled: false,
notified: false,
})),
)
.onConflictDoNothing({
target: [schema.userAchievement.achievementId, schema.userAchievement.userId],
})
.returning({ achievementId: schema.userAchievement.achievementId })
if (inserted.length === 0) return []
const insertedIds = new Set(inserted.map((row) => row.achievementId))
await db
.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` })
.where(inArray(schema.achievement.id, [...insertedIds]))
return hits.filter((achievement) => insertedIds.has(achievement.id))
}
export async function updateAchievementsForSubmission(submissionId: string) {
const [row] = await db
.select({ submission: schema.submission, problem: schema.problem })
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId))
.limit(1)
if (!row || row.submission.contestId !== null) return []
const priorRows = await db
.select({ result: schema.submission.result })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
ne(schema.submission.id, row.submission.id),
),
)
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
const accepted = isAccepted(row.submission.result)
const firstAc = accepted && !priorAccepted
const firstTry = accepted && priorRows.length === 0
const date = calendarDay(row.submission.createTime)
const hour = localHour(row.submission.createTime)
const metrics = await db.transaction(async (tx) => {
await tx
.insert(schema.userStat)
.values({
userId: row.submission.userId,
metrics: {},
updateTime: new Date().toISOString(),
})
.onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, row.submission.userId))
.for("update")
if (!stat) throw new Error("User achievement stat could not be created")
const value = asRecord(stat.metrics)
value.submission_count = numberMetric(value, "submission_count") + 1
if (firstAc) {
value.accepted_count = numberMetric(value, "accepted_count") + 1
if (row.problem.difficulty === "Mid")
value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
if (row.problem.difficulty === "High")
value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
const perDay = asRecord(value._ac_per_day)
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
value._ac_per_day = perDay
value.max_ac_in_one_day = Math.max(
...Object.values(perDay).filter((item): item is number => typeof item === "number"),
)
Object.assign(value, nextWeekStreak(value, calendarDay(weekStart(row.submission.createTime))))
}
const activeDates = Array.isArray(value._active_dates)
? value._active_dates.filter((item): item is string => typeof item === "string")
: []
if (!activeDates.includes(date)) activeDates.push(date)
value._active_dates = activeDates
value.active_days = activeDates.length
if (accepted) {
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null
if (last !== date) {
// 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时,
// 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。
const current =
last && dayNumber(date) - dayNumber(last) === 1
? numberMetric(value, "_current_ac_streak") + 1
: 1
value._last_ac_date = date
value._current_ac_streak = current
value.max_ac_streak_days = Math.max(numberMetric(value, "max_ac_streak_days"), current)
}
}
const languages = Array.isArray(value._languages)
? value._languages.filter((item): item is string => typeof item === "string")
: []
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
value._languages = languages
value.languages_used = languages.length
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
else if (hour < 7)
value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
// 只算做对的、空行不算:原来交什么都算,贴一大段就拿到「长篇巨制」(0032 按这个口径重算过存量)
if (accepted)
value.max_code_lines = Math.max(
numberMetric(value, "max_code_lines"),
row.submission.code.split("\n").filter((line) => line.trim() !== "").length,
)
await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(row.submission.userId, metrics)
if (!first.length) return []
const [meta] = await db
.select({ value: count() })
.from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(
and(
eq(schema.userAchievement.userId, row.submission.userId),
ne(schema.achievement.rarity, "platinum"),
eq(schema.achievement.visible, true),
),
)
metrics.achievement_unlocked_count = meta?.value ?? 0
await db
.update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, row.submission.userId))
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
}
export async function updateAchievementsForProblemSet(userId: number) {
const [[badgeRow], [completedRow]] = await Promise.all([
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)),
db
.select({ value: count() })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.userId, userId),
eq(schema.problemsetProgress.isCompleted, true),
),
),
])
const metrics = await db.transaction(async (tx) => {
await tx
.insert(schema.userStat)
.values({
userId,
metrics: {},
updateTime: new Date().toISOString(),
})
.onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId))
.for("update")
.limit(1)
if (!stat) throw new Error("User achievement stat could not be created")
const value = asRecord(stat.metrics)
value.badge_count = badgeRow?.value ?? 0
value.problemset_completed = completedRow?.value ?? 0
await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(userId, metrics)
if (!first.length) return []
const [meta] = await db
.select({ value: count() })
.from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(
and(
eq(schema.userAchievement.userId, userId),
ne(schema.achievement.rarity, "platinum"),
eq(schema.achievement.visible, true),
),
)
metrics.achievement_unlocked_count = meta?.value ?? 0
await db
.update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, userId))
return [...first, ...(await unlockAchievements(userId, metrics, true))]
}
/** 一条 INSERT 里塞多少行。5 个参数一行,离 Postgres 的 65535 个参数上限还很远 */
const USER_ACHIEVEMENT_INSERT_CHUNK = 1000
/**
* 新建成就、调低阈值、或从下架改成上架之后,把已达标的存量用户补发一遍。
*
* 判定平时只在判题结算时发生,后台改了配置不会自动补发,必须显式扫。
* 对齐旧 `rescan_achievement`:只处理 visible 的成就,逐个用户 unlock,
* 且标记 backfilled=true —— 这是补发不是刚挣到的,前端据此只显示「已获得」
* 而不显示日期,否则一次补发会给几百人盖同一个时间戳,把「最近获得」板块冲垮。
*/
export async function rescanAchievement(achievementId: number) {
const [achievement] = await db
.select()
.from(schema.achievement)
.where(and(eq(schema.achievement.id, achievementId), eq(schema.achievement.visible, true)))
.limit(1)
if (!achievement) return { scanned: 0, unlocked: 0 }
const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
if (achievement.metric === "contest_joined") await refreshContestJoinedForAll()
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
if (achievement.metric === "max_ac_week_streak") await refreshAcWeekStreakForAll()
const already = new Set(
(
await db
.select({ userId: schema.userAchievement.userId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.achievementId, achievement.id))
).map((row) => row.userId),
)
const stats = await db
.select({
userId: schema.userStat.userId,
metrics: schema.userStat.metrics,
})
.from(schema.userStat)
const eligible = stats.filter((stat) => {
if (already.has(stat.userId)) return false
const value = asRecord(stat.metrics)[achievement.metric]
if (typeof value !== "number") return false
return achievement.operator === "gte"
? value >= achievement.threshold
: value <= achievement.threshold
})
// 达标的人分批插,冲突忽略后 returning 回来的就是真新解锁的那批 —— 以前是每人
// 一条 insert 加一条 unlockCount+1,全站补发一次就是几千次往返。
// 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。
const unlockTime = new Date().toISOString()
const unlockedUserIds: number[] = []
for (let start = 0; start < eligible.length; start += USER_ACHIEVEMENT_INSERT_CHUNK) {
const chunk = eligible.slice(start, start + USER_ACHIEVEMENT_INSERT_CHUNK)
const inserted = await db
.insert(schema.userAchievement)
.values(
chunk.map((stat) => ({
userId: stat.userId,
achievementId: achievement.id,
unlockTime,
backfilled: true,
notified: false,
})),
)
.onConflictDoNothing({
target: [schema.userAchievement.achievementId, schema.userAchievement.userId],
})
.returning({ userId: schema.userAchievement.userId })
unlockedUserIds.push(...inserted.map((row) => row.userId))
}
if (unlockedUserIds.length) {
await db
.update(schema.achievement)
.set({
unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}`,
})
.where(eq(schema.achievement.id, achievement.id))
for (const userId of unlockedUserIds) {
await publishAchievementNotification(userId, [
{
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
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。
*
* 口径和判题结算一致:已解锁的、还上架的**非白金**成就数(下架的不算,见 0032)。只 `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' and a.visible
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
/**
* 把所有有过比赛提交的用户的 contest_joined 重算一遍,供 rescan 前置调用。
*
* 参赛场次这个指标:旧后端 `ContestJoined` 只实现了 recompute、不走 on_submission
* (比赛提交在 build_ctx 就被跳过了),所以它只在 rescan 时刷新。这里保持同样口径:
* 去重数一遍该用户有过提交的比赛数。
* (迁移过来时新后端整个漏掉过这个指标,配在 contest_joined 上的成就永远解锁不了。)
*
* 一条 group by 出全部用户的场次,再分批 upsert —— 以前是每个用户 1 条 count
* 加一个独立事务里的 insert/select for update/update,四次往返乘以全站用户数。
*
* `metrics || excluded.metrics` 是 jsonb 的浅合并,只覆盖 contest_joined 这一个键,
* 其余指标原样保留,语义和原来「读出来改一个键再写回去」一致,而且不需要 for update:
* 合并在一条语句里完成,并发写不会互相盖掉。
*/
async function refreshContestJoinedForAll() {
const rows = await db
.select({
userId: schema.submission.userId,
value: countDistinct(schema.submission.contestId),
})
.from(schema.submission)
// 只算还在的用户,理由同 refreshAcWeekStreakForAll:库里有 11 个已不存在的用户
// 留下了比赛提交,不关联的话这里 upsert 撞外键,比赛类成就的重扫一直是失败的
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(isNotNull(schema.submission.contestId))
.groupBy(schema.submission.userId)
const now = new Date().toISOString()
for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) {
const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK)
await db
.insert(schema.userStat)
.values(
chunk.map((row) => ({
userId: row.userId,
metrics: { contest_joined: row.value },
updateTime: now,
})),
)
.onConflictDoUpdate({
target: schema.userStat.userId,
set: {
metrics: sql`${schema.userStat.metrics} || excluded.metrics`,
updateTime: sql`excluded.update_time`,
},
})
}
}
/**
* 从提交记录重算所有人的「最长连续新通过周数」,连同接着累计要用的
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
* 其余指标不动,写法同 refreshContestJoinedForAll)。
*
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径;
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
*/
async function refreshAcWeekStreakForAll() {
const firstAccepted = db
.select({
userId: schema.submission.userId,
firstAt: sql<string>`min(${schema.submission.createTime})`.as("first_at"),
})
.from(schema.submission)
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
isNull(schema.submission.contestId),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
),
)
.groupBy(schema.submission.userId, schema.submission.problemId)
.as("first_accepted")
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
const rows = await db
.select({ userId: firstAccepted.userId, week })
.from(firstAccepted)
.groupBy(firstAccepted.userId, week)
.orderBy(firstAccepted.userId, week)
const byUser = new Map<number, Record<string, unknown>>()
for (const row of rows) {
const value = byUser.get(row.userId) ?? {}
Object.assign(value, nextWeekStreak(value, row.week))
byUser.set(row.userId, value)
}
const entries = [...byUser]
const now = new Date().toISOString()
for (let start = 0; start < entries.length; start += STAT_UPSERT_CHUNK) {
const chunk = entries.slice(start, start + STAT_UPSERT_CHUNK)
await db
.insert(schema.userStat)
.values(chunk.map(([userId, metrics]) => ({ userId, metrics, updateTime: now })))
.onConflictDoUpdate({
target: schema.userStat.userId,
set: {
metrics: sql`${schema.userStat.metrics} || excluded.metrics`,
updateTime: sql`excluded.update_time`,
},
})
}
}