From 498fc1ceca154f58cecd840baecd8c09ebb46bde Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 6 Sep 2026 08:41:54 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0):=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=90=8A=E9=94=80=E3=80=81=E5=AF=BC=E5=85=A5=E6=9F=A5=E9=87=8D?= =?UTF-8?q?=E3=80=81=E7=BB=83=E4=B9=A0=E9=A2=98=E6=A0=A1=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E4=B8=80=E6=89=B9=E5=90=8E=E5=8F=B0=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E5=B0=8F=E6=AF=9B=E7=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台代码审查后的一批修复。 后端: - 改密码 / 重置密码 / 禁用账号现在真的把该用户所有设备的会话删掉。原来只 publishSessionRevoked 广播断 WebSocket,HTTP 拿着旧 cookie 照样能用到会话 自然过期 —— 给被盗用的账号改密码等于没改。为此在 Redis 里补了反向索引 user-sessions:(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 Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv --- apps/api/src/auth/session.ts | 61 ++++++++++- apps/api/src/routes/admin/account.ts | 92 +++++++++++++--- apps/api/src/routes/admin/conf.ts | 4 +- apps/api/src/routes/admin/contest.ts | 17 ++- apps/api/src/routes/admin/tutorial.ts | 5 + apps/api/src/services/exercise.ts | 101 ++++++++++++++++++ apps/web/src/admin/problem/detail.vue | 18 ++-- apps/web/src/admin/problem/list.vue | 2 +- apps/web/src/admin/setting/config.vue | 2 + apps/web/src/admin/setting/home.vue | 9 ++ .../tutorial/components/ExerciseManager.vue | 26 ++++- apps/web/src/admin/user/generate.vue | 7 +- apps/web/src/admin/user/list.vue | 21 +++- apps/web/src/utils/download.ts | 31 +++++- 14 files changed, 364 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/services/exercise.ts diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index a690c92..182cbd3 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -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:`,里面记着 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` 只断 WebSocket,HTTP 请求照样能拿着 + * 那张 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 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 // 都返回 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: { diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 872c58d..742f8d7 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -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() @@ -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 = { @@ -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() + 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 })) }) diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 5ee80e8..2ad8337 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -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() @@ -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() 把积压的待判任务重新分发。 diff --git a/apps/api/src/routes/admin/contest.ts b/apps/api/src/routes/admin/contest.ts index ebec813..5624068 100644 --- a/apps/api/src/routes/admin/contest.ts +++ b/apps/api/src/routes/admin/contest.ts @@ -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) diff --git a/apps/api/src/routes/admin/tutorial.ts b/apps/api/src/routes/admin/tutorial.ts index 8aa983f..f65a36f 100644 --- a/apps/api/src/routes/admin/tutorial.ts +++ b/apps/api/src/routes/admin/tutorial.ts @@ -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() @@ -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 }))) diff --git a/apps/api/src/services/exercise.ts b/apps/api/src/services/exercise.ts new file mode 100644 index 0000000..f8e103b --- /dev/null +++ b/apps/api/src/services/exercise.ts @@ -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 | 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 +} diff --git a/apps/web/src/admin/problem/detail.vue b/apps/web/src/admin/problem/detail.vue index b2b9cce..d75da86 100644 --- a/apps/web/src/admin/problem/detail.vue +++ b/apps/web/src/admin/problem/detail.vue @@ -500,12 +500,18 @@ function clear() { async function generateMermaid() { isAIGenerating.value = true - const res = await generateFlowchartFromPythonCode( - problem.value.answers.filter((a) => a.language === "Python3")[0].code, - ) - isAIGenerating.value = false - message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法") - problem.value.mermaidCode = res.flowchart + try { + const res = await generateFlowchartFromPythonCode( + problem.value.answers.filter((a) => a.language === "Python3")[0].code, + ) + problem.value.mermaidCode = res.flowchart + message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法") + } catch (err: any) { + // 没有 finally 的话,AI 接口回 502 时按钮永远卡在 loading,只能刷页面 + message.error(err.data || "生成失败,请稍后再试") + } finally { + isAIGenerating.value = false + } } const showGeneratorModal = ref(false) diff --git a/apps/web/src/admin/problem/list.vue b/apps/web/src/admin/problem/list.vue index b162d0d..65d7547 100644 --- a/apps/web/src/admin/problem/list.vue +++ b/apps/web/src/admin/problem/list.vue @@ -185,7 +185,7 @@ const baseColumns: DataTableColumn[] = [ // 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示 const columns = computed[]>(() => isContestProblemList.value - ? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction") + ? baseColumns.filter((it) => !("key" in it) || it.key !== "topReaction") : [{ type: "selection" }, ...baseColumns], ) diff --git a/apps/web/src/admin/setting/config.vue b/apps/web/src/admin/setting/config.vue index 630300a..c143033 100644 --- a/apps/web/src/admin/setting/config.vue +++ b/apps/web/src/admin/setting/config.vue @@ -181,6 +181,8 @@ async function getJudgeServerData() { async function delJudgeServer(hostname: string) { await deleteJudgeServer(hostname) message.success("删除成功") + // 不刷新的话删掉的那行还挂在表里,得手动刷新页面才消失(批量删除那支一直是刷的) + getJudgeServerData() } async function deleteAbnormalServers() { diff --git a/apps/web/src/admin/setting/home.vue b/apps/web/src/admin/setting/home.vue index 7330a67..9d866d7 100644 --- a/apps/web/src/admin/setting/home.vue +++ b/apps/web/src/admin/setting/home.vue @@ -11,6 +11,7 @@ import { getAdminUserRank, getBaseInfo, randomUser10 } from "../api" const userCount = ref(0) const submissionCount = ref(0) const contestCount = ref(0) +const judgeServerCount = ref(0) const userStore = useUserStore() const router = useRouter() const showModal = ref(false) @@ -67,6 +68,7 @@ onMounted(async () => { userCount.value = res.userCount submissionCount.value = res.todaySubmissionCount contestCount.value = res.recentContestCount + judgeServerCount.value = res.judgeServerCount }) async function listRanks() { @@ -169,6 +171,13 @@ watch(showModal, (v) => { 近期比赛:{{ contestCount }} +

+ + + 在线判题机:{{ judgeServerCount }} + +

我猜你要: diff --git a/apps/web/src/admin/tutorial/components/ExerciseManager.vue b/apps/web/src/admin/tutorial/components/ExerciseManager.vue index 74aa1d9..197b6bd 100644 --- a/apps/web/src/admin/tutorial/components/ExerciseManager.vue +++ b/apps/web/src/admin/tutorial/components/ExerciseManager.vue @@ -162,6 +162,15 @@ function splitLines(text: string): string[] { function buildData(): Record | null { if (formType.value === "mcq") { + // 后端 exerciseDataError 也卡这三条,先在这儿报,省一次往返 + if (mcqOptions.value.length < 2) { + message.error("选择题至少要有 2 个选项") + return null + } + if (mcqOptions.value.some((option) => !option.trim())) { + message.error("选项不能留空") + return null + } if (mcqAnswer.value.length === 0) { message.error("请至少勾选一个正确答案") return null @@ -173,12 +182,27 @@ function buildData(): Record | null { } } if (formType.value === "sort") { + const lines = sortCode.value.split("\n").filter((l) => l.trim() !== "") + if (lines.length < 2) { + message.error("排序题至少要有 2 行代码") + return null + } return { question: sortQuestion.value || "将下列代码行排列为正确顺序", - lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""), + lines, } } if (formType.value === "fill") { + if (!fillCode.value.trim()) { + message.error("请填写含空位的代码") + return null + } + // 学生端按 {{...}} 抠空位,没有标记就是一段谁也做不了的代码, + // 而老师这边看不出任何异样 —— 这是这个编辑器以前唯一完全不校验的题型 + if (!/\{\{[^}]+\}\}/.test(fillCode.value)) { + message.error("代码里没有空位,用 {{答案}} 标记,多个合法答案用 | 分隔") + return null + } return { question: fillQuestion.value, code: fillCode.value } } if (formType.value === "match") { diff --git a/apps/web/src/admin/user/generate.vue b/apps/web/src/admin/user/generate.vue index b31abe9..4297d49 100644 --- a/apps/web/src/admin/user/generate.vue +++ b/apps/web/src/admin/user/generate.vue @@ -30,6 +30,11 @@ function generateUsers() { return false } let className = !!prefix.value ? `ks${prefix.value}` : "" + // 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重,而且库里同一个邮箱 + // 出现两次的话,那两个账号在「编辑用户」里保存一次就撞 409,从此改不动。 + // 原来只按「班级 + 批内序号」拼,同一个班分两批导入必然重号 —— 加一段每批随机的 + // 后缀错开。这个地址不发给任何人,纯占位。 + const batch = Math.random().toString(36).slice(2, 8) rawInput.value = rawInput.value.trim() const inputs = rawInput.value.split("\n") users.value = inputs.map((u, i) => { @@ -39,7 +44,7 @@ function generateUsers() { password += "123456789".charAt(Math.floor(Math.random() * 9)) } const realName = u - const email = `${className}.${i + 1}@example.com` + const email = `${className || "user"}.${batch}.${i + 1}@example.com` return [username, password, email, realName] }) return true diff --git a/apps/web/src/admin/user/list.vue b/apps/web/src/admin/user/list.vue index 4a3a988..8573588 100644 --- a/apps/web/src/admin/user/list.vue +++ b/apps/web/src/admin/user/list.vue @@ -178,8 +178,12 @@ function createNewUser() { id: 0, username: "", realName: "", - email: "", - adminType: "Student Admin", + // 预填一个唯一的占位邮箱:这条路走的是导入接口,后端要求邮箱合法且不重复, + // 留空会直接被打回。想填真邮箱就改掉它 + email: `user.${Math.random().toString(36).slice(2, 10)}@example.com`, + // 导入接口写死建成普通用户(后端 POST /admin/users),这里不摆一个建不出来的 + // 角色。要给管理员权限,建完再用「编辑」改 + adminType: "Regular User", problemPermission: "None", createTime: null, lastLogin: null, @@ -204,6 +208,11 @@ function onCloseEditModal() { async function handleEditUser() { if (!userEditing.value) return + // 新建走的是导入接口,密码是必填的(后端 min(1)),留空只会换来一句 400 + if (create.value && !password.value) { + message.error("新建用户必须填写密码") + return + } if (password.value && password.value.length < 6) { message.error("密码长度不得小于 6") return @@ -317,8 +326,14 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers) + - + diff --git a/apps/web/src/utils/download.ts b/apps/web/src/utils/download.ts index 8859724..7e26b90 100644 --- a/apps/web/src/utils/download.ts +++ b/apps/web/src/utils/download.ts @@ -1,4 +1,5 @@ import axios from "axios" +import { createDiscreteApi } from "naive-ui" // 指向新后端的 /api/admin。响应是 zip 二进制,不走 { error, data } 信封, // 所以不能复用 utils/api 的拦截器(它会把 response.data.data 取出来)。 @@ -8,8 +9,36 @@ const http = axios.create({ withCredentials: true, }) +// 脱离 n-message-provider 也能弹,理由同 utils/api.ts +const { message: toast } = createDiscreteApi(["message"]) + +/** + * 失败时把错误信封读出来。`responseType: "blob"` 是给成功路径(zip)设的, + * 出错时 axios 照样把 JSON 信封包成 Blob 交回来,直接 `.error.message` 取不到。 + */ +async function failureMessage(error: unknown) { + const data = (error as { response?: { data?: unknown } }).response?.data + if (data instanceof Blob) { + try { + const payload = JSON.parse(await data.text()) + const message = payload?.error?.message + if (typeof message === "string" && message) return message + } catch { + // 不是 JSON(网关的 HTML 错误页之类),走下面的兜底文案 + } + } + return "下载失败" +} + async function download(url: string) { - const res = await http.get(url) + let res + try { + res = await http.get(url) + } catch (error) { + // 以前这里什么都不接:题目还没传测试点时后端回 404,页面上一点反应都没有 + toast.error(await failureMessage(error)) + return + } const headers = res.headers const link = document.createElement("a") link.href = window.URL.createObjectURL(