后台代码审查后的一批修复。
后端:
- 改密码 / 重置密码 / 禁用账号现在真的把该用户所有设备的会话删掉。原来只
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:
101
apps/api/src/services/exercise.ts
Normal file
101
apps/api/src/services/exercise.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user