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>
This commit is contained in:
2026-08-07 07:27:36 -06:00
parent af9cb796bd
commit 7e1a782747
7 changed files with 700 additions and 15 deletions

View File

@@ -0,0 +1,276 @@
import {
adminUserListSchema,
adminUserSchema,
deleteUsersRequestSchema,
importUsersRequestSchema,
resetPasswordResponseSchema,
updateUserRequestSchema,
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { queryInteger } from "../helpers"
export const adminAccountRoutes = new Hono<AppEnv>()
const CLASS_NAME_MIN_DIGITS = 3
const CLASS_NAME_MAX_DIGITS = 4
/**
* `ks251XXX` / `ks2510XX` → `251` / `2510`。不以 `ks+数字` 开头的(管理员、教师账号)返回 null。
*
* 位数不对**直接报错,不猜** —— 猜错会把 className 存歪,而剥前缀显示姓名、班级下拉、
* 统计页都依赖它准确。先用 `\d+` 抓全再判位数,不能直接用固定位数的正则匹配:
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
* 对齐旧 `account/views/admin.py:get_class_name`。
*/
function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } {
const matched = /^ks(\d+)/.exec(username)
if (!matched) return { ok: true, value: null }
const digits = matched[1]!
if (digits.length < CLASS_NAME_MIN_DIGITS || digits.length > CLASS_NAME_MAX_DIGITS) {
return {
ok: false,
message: `用户名 ${username} 的班级号 ${digits}${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
}
}
return { ok: true, value: digits }
}
/**
* 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
*/
function normalizePermission(adminType: string, requested: string) {
if (adminType === "Super Admin") return "All"
if (adminType === "Regular User") return "None"
return requested || "Own"
}
function serialize(row: {
user: typeof schema.user.$inferSelect
realName: string | null
}) {
return adminUserSchema.parse({
id: row.user.id,
username: row.user.username,
email: row.user.email,
adminType: row.user.adminType,
problemPermission: row.user.problemPermission,
realName: row.realName,
createTime: row.user.createTime,
lastLogin: row.user.lastLogin,
openApi: row.user.openApi,
isDisabled: row.user.isDisabled,
rawPassword: row.user.rawPassword,
className: row.user.className,
})
}
function selectUser(id: number) {
return db.select({ user: schema.user, realName: schema.userProfile.realName })
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.id, id)).limit(1)
}
adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = []
const type = c.req.query("type")?.trim()
const keyword = c.req.query("keyword")?.trim()
if (type) filters.push(eq(schema.user.adminType, type))
if (keyword) {
filters.push(or(
ilike(schema.user.username, `%${keyword}%`),
ilike(schema.userProfile.realName, `%${keyword}%`),
ilike(schema.user.email, `%${keyword}%`),
)!)
}
const where = filters.length ? and(...filters) : undefined
// 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了
const order = c.req.query("orderBy") === "-lastLogin"
? [sql`${schema.user.lastLogin} desc nulls last`]
: [desc(schema.user.createTime)]
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where),
db.select({ user: schema.user, realName: schema.userProfile.realName }).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
])
return success(c, adminUserListSchema.parse({
results: rows.map(serialize),
total: totalRows[0]?.value ?? 0,
}))
})
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "user-not-found", "User does not exist")
return success(c, serialize(row))
})
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateUserRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const data = parsed.data
const [existing] = await selectUser(id)
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
const username = data.username.toLowerCase()
const email = data.email.toLowerCase()
const className = classNameOf(username)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
const [dupUsername] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(eq(schema.user.username, username), ne(schema.user.id, id))).limit(1)
if (dupUsername) return failure(c, 409, "username-exists", "Username already exists")
const [dupEmail] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(eq(schema.user.email, email), ne(schema.user.id, id))).limit(1)
if (dupEmail) return failure(c, 409, "email-exists", "Email already exists")
const patch: Partial<typeof schema.user.$inferInsert> = {
username,
email,
className: className.value,
adminType: data.adminType,
isDisabled: data.isDisabled,
problemPermission: normalizePermission(data.adminType, data.problemPermission),
}
if (data.password) {
// 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求,
// 老师要能查学生密码,见设计文档 7.1.1。
patch.password = await Bun.password.hash(data.password, { algorithm: "argon2id" })
patch.rawPassword = data.password
}
if (data.openApi) {
// 已经开着就不重置 appkey否则每次保存用户都会把对方的 key 换掉
if (!existing.user.openApi) patch.openApiAppkey = randomBytes32()
} else {
patch.openApiAppkey = null
}
patch.openApi = data.openApi
await db.transaction(async (tx) => {
await tx.update(schema.user).set(patch).where(eq(schema.user.id, id))
// submission.username 是冗余列(判题历史按用户名查),改名后必须一起改,否则历史提交查不到
if (existing.user.username !== username) {
await tx.update(schema.submission).set({ username })
.where(eq(schema.submission.username, existing.user.username))
}
await tx.update(schema.userProfile).set({ realName: data.realName })
.where(eq(schema.userProfile.userId, id))
})
const [row] = await selectUser(id)
return success(c, serialize(row!))
})
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
const parsed = importUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const rows = parsed.data.users
const prepared: { username: string; password: string; raw: string; email: string; realName: string; className: string | null }[] = []
for (const [username, password, email, realName] of rows) {
const className = classNameOf(username)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
prepared.push({
username,
password: await Bun.password.hash(password, { algorithm: "argon2id" }),
raw: password,
email,
realName,
className: className.value,
})
}
const existing = await db.select({ username: schema.user.username }).from(schema.user)
.where(inArray(schema.user.username, prepared.map((item) => item.username)))
if (existing.length) {
return failure(c, 409, "username-exists", `用户名已存在:${existing.map((row) => row.username).join("、")}`)
}
// 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在
const created = await db.transaction(async (tx) => {
const users = await tx.insert(schema.user).values(prepared.map((item) => ({
username: item.username,
password: item.password,
rawPassword: item.raw,
email: item.email,
className: item.className,
adminType: "Regular User",
problemPermission: "None",
createTime: new Date().toISOString(),
openApi: false,
isDisabled: false,
sessionKeys: [],
}))).returning({ id: schema.user.id, username: schema.user.username })
const byName = new Map(users.map((row) => [row.username, row.id]))
await tx.insert(schema.userProfile).values(prepared.map((item) => ({
userId: byName.get(item.username)!,
realName: item.realName,
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
avatar: "/public/avatar/default.png",
acmProblemsStatus: {},
submissionNumber: 0,
acceptedNumber: 0,
totalScore: 0,
})))
return users.length
})
return success(c, { imported: created }, 201)
})
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required")
const me = c.get("user")!.id
if (parsed.data.ids.includes(me)) {
return failure(c, 400, "cannot-delete-self", "Current user can not be deleted")
}
// 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大,
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。
try {
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.userProfile).where(inArray(schema.userProfile.userId, parsed.data.ids))
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id })
})
return success(c, { deleted: deleted.length })
} catch {
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
}
})
adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db.select({ id: schema.user.id }).from(schema.user)
.where(eq(schema.user.id, id)).limit(1)
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
// 6 位随机数字、不含 0与旧后端一致学生要照着念、要手输0 和 O 分不清
const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("")
await db.update(schema.user).set({
password: await Bun.password.hash(password, { algorithm: "argon2id" }),
rawPassword: password,
}).where(eq(schema.user.id, id))
return success(c, resetPasswordResponseSchema.parse({ password }))
})
function randomBytes32() {
return Array.from({ length: 32 }, () =>
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[randomInt(62)]).join("")
}

View File

@@ -0,0 +1,114 @@
import {
achievementMetricSchema,
adminAchievementSchema,
createAchievementRequestSchema,
updateAchievementRequestSchema,
} from "@oj2/contract"
import { asc, eq } from "drizzle-orm"
import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { ACHIEVEMENT_METRICS, findMetric, metricName } from "../../services/achievement-metrics"
import { rescanAchievement } from "../../services/achievements"
import { queryInteger } from "../helpers"
export const adminAchievementRoutes = new Hono<AppEnv>()
function serialize(row: typeof schema.achievement.$inferSelect) {
return adminAchievementSchema.parse({
id: row.id,
name: row.name,
description: row.description,
icon: row.icon,
rarity: row.rarity,
hidden: row.hidden,
metric: row.metric,
metricName: metricName(row.metric),
operator: row.operator,
threshold: row.threshold,
visible: row.visible,
unlockCount: row.unlockCount,
order: row.order,
createTime: row.createTime,
})
}
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
success(c, ACHIEVEMENT_METRICS.map((item) => achievementMetricSchema.parse(item))))
adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.achievement)
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id))
return success(c, rows.map(serialize))
})
adminAchievementRoutes.get("/achievements/:id", requireSuperAdmin, async (c) => {
const [row] = await db.select().from(schema.achievement)
.where(eq(schema.achievement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
if (!row) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, serialize(row))
})
adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => {
const parsed = createAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
const [created] = await db.insert(schema.achievement).values({
...parsed.data,
unlockCount: 0,
createTime: new Date().toISOString(),
}).returning()
// 新建的成就要补发给已达标的存量用户否则「AC 满 10 题」这种成就
// 只有从今往后的提交才算,老用户永远拿不到
await rescanAchievement(created!.id)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, created!.id)).limit(1)
return success(c, serialize(row!), 201)
})
adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
const [before] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1)
if (!before) return failure(c, 404, "achievement-not-found", "成就不存在")
const [after] = await db.update(schema.achievement).set(parsed.data)
.where(eq(schema.achievement.id, id)).returning()
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
// 判据必须包含 metric换了维度和 visible草稿期已达标的人
// 只看 operator/threshold 会漏掉这两种。
const changed =
before.metric !== after!.metric ||
before.operator !== after!.operator ||
before.threshold !== after!.threshold ||
before.visible !== after!.visible
if (after!.visible && changed) await rescanAchievement(id)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1)
return success(c, serialize(row!))
})
adminAchievementRoutes.delete("/achievements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// user_achievement 的外键同样是 NO ACTIONDjango 的级联在应用层),先清子表
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.userAchievement).where(eq(schema.userAchievement.achievementId, id))
return tx.delete(schema.achievement).where(eq(schema.achievement.id, id))
.returning({ id: schema.achievement.id })
})
if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, null)
})

View File

@@ -1,6 +1,8 @@
import { Hono } from "hono"
import type { AppEnv } from "../../auth/middleware"
import { adminAccountRoutes } from "./account"
import { adminAchievementRoutes } from "./achievement"
import { adminAiRoutes } from "./ai"
import { adminAnnouncementRoutes } from "./announcement"
import { adminTutorialRoutes } from "./tutorial"
@@ -15,6 +17,8 @@ import { adminTutorialRoutes } from "./tutorial"
*/
export const adminRoutes = new Hono<AppEnv>()
adminRoutes.route("/", adminAccountRoutes)
adminRoutes.route("/", adminAchievementRoutes)
adminRoutes.route("/", adminAiRoutes)
adminRoutes.route("/", adminAnnouncementRoutes)
adminRoutes.route("/", adminTutorialRoutes)

View File

@@ -0,0 +1,49 @@
/**
* 成就指标注册表。对齐旧后端 `achievement/metrics.py` 的 METRIC_REGISTRY ——
* 那边是装饰器注册,这里是一张表,作用一样:**后台下拉框里有什么,取决于代码里注册了什么**。
*
* 加一个新维度必须同时改这里和 `achievements.ts` 的计算逻辑并部署。
* 只加这里而不算,会造出一个谁也拿不到的成就;只算而不加这里,后台就选不到它。
*/
export interface AchievementMetric {
key: string
name: string
helpText: string
/** 元指标:统计的是「已解锁成就数」自身,判定要在其它成就结算完之后再跑一轮 */
meta?: boolean
}
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ key: "accepted_count", name: "AC 题目数", helpText: "去重后通过的题目数量(不含比赛)" },
{ key: "mid_ac_count", name: "中等题 AC 数", helpText: "去重后通过的中等难度题目数(不含比赛)" },
{ key: "hard_ac_count", name: "困难题 AC 数", helpText: "去重后通过的困难题目数(不含比赛)" },
{ key: "submission_count", name: "提交总数", helpText: "提交次数(不含比赛)" },
{ key: "active_days", name: "活跃天数", helpText: "有过提交的累计天数" },
{ key: "max_ac_streak_days", name: "最长连续 AC 天数", helpText: "连续每天至少 AC 一题的最长天数" },
{ key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" },
{ key: "contest_joined", name: "参赛场次", helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)" },
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
{ key: "problemset_completed", name: "完成题单数", helpText: "完成的题单数量" },
{ key: "first_try_ac_count", name: "一发入魂次数", helpText: "首次提交即通过的次数" },
{ key: "midnight_submissions", name: "凌晨提交次数", helpText: "0:005:00 之间的提交次数" },
{ key: "early_bird_submissions", name: "早起提交次数", helpText: "5:007:00 之间的提交次数" },
{ key: "compile_error_count", name: "编译错误次数", helpText: "累计编译错误的次数" },
{ key: "max_wa_before_ac", name: "屡败屡战", helpText: "单题失败最多多少次后终于通过" },
{ key: "max_ac_in_one_day", name: "单日最多 AC", helpText: "一天之内最多通过多少题" },
{ key: "max_code_lines", name: "最长代码行数", helpText: "提交过的最长代码有多少行" },
{ key: "achievement_unlocked_count", name: "已解锁成就数", helpText: "已解锁的成就数量(不含白金档)", meta: true },
]
const BY_KEY = new Map(ACHIEVEMENT_METRICS.map((item) => [item.key, item]))
export function findMetric(key: string) {
return BY_KEY.get(key) ?? null
}
export function metricName(key: string) {
return BY_KEY.get(key)?.name ?? key
}
/** 稀有度四档。乱填的值会让成就汇总接口的分档统计对不上:野值算进总数却不出现在任何一档 */
export const RARITIES = ["bronze", "silver", "gold", "platinum"] as const
export const OPERATORS = ["gte", "lte"] as const

View File

@@ -1,6 +1,8 @@
import { and, count, eq, isNull, ne, notInArray, sql } from "drizzle-orm"
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"
@@ -158,3 +160,105 @@ export async function updateAchievementsForProblemSet(userId: number) {
.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))
})
}
}

View File

@@ -126,29 +126,53 @@ export function getUserList(
keyword: string,
orderBy = "",
) {
return http.get("admin/user", {
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
})
return legacyResponse(
api2.get("admin/users", {
// 旧接口的 order_by 只有 "-last_login" 一个取值
params: {
offset,
limit,
keyword,
type,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
},
}),
)
}
// 编辑用户
export function editUser(user: User) {
return http.put("admin/user", user)
return legacyResponse(
api2.put(`admin/users/${user.id}`, {
username: user.username,
email: user.email,
adminType: user.admin_type,
problemPermission: user.problem_permission,
realName: user.real_name ?? null,
isDisabled: user.is_disabled,
openApi: user.open_api,
password: user.password ?? "",
}),
)
}
// 重置用户密码
export function resetPassword(userID: number) {
return http.post("admin/reset_password", { id: userID })
// 重置用户密码。调用方直接用 res.data 当密码字符串(旧后端返回的就是裸字符串),
// 新后端返回 { password },在这里解包,组件不动
export async function resetPassword(userID: number) {
const res = await api2.post<{ password: string }>(
`admin/users/${userID}/reset-password`,
)
return { error: res.error, data: res.data.password }
}
// 导入用户
export function importUsers(users: string[][]) {
return http.post("admin/user", { users })
return api2.post("admin/users", { users })
}
// 批量删除用户
export function deleteUsers(userIDs: number[]) {
return http.delete("admin/user", { params: { id: userIDs.join(",") } })
return api2.delete("admin/users", { data: { ids: userIDs } })
}
export function getContestList(offset = 0, limit = 10, keyword: string) {
@@ -622,22 +646,42 @@ export interface MetricOption {
help_text: string
}
/** 组件里的成就对象是 snake_case出站转成新后端要的 camelCase */
function toAchievementBody(data: Partial<AdminAchievement>) {
return {
name: data.name,
description: data.description,
icon: data.icon,
rarity: data.rarity,
hidden: data.hidden ?? false,
metric: data.metric,
operator: data.operator,
threshold: data.threshold,
visible: data.visible ?? true,
order: data.order ?? 0,
}
}
export function getAdminAchievements() {
return http.get<AdminAchievement[]>("admin/achievement")
return legacyResponse<AdminAchievement[]>(api2.get("admin/achievements"))
}
export function getMetricOptions() {
return http.get<MetricOption[]>("admin/achievement/metrics")
return legacyResponse<MetricOption[]>(api2.get("admin/achievement-metrics"))
}
export function createAchievement(data: Partial<AdminAchievement>) {
return http.post<AdminAchievement>("admin/achievement", data)
return legacyResponse<AdminAchievement>(
api2.post("admin/achievements", toAchievementBody(data)),
)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return http.put<AdminAchievement>("admin/achievement", data)
return legacyResponse<AdminAchievement>(
api2.put(`admin/achievements/${data.id}`, toAchievementBody(data)),
)
}
export function deleteAchievement(id: number) {
return http.delete("admin/achievement", { params: { id } })
return api2.delete(`admin/achievements/${id}`)
}