style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 13:52:24 +08:00
1 parent 530e1a3d5d
commit 195069450f
480 files changed
+15507 -24650

No files matched your search

+84 -139
View File
@@ -14,21 +14,21 @@ import {
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"
} 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 { classPrefixCondition, queryInteger, sampleUser } from "../helpers"
import { publishConfigUpdate } from "../../events"
import { getWebsiteOptions } from "../../services/options"
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 { classPrefixCondition, queryInteger, sampleUser } from '../helpers'
import { publishConfigUpdate } from '../../events'
import { getWebsiteOptions } from '../../services/options'
export const adminAccountRoutes = new Hono<AppEnv>()
@@ -43,9 +43,7 @@ const CLASS_NAME_MAX_DIGITS = 4
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
* 对齐旧 `account/views/admin.py:get_class_name`。
*/
function classNameOf(
username: string,
): { ok: true; value: string | null } | { ok: false; message: string } {
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]!
@@ -66,14 +64,11 @@ function classNameOf(
* 老师比旧后端多一条:不能是 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 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(
@@ -118,35 +113,25 @@ function selectUser(id: number) {
* 排序口径与公开榜单一致(见 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()
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),
// 输入班级前缀(ks251)按班级匹配,否则照旧按用户名包含
keyword
? (classPrefixCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`))
: undefined,
keyword ? (classPrefixCondition(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({ 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),
)
.orderBy(desc(schema.userProfile.acceptedNumber), asc(schema.userProfile.submissionNumber), asc(schema.user.id))
.limit(limit)
.offset(offset),
])
@@ -169,56 +154,43 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
} 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 })
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()
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", "角色筛选值不合法")
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}%`),
)!,
)
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()
const className = c.req.query('className')?.trim()
if (className) {
filters.push(
className === NO_CLASS ? isNull(schema.user.className) : eq(schema.user.className, 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")
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"
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"
orderBy === 'username'
? [asc(schema.user.username)]
: [desc(schema.user.createTime)]
@@ -226,8 +198,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
db
.select({
value: count(),
neverLoggedIn:
sql<number>`count(*) filter (where ${schema.user.lastLogin} is null)`.mapWith(Number),
neverLoggedIn: sql<number>`count(*) filter (where ${schema.user.lastLogin} is null)`.mapWith(Number),
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -252,7 +223,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。
* 比较时归一成小写,和导入、登录同一个口径
*/
adminAccountRoutes.post("/users/check", requireSuperAdmin, async (c) => {
adminAccountRoutes.post('/users/check', requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, checkUsernamesRequestSchema)
if (!parsed.success) return parsed.response
const names = [...new Set(parsed.data.usernames.map((name) => name.toLowerCase()))]
@@ -263,38 +234,38 @@ adminAccountRoutes.post("/users/check", requireSuperAdmin, async (c) => {
return success(c, { existing: rows } satisfies CheckUsernamesResponse)
})
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")
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 })
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")
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()
const className = classNameOf(username)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
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")
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")
if (dupEmail) return failure(c, 409, 'email-exists', 'Email already exists')
const patch: Partial<typeof schema.user.$inferInsert> = {
username,
@@ -327,10 +298,7 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
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))
await tx.update(schema.userProfile).set({ realName: data.realName }).where(eq(schema.userProfile.userId, id))
})
// 禁用只改数据库这一列,会话在 Redis 里还好好的 —— 那个学生挂着的 WebSocket
@@ -340,16 +308,16 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
// 登着的那一方毫无作用,他能一直用到会话自然过期。两件事都发生时按禁用报,
// 学生看到的提示更贴近实际。
if (data.isDisabled && !existing.user.isDisabled) {
await revokeUserSessions(id, "account-disabled")
await revokeUserSessions(id, 'account-disabled')
} else if (data.password) {
await revokeUserSessions(id, "session-ended")
await revokeUserSessions(id, 'session-ended')
}
const [row] = await selectUser(id)
return success(c, serialize(row!, await isUserOnline(id)))
})
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, importUsersRequestSchema)
if (!parsed.success) return parsed.response
const rows = parsed.data.users
@@ -372,23 +340,18 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
for (const [username, password, email, realName] of rows) {
const name = username.toLowerCase()
const className = classNameOf(name)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message)
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 || "(空)"} 不是合法邮箱`,
)
return failure(c, 400, 'invalid-email', `用户 ${name} 的邮箱 ${mail || '(空)'} 不是合法邮箱`)
}
prepared.push({
username: name,
password: "",
password: '',
raw: password,
email: mail,
realName,
@@ -402,11 +365,11 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
}
const batchNames = dupInBatch(prepared.map((item) => item.username))
if (batchNames.length) {
return failure(c, 409, "username-exists", `这批名单里用户名重复:${batchNames.join("、")}`)
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("、")}`)
return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`)
}
const existing = await db
@@ -425,18 +388,14 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
),
)
const takenNames = new Set(prepared.map((item) => item.username))
const clashNames = existing
.filter((row) => takenNames.has(row.username))
.map((row) => row.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("、")}`)
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))
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("、")}`)
return failure(c, 409, 'email-exists', `邮箱已被占用:${[...new Set(clashMails)].join('、')}`)
}
// argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all
@@ -465,8 +424,8 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
rawPassword: item.raw,
email: item.email,
className: item.className,
adminType: "Regular User" as const,
problemPermission: "None" as const,
adminType: 'Regular User' as const,
problemPermission: 'None' as const,
createTime: new Date().toISOString(),
isDisabled: false,
})),
@@ -478,7 +437,7 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
userId: byName.get(item.username)!,
realName: item.realName,
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
avatar: "/public/avatar/default.png",
avatar: '/public/avatar/default.png',
acmProblemsStatus: {},
submissionNumber: 0,
acceptedNumber: 0,
@@ -493,14 +452,12 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
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 }),
)
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 })
.values({ key: 'class_list', value: next })
.onConflictDoUpdate({ target: schema.optionsSysoptions.key, set: { value: next } })
await publishConfigUpdate("classList", next)
await publishConfigUpdate('classList', next)
}
return success(c, { imported: created, addedClasses: missing }, 201)
})
@@ -512,7 +469,7 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
*/
function isForeignKeyViolation(error: unknown) {
for (let current = error; current; current = (current as { cause?: unknown }).cause) {
if ((current as { code?: string }).code === "23503") return true
if ((current as { code?: string }).code === '23503') return true
}
return false
}
@@ -520,12 +477,12 @@ function isForeignKeyViolation(error: unknown) {
/** 「这人还有提交」的信号。提交那张表没有外键,拦不住,只能自己查出来再把事务掀了 */
class UserHasSubmissionsError extends Error {}
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, deleteUsersRequestSchema, "ids is required")
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
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")
return failure(c, 400, 'cannot-delete-self', 'Current user can not be deleted')
}
// 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大,
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
@@ -556,35 +513,23 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
.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 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",
"该用户还有提交、题目等历史数据,无法删除;请改为禁用账号",
)
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")
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("")
const password = Array.from({ length: 6 }, () => '123456789'[randomInt(9)]).join('')
await db
.update(schema.user)
.set({
@@ -593,6 +538,6 @@ adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c
})
.where(eq(schema.user.id, id))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended")
await revokeUserSessions(id, 'session-ended')
return success(c, { password } satisfies ResetPasswordResponse)
})