import { adminTypeSchema, checkUsernamesRequestSchema, deleteUsersRequestSchema, displayName, legacyClassOf, NO_CLASS, type CheckUsernamesResponse, importUsersRequestSchema, STUDENT_ROLES, updateUserRequestSchema, type AdminType, type AdminUser, type AdminUserList, type AdminUserRank, type ProblemPermission, type RankProfile, type ResetPasswordResponse, } from '@oj2/contract' import { randomInt } from 'node:crypto' import { z } from 'zod' import { and, asc, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from 'drizzle-orm' import { Hono } from 'hono' import { hashPassword } from '../../auth/password' import { isUserOnline, onlineUserIds } from '../../auth/presence' import { revokeUserSessions } from '../../auth/session' import { requireSuperAdmin, type AppEnv } from '../../auth/middleware' import { db, schema } from '../../db' import { failure, parseBody, success } from '../../http' import { classKeywordCondition, queryInteger, sampleUser } from '../helpers' import { publishConfigUpdate } from '../../events' import { getWebsiteOptions } from '../../services/options' export const adminAccountRoutes = new Hono() /** * 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission: * 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。 * 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。 * * 老师比旧后端多一条:不能是 None。比赛归老师管,而比赛题的接口挂的是 * requireProblemPermission —— 老师设成 None 就是进得了比赛、点题目全是「权限不足」。 */ function normalizePermission(adminType: AdminType, requested: ProblemPermission): ProblemPermission { if (adminType === 'Super Admin') return 'All' if (adminType === 'Regular User') return 'None' if (adminType === 'Teacher Admin' && requested === 'None') return 'Own' return requested || 'Own' } function serialize( row: { user: typeof schema.user.$inferSelect realName: string | null }, isOnline: boolean, ) { return { 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, isDisabled: row.user.isDisabled, isOnline, rawPassword: row.user.rawPassword, className: row.user.className, } satisfies AdminUser } 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) } /** * 后台的用户排名:老师按班级前缀翻学生,**不设 100 名上限**。 * * 这份逻辑原来是公开榜单 `/rankings/users` 的 `top=0` 分支,搬过来是因为那意味着 * 任何匿名请求都能 `?top=0&limit=250` 翻走全校学生名单和个性签名 —— * 而 `/profiles/:username` 恰恰为了收紧枚举面才做了「匿名一律返回空」。 * * 排序口径与公开榜单一致(见 routes/account.ts 的 leaderboardOrder): * AC 降序 → 提交数升序 → id 升序,第三档保证翻页稳定。 */ adminAccountRoutes.get('/rankings/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 keyword = c.req.query('keyword')?.trim() const where = and( inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false), // 填的是班号(251,或者老习惯 ks251)按班级圈,否则按用户名包含 keyword ? (classKeywordCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined, ) const [totalRows, rows] = await Promise.all([ db.select({ value: count() }).from(schema.userProfile).innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where), db .select({ profile: schema.userProfile, user: schema.user }) .from(schema.userProfile) .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) .where(where) .orderBy(desc(schema.userProfile.acceptedNumber), asc(schema.userProfile.submissionNumber), asc(schema.user.id)) .limit(limit) .offset(offset), ]) return success(c, { results: rows.map( ({ profile, user }) => ({ id: profile.id, user: sampleUser(user, profile.realName), acceptedNumber: profile.acceptedNumber, submissionNumber: profile.submissionNumber, mood: profile.mood, // 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。 // 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。 isOnline: null, }) satisfies RankProfile, ), total: totalRows[0]?.value ?? 0, } satisfies AdminUserRank) }) 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) { // 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。 // 列加了 $type 之后编译器会拦下来,顺势改成校验:前端的下拉只有这四个值。 const parsedType = adminTypeSchema.safeParse(type) if (!parsedType.success) return failure(c, 400, 'invalid-request', '角色筛选值不合法') filters.push(eq(schema.user.adminType, parsedType.data)) } if (keyword) { filters.push(or(ilike(schema.user.username, `%${keyword}%`), ilike(schema.userProfile.realName, `%${keyword}%`), ilike(schema.user.email, `%${keyword}%`))!) } // 班级:和全站班级下拉同一套值,NO_CLASS 是「没填班级」 const className = c.req.query('className')?.trim() if (className) { filters.push(className === NO_CLASS ? isNull(schema.user.className) : eq(schema.user.className, className)) } const where = filters.length ? and(...filters) : undefined // 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次 const online = await onlineUserIds() const orderBy = c.req.query('orderBy') // 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了 // // 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来 // 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。 // 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。 const order = orderBy === '-online' ? [...(online.size ? [sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`] : []), sql`${schema.user.lastLogin} desc nulls last`] : orderBy === '-lastLogin' ? [sql`${schema.user.lastLogin} desc nulls last`] : // 按用户名:选了一个班看名单时最顺手(和点名册一个顺序) orderBy === 'username' ? [asc(schema.user.username)] : [desc(schema.user.createTime)] const [totalRows, rows] = await Promise.all([ db .select({ value: count(), neverLoggedIn: sql`count(*) filter (where ${schema.user.lastLogin} is null)`.mapWith(Number), }) .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, { results: rows.map((row) => serialize(row, online.has(row.user.id))), total: totalRows[0]?.value ?? 0, neverLoggedIn: totalRows[0]?.neverLoggedIn ?? 0, } satisfies AdminUserList) }) /** * 批量建号之前查重(批量建号页边粘名单边查):这个班里已经有谁了(多半是同一个人粘了两次), * 以及每个名字会建成什么用户名(名字被别的班、注册的号占了就加数字)。比较归一成小写,和导入、登录同一个口径 */ adminAccountRoutes.post('/users/check', requireSuperAdmin, async (c) => { const parsed = await parseBody(c, checkUsernamesRequestSchema) if (!parsed.success) return parsed.response const { className, names } = parsed.data const wanted = [...new Set(names.map((name) => name.toLowerCase()))] // 同班同名:老号是 ks232张三,按剥掉前缀的名字比 const classmates = await db .select({ username: schema.user.username, createTime: schema.user.createTime }) .from(schema.user) .where(eq(schema.user.className, className)) const existing = classmates .map((row) => ({ name: displayName(row.username), username: row.username, createTime: row.createTime })) .filter((row) => wanted.includes(row.name.toLowerCase())) // 用户名全站唯一:名字本身和「名字+数字」占了哪些 const taken = await db .select({ username: sql`lower(${schema.user.username})` }) .from(schema.user) .where(inArray(sql`regexp_replace(lower(${schema.user.username}), '[0-9]+$', '')`, wanted)) return success(c, { existing, usernames: suggestUsernames(names, new Set(taken.map((row) => row.username))) } satisfies CheckUsernamesResponse) }) /** * 新号的用户名:名字没被占就是名字本身,占了往后加数字(杨涵宇2、杨涵宇3)。同一批里先到先得。 * 比较按小写,和登录、导入同一个口径 */ function suggestUsernames(names: string[], taken: Set) { return names.map((name) => { let candidate = name for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${name}${n}` taken.add(candidate.toLowerCase()) return candidate }) } 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, await isUserOnline(row.user.id))) }) adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => { const id = queryInteger(c.req.param('id'), 0, { min: 1 }) const parsed = await parseBody(c, updateUserRequestSchema) if (!parsed.success) return parsed.response 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.trim().toLowerCase() const email = data.email.trim().toLowerCase() // 班级明确给了就用;老页面不发这一项,不动原来的班(以前是从用户名的 ks 前缀推,改名就跟着变) const className = data.className !== undefined ? data.className : existing.user.className 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') // 比 lower(email):存量数据里有大小写混着的邮箱,按原值比会漏掉冲突 const [dupEmail] = await db .select({ id: schema.user.id }) .from(schema.user) .where(and(sql`lower(${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 = { username, email, className, adminType: data.adminType, isDisabled: data.isDisabled, problemPermission: normalizePermission(data.adminType, data.problemPermission), } if (data.password) { // 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求, // 老师要能查学生密码,见设计文档 7.1.1。 patch.password = await hashPassword(data.password) patch.rawPassword = data.password } await db.transaction(async (tx) => { await tx.update(schema.user).set(patch).where(eq(schema.user.id, id)) /** * submission.username 是冗余列,改名后跟着改。 * * 条件按 **user_id** 而不是「等于旧用户名」:后者只改得动「当前正好还等于旧名」 * 的行,一个已经漂移过的账号再改一次名,更早那批仍然改不动 —— 生产库里 726 条 * 挂着旧名字的提交就是旧栈时代这么留下的,之后每次改名都从它身边绕过去。 * 按 user_id 写是幂等的,顺带把这个人的历史行一次性拉平。 * * 读路径本身已经不依赖这一列了(列表和统计都从 user 表取当前名字), * 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。 */ if (existing.user.username !== username) { await tx.update(schema.submission).set({ username }).where(eq(schema.submission.userId, id)) } await tx.update(schema.userProfile).set({ realName: data.realName }).where(eq(schema.userProfile.userId, id)) }) // 禁用只改数据库这一列,会话在 Redis 里还好好的 —— 那个学生挂着的 WebSocket // 靠会话巡检永远发现不了(token 还是好的),只能在这里主动断。 // // 改密码同样要吊销:不删旧会话的话,「给被盗用的账号改个密码」这个动作对已经 // 登着的那一方毫无作用,他能一直用到会话自然过期。两件事都发生时按禁用报, // 学生看到的提示更贴近实际。 if (data.isDisabled && !existing.user.isDisabled) { await revokeUserSessions(id, 'account-disabled') } else if (data.password) { await revokeUserSessions(id, 'session-ended') } const [row] = await selectUser(id) return success(c, serialize(row!, await isUserOnline(id))) }) adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => { const parsed = await parseBody(c, importUsersRequestSchema) if (!parsed.success) return parsed.response const rows = parsed.data.users type Prepared = { username: string password: string raw: string email: string realName: string className: string | null } // 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数 // (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。 // // 用户名和邮箱都归一成小写:登录是 `lower(username) = lower(?)` 比的,注册和 // PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的 // 查重建出第二个账号,两个人登录时撞成同一条记录。 const prepared: Prepared[] = [] for (const [username, password, email, realName, explicitClass] of rows) { const name = username.toLowerCase() // 班级明确给了就用(新号不带 ks 前缀);老写法没给,退回从 ks 前缀推,位数不对报错不猜 const legacy = explicitClass === undefined ? legacyClassOf(name) : null if (legacy === undefined) { return failure(c, 400, 'invalid-class-name', `用户名 ${name} 里 ks 后面的班级号位数不对,班级号要是 3~4 位数字`) } const className = explicitClass ?? legacy const mail = email.trim().toLowerCase() // 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前 // 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批 // 必然重号。存进去不会报错(库里没有唯一约束),但这两个账号从此**编辑不了**: // PUT 一保存就撞自己的查重回 409,老师只看到「Email already exists」。 if (!z.email().max(64).safeParse(mail).success) { return failure(c, 400, 'invalid-email', `用户 ${name} 的邮箱 ${mail || '(空)'} 不是合法邮箱`) } prepared.push({ username: name, password: '', raw: password, email: mail, realName, className, }) } const dupInBatch = (values: string[]) => { const seen = new Set() return [...new Set(values.filter((value) => seen.size === seen.add(value).size))] } const batchNames = dupInBatch(prepared.map((item) => item.username)) if (batchNames.length) { return failure(c, 409, 'username-exists', `这批名单里用户名重复:${batchNames.join('、')}`) } const batchMails = dupInBatch(prepared.map((item) => item.email)) if (batchMails.length) { return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`) } const existing = await db .select({ username: schema.user.username, email: schema.user.email }) .from(schema.user) .where( or( inArray( schema.user.username, prepared.map((item) => item.username), ), inArray( sql`lower(${schema.user.email})`, prepared.map((item) => item.email), ), ), ) const takenNames = new Set(prepared.map((item) => item.username)) const clashNames = existing.filter((row) => takenNames.has(row.username)).map((row) => row.username) if (clashNames.length) { return failure(c, 409, 'username-exists', `用户名已存在:${clashNames.join('、')}`) } const takenMails = new Set(prepared.map((item) => item.email)) const clashMails = existing.map((row) => row.email?.toLowerCase()).filter((mail): mail is string => !!mail && takenMails.has(mail)) if (clashMails.length) { return failure(c, 409, 'email-exists', `邮箱已被占用:${[...new Set(clashMails)].join('、')}`) } // argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all // 全量:每次哈希占 m=19MiB(见 auth/password.ts 的 ARGON2_OPTIONS),一个年级 300 人 // 同时开就是 5.7GB,而 oj-api 的 mem_limit 只有 512m(docker/compose.debian.yml)。 // 固定 4 路并发,瞬时峰值 76MiB 封顶。 const HASH_CONCURRENCY = 4 let cursor = 0 await Promise.all( Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => { while (cursor < prepared.length) { const item = prepared[cursor++]! item.password = await hashPassword(item.raw) } }), ) // 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在 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' as const, problemPermission: 'None' as const, createTime: new Date().toISOString(), isDisabled: false, })), ) .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, })), ) return users.length }) // 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它, // 原来要再去设置页手动加,经常忘 const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))] const options = await getWebsiteOptions() const missing = classes.filter((name) => !options.class_list.includes(name!)) as string[] if (missing.length) { const next = [...options.class_list, ...missing].sort((a, b) => a.localeCompare(b, 'zh-CN', { numeric: true })) await db .insert(schema.optionsSysoptions) .values({ key: 'class_list', value: next }) .onConflictDoUpdate({ target: schema.optionsSysoptions.key, set: { value: next } }) await publishConfigUpdate('classList', next) } return success(c, { imported: created, addedClasses: missing }, 201) }) /** * 外键冲突(PostgresError 23503)。要顺着 cause 链找 —— drizzle 0.45 把驱动的错误 * 包进 DrizzleQueryError,`error.code` 在最外层是 undefined,只看外层会把所有 * 删除失败都当成系统故障报 500。 */ function isForeignKeyViolation(error: unknown) { for (let current = error; current; current = (current as { cause?: unknown }).cause) { if ((current as { code?: string }).code === '23503') return true } return false } /** 「这人还有提交」的信号。提交那张表没有外键,拦不住,只能自己查出来再把事务掀了 */ class UserHasSubmissionsError extends Error {} adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => { const parsed = await parseBody(c, deleteUsersRequestSchema, 'ids is required') if (!parsed.success) return parsed.response 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 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来: // 撞外键说明该用户还有历史数据,应当禁用而不是删除。 // // 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、 // AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE —— // 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。 // 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个 // 裸 IntegerField),全连坐的结果是成就没了、提交却留成孤儿行,一半删一半留。 try { const deleted = await db.transaction(async (tx) => { /** * 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** —— * `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经 * 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票: * 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 —— * 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测: * 28 个已删账号留下 935 条这样的提交。 * * 补一次查询把它拦下来,口径和外键那批一致:有历史数据就该禁用,不该删。 * 和 delete 放同一个事务里,免得中间正好交了一发。 */ const [withSubmission] = await tx .select({ userId: schema.submission.userId }) .from(schema.submission) .where(inArray(schema.submission.userId, parsed.data.ids)) .limit(1) if (withSubmission) throw new UserHasSubmissionsError() 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 (error) { // 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸 // catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见 if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error 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 hashPassword(password), rawPassword: password, }) .where(eq(schema.user.id, id)) // 旧密码登出来的会话立刻作废,理由同 PUT /users/:id await revokeUserSessions(id, 'session-ended') return success(c, { password } satisfies ResetPasswordResponse) })