fix(后台): 会话吊销、导入查重、练习题校验,以及一批后台页面的小毛病
Some checks failed
Deploy / deploy (push) Has been cancelled

后台代码审查后的一批修复。

后端:
- 改密码 / 重置密码 / 禁用账号现在真的把该用户所有设备的会话删掉。原来只
  publishSessionRevoked 广播断 WebSocket,HTTP 拿着旧 cookie 照样能用到会话
  自然过期 —— 给被盗用的账号改密码等于没改。为此在 Redis 里补了反向索引
  user-sessions:<id>(createSession 写入、登出和失效路径清理、跟着会话续期)。
- 导入用户补齐校验:邮箱走 z.email()、批内查重、库内查重,用户名和邮箱各报各的;
  用户名和邮箱都归一成小写,和登录的 lower(username) 比较口径对齐。以前导入这条
  路什么都不查,而前端占位邮箱按「班级+批内序号」拼,同一个班导第二批必然重号,
  那两个账号从此在后台保存一次就撞 409、再也改不动。前端生成的占位邮箱同步加了
  每批随机后缀。
- PUT /users/:id 的邮箱查重改比 lower(email),存量大小写混着的数据也能拦住。
- 删用户的裸 catch 收窄成只认外键冲突 23503(顺 cause 链找,drizzle 0.45 把驱动
  错误包了一层),别的错照常抛 500,不再把连接故障说成「该用户还有历史数据」。
- 练习题 data 补语义校验(services/exercise.ts):没有 {{空位}} 的填空题、空选项的
  选择题、越界的下标等一律拒收。以前后端零校验,坏数据只有学生端会撞到。
- PUT /judge-servers/:id 改用 queryInteger,非数字 id 回 404 而不是 500。
- 比赛克隆不加归属校验是**有意的**(快速再开一场以前的比赛;保密边界在师生之间不在
  教师之间),把这条政策和它的副作用写进注释,免得反复被当成漏洞。

前端:
- 编辑用户弹窗的「班级」输入框改成只读 —— 它一直是个改了没用的控件,班级由后端从
  用户名推导。
- 新建用户预填唯一占位邮箱、角色默认改成实际会建出来的 Regular User、密码留空直接拦。
- 比赛题目列表的列过滤写的是 top_reaction,实际 key 是 topReaction,空列一直没被滤掉。
- AI 生成流程图加 try/finally,接口失败不再把按钮卡在 loading。
- 单个判题机删除后刷新表格;后台首页显示在线判题机数量(后端一直在下发)。
- 下载测试点失败时读 Blob 里的错误信封弹提示,不再毫无反应。
- 练习题编辑器补上和后端一致的前置校验。

验证:tsc / vue-tsc / vite build / check:routes 全过;后端每条改动都在本机起服务
实跑确认(会话吊销、导入各种重复、练习题七种题型、外键 409、非数字 id)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv
This commit is contained in:
2026-09-06 08:41:54 -06:00
parent bb0f5ec5ef
commit 498fc1ceca
14 changed files with 364 additions and 32 deletions

View File

