- 0032:下架六边形战士(判题机只剩 3 种语言)、坚持三天/一周(按天算基本拿不到); 不撞南墙改白银、屡败屡战改黄金;百发百中换 💯、屡败屡战换 💪,不再和别的撞图标; 代码行数只算做对的、空行不算,并按新口径重算所有人;已经拿到的一个不收回 - 奖杯收藏家只数上架的成就(判题结算、补发重算、0032 存量一起改) - GET /achievements:每个成就带全站几人有、本班几人有;看自己时带「本班」(最近拿到、成就最多前五、自己第几); 隐藏成就同一串拿到一档就整串亮出来;连续几周给「现在连着几周」而不是历史最长 - 删掉只有旧成就页在用的 /achievements/summary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
553 lines
22 KiB
TypeScript
553 lines
22 KiB
TypeScript
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`,
|
||
},
|
||
})
|
||
}
|
||
}
|