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>
265 lines
13 KiB
TypeScript
265 lines
13 KiB
TypeScript
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))
|
||
})
|
||
}
|
||
}
|