@@ -13,9 +13,11 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"
import { config } from "../config"
import { db, schema } from "../db"
import { publishSessionRevoked, type SessionRevokedReason } from "../events"
import { redis } from "../redis"
const SESSION_PREFIX = "session:"
const USER_SESSIONS_PREFIX = "user-sessions:"
interface StoredSession {
userId: number
@@ -43,6 +45,21 @@ function sessionKey(token: string) {
return `${SESSION_PREFIX}${token}`
}
/**
* 某个用户名下所有还活着的会话 token。
*
* 会话本体是 `session:<token>`,里面记着 userId —— 从 token 找人很快,从人找 token
* 却只能 SCAN 整个 Redis。改密码、重置密码、禁用账号这三件事都要求「把这个人所有
* 设备上的会话立刻作废」,所以额外维护这张反向索引。
*
* **它是索引,不是真相**:成员可能指向已经过期的 token集合成员没有各自的 TTL
* 吊销时按成员逐个 DEL 即可,删到不存在的 key 没有代价。集合自己跟着会话续期,
* 整个人不活动满一个 TTL 之后自然消失。
*/
function userSessionsKey(userId: number) {
return `${USER_SESSIONS_PREFIX}${userId}`
}
export async function createSession(
c: Context,
userId: number,
@@ -61,6 +78,8 @@ export async function createSession(
"EX",
config.sessionTtlSeconds,
)
await redis.sadd(userSessionsKey(userId), token)
await redis.expire(userSessionsKey(userId), config.sessionTtlSeconds)
setCookie(c, config.sessionCookie, token, {
httpOnly: true,
sameSite: "Lax",
@@ -73,11 +92,46 @@ export async function createSession(
/** 返回被删掉的 token调用方要拿它去广播会话吊销好断掉同一浏览器里其他标签页的连接 */
export async function destroySession(c: Context) {
const token = getCookie(c, config.sessionCookie)
if (token) await redis.del(sessionKey(token))
if (token) {
// 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员
const userId = await sessionUserId(token)
await redis.del(sessionKey(token))
if (userId !== null) await redis.srem(userSessionsKey(userId), token)
}
deleteCookie(c, config.sessionCookie, { path: "/" })
return token ?? null
}
async function sessionUserId(token: string) {
const raw = await redis.get(sessionKey(token))
if (!raw) return null
try {
const value = JSON.parse(raw) as StoredSession
return Number.isInteger(value.userId) ? value.userId : null
} catch {
return null
}
}
/**
* 把一个用户所有设备上的会话真的删掉,并广播给还挂着的 WebSocket。
*
* 光广播是不够的:`publishSessionRevoked` 只断 WebSocketHTTP 请求照样能拿着
* 那张 cookie 继续用。改密码之后旧密码登出来的会话必须立刻失效,否则「改密码」
* 对已经被别人登着的账号毫无作用 —— 而学生密码是明文存着给老师查的,
* 改密码正是发现密码泄露之后唯一的补救手段。
*/
export async function revokeUserSessions(
userId: number,
reason: SessionRevokedReason,
) {
const tokens = await redis.smembers(userSessionsKey(userId))
if (tokens.length) await redis.del(...tokens.map(sessionKey))
await redis.del(userSessionsKey(userId))
await publishSessionRevoked({ userId }, reason)
return tokens.length
}
function readCookie(request: Request, name: string) {
const header = request.headers.get("cookie")
if (!header) return undefined
@@ -126,6 +180,7 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
if (!user) {
await redis.del(sessionKey(token))
await redis.srem(userSessionsKey(session.userId), token)
return { user: null, reason: "anonymous" }
}
@@ -134,10 +189,14 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
// 都返回 null 的话,中途被禁用的学生看到的是 401 login-required
// 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。
await redis.del(sessionKey(token))
await redis.srem(userSessionsKey(session.userId), token)
return { user: null, reason: "disabled" }
}
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
// 之后再吊销就找不到这张会话了
await redis.expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
return {
user: {

View File

@@ -13,15 +13,16 @@ import {
type ProblemPermission,
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { z } from "zod"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { hashPassword } from "../../auth/password"
import { revokeUserSessions } from "../../auth/session"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { queryInteger, sampleUser } from "../helpers"
import { publishSessionRevoked } from "../../events"
export const adminAccountRoutes = new Hono<AppEnv>()
@@ -186,16 +187,17 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
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 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)
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(eq(schema.user.email, email), ne(schema.user.id, id))).limit(1)
.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<typeof schema.user.$inferInsert> = {
@@ -224,10 +226,16 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
.where(eq(schema.userProfile.userId, id))
})
// 禁用只改数据库这一列,不动 Redis 里的会话 —— 那个学生挂着的 WebSocket
// 靠会话巡检永远发现不了token 还是好的),只能在这里主动断
// 禁用只改数据库这一列,会话在 Redis 里还好好的 —— 那个学生挂着的 WebSocket
// 靠会话巡检永远发现不了token 还是好的),只能在这里主动断
//
// 改密码同样要吊销:不删旧会话的话,「给被盗用的账号改个密码」这个动作对已经
// 登着的那一方毫无作用,他能一直用到会话自然过期。两件事都发生时按禁用报,
// 学生看到的提示更贴近实际。
if (data.isDisabled && !existing.user.isDisabled) {
await publishSessionRevoked({ userId: id }, "account-disabled")
await revokeUserSessions(id, "account-disabled")
} else if (data.password) {
await revokeUserSessions(id, "session-ended")
}
const [row] = await selectUser(id)
@@ -244,17 +252,56 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
// 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数
// (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。
//
// 用户名和邮箱都归一成小写:登录是 `lower(username) = lower(?)` 比的,注册和
// PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的
// 查重建出第二个账号,两个人登录时撞成同一条记录。
const prepared: Prepared[] = []
for (const [username, password, email, realName] of rows) {
const className = classNameOf(username)
const name = username.toLowerCase()
const className = classNameOf(name)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
prepared.push({ username, password: "", raw: password, email, realName, className: className.value })
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: 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 dupInBatch = (values: string[]) => {
const seen = new Set<string>()
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
@@ -298,6 +345,18 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
return success(c, { imported: created }, 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
}
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")
@@ -318,7 +377,10 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const deleted = await db.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id })
return success(c, { deleted: deleted.length })
} catch {
} catch (error) {
// 只有外键冲突23503才是「这人还有历史数据」。以前这里是裸 catch
// 连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
if (!isForeignKeyViolation(error)) throw error
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
}
})
@@ -334,5 +396,7 @@ adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c
password: await hashPassword(password),
rawPassword: password,
}).where(eq(schema.user.id, id))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended")
return success(c, resetPasswordResponseSchema.parse({ password }))
})

