diff --git a/apps/api/package.json b/apps/api/package.json index 6e915ca..6ba64ce 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "@oj2/contract": "workspace:*", "bullmq": "^6.0.9", "drizzle-orm": "^0.45.2", + "fflate": "^0.8.3", "hono": "^4.0.0", "ioredis": "^6.0.0", "postgres": "^3.4.0", diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index f0fe403..2d4339f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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", diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 9878d42..1f84a25 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -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() @@ -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 + } +}) diff --git a/apps/api/src/services/test-case.ts b/apps/api/src/services/test-case.ts new file mode 100644 index 0000000..014c2c0 --- /dev/null +++ b/apps/api/src/services/test-case.ts @@ -0,0 +1,220 @@ +import { createHash } from "node:crypto" +import { mkdir, chmod, readdir, readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" + +import { unzipSync, zipSync } from "fflate" + +import { config } from "../config" + +/** + * 测试点压缩包的解析与落盘。对齐旧 `problem/views/admin.py:TestCaseZipProcessor`。 + * + * 落盘格式必须与判题沙箱镜像的约定一致 —— 沙箱直接读挂载进去的目录: + * /1.in 1.out 2.in 2.out ... info + * `info` 里 `test_cases` 的键是从 "1" 开始的字符串序号。 + */ + +/** 单个测试点文件上限。机房那台机器盘不大,一个失手的大文件能把判题一起拖挂 */ +const MAX_ENTRY_BYTES = 32 * 1024 * 1024 +/** 解压后总大小上限,防 zip bomb */ +const MAX_TOTAL_BYTES = 128 * 1024 * 1024 +/** 测试点数量上限 */ +const MAX_CASES = 500 + +export class TestCaseError extends Error {} + +export interface TestCaseEntry { + stripped_output_md5: string + input_size: number + output_size: number + input_name: string + output_name: string +} + +/** 等价于 Python 的 bytes.rstrip():只剥尾部 ASCII 空白 */ +function rstrip(buffer: Uint8Array) { + const whitespace = new Set([0x20, 0x09, 0x0a, 0x0d, 0x0b, 0x0c]) + let end = buffer.length + while (end > 0 && whitespace.has(buffer[end - 1]!)) end -= 1 + return buffer.subarray(0, end) +} + +/** CRLF → LF,与旧后端 `content.replace(b"\r\n", b"\n")` 一致 */ +function normalizeNewlines(buffer: Uint8Array) { + const out = new Uint8Array(buffer.length) + let length = 0 + for (let i = 0; i < buffer.length; i += 1) { + if (buffer[i] === 0x0d && buffer[i + 1] === 0x0a) continue + out[length] = buffer[i]! + length += 1 + } + return out.subarray(0, length) +} + +/** + * 从 1 开始找连续编号的测试点,遇到缺口就停。 + * 缺口之后的文件一律忽略 —— 与旧 `filter_name_list` 一致:编号断了说明打包出了问题, + * 沉默地跳过一段比按乱序判题安全。 + */ +function collectPairs(names: Set) { + const pairs: [string, string][] = [] + for (let index = 1; index <= MAX_CASES; index += 1) { + const input = `${index}.in` + const output = `${index}.out` + if (!names.has(input) || !names.has(output)) break + pairs.push([input, output]) + } + return pairs +} + +function collectSqlScripts(names: Set) { + const scripts: string[] = [] + for (let index = 1; index <= MAX_CASES; index += 1) { + const name = `${index}.sql` + if (!names.has(name)) break + scripts.push(name) + } + return scripts +} + +export interface ProcessedTestCase { + testCaseId: string + info: TestCaseEntry[] +} + +export async function processTestCaseZip( + archive: Uint8Array, + options: { sql?: boolean } = {}, +): Promise { + let files: Record + try { + files = unzipSync(archive) + } catch { + throw new TestCaseError("压缩包损坏或不是 zip 格式") + } + + // 只按「精确文件名」取内容,不遍历压缩包里的条目 —— + // 条目名一律不参与路径拼接,zip slip(`../../etc/passwd` 这类条目名)从设计上就进不来。 + const names = new Set(Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name))) + + const selected = options.sql ? collectSqlScripts(names) : collectPairs(names).flat() + if (selected.length === 0) throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点") + if (options.sql && selected.length < 2) { + // 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC + throw new TestCaseError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果") + } + + let total = 0 + const contents = new Map() + for (const name of selected) { + const raw = files[name]! + if (raw.length > MAX_ENTRY_BYTES) { + throw new TestCaseError(`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`) + } + const content = normalizeNewlines(raw) + total += content.length + if (total > MAX_TOTAL_BYTES) { + throw new TestCaseError(`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`) + } + contents.set(name, content) + } + + const testCaseId = randomId() + const directory = resolve(config.testCaseDirectory, testCaseId) + await mkdir(directory, { recursive: true }) + await chmod(directory, 0o710) + + for (const [name, content] of contents) { + await writeFile(resolve(directory, name), content) + await chmod(resolve(directory, name), 0o640) + } + + const info: TestCaseEntry[] = [] + const testCases: Record = {} + if (options.sql) { + // SQL 题:每个 N.sql 是一个测试点的建表+数据脚本,没有期望输出(判题时跑标准答案生成)。 + // output_name 复用同名、md5 置空,以兼容前端的测试点表格。 + selected.forEach((name, index) => { + const entry: TestCaseEntry = { + stripped_output_md5: "", + input_size: contents.get(name)!.length, + output_size: 0, + input_name: name, + output_name: name, + } + info.push(entry) + testCases[String(index + 1)] = entry + }) + } else { + collectPairs(names).forEach(([input, output], index) => { + const outputContent = contents.get(output)! + const entry: TestCaseEntry = { + stripped_output_md5: createHash("md5").update(rstrip(outputContent)).digest("hex"), + input_size: contents.get(input)!.length, + output_size: outputContent.length, + input_name: input, + output_name: output, + } + info.push(entry) + testCases[String(index + 1)] = entry + }) + } + + const payload: Record = { test_cases: testCases } + if (options.sql) payload.sql = true + const infoPath = resolve(directory, "info") + await writeFile(infoPath, JSON.stringify(payload, null, 4), "utf8") + await chmod(infoPath, 0o640) + + return { testCaseId, info } +} + +/** 把一个测试点目录重新打包成 zip 供后台下载 */ +export async function packTestCaseZip(testCaseId: string) { + const directory = resolve(config.testCaseDirectory, testCaseId) + let entries: string[] + try { + entries = await readdir(directory) + } catch { + throw new TestCaseError("Test case does not exists") + } + const names = new Set(entries) + const isSql = await readInfo(testCaseId).then((info) => Boolean(info?.sql)).catch(() => false) + const selected = isSql ? collectSqlScripts(names) : collectPairs(names).flat() + const bundle: Record = {} + for (const name of [...selected, "info"]) { + if (!names.has(name)) continue + bundle[name] = new Uint8Array(await readFile(resolve(directory, name))) + } + return zipSync(bundle) +} + +export async function readInfo(testCaseId: string) { + const path = resolve(config.testCaseDirectory, testCaseId, "info") + try { + return JSON.parse(await readFile(path, "utf8")) as { + sql?: boolean + test_cases?: Record + } + } catch { + return null + } +} + +/** 读回 SQL 测试点的脚本内容,供后台回显 */ +export async function readSqlScripts(testCaseId: string) { + const directory = resolve(config.testCaseDirectory, testCaseId) + const names = collectSqlScripts(new Set(await readdir(directory))) + const scripts: { name: string; content: string }[] = [] + for (const name of names) { + scripts.push({ name, content: await readFile(resolve(directory, name), "utf8") }) + } + return scripts +} + +function randomId() { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join("") +} diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index bfc4179..9cecab5 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -212,7 +212,7 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) { if (options.sql) { form.append("sql", "1") } - return http.post("admin/test_case", form, { + return api2.post("admin/test-cases", form, { headers: { "content-type": "multipart/form-data" }, }) } diff --git a/apps/web/src/admin/problem/components/Actions.vue b/apps/web/src/admin/problem/components/Actions.vue index d958a82..9837e86 100644 --- a/apps/web/src/admin/problem/components/Actions.vue +++ b/apps/web/src/admin/problem/components/Actions.vue @@ -43,7 +43,7 @@ async function handleDeleteProblem() { } function downloads() { - download("test_case?problem_id=" + props.problemID) + download(`problems/${props.problemID}/test-cases`) } function goEdit() { diff --git a/apps/web/src/admin/problem/detail.vue b/apps/web/src/admin/problem/detail.vue index 568e016..af3fdc1 100644 --- a/apps/web/src/admin/problem/detail.vue +++ b/apps/web/src/admin/problem/detail.vue @@ -313,7 +313,7 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) { } function downloadTestcases() { - download("test_case?problem_id=" + problem.value.id) + download(`problems/${problem.value.id}/test-cases`) } // Mermaid 渲染事件处理 diff --git a/apps/web/src/utils/download.ts b/apps/web/src/utils/download.ts index 327e17a..d2f4e5c 100644 --- a/apps/web/src/utils/download.ts +++ b/apps/web/src/utils/download.ts @@ -1,10 +1,11 @@ import axios from "axios" +// 指向新后端的 /api2/admin。响应是 zip 二进制,不走 { error, data } 信封, +// 所以不能复用 utils/api2 的拦截器(它会把 response.data.data 取出来)。 const http = axios.create({ - baseURL: "/api/admin", + baseURL: "/api2/admin", responseType: "blob", - xsrfHeaderName: "X-CSRFToken", - xsrfCookieName: "csrftoken", + withCredentials: true, }) async function download(url: string) { diff --git a/bun.lock b/bun.lock index 3bda099..8e53b6b 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@oj2/contract": "workspace:*", "bullmq": "^6.0.9", "drizzle-orm": "^0.45.2", + "fflate": "^0.8.3", "hono": "^4.0.0", "ioredis": "^6.0.0", "postgres": "^3.4.0", @@ -350,11 +351,11 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "https://registry.npmjs.com/@drizzle-team/brocli/-/brocli-0.10.2.tgz", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + "@emnapi/core": ["@emnapi/core@1.11.3", "https://registry.npmjs.com/@emnapi/core/-/core-1.11.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "https://registry.npmjs.com/@emnapi/runtime/-/runtime-1.11.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "https://registry.npmjs.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], "@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmjs.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], @@ -490,37 +491,37 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "https://registry.npmjs.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmjs.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@node-rs/jieba": ["@node-rs/jieba@2.0.1", "", { "optionalDependencies": { "@node-rs/jieba-android-arm-eabi": "2.0.1", "@node-rs/jieba-android-arm64": "2.0.1", "@node-rs/jieba-darwin-arm64": "2.0.1", "@node-rs/jieba-darwin-x64": "2.0.1", "@node-rs/jieba-freebsd-x64": "2.0.1", "@node-rs/jieba-linux-arm-gnueabihf": "2.0.1", "@node-rs/jieba-linux-arm64-gnu": "2.0.1", "@node-rs/jieba-linux-arm64-musl": "2.0.1", "@node-rs/jieba-linux-x64-gnu": "2.0.1", "@node-rs/jieba-linux-x64-musl": "2.0.1", "@node-rs/jieba-wasm32-wasi": "2.0.1", "@node-rs/jieba-win32-arm64-msvc": "2.0.1", "@node-rs/jieba-win32-ia32-msvc": "2.0.1", "@node-rs/jieba-win32-x64-msvc": "2.0.1" } }, "sha512-tnfzXOMqzVQF2dSKMhPC9HrHzzWmN6KheL/zYtGenhOpq/bCKHJWVASSggEnHlkmHgXGeIJHR2N/IuPzewz1BQ=="], + "@node-rs/jieba": ["@node-rs/jieba@2.0.1", "https://registry.npmjs.com/@node-rs/jieba/-/jieba-2.0.1.tgz", { "optionalDependencies": { "@node-rs/jieba-android-arm-eabi": "2.0.1", "@node-rs/jieba-android-arm64": "2.0.1", "@node-rs/jieba-darwin-arm64": "2.0.1", "@node-rs/jieba-darwin-x64": "2.0.1", "@node-rs/jieba-freebsd-x64": "2.0.1", "@node-rs/jieba-linux-arm-gnueabihf": "2.0.1", "@node-rs/jieba-linux-arm64-gnu": "2.0.1", "@node-rs/jieba-linux-arm64-musl": "2.0.1", "@node-rs/jieba-linux-x64-gnu": "2.0.1", "@node-rs/jieba-linux-x64-musl": "2.0.1", "@node-rs/jieba-wasm32-wasi": "2.0.1", "@node-rs/jieba-win32-arm64-msvc": "2.0.1", "@node-rs/jieba-win32-ia32-msvc": "2.0.1", "@node-rs/jieba-win32-x64-msvc": "2.0.1" } }, "sha512-tnfzXOMqzVQF2dSKMhPC9HrHzzWmN6KheL/zYtGenhOpq/bCKHJWVASSggEnHlkmHgXGeIJHR2N/IuPzewz1BQ=="], - "@node-rs/jieba-android-arm-eabi": ["@node-rs/jieba-android-arm-eabi@2.0.1", "", { "os": "android", "cpu": "arm" }, "sha512-tavsIaxybnlA9tRbJ+oc3NW3zhx0d5rNiCGdpIdGWjflwS7HyeUTVAZmAFDlg58Mc6EjTdVKZH+RolBbAJtgcQ=="], + "@node-rs/jieba-android-arm-eabi": ["@node-rs/jieba-android-arm-eabi@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-android-arm-eabi/-/jieba-android-arm-eabi-2.0.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-tavsIaxybnlA9tRbJ+oc3NW3zhx0d5rNiCGdpIdGWjflwS7HyeUTVAZmAFDlg58Mc6EjTdVKZH+RolBbAJtgcQ=="], - "@node-rs/jieba-android-arm64": ["@node-rs/jieba-android-arm64@2.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-AwdyqKvVNuSDnDq3anUfq+nJ5J/kzXjkfbr/1WY6TfaAlTNuuGVskuQv72/wIx/jn7NoXfm/UPuJrWYG16NC6w=="], + "@node-rs/jieba-android-arm64": ["@node-rs/jieba-android-arm64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-android-arm64/-/jieba-android-arm64-2.0.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-AwdyqKvVNuSDnDq3anUfq+nJ5J/kzXjkfbr/1WY6TfaAlTNuuGVskuQv72/wIx/jn7NoXfm/UPuJrWYG16NC6w=="], - "@node-rs/jieba-darwin-arm64": ["@node-rs/jieba-darwin-arm64@2.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-10+nwGQ6KzXXJlIL/sELA6Fi6m7eJ7xJksBiKuw1kxKUgaJwtVfAG0iqRF+NRQv0Sdq7r3k5ew9K9y0+IYaEcA=="], + "@node-rs/jieba-darwin-arm64": ["@node-rs/jieba-darwin-arm64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-darwin-arm64/-/jieba-darwin-arm64-2.0.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-10+nwGQ6KzXXJlIL/sELA6Fi6m7eJ7xJksBiKuw1kxKUgaJwtVfAG0iqRF+NRQv0Sdq7r3k5ew9K9y0+IYaEcA=="], - "@node-rs/jieba-darwin-x64": ["@node-rs/jieba-darwin-x64@2.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJ5RK0X/uPQa1XRmTvwKSieya+w1IJeiKLw0EekoBFJKybXQdvo8/uqM/8z2eVJ8vQxW9X6K2vkVGFvYQa9dYA=="], + "@node-rs/jieba-darwin-x64": ["@node-rs/jieba-darwin-x64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-darwin-x64/-/jieba-darwin-x64-2.0.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-IJ5RK0X/uPQa1XRmTvwKSieya+w1IJeiKLw0EekoBFJKybXQdvo8/uqM/8z2eVJ8vQxW9X6K2vkVGFvYQa9dYA=="], - "@node-rs/jieba-freebsd-x64": ["@node-rs/jieba-freebsd-x64@2.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yg7vyhqzP2weJu5DJ3q9q4pb0b4GWWRwcv54zK7MSSA6KNJ/uQv2a4R9/qmptLU/fZv14gWuJBEMFdL7y1Dv2w=="], + "@node-rs/jieba-freebsd-x64": ["@node-rs/jieba-freebsd-x64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-freebsd-x64/-/jieba-freebsd-x64-2.0.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-yg7vyhqzP2weJu5DJ3q9q4pb0b4GWWRwcv54zK7MSSA6KNJ/uQv2a4R9/qmptLU/fZv14gWuJBEMFdL7y1Dv2w=="], - "@node-rs/jieba-linux-arm-gnueabihf": ["@node-rs/jieba-linux-arm-gnueabihf@2.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-fxQYunS7w2tv8XV9GigkWJPzHnbcw6tjrUdDu5/qU0FdQVEzGuEYG85DjlNf8lZTDGSUKHBVyAQs7bBIvq8yqg=="], + "@node-rs/jieba-linux-arm-gnueabihf": ["@node-rs/jieba-linux-arm-gnueabihf@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-arm-gnueabihf/-/jieba-linux-arm-gnueabihf-2.0.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-fxQYunS7w2tv8XV9GigkWJPzHnbcw6tjrUdDu5/qU0FdQVEzGuEYG85DjlNf8lZTDGSUKHBVyAQs7bBIvq8yqg=="], - "@node-rs/jieba-linux-arm64-gnu": ["@node-rs/jieba-linux-arm64-gnu@2.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VnLU630hQIyO/fwyxh2vqZi72mO+hXkVUC3jVLPfOAlppinmsGX9N81tpTPUK3840hbV8WLtbYTWN1XodI38eg=="], + "@node-rs/jieba-linux-arm64-gnu": ["@node-rs/jieba-linux-arm64-gnu@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-arm64-gnu/-/jieba-linux-arm64-gnu-2.0.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-VnLU630hQIyO/fwyxh2vqZi72mO+hXkVUC3jVLPfOAlppinmsGX9N81tpTPUK3840hbV8WLtbYTWN1XodI38eg=="], - "@node-rs/jieba-linux-arm64-musl": ["@node-rs/jieba-linux-arm64-musl@2.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-K4EDyNixSLVdTNYnHwD+7I/ytvzpo7tt+vdCLqwQViiek2PMpL/FFRvA39uU2tk99jXIxvkczdxARG20BRZppg=="], + "@node-rs/jieba-linux-arm64-musl": ["@node-rs/jieba-linux-arm64-musl@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-arm64-musl/-/jieba-linux-arm64-musl-2.0.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-K4EDyNixSLVdTNYnHwD+7I/ytvzpo7tt+vdCLqwQViiek2PMpL/FFRvA39uU2tk99jXIxvkczdxARG20BRZppg=="], - "@node-rs/jieba-linux-x64-gnu": ["@node-rs/jieba-linux-x64-gnu@2.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-sq3J6L2ANTE25I9eVFq/nb57OtXcvUIeUD1CTKJxwgTKIVmcB2LyOZpWf20AjHRUfbMER9Klqg5dgyyO+Six+w=="], + "@node-rs/jieba-linux-x64-gnu": ["@node-rs/jieba-linux-x64-gnu@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-x64-gnu/-/jieba-linux-x64-gnu-2.0.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-sq3J6L2ANTE25I9eVFq/nb57OtXcvUIeUD1CTKJxwgTKIVmcB2LyOZpWf20AjHRUfbMER9Klqg5dgyyO+Six+w=="], - "@node-rs/jieba-linux-x64-musl": ["@node-rs/jieba-linux-x64-musl@2.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0zfP9Qy68yEXrhBFknfhF6WUJDPU/8eRuyIrkMGdMjfRpxhpSbr2fMfnsqhOQLvhuK4w3iDFvTy4t5d0s6JKMA=="], + "@node-rs/jieba-linux-x64-musl": ["@node-rs/jieba-linux-x64-musl@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-x64-musl/-/jieba-linux-x64-musl-2.0.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-0zfP9Qy68yEXrhBFknfhF6WUJDPU/8eRuyIrkMGdMjfRpxhpSbr2fMfnsqhOQLvhuK4w3iDFvTy4t5d0s6JKMA=="], - "@node-rs/jieba-wasm32-wasi": ["@node-rs/jieba-wasm32-wasi@2.0.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.5" }, "cpu": "none" }, "sha512-7I5rJya5rlQNJIhv8PvPzIVT1/gVc0vFzHmlfRGwCPGDJ3tHVxkSPW34dDx3OgDmbIeadNpmgIyC1RaS9djPJg=="], + "@node-rs/jieba-wasm32-wasi": ["@node-rs/jieba-wasm32-wasi@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-wasm32-wasi/-/jieba-wasm32-wasi-2.0.1.tgz", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.5" }, "cpu": "none" }, "sha512-7I5rJya5rlQNJIhv8PvPzIVT1/gVc0vFzHmlfRGwCPGDJ3tHVxkSPW34dDx3OgDmbIeadNpmgIyC1RaS9djPJg=="], - "@node-rs/jieba-win32-arm64-msvc": ["@node-rs/jieba-win32-arm64-msvc@2.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Aj/2EwYSaPgAbKnSl+vKM/2kOaZNMZWnShiZzbSNyzlLy3eIOyOYVLbYRDno4547KngRxer8uzROhIQIwXwkvw=="], + "@node-rs/jieba-win32-arm64-msvc": ["@node-rs/jieba-win32-arm64-msvc@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-win32-arm64-msvc/-/jieba-win32-arm64-msvc-2.0.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Aj/2EwYSaPgAbKnSl+vKM/2kOaZNMZWnShiZzbSNyzlLy3eIOyOYVLbYRDno4547KngRxer8uzROhIQIwXwkvw=="], - "@node-rs/jieba-win32-ia32-msvc": ["@node-rs/jieba-win32-ia32-msvc@2.0.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-tpJt3uuBlGrcOInQLTYvcgamQgfadl5cwExLYU+CX9rXKpXLDO31dIujUDBgNWoiQq3tOiU1/AKbT7ZdNd4lBQ=="], + "@node-rs/jieba-win32-ia32-msvc": ["@node-rs/jieba-win32-ia32-msvc@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-win32-ia32-msvc/-/jieba-win32-ia32-msvc-2.0.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-tpJt3uuBlGrcOInQLTYvcgamQgfadl5cwExLYU+CX9rXKpXLDO31dIujUDBgNWoiQq3tOiU1/AKbT7ZdNd4lBQ=="], - "@node-rs/jieba-win32-x64-msvc": ["@node-rs/jieba-win32-x64-msvc@2.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-LDOyo2/2CO8UnpSGLJdgqtH8mOnsABPhNxkfIky7UT9cyLEzOaU44nbA5YzPGpBI3qzMbWcwJYQsjBcgK2VqAg=="], + "@node-rs/jieba-win32-x64-msvc": ["@node-rs/jieba-win32-x64-msvc@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-win32-x64-msvc/-/jieba-win32-x64-msvc-2.0.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-LDOyo2/2CO8UnpSGLJdgqtH8mOnsABPhNxkfIky7UT9cyLEzOaU44nbA5YzPGpBI3qzMbWcwJYQsjBcgK2VqAg=="], "@oj2/api": ["@oj2/api@workspace:apps/api"], @@ -562,7 +563,7 @@ "@transloadit/prettier-bytes": ["@transloadit/prettier-bytes@0.3.5", "https://registry.npmjs.com/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", {}, "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "https://registry.npmjs.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 138a2be..ad5c6a1 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -586,3 +586,20 @@ export const addContestProblemRequestSchema = z.object({ problemId: z.number().int().positive(), displayId: z.string().trim().min(1).max(32), }) + +export const testCaseEntrySchema = z.object({ + stripped_output_md5: z.string(), + input_size: z.number().int(), + output_size: z.number().int(), + input_name: z.string(), + output_name: z.string(), +}) + +/** + * 测试点上传的返回。字段名保持 snake_case —— 它会被原样存进 problem.test_case_score + * 和落盘的 info 文件,而判题沙箱读的就是这套键名,改成 camelCase 会判不了题。 + */ +export const uploadTestCaseResponseSchema = z.object({ + id: z.string(), + info: z.array(testCaseEntrySchema), +})