Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
530e1a3d5d
commit
195069450f
480 files changed
+15507
-24650
No files matched your search
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user