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:
@@ -18,6 +18,7 @@
|
|||||||
"@oj2/contract": "workspace:*",
|
"@oj2/contract": "workspace:*",
|
||||||
"bullmq": "^6.0.9",
|
"bullmq": "^6.0.9",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"hono": "^4.0.0",
|
"hono": "^4.0.0",
|
||||||
"ioredis": "^6.0.0",
|
"ioredis": "^6.0.0",
|
||||||
"postgres": "^3.4.0",
|
"postgres": "^3.4.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto"
|
import { randomBytes } from "node:crypto"
|
||||||
import { readFileSync } from "node:fs"
|
import { readFileSync } from "node:fs"
|
||||||
import { resolve } from "node:path"
|
import { isAbsolute, resolve } from "node:path"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bun 只自动加载「当前工作目录」下的 .env。而本应用的启动方式(`bun run --filter '@oj2/api' dev`)
|
* Bun 只自动加载「当前工作目录」下的 .env。而本应用的启动方式(`bun run --filter '@oj2/api' dev`)
|
||||||
@@ -34,6 +34,18 @@ loadRepoRootEnv()
|
|||||||
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警),
|
* 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() {
|
function judgeServerToken() {
|
||||||
const fromEnv = process.env.JUDGE_SERVER_TOKEN
|
const fromEnv = process.env.JUDGE_SERVER_TOKEN
|
||||||
if (fromEnv) return fromEnv
|
if (fromEnv) return fromEnv
|
||||||
@@ -54,10 +66,10 @@ export const config = {
|
|||||||
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
||||||
judgeServerToken: judgeServerToken(),
|
judgeServerToken: judgeServerToken(),
|
||||||
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
||||||
avatarDirectory: process.env.AVATAR_DIRECTORY ?? "data/avatar",
|
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
|
||||||
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
|
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
|
||||||
testCaseDirectory: process.env.TEST_CASE_DIRECTORY ?? "data/test_case",
|
testCaseDirectory: repoPath(process.env.TEST_CASE_DIRECTORY ?? "data/test_case"),
|
||||||
uploadDirectory: process.env.UPLOAD_DIRECTORY ?? "data/upload",
|
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
|
||||||
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
|
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
|
||||||
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
||||||
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createProblemRequestSchema,
|
createProblemRequestSchema,
|
||||||
makeProblemPublicRequestSchema,
|
makeProblemPublicRequestSchema,
|
||||||
updateProblemRequestSchema,
|
updateProblemRequestSchema,
|
||||||
|
uploadTestCaseResponseSchema,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
|
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -15,6 +16,7 @@ import type { AuthUser } from "../../auth/session"
|
|||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
import { contestStatus } from "../../services/contest"
|
import { contestStatus } from "../../services/contest"
|
||||||
|
import { packTestCaseZip, processTestCaseZip, TestCaseError } from "../../services/test-case"
|
||||||
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
|
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
|
||||||
|
|
||||||
export const adminProblemRoutes = new Hono<AppEnv>()
|
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)
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
220
apps/api/src/services/test-case.ts
Normal file
220
apps/api/src/services/test-case.ts
Normal file
@@ -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`。
|
||||||
|
*
|
||||||
|
* 落盘格式必须与判题沙箱镜像的约定一致 —— 沙箱直接读挂载进去的目录:
|
||||||
|
* <test_case_id>/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<string>) {
|
||||||
|
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<string>) {
|
||||||
|
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<ProcessedTestCase> {
|
||||||
|
let files: Record<string, Uint8Array>
|
||||||
|
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<string, Uint8Array>()
|
||||||
|
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<string, TestCaseEntry> = {}
|
||||||
|
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<string, unknown> = { 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<string, Uint8Array> = {}
|
||||||
|
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<string, TestCaseEntry>
|
||||||
|
}
|
||||||
|
} 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("")
|
||||||
|
}
|
||||||
@@ -212,7 +212,7 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
|
|||||||
if (options.sql) {
|
if (options.sql) {
|
||||||
form.append("sql", "1")
|
form.append("sql", "1")
|
||||||
}
|
}
|
||||||
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
return api2.post<TestcaseUploadedReturns>("admin/test-cases", form, {
|
||||||
headers: { "content-type": "multipart/form-data" },
|
headers: { "content-type": "multipart/form-data" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async function handleDeleteProblem() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function downloads() {
|
function downloads() {
|
||||||
download("test_case?problem_id=" + props.problemID)
|
download(`problems/${props.problemID}/test-cases`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goEdit() {
|
function goEdit() {
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function downloadTestcases() {
|
function downloadTestcases() {
|
||||||
download("test_case?problem_id=" + problem.value.id)
|
download(`problems/${problem.value.id}/test-cases`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mermaid 渲染事件处理
|
// Mermaid 渲染事件处理
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
|
|
||||||
|
// 指向新后端的 /api2/admin。响应是 zip 二进制,不走 { error, data } 信封,
|
||||||
|
// 所以不能复用 utils/api2 的拦截器(它会把 response.data.data 取出来)。
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
baseURL: "/api/admin",
|
baseURL: "/api2/admin",
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
xsrfHeaderName: "X-CSRFToken",
|
withCredentials: true,
|
||||||
xsrfCookieName: "csrftoken",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function download(url: string) {
|
async function download(url: string) {
|
||||||
|
|||||||
41
bun.lock
41
bun.lock
@@ -17,6 +17,7 @@
|
|||||||
"@oj2/contract": "workspace:*",
|
"@oj2/contract": "workspace:*",
|
||||||
"bullmq": "^6.0.9",
|
"bullmq": "^6.0.9",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"hono": "^4.0.0",
|
"hono": "^4.0.0",
|
||||||
"ioredis": "^6.0.0",
|
"ioredis": "^6.0.0",
|
||||||
"postgres": "^3.4.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=="],
|
"@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=="],
|
"@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=="],
|
"@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"],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -586,3 +586,20 @@ export const addContestProblemRequestSchema = z.object({
|
|||||||
problemId: z.number().int().positive(),
|
problemId: z.number().int().positive(),
|
||||||
displayId: z.string().trim().min(1).max(32),
|
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),
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user