Files
OJ2/apps/api/src/services/achievements.ts
yuetsh 7e1a782747 feat(阶段4): 用户管理 + 成就管理;补上 rescan 与 contest_joined
GET/POST/DELETE   admin/users            (DELETE 走 body 传 ids)
  GET/PUT           admin/users/:id
  POST              admin/users/:id/reset-password
  GET               admin/achievement-metrics
  GET/POST          admin/achievements
  GET/PUT/DELETE    admin/achievements/:id

顺带补了新后端缺失的两块(不补的话成就后台建出来的东西是坏的):

1. **rescanAchievement**:旧后端 `rescan_achievement` 的对应实现。判定平时只在判题
   结算时发生,后台新建成就或调低阈值不会自动补发,必须显式扫一遍。补发标记
   backfilled=true —— 前端据此只显示「已获得」不显示日期,否则一次补发会给几百人
   盖同一个时间戳,把「最近获得」板块冲垮。
   触发判据包含 metric 与 visible 的变化,不只看 operator/threshold:换维度、
   以及从下架改上架(草稿期已达标的人)这两种都会漏。
2. **contest_joined 指标整个漏掉了**。旧 METRIC_REGISTRY 有 18 个指标,新后端只算
   17 个,配在这个指标上的成就永远解锁不了。已补上计算,并把注册表抽成
   services/achievement-metrics.ts 作为单一事实源 —— 后台下拉框和参数校验都读它,
   避免「下拉框里选得到但没人算」这种组合。

用户管理的几处要点:

- className 解析位数不对**直接报错不猜**。猜错会把 class_name 存歪,而剥前缀显示
  姓名、班级下拉、统计页都依赖它准确。
- problem_permission 按 admin_type 归一(超管恒 All、普通用户恒 None),否则把超管
  降级成普通用户后他还留着 All。
- 改用户名要同步 submission.username 这个冗余列,否则历史提交查不到。
- openApi 已开着就不重置 appkey,否则每次保存用户都把对方的 key 换掉。
- **删除用户不复刻 Django 的应用层级联硬删**。用户是被引用最广的一张表,改成让
  数据库外键拦下来:撞外键说明还有历史数据,应当禁用而不是删除,返回 409 并说明。

实测:学生 403;导入 2 人 / 重复 409 / 班级号位数报错文案正确;改名+降权后
permission 归一为 None、重名 409;重置密码 6 位无 0;删自己 400;
成就指标 18 项、新建后补发 unlockCount=2、野指标与野稀有度均 400。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:27:36 -06:00

265 lines
13 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, isNotNull, isNull, ne, notInArray, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { findMetric } from "./achievement-metrics"
import { isAccepted, JudgeStatus } from "../judge/status"
import { objectValue } from "../routes/helpers"
function numberMetric(metrics: Record<string, unknown>, key: string) {
const value = metrics[key]
return typeof value === "number" ? value : 0
}
function localDate(value: string) {
const date = new Date(value)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
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 created: typeof schema.achievement.$inferSelect[] = []
for (const achievement of candidates) {
const value = metrics[achievement.metric]
if (typeof value !== "number") continue
const hit = achievement.operator === "gte" ? value >= achievement.threshold : value <= achievement.threshold
if (!hit) continue
const inserted = await db.insert(schema.userAchievement).values({
userId,
achievementId: achievement.id,
unlockTime: new Date().toISOString(),
backfilled: false,
notified: false,
}).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] }).returning({ id: schema.userAchievement.id })
if (inserted.length) {
await db.update(schema.achievement).set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` }).where(eq(schema.achievement.id, achievement.id))
created.push(achievement)
}
}
return created
}
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 = localDate(row.submission.createTime)
const hour = new Date(row.submission.createTime).getHours()
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 = objectValue(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 = objectValue(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"))
}
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) {
const current = last && (Date.parse(`${date}T00:00:00`) - Date.parse(`${last}T00:00:00`)) / 86_400_000 === 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
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).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")))
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 = objectValue(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")))
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))]
}
/**
* 参赛场次。旧后端 `ContestJoined` 只实现了 recompute、不走 on_submission
* (比赛提交在 build_ctx 就被跳过了),所以它只在 rescan 时刷新。这里保持同样口径:
* 去重数一遍该用户有过提交的比赛数。
*
* 注意:迁移过来时新后端**整个漏掉了这个指标**,配在 contest_joined 上的成就
* 会永远解锁不了。补上。
*/
async function contestJoinedCount(userId: number) {
const [row] = await db
.select({ value: countDistinct(schema.submission.contestId) })
.from(schema.submission)
.where(and(eq(schema.submission.userId, userId), isNotNull(schema.submission.contestId)))
return row?.value ?? 0
}
/**
* 新建成就、调低阈值、或从下架改成上架之后,把已达标的存量用户补发一遍。
*
* 判定平时只在判题结算时发生,后台改了配置不会自动补发,必须显式扫。
* 对齐旧 `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()
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)
let unlocked = 0
for (const stat of stats) {
if (already.has(stat.userId)) continue
const value = objectValue(stat.metrics)[achievement.metric]
if (typeof value !== "number") continue
const hit = achievement.operator === "gte"
? value >= achievement.threshold
: value <= achievement.threshold
if (!hit) continue
const inserted = await db.insert(schema.userAchievement).values({
userId: stat.userId,
achievementId: achievement.id,
unlockTime: new Date().toISOString(),
backfilled: true,
notified: false,
}).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
.returning({ id: schema.userAchievement.id })
if (inserted.length === 0) continue
unlocked += 1
await db.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` })
.where(eq(schema.achievement.id, achievement.id))
await publishAchievementNotification(stat.userId, [{
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
}])
}
return { scanned: stats.length, unlocked }
}
/** 把所有有过比赛提交的用户的 contest_joined 重算一遍,供 rescan 前置调用 */
async function refreshContestJoinedForAll() {
const rows = await db
.selectDistinct({ userId: schema.submission.userId })
.from(schema.submission)
.where(isNotNull(schema.submission.contestId))
for (const { userId } of rows) {
const value = await contestJoinedCount(userId)
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) return
const merged = objectValue(stat.metrics)
merged.contest_joined = value
await tx.update(schema.userStat)
.set({ metrics: merged, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
})
}
}