feat(阶段4): 测试用例压缩包上传与下载

POST  admin/test-cases
  GET   admin/problems/:id/test-cases   (返回 zip 二进制)

落盘格式必须与判题沙箱镜像的约定一致(沙箱直接读挂进去的目录),已用真判题验证:
上传 zip → 建题 → 提交 Python 解法 → 沙箱读到用例并判出 Accepted。

安全与健壮性上比旧后端多做的几件事:

- **zip slip 从设计上进不来**:不遍历压缩包条目,只按精确文件名(`N.in`/`N.out`/`N.sql`)
  取内容,条目名一律不参与路径拼接。实测带 `../../etc/passwd` 条目的包能正常处理,
  且只取到 1.in/1.out。
- 单文件 32MB、解压后总量 128MB、测试点数 500 的上限,防 zip bomb 与写满磁盘 ——
  旧后端一概没有,机房那台机器盘写满之后判题也会一起挂。
- 坏 zip 返回 400 而不是 500。

对齐旧后端的细节:CRLF→LF 归一;`stripped_output_md5` 按 Python `bytes.rstrip()`
的口径只剥尾部 ASCII 空白后再算(实测与 hashlib.md5 结果一致);编号从 1 起连续、
遇缺口即停;SQL 包至少 2 个测试点(题目页会展示测试点 1 的期望结果,只有一个时
学生可以对照着硬编码 AC);目录 0710、文件 0640。

## 顺带修掉一个只在判题时才暴露的路径 bug

config 里的相对路径(data/test_case、data/avatar、data/upload)原先按进程 cwd 解析,
而起服务的方式会把 cwd 切到 apps/api/,于是测试点落在 apps/api/data/ 下 ——
但 docker/compose.dev.yml 把**仓库根**的 data/test_case 挂进判题沙箱。两边不是同一个
目录,新传的测试点判题时会「找不到测试数据」,且只在真正判题时才暴露。
改成一律按仓库根解析,实测沙箱能看到新传的目录。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:42:54 -06:00
parent e396d78a07
commit cd5dd16f3b
10 changed files with 325 additions and 30 deletions

View File

@@ -6,6 +6,7 @@ import {
createProblemRequestSchema,
makeProblemPublicRequestSchema,
updateProblemRequestSchema,
uploadTestCaseResponseSchema,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -15,6 +16,7 @@ import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { contestStatus } from "../../services/contest"
import { packTestCaseZip, processTestCaseZip, TestCaseError } from "../../services/test-case"
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
export const adminProblemRoutes = new Hono<AppEnv>()
@@ -526,3 +528,44 @@ adminProblemRoutes.post("/contests/:contestId/problems/from-public", requireProb
})
return success(c, await serialize(created), 201)
})
// ---------------------------------------------------------------- 测试用例
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
const form = await c.req.formData().catch(() => null)
const file = form?.get("file")
if (!(file instanceof File)) return failure(c, 400, "invalid-request", "Upload failed")
const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? ""))
try {
const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql })
return success(c, uploadTestCaseResponseSchema.parse({
id: result.testCaseId,
info: result.info,
}), 201)
} catch (error) {
if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message)
console.error("Failed to process test case zip", error)
return failure(c, 500, "test-case-error", "测试点处理失败")
}
})
adminProblemRoutes.get("/problems/:id/test-cases", requireProblemPermission, async (c) => {
const [problem] = await db.select().from(schema.problem)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exists")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exists")
}
try {
const archive = await packTestCaseZip(problem.testCaseId)
return new Response(archive, {
headers: {
"content-type": "application/zip",
"content-disposition": `attachment; filename=problem_${problem.id}_test_cases.zip`,
},
})
} catch (error) {
if (error instanceof TestCaseError) return failure(c, 404, "test-case-not-found", error.message)
throw error
}
})