- 补档:做对 20 道、第一道中等题、交满 30 次、5 天交过题、一次就对 5 道、错 3 次后做对、一天做对 5 道; 新线「挑战成功」1 次 / 5 次 - 比赛里的提交也计入成就(比赛其实是单班测验,2612 班整班在比赛里做题,原来一个成就都没有); 参加过几场比赛改成判题时就更新,以前只在后台重扫时刷新 - 奖杯收藏家从 15 个提到 20 个 - 0044 迁移:按新口径从提交记录重数指标,给达标的人补发(已经拿到的不收回) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
541 lines
26 KiB
TypeScript
541 lines
26 KiB
TypeScript
import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
||
|
||
import { db, schema, type DbOrTx } 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))
|
||
}
|
||
|
||
const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStatus.AST_CHECK_FAILED}`], sql`, `)
|
||
|
||
/**
|
||
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
||
* 参加过几场比赛、挑战成功几次也在这里一起数。
|
||
*
|
||
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
||
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
||
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
||
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
||
* 一个人的提交也就几百条。
|
||
*
|
||
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
|
||
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
|
||
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
|
||
*/
|
||
async function solvedCounts(tx: DbOrTx, userId: number) {
|
||
const [row] = await tx.execute<{
|
||
accepted: number
|
||
mid: number
|
||
hard: number
|
||
first_try: number
|
||
contests: number
|
||
challenges: number
|
||
}>(
|
||
sql`
|
||
with mine as (
|
||
select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
|
||
from ${schema.submission} s
|
||
join ${schema.problem} p on p.id = s.problem_id
|
||
where s.user_id = ${userId}
|
||
)
|
||
select
|
||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}) and difficulty = 'Mid')::int as mid,
|
||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}) and difficulty = 'High')::int as hard,
|
||
(
|
||
select count(*)::int from (
|
||
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
||
) first where result in (${ACCEPTED_RESULTS})
|
||
) as first_try,
|
||
count(distinct contest_id)::int as contests,
|
||
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
|
||
from mine
|
||
`,
|
||
)
|
||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
|
||
}
|
||
|
||
/**
|
||
* `countedAs`:这条提交上次结算时的结果,重判时由判题传进来;新提交是 null。
|
||
* 重判不是又交了一次 —— 提交次数、凌晨/早起这类「每交一次 +1」的不能再加,
|
||
* 编译错误按前后差值改。
|
||
*/
|
||
export async function updateAchievementsForSubmission(submissionId: string, countedAs: number | null = null) {
|
||
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) return []
|
||
|
||
const rejudged = countedAs !== null
|
||
const accepted = isAccepted(row.submission.result)
|
||
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)
|
||
// 在锁里数:同一个人的结算排成一队,后来的一定看得见先来的写进去的结果
|
||
const solved = await solvedCounts(tx, row.submission.userId)
|
||
// 这次让做对的题多了一道,才算「新做对」:每天做对几道、连续几周这些跟着它走
|
||
const firstAc = accepted && solved.accepted > numberMetric(value, 'accepted_count')
|
||
value.accepted_count = solved.accepted
|
||
value.mid_ac_count = solved.mid
|
||
value.hard_ac_count = solved.hard
|
||
value.first_try_ac_count = solved.first_try
|
||
value.contest_joined = solved.contests
|
||
value.challenge_count = solved.challenges
|
||
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
|
||
if (firstAc) {
|
||
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
||
const [prior] = await tx
|
||
.select({ value: count() })
|
||
.from(schema.submission)
|
||
.where(
|
||
and(
|
||
eq(schema.submission.userId, row.submission.userId),
|
||
eq(schema.submission.problemId, row.submission.problemId),
|
||
lt(schema.submission.createTime, row.submission.createTime),
|
||
),
|
||
)
|
||
value.max_wa_before_ac = Math.max(numberMetric(value, 'max_wa_before_ac'), prior?.value ?? 0)
|
||
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
|
||
// 比记下的还早(重判了一条以前的提交)就不动,和 nextWeekStreak 一样
|
||
if (!last || date > last) {
|
||
// 差一天要按日历日算,不能用 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 (!rejudged) {
|
||
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
|
||
}
|
||
// 编译错误是从上线那天起逐次累计的(之前的历史没补),没法按定义重数,只能按差值改
|
||
const compileError = Number(row.submission.result === JudgeStatus.COMPILE_ERROR)
|
||
const wasCompileError = Number(countedAs === JudgeStatus.COMPILE_ERROR)
|
||
if (compileError !== wasCompileError) value.compile_error_count = Math.max(0, numberMetric(value, 'compile_error_count') + compileError - wasCompileError)
|
||
// 只算做对的、空行不算:原来交什么都算,贴一大段就拿到「长篇巨制」(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)))
|
||
const unlockedCount = meta?.value ?? 0
|
||
metrics.achievement_unlocked_count = unlockedCount
|
||
await setUnlockedCount(row.submission.userId, unlockedCount)
|
||
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
|
||
}
|
||
|
||
/**
|
||
* 只 `jsonb_set` 这一个键:这一步在锁外,整体写回 metrics 会把并发判题刚写的其它指标
|
||
* 冲掉(和 refreshUnlockedCount 同一个理由)。
|
||
*/
|
||
async function setUnlockedCount(userId: number, value: number) {
|
||
await db
|
||
.update(schema.userStat)
|
||
.set({
|
||
metrics: sql`jsonb_set(${schema.userStat.metrics}, '{achievement_unlocked_count}', to_jsonb(${value}::int))`,
|
||
updateTime: new Date().toISOString(),
|
||
})
|
||
.where(eq(schema.userStat.userId, userId))
|
||
}
|
||
|
||
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)))
|
||
const unlockedCount = meta?.value ?? 0
|
||
metrics.achievement_unlocked_count = unlockedCount
|
||
await setUnlockedCount(userId, unlockedCount)
|
||
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 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
|
||
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(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`,
|
||
},
|
||
})
|
||
}
|
||
}
|