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:
276
apps/api/src/routes/admin/account.ts
Normal file
276
apps/api/src/routes/admin/account.ts
Normal 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("")
|
||||
}
|
||||
Reference in New Issue
Block a user