后台代码审查后的一批修复。
后端:
- 改密码 / 重置密码 / 禁用账号现在真的把该用户所有设备的会话删掉。原来只
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:
@@ -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)
|
||||
|
||||
@@ -185,7 +185,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
|
||||
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
|
||||
isContestProblemList.value
|
||||
? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
|
||||
? baseColumns.filter((it) => !("key" in it) || it.key !== "topReaction")
|
||||
: [{ type: "selection" }, ...baseColumns],
|
||||
)
|
||||
|
||||
|
||||
@@ -181,6 +181,8 @@ async function getJudgeServerData() {
|
||||
async function delJudgeServer(hostname: string) {
|
||||
await deleteJudgeServer(hostname)
|
||||
message.success("删除成功")
|
||||
// 不刷新的话删掉的那行还挂在表里,得手动刷新页面才消失(批量删除那支一直是刷的)
|
||||
getJudgeServerData()
|
||||
}
|
||||
|
||||
async function deleteAbnormalServers() {
|
||||
|
||||
@@ -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 }}
|
||||
</n-gradient-text>
|
||||
</h2>
|
||||
<h2>
|
||||
<!-- 判题机数量后端一直在下发,这里以前没显示 —— 判题机全掉线的时候,
|
||||
这一栏是 0,比学生喊「交了没反应」早得多 -->
|
||||
<n-gradient-text :type="judgeServerCount > 0 ? 'success' : 'error'">
|
||||
在线判题机:{{ judgeServerCount }}
|
||||
</n-gradient-text>
|
||||
</h2>
|
||||
</n-flex>
|
||||
<n-flex align="center" class="actions">
|
||||
<span>我猜你要:</span>
|
||||
|
||||
@@ -162,6 +162,15 @@ function splitLines(text: string): string[] {
|
||||
|
||||
function buildData(): Record<string, unknown> | 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<string, unknown> | 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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
<n-form-item-gi :span="1" label="真名">
|
||||
<n-input v-model:value="userEditing.realName" />
|
||||
</n-form-item-gi>
|
||||
<!-- 班级是后端从用户名的 ks 数字前缀推出来的(classNameOf),
|
||||
editUser 也不发这个字段,所以这里只读 —— 以前是个能改、改了没用的输入框 -->
|
||||
<n-form-item-gi v-if="!create" :span="1" label="班级">
|
||||
<n-input v-model:value="userEditing.className" />
|
||||
<n-input
|
||||
:value="userEditing.className"
|
||||
disabled
|
||||
placeholder="由用户名自动推导"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="1" label="邮箱">
|
||||
<n-input v-model:value="userEditing.email" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user