View File

@@ -20,7 +20,7 @@ import { db, schema } from "../../db"
import { publishConfigUpdate } from "../../events"
import { failure, success } from "../../http"
import { getWebsiteOptions } from "../../services/options"
import { todayStart } from "../helpers"
import { queryInteger, todayStart } from "../helpers"
export const adminConfRoutes = new Hono<AppEnv>()
@@ -111,7 +111,7 @@ adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
if (!parsed.success) return failure(c, 400, "invalid-request", "isDisabled is required")
const updated = await db.update(schema.judgeServer)
.set({ isDisabled: parsed.data.isDisabled })
.where(eq(schema.judgeServer.id, Number(c.req.param("id"))))
.where(eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning({ id: schema.judgeServer.id })
if (updated.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
// 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。

View File

@@ -154,8 +154,21 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [original] = await selectContest(id)
// 克隆不要求 ownedBy旧后端这里也没有 ensure_created_by教师可以拿别人的比赛做模板
// 克隆出来的归调用者所有、且默认不可见,所以不构成越权修改。
// 这个接口是干什么的:**把以前那场比赛快速再开一场**,不用从头建一遍题
// 所以副本要带着整套题(含 answers 和 testCaseId时间挪到 10 分钟后、默认不可见,
// 前端点完「复制」直接跳进副本的编辑页改标题和时间admin/contest/components/Actions.vue
//
// 克隆**故意不要求 ownedBy**,任何教师都能克隆任何一场比赛,包括别人的、隐藏的、
// 还没开始的 —— 于是克隆完就能从 GET /admin/problems/:id 读到别人的标准答案、
// 从 /test-cases 下载别人的测试点。这是**明确定过的政策**2026-09-06 确认):
// 保密边界在师生之间,不在教师之间。别再把它当越权读取报上来。
//
// 注意这条政策**不能顺手推广到 make-public / from-public**:那两条守的是另一件事 ——
// 别让 B 把 A 还没考的卷子发布给**学生**,或者把 A 的草稿拖进自己比赛再放出去。
// 边界是学生,所以那两处的归属校验照旧(见 admin/problem.ts 的注释)。
//
// 已知的副作用,别当成 bug 去"修"副本和原题共用同一个测试点目录testCaseId 原样复制),
// 今天无害(删题特意不删目录),但以后要是加"删题顺手清测试点",得先把这里改成复制目录。
if (!original) return failure(c, 404, "contest-not-found", "Contest does not exist")
const duration = Date.parse(original.contest.endTime) - Date.parse(original.contest.startTime)

View File

@@ -14,6 +14,7 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { exerciseDataError } from "../../services/exercise"
import { objectValue, queryInteger, sampleUser } from "../helpers"
export const adminTutorialRoutes = new Hono<AppEnv>()
@@ -150,6 +151,8 @@ adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [created] = await db.insert(schema.exercise).values({
tutorialId: parsed.data.tutorialId,
type: parsed.data.type,
@@ -165,6 +168,8 @@ adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [updated] = await db.update(schema.exercise)
.set({ type: parsed.data.type, data: parsed.data.data, order: parsed.data.order })
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))

View File

@@ -0,0 +1,101 @@
import type { ExerciseType } from "@oj2/contract"
/**
* 练习题 `data` 的语义校验。
*
* 契约里 `data` 是 `z.record(z.string(), z.unknown())` —— 七种题型的字段完全不同,
* 用 zod 写成判别联合会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema
* 历史脏数据会把整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行。
*
* 为什么非校验不可:以前唯一的校验在前端 ExerciseManager 的 buildData(),而它对
* fill 和 mcq 几乎不查 —— 一道没有 `{{空位}}` 的填空题能存进库,学生端渲染出来是
* 一段没有输入框的代码,交不了也做不完,而老师那边显示「已发布」。坏数据只有学生撞得到。
*
* 返回 null 表示通过,否则是给老师看的中文原因。
*/
export function exerciseDataError(
type: ExerciseType,
data: Record<string, unknown>,
): string | null {
switch (type) {
case "mcq": {
const options = strings(data.options)
if (options.length < 2) return "选择题至少要有 2 个选项"
if (options.some((option) => !option.trim())) return "选择题的选项不能为空"
return indexAnswerError(data.answer, options.length, "正确答案")
}
case "sort": {
if (strings(data.lines).length < 2) return "排序题至少要有 2 行代码"
return null
}
case "fill": {
const code = typeof data.code === "string" ? data.code : ""
if (!code.trim()) return "填空题的代码不能为空"
// 学生端按 /\{\{([^}]+)\}\}/g 抠空位ExerciseFill.vue没有标记就没有空位
if (!/\{\{[^}]+\}\}/.test(code)) {
return "填空题的代码里没有空位,用 {{答案}} 标记,多个合法答案用 | 分隔"
}
return null
}
case "match": {
const left = strings(data.left)
const right = strings(data.right)
if (left.length < 2 || right.length < 2) return "连线题左右两列各至少 2 项"
if (left.length !== right.length) return "连线题左右两列的行数必须相等"
return indexAnswerError(data.answer, right.length, "连线答案", left.length)
}
case "predict": {
if (!(typeof data.code === "string" && data.code.trim())) return "输出预测题的代码不能为空"
if (strings(data.answer).filter((item) => item.trim()).length === 0) {
return "输出预测题至少要有一个正确输出"
}
return null
}
case "debug": {
const lines = strings(data.lines)
if (lines.length === 0) return "找错题的代码不能为空"
return indexAnswerError(data.answer, lines.length, "错误行")
}
case "group": {
const buckets = strings(data.buckets)
const items = strings(data.items)
if (buckets.length < 2) return "归类题至少要有 2 个分组"
if (items.length === 0) return "归类题至少要有一个项目"
// 归类题的下标**允许重复**:好几个项目落在同一个分组是常态,别顺手加去重
return indexAnswerError(data.answer, buckets.length, "归类答案", items.length, false)
}
}
}
function strings(value: unknown): string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string")
? (value as string[])
: []
}
/**
* 答案是一串下标:每个都得落在 `bound` 之内、不能重复。
* `length` 给定时还要求答案条数正好等于它(连线、归类是一项一个答案,
* 而选择题、找错题是「挑出若干个」,条数不固定)。`unique` 关掉时允许下标重复 ——
* 归类题就是这样,好几个项目落进同一个分组。
*/
function indexAnswerError(
value: unknown,
bound: number,
label: string,
length?: number,
unique = true,
): string | null {
if (!Array.isArray(value) || value.some((item) => !Number.isInteger(item))) {
return `${label}必须是一组下标`
}
const answer = value as number[]
if (length === undefined ? answer.length === 0 : answer.length !== length) {
return length === undefined
? `请至少勾选一个${label}`
: `${label}的条数(${answer.length})和项目数(${length})对不上`
}
if (answer.some((item) => item < 0 || item >= bound)) return `${label}的下标越界`
if (unique && new Set(answer).size !== answer.length) return `${label}里有重复的下标`
return null
}