fix: 提交列表和删除提交的慢查询,素材上传、登出和几处前端加载问题
性能(6 万条提交、18 万条消息的测试库上测的) - 提交列表 join task_task 时用 +s.task_id,不再从任务表出发经 task_id 单列索引回表读 html/css/js 大行,改走 slim 覆盖索引:翻页 260-700ms → 4-18ms - 删除提交时先查出 id 再按等值删关联行:prompt_message.submission_id 几乎全是 NULL, 写成 IN (子查询) 时统计信息会让 SQLite 扫整张表 bug - 提交列表排序加 rowid 作次级键,同分的提交翻页不再重复或遗漏 - 素材文件名只允许字母数字和 - _ .(否则 422),覆盖同名文件改成写临时文件再 rename; 前端删除时对名字做 URL 编码 - 刷新 token 只是网络失败时不再强制登出;退出请求失败时界面也显示为已退出 - 历史面板加载中收到的刷新不再丢掉;成绩册只用最后一次请求的结果,打开时不再加载两次 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40f3063b91
commit
03878f6cc2
8 files changed
+124
-41
No files matched your search
+13
-3
@@ -60,6 +60,8 @@ http.interceptors.response.use(
|
|||||||
) {
|
) {
|
||||||
err.config._retried = true
|
err.config._retried = true
|
||||||
if (await refreshAccessToken()) return http(err.config)
|
if (await refreshAccessToken()) return http(err.config)
|
||||||
|
// 刷新失败但 refresh token 还在,说明只是网络问题:保留登录,下次请求再试
|
||||||
|
if (hasLogin()) return Promise.reject(err)
|
||||||
}
|
}
|
||||||
switch (err.response.status) {
|
switch (err.response.status) {
|
||||||
case 401: // 未授权
|
case 401: // 未授权
|
||||||
@@ -277,7 +279,7 @@ export const Submission = {
|
|||||||
username?: string
|
username?: string
|
||||||
user_id?: number
|
user_id?: number
|
||||||
flag?: string | null
|
flag?: string | null
|
||||||
zone?: string
|
zone?: string | null
|
||||||
task_id?: number
|
task_id?: number
|
||||||
task_type?: string
|
task_type?: string
|
||||||
score_min?: number
|
score_min?: number
|
||||||
@@ -553,7 +555,11 @@ export const TaskAssets = {
|
|||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
async deleteChallenge(display: number, name: string) {
|
async deleteChallenge(display: number, name: string) {
|
||||||
return (await http.delete(`/assets/challenge/${display}/${name}`)).data
|
return (
|
||||||
|
await http.delete(
|
||||||
|
`/assets/challenge/${display}/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
).data
|
||||||
},
|
},
|
||||||
async listTutorial(display: number): Promise<TaskAsset[]> {
|
async listTutorial(display: number): Promise<TaskAsset[]> {
|
||||||
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
||||||
@@ -573,6 +579,10 @@ export const TaskAssets = {
|
|||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
async deleteTutorial(display: number, name: string) {
|
async deleteTutorial(display: number, name: string) {
|
||||||
return (await http.delete(`/assets/tutorial/${display}/${name}`)).data
|
return (
|
||||||
|
await http.delete(
|
||||||
|
`/assets/tutorial/${display}/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
).data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -210,8 +210,15 @@ function selectItem(item: HistoryViewItem) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载中又要求刷新(比如自动提交了新代码、换了任务),等这次完成后再加载一次
|
||||||
|
let reloadAfter = false
|
||||||
|
|
||||||
async function load(force = true) {
|
async function load(force = true) {
|
||||||
if (!props.taskId || loading.value) return
|
if (!props.taskId) return
|
||||||
|
if (loading.value) {
|
||||||
|
reloadAfter = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!force && loadedTaskId === props.taskId) return
|
if (!force && loadedTaskId === props.taskId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -230,6 +237,10 @@ async function load(force = true) {
|
|||||||
loadedTaskId = props.taskId
|
loadedTaskId = props.taskId
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
if (reloadAfter) {
|
||||||
|
reloadAfter = false
|
||||||
|
load(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,9 +107,13 @@ function handleLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await Account.logout()
|
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
|
||||||
user.username = ""
|
try {
|
||||||
user.role = Role.Normal
|
await Account.logout()
|
||||||
|
} finally {
|
||||||
|
user.username = ""
|
||||||
|
user.role = Role.Normal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -317,20 +317,28 @@ async function loadClasses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速切换班级时只用最后一次请求的结果
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function loadGradebook() {
|
async function loadGradebook() {
|
||||||
|
const seq = ++loadSeq
|
||||||
if (!query.classname) {
|
if (!query.classname) {
|
||||||
gradebook.value = null
|
gradebook.value = null
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ""
|
loadError.value = ""
|
||||||
try {
|
try {
|
||||||
gradebook.value = await Gradebook.get(query)
|
const data = await Gradebook.get(query)
|
||||||
classes.value = gradebook.value.classes
|
if (seq !== loadSeq) return
|
||||||
|
gradebook.value = data
|
||||||
|
classes.value = data.classes
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (seq !== loadSeq) return
|
||||||
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,8 +366,10 @@ watchDebounced(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const before = query.classname
|
||||||
await loadClasses()
|
await loadClasses()
|
||||||
await loadGradebook()
|
// loadClasses 选了默认班级时,上面的 watch 已经会加载
|
||||||
|
if (query.classname === before) loadGradebook()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+22
-23
@@ -1,6 +1,5 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { mkdir, rm } from "node:fs/promises"
|
import { mkdir, rename, rm } from "node:fs/promises"
|
||||||
import { existsSync } from "node:fs"
|
|
||||||
import { dirname, extname, join, resolve, sep } from "node:path"
|
import { dirname, extname, join, resolve, sep } from "node:path"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { all, get, run } from "../db"
|
import { all, get, run } from "../db"
|
||||||
@@ -22,20 +21,6 @@ export function mediaPath(relative: string) {
|
|||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFile(relative: string, file: File) {
|
|
||||||
let target = relative
|
|
||||||
// 同名文件已存在时加随机后缀(Django 存储的行为)
|
|
||||||
while (existsSync(mediaPath(target))) {
|
|
||||||
const ext = extname(relative)
|
|
||||||
const suffix = Math.random().toString(36).slice(2, 9)
|
|
||||||
target = relative.slice(0, relative.length - ext.length) + "_" + suffix + ext
|
|
||||||
}
|
|
||||||
const full = mediaPath(target)
|
|
||||||
await mkdir(dirname(full), { recursive: true })
|
|
||||||
await Bun.write(full, file)
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formFile(c: Ctx, field: string) {
|
async function formFile(c: Ctx, field: string) {
|
||||||
const form = await c.req.formData().catch(() => null)
|
const form = await c.req.formData().catch(() => null)
|
||||||
const value = form?.get(field)
|
const value = form?.get(field)
|
||||||
@@ -88,21 +73,35 @@ for (const kind of ["challenge", "tutorial"] as const) {
|
|||||||
if (typeof name !== "string" || !name) {
|
if (typeof name !== "string" || !name) {
|
||||||
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
|
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
|
||||||
}
|
}
|
||||||
const existing = get<{ id: number; file: string }>(
|
// 学生代码按这个名字引用素材(<base href> 指向素材目录),存储的文件名必须和它一致
|
||||||
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
if (name !== validFilename(name) || name.length > 100) {
|
||||||
|
throw new ValidationError([
|
||||||
|
{ loc: ["body", "name"], msg: "文件名只能包含字母、数字和 - _ .,最多 100 个字符" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const existing = get<{ file: string }>(
|
||||||
|
`SELECT file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
||||||
{ task_id: task.id, name },
|
{ task_id: task.id, name },
|
||||||
)
|
)
|
||||||
if (existing?.file) await rm(mediaPath(existing.file), { force: true })
|
// 先写临时文件再 rename 覆盖:写失败时旧文件还在,并发上传同名文件也不会留下坏文件
|
||||||
const stored = await saveFile(
|
const stored = `tasks/${task.task_type}/${task.display}/${name}`
|
||||||
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`,
|
const full = mediaPath(stored)
|
||||||
file,
|
const tmp = `${full}.${crypto.randomUUID()}.uploading`
|
||||||
)
|
await mkdir(dirname(full), { recursive: true })
|
||||||
|
try {
|
||||||
|
await Bun.write(tmp, file)
|
||||||
|
await rename(tmp, full)
|
||||||
|
} finally {
|
||||||
|
await rm(tmp, { force: true })
|
||||||
|
}
|
||||||
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
||||||
run(
|
run(
|
||||||
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
||||||
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
||||||
{ name, file: stored, task_id: task.id },
|
{ name, file: stored, task_id: task.id },
|
||||||
)
|
)
|
||||||
|
// 旧数据里同名素材可能存在带随机后缀的文件
|
||||||
|
if (existing?.file && existing.file !== stored) await rm(mediaPath(existing.file), { force: true })
|
||||||
return c.json(assetOut({ name, file: stored }))
|
return c.json(assetOut({ name, file: stored }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ const SUBMISSION_SELECT = `SELECT s.id, s.user_id, u.username, s.task_id, t.disp
|
|||||||
s.view_count, s.created, s.modified,
|
s.view_count, s.created, s.modified,
|
||||||
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
|
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
|
||||||
|
|
||||||
|
// +s.task_id:不让 SQLite 从 task_task 出发、用 task_id 单列索引去找提交(那样要回表读
|
||||||
|
// html/css/js 大行,6 万条提交时列表要几百毫秒),改走 slim 覆盖索引;按 task_id 筛选不受影响
|
||||||
const SUBMISSION_FROM = `FROM submission_submission s
|
const SUBMISSION_FROM = `FROM submission_submission s
|
||||||
JOIN account_user u ON u.id = s.user_id
|
JOIN account_user u ON u.id = s.user_id
|
||||||
JOIN task_task t ON t.id = s.task_id`
|
JOIN task_task t ON t.id = +s.task_id`
|
||||||
|
|
||||||
function submissionOut(s: any) {
|
function submissionOut(s: any) {
|
||||||
return {
|
return {
|
||||||
@@ -269,7 +271,8 @@ router.get("/", (c) => {
|
|||||||
score: "s.score ASC",
|
score: "s.score ASC",
|
||||||
"-created": "s.created DESC",
|
"-created": "s.created DESC",
|
||||||
}
|
}
|
||||||
const orderBy = orderings[f.ordering ?? ""] ?? "s.created DESC"
|
// 同分、同一时间的提交很多,加上 rowid 让排序唯一,翻页才不会重复或漏掉
|
||||||
|
const orderBy = `${orderings[f.ordering ?? ""] ?? "s.created DESC"}, s.rowid DESC`
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
|
||||||
const { limit, offset } = pageWindow(f)
|
const { limit, offset } = pageWindow(f)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { run } from "../db"
|
import { all, run } from "../db"
|
||||||
|
|
||||||
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
|
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
|
||||||
// 调用方负责包在事务里。
|
// 调用方负责包在事务里。
|
||||||
|
|
||||||
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
|
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
|
||||||
const ids = `SELECT id FROM submission_submission WHERE ${where}`
|
// 先查出 id 再逐个按等值删:prompt_message.submission_id 绝大多数是 NULL,
|
||||||
run(`DELETE FROM submission_rating WHERE submission_id IN (${ids})`, params)
|
// 统计信息会让 SQLite 觉得这个索引没用,写成 IN (子查询) 时它会扫整张大表(几百毫秒)
|
||||||
run(`DELETE FROM submission_submissionaward WHERE submission_id IN (${ids})`, params)
|
const ids = all<{ id: string }>(`SELECT id FROM submission_submission WHERE ${where}`, params)
|
||||||
run(`DELETE FROM prompt_message WHERE submission_id IN (${ids})`, params)
|
for (const { id } of ids) {
|
||||||
|
run(`DELETE FROM submission_rating WHERE submission_id = $id`, { id })
|
||||||
|
run(`DELETE FROM submission_submissionaward WHERE submission_id = $id`, { id })
|
||||||
|
run(`DELETE FROM prompt_message WHERE submission_id = $id`, { id })
|
||||||
|
}
|
||||||
run(`DELETE FROM submission_submission WHERE ${where}`, params)
|
run(`DELETE FROM submission_submission WHERE ${where}`, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, test } from "bun:test"
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { get, all } from "../src/db"
|
import { get, all } from "../src/db"
|
||||||
|
import { app } from "../src/app"
|
||||||
|
import { config } from "../src/config"
|
||||||
|
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
import { client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
|
import { client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
|
||||||
|
|
||||||
beforeEach(freshDb)
|
beforeEach(freshDb)
|
||||||
@@ -373,3 +378,40 @@ describe("自由创作", () => {
|
|||||||
expect((await me("POST", "/api/submission/", { task_id: c.task_id })).json.detail).toBe("自由创作不需要提交")
|
expect((await me("POST", "/api/submission/", { task_id: c.task_id })).json.detail).toBe("自由创作不需要提交")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("任务素材", () => {
|
||||||
|
test("同名上传原地覆盖,文件名不合法 422", async () => {
|
||||||
|
const media = await mkdtemp(join(tmpdir(), "media-"))
|
||||||
|
const saved = config.mediaRoot
|
||||||
|
config.mediaRoot = media
|
||||||
|
try {
|
||||||
|
makeUser("adm", "admin")
|
||||||
|
makeTask("挑战", "challenge", 3)
|
||||||
|
const adm = await client("adm")
|
||||||
|
const upload = (name: string, body: string) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("name", name)
|
||||||
|
form.append("file", new File([body], "x"))
|
||||||
|
return app.request("/api/assets/challenge/3", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${adm.tokens.access_token}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect((await upload("a.css", "v1")).status).toBe(200)
|
||||||
|
const res = await upload("a.css", "v2")
|
||||||
|
expect(((await res.json()) as any).url).toBe("/media/tasks/challenge/3/a.css")
|
||||||
|
const dir = join(media, "tasks/challenge/3")
|
||||||
|
expect(await readdir(dir)).toEqual(["a.css"])
|
||||||
|
expect(await readFile(join(dir, "a.css"), "utf8")).toBe("v2")
|
||||||
|
for (const bad of ["a b.png", "../x.png", "a/b.png", "x#.png"]) {
|
||||||
|
expect((await upload(bad, "x")).status).toBe(422)
|
||||||
|
}
|
||||||
|
expect((await adm("DELETE", "/api/assets/challenge/3/a.css")).status).toBe(200)
|
||||||
|
expect(await readdir(dir)).toEqual([])
|
||||||
|
} finally {
|
||||||
|
config.mediaRoot = saved
|
||||||
|
await rm(media, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user