From eb32676de493008f18abedab4afa0bdee2b78720 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 29 Sep 2026 08:09:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E7=9B=AE):=20=E3=80=8C=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=BE=8B=E5=AD=90=E3=80=8D=E3=80=8C=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E3=80=8D=E6=94=B9=E7=94=A8=E5=92=8C=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=90=8C=E4=B8=80=E5=8F=B0=E5=88=A4=E9=A2=98=E6=9C=BA?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=BE=8B=E5=AD=90=E4=B8=8A=E8=B7=91=E5=87=BA?= =?UTF-8?q?=E4=BB=80=E4=B9=88=E6=A0=B7=EF=BC=8C=E6=8F=90=E4=BA=A4=E5=9C=A8?= =?UTF-8?q?=E8=BF=99=E5=87=A0=E4=B8=AA=E4=BE=8B=E5=AD=90=E4=B8=8A=E5=B0=B1?= =?UTF-8?q?=E6=98=AF=E4=BB=80=E4=B9=88=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来试运行走另一台 Judge0:C 是 GCC 9、编译参数也不一样,Python 的语法错误回来的是运行错误, 没有题目的时间内存限制和代码模板,例子上的结果常和提交对不上。现在走本站判题机 (POST /trial-runs),同一套编译命令、这道题的限制和模板,对不对也由判题机比。 - 不落库、不算提交、不进判题队列:api 进程里自己排队,全站同时 TRIAL_CONCURRENCY 批 (服务器 2、机房 4),每人最多两批,排满或等超过 30 秒就请学生过一会儿再点 - 死循环不停打印时判题机回包会很大,超过 16MB 就掐断,告诉学生输出太多、停不下来 - Python 语法错误用和提交一样的说明:第几行、中文标点标红、一键换成英文 - 运行时错误和提交用同一个解析,说清楚第几行、为什么(下标越界、int() 转换失败、段错误……); 比赛里照旧只给按结果码的那一句 - 语法没过、服务没跑成时,标题不再说「N 个例子没对上」 - 后台「生成测试点」一并迁过来,编译警告不会再写进 .out;Judge0 的地址和调用全部删掉 Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + apps/api/src/config.ts | 3 + apps/api/src/index.ts | 2 + apps/api/src/judge/run.ts | 29 ++- apps/api/src/judge/trial.ts | 188 ++++++++++++++++++ apps/api/src/routes/trial-run.ts | 110 ++++++++++ apps/web/.env.production | 1 - apps/web/.env.staging | 1 - apps/web/.env.test | 1 - apps/web/CLAUDE.md | 1 - .../problem/components/TestcaseGenerator.vue | 59 ++++-- apps/web/src/env.d.ts | 1 - .../src/oj/problem/components/CustomRun.vue | 17 +- .../oj/problem/components/EditorToolbar.vue | 4 +- .../oj/problem/components/MobileActionBar.vue | 2 +- .../src/oj/problem/components/ResultPane.vue | 2 +- .../oj/problem/components/SampleRunResult.vue | 45 ++++- .../oj/problem/components/TrialErrorNote.vue | 57 +++++- .../src/oj/problem/composables/useTrialRun.ts | 179 +++++++++++------ apps/web/src/oj/store/submission.ts | 12 +- apps/web/src/utils/judge.ts | 60 ++---- docker/.env.example | 3 + docker/compose.debian.yml | 1 + docker/compose.school.yml | 1 + ...2026-09-28-problem-page-redesign-design.md | 12 +- packages/contract/src/language.ts | 7 + packages/contract/src/submission.ts | 60 +++++- 27 files changed, 691 insertions(+), 169 deletions(-) create mode 100644 apps/api/src/judge/trial.ts create mode 100644 apps/api/src/routes/trial-run.ts diff --git a/.env.example b/.env.example index ec27bb7..d795880 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ JUDGE_SERVER_URL=http://localhost:8081 # 留空的话后端会随机生成一个并在启动日志里告警,判题机心跳会被 403 挡掉。 JUDGE_SERVER_TOKEN= JUDGE_CONCURRENCY=2 +# 试运行(运行例子 / 自己输入)同时在判题机上跑几批,和正式提交共用判题机 +TRIAL_CONCURRENCY=2 # WebSocket 升级额外放行的来源,逗号分隔的完整 origin。 # 同源本来就放行,本机开发(Vite 5173 → API 3000)也自动放行,一般不用配。 # 只有前后端分处不同域名时才需要,例如 ALLOWED_WS_ORIGINS=https://oj.example.com diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 2f1f842..4f8cf74 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -73,6 +73,7 @@ const envSchema = z.object({ ? z.preprocess(blankAsUnset, z.string({ error: "编译形态下必须设置" })) : z.preprocess(blankAsUnset, z.string().optional()), JUDGE_CONCURRENCY: positiveInt(2), + TRIAL_CONCURRENCY: positiveInt(2), AVATAR_DIRECTORY: text("data/avatar"), TEST_CASE_DIRECTORY: text("data/test_case"), UPLOAD_DIRECTORY: text("data/upload"), @@ -143,6 +144,8 @@ export const config = { judgeServerUrl: env.JUDGE_SERVER_URL, judgeServerToken: judgeServerToken(), judgeConcurrency: env.JUDGE_CONCURRENCY, + /** 试运行(运行例子 / 自己输入)同时在判题机上跑几批,见 judge/trial.ts */ + trialConcurrency: env.TRIAL_CONCURRENCY, avatarDirectory: repoPath(env.AVATAR_DIRECTORY), // 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处 testCaseDirectory: repoPath(env.TEST_CASE_DIRECTORY), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d277d0d..51f5b12 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,6 +16,7 @@ import { aiRoutes } from "./routes/ai" import { flowchartRoutes } from "./routes/flowchart" import { problemRoutes } from "./routes/problem" import { submissionRoutes } from "./routes/submission" +import { trialRunRoutes } from "./routes/trial-run" import { siteRoutes } from "./routes/site" import { bridgeSubmissionEvents, @@ -40,6 +41,7 @@ app.route("/api", aiRoutes) app.route("/api", flowchartRoutes) app.route("/api", problemRoutes) app.route("/api", submissionRoutes) +app.route("/api", trialRunRoutes) app.route("/api", judgeServerRoutes) app.route("/api/admin", adminRoutes) diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index e2aa5b5..1e97f6e 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -25,7 +25,7 @@ import { readInfo } from "../services/test-case" import { readFile } from "node:fs/promises" import { resolve as resolvePath } from "node:path" -interface JudgeCase { +export interface JudgeCase { cpu_time: number memory: number result: number @@ -35,17 +35,17 @@ interface JudgeCase { [key: string]: unknown } -interface JudgeResponse { +export interface JudgeResponse { err: string | null data: JudgeCase[] | unknown } -function statusValue(value: number): JudgeStatusValue { +export function statusValue(value: number): JudgeStatusValue { const statuses = new Set(Object.values(JudgeStatus)) return statuses.has(value) ? (value as JudgeStatusValue) : JudgeStatus.SYSTEM_ERROR } -function templateForLanguage(value: unknown, language: string) { +export function templateForLanguage(value: unknown, language: string) { const template = asRecord(value)[language] return typeof template === "string" ? template : null } @@ -67,16 +67,18 @@ function astRulesForLanguage(value: unknown, language: string): AstRule[] { /** * `testCase` 是测试点目录名(正常判题),或者内联的测试点(诊断重跑,见 - * diagnoseRuntimeError)。`output` 只在诊断时打开:它让判题机把每个测试点的 + * diagnoseRuntimeError)。`output` 只在诊断和试运行时打开:它让判题机把每个测试点的 * 程序输出原样带回来,正常判题开着的话,死循环打印能把 worker 内存撑爆。 + * + * 只发请求、不读响应体:试运行(judge/trial.ts)要边读边数字节,超了就掐断。 */ -async function requestJudge( +export async function postJudge( language: string, code: string, timeLimit: number, memoryLimit: number, testCase: string | { input: string; output: string }[], - output = false, + { output = false, signal }: { output?: boolean; signal?: AbortSignal } = {}, ) { const languageConfig = judgeConfigFor(language) if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`) @@ -84,6 +86,7 @@ async function requestJudge( const token = createHash("sha256").update(config.judgeServerToken).digest("hex") const response = await fetch(new URL("/judge", config.judgeServerUrl), { method: "POST", + signal, headers: { "content-type": "application/json", "X-Judge-Server-Token": token, @@ -106,6 +109,18 @@ async function requestJudge( if (!response.ok) { throw new Error(`JudgeServer returned HTTP ${response.status}`) } + return response +} + +async function requestJudge( + language: string, + code: string, + timeLimit: number, + memoryLimit: number, + testCase: string | { input: string; output: string }[], + output = false, +) { + const response = await postJudge(language, code, timeLimit, memoryLimit, testCase, { output }) return (await response.json()) as JudgeResponse } diff --git a/apps/api/src/judge/trial.ts b/apps/api/src/judge/trial.ts new file mode 100644 index 0000000..1ed27ee --- /dev/null +++ b/apps/api/src/judge/trial.ts @@ -0,0 +1,188 @@ +import type { TrialRunResponse } from "@oj2/contract" + +import { config } from "../config" +import { postJudge, statusValue, type JudgeCase, type JudgeResponse } from "./run" +import { nativeRuntimeError, parsePythonTraceback } from "./runtime-diagnosis" +import { JudgeStatus, type JudgeStatusValue } from "./status" + +/** + * 试运行:「运行例子」「自己输入」、后台「生成测试点」。 + * + * 原来走的是另一台 Judge0(C 是 GCC 9、编译参数也不一样),例子上的结果偶尔和提交对不上。 + * 现在和提交用同一台判题机、同一套编译命令(languages.ts),但**不进 BullMQ**: + * 学生在页面上等着看结果,排进判题队列要和正式提交抢那两个 worker。 + * + * 判题机是和正式提交共用的,所以这里自己排一道队: + * - 全站同时最多 `TRIAL_CONCURRENCY` 批在判题机上跑,其余按先来后到等; + * - 一个人同时最多两批(「运行例子」和「自己输入」可以同时点),再多直接拒; + * - 等的人太多、或者等得太久,也直接拒,让学生过一会儿再点,别无限挂着。 + * + * 队列在进程内:api 只有一个进程,机房和服务器各有各的 api 和判题机, + * 各排各的正好。 + */ + +const MAX_WAITING = 60 +const WAIT_TIMEOUT_MS = 30_000 +const PER_USER_LIMIT = 2 +/** 判题机本身有编译、运行的限时,这里只防它卡死不回 */ +const JUDGE_TIMEOUT_MS = 60_000 + +/** + * 判题机回包的总上限。判题机给每组输出留 16MB(见镜像里 judge_client.py 的 + * max_output_size),死循环打印能把每组都写满;api 容器只有 512MB,十组全满 + * 读进来再 JSON.parse 一遍就是好几百 MB。超了就掐断,告诉学生输出太多了。 + */ +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 + +/** 回给学生的每组输出最多这么长。管理员生成测试点要原样的输出,不截 */ +const STUDENT_OUTPUT_LIMIT = 64 * 1024 + +export class TrialBusyError extends Error {} +export class TrialUserLimitError extends Error {} + +let active = 0 +const waiting: (() => void)[] = [] +const perUser = new Map() + +function release() { + const next = waiting.shift() + if (next) next() + else active-- +} + +async function acquire() { + if (active < config.trialConcurrency) { + active++ + return + } + if (waiting.length >= MAX_WAITING) throw new TrialBusyError() + await new Promise((resolve, reject) => { + const wake = () => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + const index = waiting.indexOf(wake) + if (index >= 0) waiting.splice(index, 1) + reject(new TrialBusyError()) + }, WAIT_TIMEOUT_MS) + waiting.push(wake) + }) + // 被 release() 叫醒时名额是直接转交过来的,active 不用再加 +} + +/** 边读边数,超过上限就掐断连接,返回 null */ +async function readCapped(response: Response, limit: number) { + if (!response.body) return "" + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > limit) { + await reader.cancel() + return null + } + chunks.push(value) + } + return Buffer.concat(chunks).toString("utf8") +} + +function truncate(text: string) { + return text.length > STUDENT_OUTPUT_LIMIT + ? `${text.slice(0, STUDENT_OUTPUT_LIMIT)}\n……(输出太长,后面的没有显示)` + : text +} + +export async function runTrial(options: { + userId: number + language: string + /** 送去判题机的完整代码(套过题目模板) */ + source: string + /** 学生自己写的那段,和模板拼在前面的行数:运行时错误的行号要扣掉模板才对得上编辑器 */ + code: string + prependLines: number + timeLimit: number + memoryLimit: number + cases: { input: string; output?: string }[] + /** 管理员生成测试点要完整输出 */ + fullOutput: boolean +}): Promise { + const inFlight = perUser.get(options.userId) ?? 0 + if (inFlight >= PER_USER_LIMIT) throw new TrialUserLimitError() + perUser.set(options.userId, inFlight + 1) + try { + await acquire() + try { + return await judgeTrial(options) + } finally { + release() + } + } finally { + const left = (perUser.get(options.userId) ?? 1) - 1 + if (left > 0) perUser.set(options.userId, left) + else perUser.delete(options.userId) + } +} + +async function judgeTrial(options: Parameters[0]): Promise { + const response = await postJudge( + options.language, + options.source, + options.timeLimit, + options.memoryLimit, + // 输入补结尾换行,和 checkSamples 一样:题面上的例子常常没有,测试点文件都有 + options.cases.map((item) => ({ + input: item.input.endsWith("\n") || item.input === "" ? item.input : `${item.input}\n`, + output: item.output ?? "", + })), + { output: true, signal: AbortSignal.timeout(JUDGE_TIMEOUT_MS) }, + ) + const body = await readCapped(response, MAX_RESPONSE_BYTES) + if (body === null) return { status: "too-much-output" } + + const parsed = JSON.parse(body) as JudgeResponse + if (parsed.err) { + if (parsed.err === "CompileError") { + return { + status: "compile-error", + message: typeof parsed.data === "string" ? parsed.data : "", + } + } + throw new Error(`JudgeServer error ${parsed.err}: ${JSON.stringify(parsed.data)}`) + } + if (!Array.isArray(parsed.data)) throw new Error("JudgeServer returned an invalid result payload") + + const byIndex = new Map( + (parsed.data as JudgeCase[]).map((item) => [Number(item.test_case), item]), + ) + return { + status: "done", + cases: options.cases.map((item, index) => { + // 判题机给内联测试点编号是从 1 起的(server.py 里 `index += 1`) + const judged = byIndex.get(index + 1) + if (!judged) return { result: JudgeStatus.SYSTEM_ERROR, output: "" } + let result: JudgeStatusValue = statusValue(judged.result) + // 没给正确输出的组,判题机拿空串比,跑通了也是答案错误 —— 这里只看有没有出错 + if (item.output === undefined && result === JudgeStatus.WRONG_ANSWER) { + result = JudgeStatus.ACCEPTED + } + const output = typeof judged.output === "string" ? judged.output : "" + // 运行时错误和提交用同一个解析(judge/runtime-diagnosis.ts):第几行、什么异常。 + // 提交那边要重跑一次才拿得到回溯,这里 output 本来就开着,直接解析 + const runtimeError = + result === JudgeStatus.RUNTIME_ERROR + ? options.language === "Python" + ? parsePythonTraceback(output, options.code, options.prependLines) + : nativeRuntimeError(judged.signal, judged.exit_code) + : null + return { + result, + output: options.fullOutput ? output : truncate(output), + ...(runtimeError ? { runtimeError } : {}), + } + }), + } +} diff --git a/apps/api/src/routes/trial-run.ts b/apps/api/src/routes/trial-run.ts new file mode 100644 index 0000000..b8a8672 --- /dev/null +++ b/apps/api/src/routes/trial-run.ts @@ -0,0 +1,110 @@ +import { TRIAL_MAX_CASES, trialRunRequestSchema, type TrialRunResponse } from "@oj2/contract" +import { and, eq, isNull } from "drizzle-orm" +import { Hono } from "hono" + +import { requireAuth, type AppEnv } from "../auth/middleware" +import { db, schema } from "../db" +import { failure, parseBody, success } from "../http" +import { templateForLanguage } from "../judge/run" +import { parseProblemTemplate } from "../judge/template" +import { runTrial, TrialBusyError, TrialUserLimitError } from "../judge/trial" +import { canAccessContest, findAccessibleContest } from "../services/contest" +import { isAdminRole } from "./helpers" + +export const trialRunRoutes = new Hono() + +/** 后台生成测试点时题目可能还没存,没有题目的限制可用,按这两个跑 */ +const ADMIN_TIME_LIMIT = 3000 +const ADMIN_MEMORY_LIMIT = 256 + +/** + * 试运行,见 judge/trial.ts。不落库、不算提交、不走令牌桶 —— + * 令牌桶是提交的,试运行扣它的话学生多点几次「运行例子」就交不上了; + * 这里的闸是 trial.ts 里的排队和每人两批的上限。 + */ +trialRunRoutes.post("/trial-runs", requireAuth, async (c) => { + const parsed = await parseBody(c, trialRunRequestSchema, "试运行的参数不对") + if (!parsed.success) return parsed.response + const body = parsed.data + const user = c.get("user")! + const admin = isAdminRole(user) + + if (!admin && body.cases.length > TRIAL_MAX_CASES) { + return failure(c, 400, "too-many-cases", `一次最多试运行 ${TRIAL_MAX_CASES} 组`) + } + + let timeLimit = ADMIN_TIME_LIMIT + let memoryLimit = ADMIN_MEMORY_LIMIT + let source = body.code + let prependLines = 0 + if (body.problemId === undefined) { + if (!admin) return failure(c, 403, "permission-denied", "请从题目页试运行") + } else { + let contestId: number | null = null + if (body.contestId) { + const contest = await findAccessibleContest(user, body.contestId) + if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在") + const access = await canAccessContest(c, contest, "problems") + if (!access.ok) return failure(c, 403, access.code, access.message) + contestId = contest.id + } + const [problem] = await db + .select({ + languages: schema.problem.languages, + timeLimit: schema.problem.timeLimit, + memoryLimit: schema.problem.memoryLimit, + template: schema.problem.template, + visible: schema.problem.visible, + }) + .from(schema.problem) + .where( + and( + eq(schema.problem.id, body.problemId), + contestId === null + ? isNull(schema.problem.contestId) + : eq(schema.problem.contestId, contestId), + ), + ) + .limit(1) + // 管理员在后台预览没公开的题,也要能试运行 + if (!problem || (!problem.visible && !admin)) { + return failure(c, 404, "problem-not-found", "题目不存在") + } + if (!problem.languages.includes(body.language)) { + return failure(c, 400, "language-not-allowed", `这道题不能用 ${body.language}`) + } + timeLimit = problem.timeLimit + memoryLimit = problem.memoryLimit + // 和提交一样套上题目的代码模板(judgeSubmission),不然有模板的题试运行和提交跑的不是同一份代码 + const rawTemplate = templateForLanguage(problem.template, body.language) + const template = rawTemplate ? parseProblemTemplate(rawTemplate) : null + if (template) { + source = `${template.prepend}\n${body.code}\n${template.append}` + prependLines = template.prepend.split("\n").length + } + } + + try { + const result = await runTrial({ + userId: user.id, + language: body.language, + source, + code: body.code, + prependLines, + timeLimit, + memoryLimit, + cases: body.cases, + fullOutput: admin, + }) + return success(c, result satisfies TrialRunResponse) + } catch (error) { + if (error instanceof TrialUserLimitError) { + return failure(c, 429, "trial-in-progress", "上一次还没跑完,稍等一下") + } + if (error instanceof TrialBusyError) { + return failure(c, 429, "trial-busy", "现在试运行的人太多了,过一会儿再点") + } + console.error("Trial run failed", error) + return failure(c, 502, "judge-unavailable", "判题机暂时连不上") + } +}) diff --git a/apps/web/.env.production b/apps/web/.env.production index 33bf519..b6913ab 100644 --- a/apps/web/.env.production +++ b/apps/web/.env.production @@ -1,5 +1,4 @@ PUBLIC_ENV=xuyue.cc PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=dd37457027c40b39 PUBLIC_CODE_URL=https://code.xuyue.cc -PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc PUBLIC_ICONIFY_URL=https://icon.xuyue.cc \ No newline at end of file diff --git a/apps/web/.env.staging b/apps/web/.env.staging index 05f4ef6..a913ce6 100644 --- a/apps/web/.env.staging +++ b/apps/web/.env.staging @@ -1,5 +1,4 @@ PUBLIC_ENV=school PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39 PUBLIC_CODE_URL=http://10.13.114.114:82 -PUBLIC_JUDGE0_URL=http://10.13.114.114:8082 PUBLIC_ICONIFY_URL=http://10.13.114.114:8098 diff --git a/apps/web/.env.test b/apps/web/.env.test index 1ed5e55..38e91cf 100644 --- a/apps/web/.env.test +++ b/apps/web/.env.test @@ -1,5 +1,4 @@ PUBLIC_ENV=test PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39 PUBLIC_CODE_URL=http://10.13.114.114:82 -PUBLIC_JUDGE0_URL=http://10.13.114.114:8082 PUBLIC_ICONIFY_URL=http://10.13.114.114:8098 diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index cd67c69..cfb8178 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -168,7 +168,6 @@ Env files: `.env`(本机)、`.env.production`(服务器)、`.env.staging |---|---| | `PUBLIC_ENV` | 环境角标:`test` → 「测试版」,`dev` → 「开发版」,其余不显示 | | `PUBLIC_CODE_URL` | 代码分享服务(提交详情、题目页的「分享」) | -| `PUBLIC_JUDGE0_URL` | Judge0 API(`utils/judge.ts` 的在线运行) | | `PUBLIC_MAXKB_URL` | 知识库问答挂件 | | `PUBLIC_ICONIFY_URL` | 自建 Iconify 图标源,不设则走公共 CDN | diff --git a/apps/web/src/admin/problem/components/TestcaseGenerator.vue b/apps/web/src/admin/problem/components/TestcaseGenerator.vue index 3edbb87..693a01b 100644 --- a/apps/web/src/admin/problem/components/TestcaseGenerator.vue +++ b/apps/web/src/admin/problem/components/TestcaseGenerator.vue @@ -1,7 +1,10 @@