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

@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { isAbsolute, resolve } from "node:path"
/**
* Bun 只自动加载「当前工作目录」下的 .env。而本应用的启动方式`bun run --filter '@oj2/api' dev`
@@ -34,6 +34,18 @@ loadRepoRootEnv()
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警),
* 也不要在仓库里写死一个人人都知道的弱默认值。
*/
/**
* 相对路径一律按**仓库根**解析,而不是进程 cwd。
*
* 起服务的方式(`bun run --filter '@oj2/api' dev`)会把 cwd 切到 apps/api/
* 于是 "data/test_case" 落在 apps/api/data/ 下 —— 而 docker/compose.dev.yml 把
* 仓库根的 data/test_case 挂进判题沙箱。两边不是同一个目录,新传的测试点判题时
* 会「找不到测试数据」,而且只在真正判题时才暴露。
*/
function repoPath(value: string) {
return isAbsolute(value) ? value : resolve(import.meta.dir, "../../..", value)
}
function judgeServerToken() {
const fromEnv = process.env.JUDGE_SERVER_TOKEN
if (fromEnv) return fromEnv
@@ -54,10 +66,10 @@ export const config = {
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
judgeServerToken: judgeServerToken(),
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
avatarDirectory: process.env.AVATAR_DIRECTORY ?? "data/avatar",
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
// 判题沙箱把这个目录挂成只读的 /test_case两边必须指同一处
testCaseDirectory: process.env.TEST_CASE_DIRECTORY ?? "data/test_case",
uploadDirectory: process.env.UPLOAD_DIRECTORY ?? "data/upload",
testCaseDirectory: repoPath(process.env.TEST_CASE_DIRECTORY ?? "data/test_case"),
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",