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
56 lines
1.8 KiB
TypeScript
56 lines
1.8 KiB
TypeScript
import axios from "axios"
|
|
import { createDiscreteApi } from "naive-ui"
|
|
|
|
// 指向新后端的 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,
|
|
// 所以不能复用 utils/api 的拦截器(它会把 response.data.data 取出来)。
|
|
const http = axios.create({
|
|
baseURL: "/api/admin",
|
|
responseType: "blob",
|
|
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) {
|
|
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(
|
|
new window.Blob([res.data], {
|
|
type: String(headers["content-type"] ?? ""),
|
|
}),
|
|
)
|
|
link.download = (headers["content-disposition"] || "").split("filename=")[1]
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
link.remove()
|
|
}
|
|
|
|
export default download
|