feat(题目): 「运行例子」「自己输入」改用和提交同一台判题机——例子上跑出什么样,提交在这几个例子上就是什么样
Deploy / deploy (push) Canceled after 0s

原来试运行走另一台 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 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 08:09:44 +08:00
1 parent 8834536135
commit eb32676de4
27 files changed
+691 -169

No files matched your search

+2
View File
@@ -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
+3
View File
@@ -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),
+2
View File
@@ -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)
+22 -7
View File
@@ -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<number>(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
}
+188
View File
@@ -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<number, number>()
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<void>((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<TrialRunResponse> {
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<typeof runTrial>[0]): Promise<TrialRunResponse> {
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 } : {}),
}
}),
}
}
+110
View File
@@ -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<AppEnv>()
/** 后台生成测试点时题目可能还没存,没有题目的限制可用,按这两个跑 */
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", "判题机暂时连不上")
}
})
-1
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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 |
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { TRIAL_ADMIN_MAX_CASES } from "@oj2/contract"
import type { LANGUAGE, Testcase } from "utils/types"
import { errorMessage } from "utils/api"
import { SubmissionStatus } from "utils/constants"
import { createZipBlob } from "utils/functions"
import { createTestSubmission } from "utils/judge"
import { isRunnableLanguage, trialRun } from "utils/judge"
import { uploadTestcases } from "../../api"
interface FileEntry {
@@ -146,25 +149,45 @@ async function run() {
// 清空旧输出
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
// 走本站判题机(和判题同一套编译命令),不带题号:题目可能还没存,按后端给管理员的默认限制跑。
// 一次最多 TRIAL_ADMIN_MAX_CASES 组,多了分几批
const language = selectedLanguage.value
if (!isRunnableLanguage(language)) {
message.error(`${language} 不能用来生成测试点`)
return
}
isRunning.value = true
await Promise.all(
files.value.map(async (_, i) => {
try {
const result = await createTestSubmission(
{ language: selectedLanguage.value, value: answer.code },
files.value[i].in,
)
files.value[i] = {
...files.value[i],
out: result.output,
error: result.status !== 3,
try {
for (let start = 0; start < files.value.length; start += TRIAL_ADMIN_MAX_CASES) {
const batch = files.value.slice(start, start + TRIAL_ADMIN_MAX_CASES)
const response = await trialRun({
language,
code: answer.code,
cases: batch.map((f) => ({ input: f.in })),
})
batch.forEach((_, offset) => {
const i = start + offset
if (response.status === "compile-error") {
files.value[i] = { ...files.value[i], out: response.message, error: true }
} else if (response.status === "too-much-output") {
files.value[i] = { ...files.value[i], out: "输出太多,判题机回包超过上限", error: true }
} else {
const run = response.cases[offset]!
const ok = run.result === SubmissionStatus.accepted
files.value[i] = {
...files.value[i],
out: ok ? run.output.trimEnd() : run.output,
error: !ok,
}
}
} catch {
files.value[i] = { ...files.value[i], out: "", error: true }
}
}),
)
isRunning.value = false
})
}
} catch (err) {
message.error(errorMessage(err, "运行失败"))
files.value = files.value.map((f) => (f.out ? f : { ...f, error: true }))
} finally {
isRunning.value = false
}
}
async function upload() {
-1
View File
@@ -4,7 +4,6 @@ interface ImportMetaEnv {
readonly PUBLIC_ENV: string
readonly PUBLIC_MAXKB_URL: string
readonly PUBLIC_CODE_URL: string
readonly PUBLIC_JUDGE0_URL: string
readonly PUBLIC_ICONIFY_URL: string
}
@@ -13,10 +13,21 @@ import TrialErrorNote from "./TrialErrorNote.vue"
*/
const submissionStore = useSubmissionStore()
const { customInput, customOutput, customResult, customRunning } = submissionStore.trial
const {
customInput,
customOutput,
customResult,
customNote,
customRuntimeError,
customCode,
customRunning,
} = submissionStore.trial
const { problem } = storeToRefs(useProblemStore())
const codeStore = useCodeStore()
const theme = useThemeVars()
const route = useRoute()
// 比赛里出错只给按结果码的那一句,不说第几行、为什么(和「运行例子」一样)
const inContest = computed(() => !!route.params.contestID)
function fillSample(input: string) {
customInput.value = input
@@ -61,6 +72,10 @@ function fillSample(input: string) {
:result="customResult"
:output="customOutput"
:language="codeStore.code.language"
:note="customNote"
:runtime-error="customRuntimeError"
:code="customCode"
:plain="inContest"
/>
<pre v-else class="output" :class="{ empty: !customOutput }">{{
customResult === null ? "" : customOutput || "(什么都没有输出)"
@@ -51,9 +51,9 @@ const { isDesktop } = useBreakpoints()
const theme = useThemeVars()
/**
* 「运行例子」:用题目里的例子试跑(Judge0),结果在左栏「结果」页签。顶替原来题面里
* 「运行例子」:用题目里的例子试跑(本站判题机),结果在左栏「结果」页签。顶替原来题面里
* 每个例子旁的「测试」按钮 —— 那个在左栏、离编辑器远,只给通过 / 不通过、2 秒后复位。
* Judge0 跑不了 SQL,流程图也没得跑;没有例子的题不给。
* SQL 不走判题机,流程图也没得跑;没有例子的题不给。
*/
const submissionStore = useSubmissionStore()
const { samplesRunning } = submissionStore.trial
@@ -17,7 +17,7 @@ const submissionStore = useSubmissionStore()
const { samplesRunning } = submissionStore.trial
const { problem } = storeToRefs(useProblemStore())
// 和工具栏同一个条件:Judge0 跑不了 SQL,流程图没得跑,没有例子的题不给
// 和工具栏同一个条件:SQL 不走判题机、流程图没得跑,没有例子的题不给
const canRunSamples = computed(
() =>
!!problem.value?.samples.length &&
@@ -80,7 +80,7 @@ const codeSegments = computed<{ value: Segment; label: string; time: string }[]>
/** 正在画流程图:结果就是 AI 的点评,没有例子可跑 */
const drawing = computed(() => codeStore.code.language === "Flowchart")
// 试跑走 Judge0,它跑不了 SQL;流程图也没得跑。这两种只留「提交结果」
// 试跑走判题机,SQL 题不走那里;流程图也没得跑。这两种只留「提交结果」
const canTrial = computed(() => {
const languages = problem.value?.languages ?? []
return !languages.includes("SQL")
@@ -20,6 +20,39 @@ const failedCount = computed(
() => sampleRuns.value.filter((run) => run.result !== SubmissionStatus.accepted).length,
)
/**
* 一个例子都没跑起来:编译 / 语法没过,或者试跑服务本身没跑成(排队满了、判题机连不上)。
* 这时不能说「N 个例子没对上」—— 程序一行都没跑,每个例子也都是同一个原因,芯片不摆
*/
const header = computed(() => {
const runs = sampleRuns.value
const first = runs[0]
if (first && runs.every((run) => run.result === SubmissionStatus.compile_error)) {
return {
kind: "warning" as const,
title:
samplesCode.value?.language === "Python"
? "试跑:代码有语法错误,一个例子都没跑"
: "试跑:编译没通过,一个例子都没跑",
chips: false,
}
}
if (first?.note && runs.every((run) => run.note === first.note)) {
// 输出太多是程序自己的问题(多半死循环),其余带 note 的都是没跑成
const ran = first.result === SubmissionStatus.runtime_error
return {
kind: ran ? ("error" as const) : ("warning" as const),
title: ran ? "试跑:程序一直在输出,停不下来" : "试跑没跑成",
chips: false,
}
}
return {
kind: failedCount.value ? ("error" as const) : ("success" as const),
title: failedCount.value ? `试跑:${failedCount.value} 个例子没对上` : "试跑:例子都对上了",
chips: true,
}
})
/** 看的是哪一个例子:默认第一个没对上的 */
const selected = ref(0)
watch(sampleRuns, (runs) => {
@@ -43,12 +76,8 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
<n-flex v-else vertical :size="12">
<!-- 设计稿「运行例子:例子 2 没对上(不算提交)」:标题、「不算提交」、各例子的芯片在一行 -->
<ResultHeader
:kind="failedCount ? 'error' : 'success'"
:title="failedCount ? `试跑:${failedCount} 个例子没对上` : '试跑:例子都对上了'"
sub="(不算提交)"
>
<template #extra>
<ResultHeader :kind="header.kind" :title="header.title" sub="(不算提交)">
<template v-if="header.chips" #extra>
<div class="runs">
<button
v-for="run in sampleRuns"
@@ -134,6 +163,10 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
:result="current.result"
:output="current.output"
:language="samplesCode?.language ?? ''"
:note="current.note"
:runtime-error="current.runtimeError"
:code="samplesCode?.value ?? ''"
:plain="inContest"
/>
</template>
@@ -1,38 +1,79 @@
<script setup lang="ts">
import { SubmissionStatus } from "utils/constants"
import type { StatisticInfo } from "@oj2/contract"
import PythonErrorExplain from "./PythonErrorExplain.vue"
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
/**
* 试跑(Judge0)没跑完时给学生看的中文说明。Judge0 回来的是英文原文(Traceback、
* gcc 的报错),学生读不懂,原文收进折叠区。
* 试跑没跑完时给学生看的中文说明。判题机回来的是英文原文(Traceback、gcc 的报错),
* 学生读不懂,原文收进折叠区。Python 的语法错误是判题机上 CPython 的原文,和提交判出来的
* 一样,直接交给 PythonErrorExplain(中文说明 + 编辑器里标出位置 + 一键换中文标点)。
*/
const props = defineProps<{
result: SubmissionStatus
output: string
language: string
/** 调用方给的说明(没跑成的原因、输出太多),有它就不用下面按结果码挑的那句 */
note?: string
/** 运行时错误的诊断(后端和提交同一个解析),有它就说清楚第几行、为什么 */
runtimeError?: NonNullable<StatisticInfo["runtime_error"]> | null
/** 跑的那份代码,摆出出错的那一行 */
code?: string
/**
* 比赛里:不给具体到哪一行、什么原因的说明(设计文档第 8 节「运行例子只给对 / 没对上和
* 原样输出」;提交那边比赛里也只给判题状态),只留按结果码的那一句
*/
plain?: boolean
}>()
/** 运行时错误、而且诊断出了东西:用和提交一样的说明卡片(第几行 + 中文原因 + 编辑器标红) */
const runtimeExplain = computed(
() =>
!props.note &&
!props.plain &&
props.result === SubmissionStatus.runtime_error &&
!!props.runtimeError,
)
const pythonSyntaxError = computed(
() =>
!props.note &&
props.result === SubmissionStatus.compile_error &&
props.language === "Python" &&
!!props.output,
)
const message = computed(() => {
if (props.note) return props.note
switch (props.result) {
case SubmissionStatus.compile_error:
return props.language === "Python"
? "代码有语法错误,程序一行都没跑。最常见的是用了中文标点(,:()“”)。按「提交」会先检查一遍,告诉你错在哪一行,中文标点能一键换成英文。"
? "代码有语法错误,程序一行都没跑。最常见的是用了中文标点(,:()“”)。"
: "代码没能编译通过,写法有错误。看看是不是漏了分号、括号没配对、变量没声明。"
case SubmissionStatus.runtime_error:
return "程序运行到一半出错,停下来了。检查一下输入是怎么读的、下标有没有越界、除数是不是 0。"
case SubmissionStatus.cpu_time_limit_exceeded:
case SubmissionStatus.real_time_limit_exceeded:
return "运行超时了:程序跑了太久还没结束。看看是不是有死循环,或者在等一个例子里没有的输入。"
return "运行超时了:程序跑了太久还没结束。看看是不是有死循环(条件一直成立、循环变量忘了改),或者在等一个例子里没有的输入(多写了一个 input)。"
case SubmissionStatus.memory_limit_exceeded:
return "程序占的内存太多了。通常是列表、数组开得太大,或者在循环里不停地往里加东西、停不下来。"
default:
return "试跑的服务暂时连不上,稍后再试一次。"
}
})
/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */
const rawOutput = computed(() => props.output.replace(/\/judger\/run\/[^/"]+\//g, ""))
</script>
<template>
<n-flex vertical :size="8">
<n-alert type="error" :show-icon="false">{{ message }}</n-alert>
<n-collapse v-if="output">
<PythonErrorExplain v-if="pythonSyntaxError" :err-info="output" />
<n-flex v-else vertical :size="8">
<RuntimeErrorExplain v-if="runtimeExplain" :info="runtimeError!" :code="code ?? ''" />
<n-alert v-else type="error" :show-icon="false">{{ message }}</n-alert>
<n-collapse v-if="rawOutput">
<n-collapse-item title="原始输出(英文)" name="raw">
<pre class="raw">{{ output }}</pre>
<pre class="raw">{{ rawOutput }}</pre>
</n-collapse-item>
</n-collapse>
</n-flex>
@@ -1,51 +1,96 @@
import { createTestSubmission } from "utils/judge"
import type { StatisticInfo, TrialRunResponse } from "@oj2/contract"
import { errorMessage } from "utils/api"
import { SubmissionStatus } from "utils/constants"
import { isRunnableLanguage, trialRun } from "utils/judge"
import type { Code } from "utils/types"
/**
* 试跑:「运行例子」和「自己输入」。走 Judge0(utils/judge.ts),不经过判题机、不算提交。
* 试跑:「运行例子」和「自己输入」。走本站判题机(utils/judge.ts → POST /trial-runs),
* 不算提交。和提交是同一台判题机、同一套编译命令、这道题的时间内存限制和代码模板,
* 所以例子上跑出来什么样,提交在这几个例子上就是什么样。
*
* 编译器版本和判题机不一样(Judge0 的 C 是 GCC 9,判题机是 gcc-14 加宽松参数),
* 所以这里的结果偶尔会和提交不一致 —— 这是拍板过的取舍(设计文档第 3 节决定 3)。
* 结果码就是判题机那套(SubmissionStatus),直接交给 WrongAnswerExplain 和 JUDGE_STATUS。
* 对不对也是判题机比的(去掉末尾空白再比,和提交一样),这边不再自己比。
*/
/** Judge0 的状态码(见 Judge0 的 /statuses)。同步调用(wait=true)只会拿到终态 */
const JUDGE0_ACCEPTED = 3
const JUDGE0_TIME_LIMIT = 5
const JUDGE0_COMPILE_ERROR = 6
const isJudge0RuntimeError = (status: number) => status >= 7 && status <= 12
/**
* Python 的语法错误。Judge0 的 Python 没有编译这一步,SyntaxError 回来的是运行时错误
* (status 11),得看回溯的最后一行才认得出来
*/
const PYTHON_SYNTAX_ERROR = /^(SyntaxError|IndentationError|TabError): /m
/**
* 把 Judge0 的状态换成判题机那套码,好直接交给 WrongAnswerExplain 和 JUDGE_STATUS。
* 跑完、只是输出对不上的,按答案错误算。Python 的语法错误按编译失败算:
* 学生最常犯的中文冒号、中文括号,原来在这里被说成「程序运行到一半出错……下标有没有越界」
*/
export function trialStatus(status: number | null, passed: boolean, output: string) {
if (status === JUDGE0_ACCEPTED) {
return passed ? SubmissionStatus.accepted : SubmissionStatus.wrong_answer
}
if (status === JUDGE0_TIME_LIMIT) return SubmissionStatus.real_time_limit_exceeded
if (status === JUDGE0_COMPILE_ERROR) return SubmissionStatus.compile_error
if (status !== null && isJudge0RuntimeError(status)) {
return PYTHON_SYNTAX_ERROR.test(output)
? SubmissionStatus.compile_error
: SubmissionStatus.runtime_error
}
return SubmissionStatus.system_error
/** 试跑的是哪道题:按这道题的限制和模板跑 */
export interface TrialTarget {
problemId: number
contestId: number | null
}
/** 一组跑下来的结果 */
interface TrialOutcome {
result: SubmissionStatus
output: string
/** 没跑成、或者要换一种说法时给学生看的中文,有它就不用 TrialErrorNote 的默认说明 */
note?: string
/** 运行时错误的诊断(第几行、什么异常),和提交的同一套,交给 RuntimeErrorExplain */
runtimeError?: NonNullable<StatisticInfo["runtime_error"]>
}
const TOO_MUCH_OUTPUT =
"程序输出太多了,一直停不下来:多半是循环没有结束的条件(条件一直成立、循环变量忘了改),在不停地打印。"
/**
* 判题机比的是去掉末尾空白之后的输出(JudgeServer 的 stripped_output_md5),
* 这里两边都去。原来题目页的「测试」按钮只 trim 了运行输出,样例输出末尾带个
* 空格或换行,就永远显示「不通过」。
* 把一次试跑的回包摊成每组一条。编译没过就每组都是编译失败、带同一份报错;
* 输出太多的时候判题机回包被掐断了,分不出是哪一组,每组都挂上同一句说明。
*/
const sameOutput = (output: string, expected: string) => output.trimEnd() === expected.trimEnd()
function outcomes(response: TrialRunResponse, count: number): TrialOutcome[] {
if (response.status === "compile-error") {
return Array.from({ length: count }, () => ({
result: SubmissionStatus.compile_error,
output: response.message,
}))
}
if (response.status === "too-much-output") {
return Array.from({ length: count }, () => ({
result: SubmissionStatus.runtime_error,
output: "",
note: TOO_MUCH_OUTPUT,
}))
}
// 跑完了(对或错)只去掉末尾空白(print 带的那个换行),判题机比对时也不看它;
// 开头的空格是输出的一部分(打印菱形、三角形),不能动
return response.cases.map((item) => {
const result = item.result as SubmissionStatus
const finished =
result === SubmissionStatus.accepted || result === SubmissionStatus.wrong_answer
return {
result,
output: finished ? item.output.trimEnd() : item.output,
...(item.runtimeError ? { runtimeError: item.runtimeError } : {}),
}
})
}
async function runCases(
code: Code,
target: TrialTarget,
cases: { input: string; output?: string }[],
): Promise<TrialOutcome[]> {
if (!isRunnableLanguage(code.language)) {
return cases.map(() => ({
result: SubmissionStatus.system_error,
output: "",
note: `${code.language} 不能试运行`,
}))
}
try {
const response = await trialRun({
problemId: target.problemId,
contestId: target.contestId ?? undefined,
language: code.language,
code: code.value,
cases,
})
return outcomes(response, cases.length)
} catch (error) {
// 后端的文案是给学生看的(「上一次还没跑完」「试运行的人太多了」)
const note = errorMessage(error, "试跑的服务暂时连不上,稍后再试一次。")
return cases.map(() => ({ result: SubmissionStatus.system_error, output: "", note }))
}
}
export interface SampleRun {
/** 第几个例子,0 起 */
@@ -53,8 +98,9 @@ export interface SampleRun {
input: string
expected: string
output: string
/** 换算成判题机那套码之后的结果,见 trialStatus */
result: SubmissionStatus
note?: string
runtimeError?: TrialOutcome["runtimeError"]
}
export function useTrialRun() {
@@ -68,6 +114,10 @@ export function useTrialRun() {
const customInput = ref("")
const customOutput = ref("")
const customResult = ref<SubmissionStatus | null>(null)
const customNote = ref("")
const customRuntimeError = ref<TrialOutcome["runtimeError"] | null>(null)
/** 跑「自己输入」时用的那份代码:出错那一行要从它里面摆出来,编辑器里可能已经改了 */
const customCode = ref("")
const customRunning = ref(false)
/**
@@ -77,44 +127,37 @@ export function useTrialRun() {
let samplesGeneration = 0
let customGeneration = 0
async function runOne(code: Code, input: string) {
try {
return await createTestSubmission(code, input)
} catch {
return { status: null, output: "" }
}
}
async function runSamples(code: Code, samples: { input: string; output: string }[]) {
async function runSamples(
code: Code,
target: TrialTarget,
samples: { input: string; output: string }[],
) {
const mine = ++samplesGeneration
samplesRunning.value = true
// 例子一般两三个,一起发
const outputs = await Promise.all(samples.map((sample) => runOne(code, sample.input)))
const runs = await runCases(code, target, samples)
if (mine !== samplesGeneration) return
sampleRuns.value = samples.map((sample, index) => {
const run = outputs[index]!
const passed = run.status === JUDGE0_ACCEPTED && sameOutput(run.output, sample.output)
return {
index,
input: sample.input,
expected: sample.output,
output: run.output,
result: trialStatus(run.status, passed, run.output),
}
})
sampleRuns.value = samples.map((sample, index) => ({
index,
input: sample.input,
expected: sample.output,
...runs[index]!,
}))
samplesRunAt.value = new Date().toISOString()
samplesCode.value = code
samplesRunning.value = false
}
async function runCustom(code: Code) {
async function runCustom(code: Code, target: TrialTarget) {
const mine = ++customGeneration
customRunning.value = true
const run = await runOne(code, customInput.value)
// 自己输入的没有标准答案,不给 output:跑完没出错就算「对」
const [run] = await runCases(code, target, [{ input: customInput.value }])
if (mine !== customGeneration) return
customOutput.value = run.output
// 自己输入的没有标准答案,跑完就算「对」,只看有没有出错
customResult.value = trialStatus(run.status, true, run.output)
customOutput.value = run!.output
customResult.value = run!.result
customNote.value = run!.note ?? ""
customRuntimeError.value = run!.runtimeError ?? null
customCode.value = code.value
customRunning.value = false
}
@@ -129,6 +172,9 @@ export function useTrialRun() {
customInput.value = ""
customOutput.value = ""
customResult.value = null
customNote.value = ""
customRuntimeError.value = null
customCode.value = ""
customRunning.value = false
}
@@ -140,6 +186,9 @@ export function useTrialRun() {
customInput,
customOutput,
customResult,
customNote,
customRuntimeError,
customCode,
customRunning,
runSamples,
runCustom,
+9 -3
View File
@@ -46,18 +46,24 @@ export const useSubmissionStore = defineStore("submission", () => {
resultSeq.value++
}
/** 用题目里的例子试跑(Judge0),不算提交 */
/** 用题目里的例子试跑(本站判题机),不算提交 */
function runSamples() {
const problem = problemStore.problem
if (!problem?.samples.length) return
revealResult("samples")
trial.runSamples({ ...codeStore.code }, problem.samples)
trial.runSamples(
{ ...codeStore.code },
{ problemId: problem.id, contestId: problem.contestId },
problem.samples,
)
}
/** 用「自己输入」框里的数据跑一次 */
function runCustom() {
const problem = problemStore.problem
if (!problem) return
revealResult("custom")
trial.runCustom({ ...codeStore.code })
trial.runCustom({ ...codeStore.code }, { problemId: problem.id, contestId: problem.contestId })
}
const isFormatting = ref(false)
const isSubmittingRequest = ref(false)
+14 -46
View File
@@ -1,50 +1,18 @@
import axios from "axios"
import { base64DecodeUtf8, base64EncodeUtf8 } from "./functions"
import type { Code, LANGUAGE } from "./types"
import type { RunnableLanguage, TrialRunRequest, TrialRunResponse } from "@oj2/contract"
import api from "./api"
import type { LANGUAGE } from "./types"
const http = axios.create({ baseURL: import.meta.env.PUBLIC_JUDGE0_URL })
// Judge0 的语言 id。Flowchart 和 SQL 不在其中 —— 前者根本不是可执行代码,
// 后者由本站自己的 SQL 沙箱判,都走不到 Judge0。
const JUDGE0_LANGUAGE_ID: Partial<Record<LANGUAGE, number>> = {
C: 50,
"C++": 54,
Java: 62,
Golang: 60,
JavaScript: 63,
Python: 71,
/**
* 试运行:走本站判题机(`POST /trial-runs`,见 apps/api/src/judge/trial.ts),不算提交。
* 和提交是同一台判题机、同一套编译命令,例子上跑出来的结果和提交一个口径。
*
* 原来走的是另一台 Judge0(C 是 GCC 9、参数也不同),2026-09 换掉了。
*/
export function trialRun(payload: TrialRunRequest) {
return api.post<TrialRunResponse>("trial-runs", payload)
}
export async function createTestSubmission(code: Code, input: string) {
const encodedCode = base64EncodeUtf8(code.value)
const id = JUDGE0_LANGUAGE_ID[code.language]
if (id === undefined) {
return { status: null, output: `${code.language} 不支持在线试运行` }
}
let compilerOptions = ""
if (id === 50) compilerOptions = "-lm" // 解决 GCC 的链接问题
const payload = {
source_code: encodedCode,
language_id: id,
stdin: base64EncodeUtf8(input),
redirect_stderr_to_stdout: true,
compiler_options: compilerOptions,
}
const response = await http.post("/submissions", payload, {
params: { base64_encoded: true, wait: true },
})
const data = response.data
const status: number | null = data.status?.id ?? null
const stdout = base64DecodeUtf8(data.stdout)
// 跑通了(3 = Accepted)就只要程序自己的输出,而且只去掉末尾的空白:
// - C 有警告(隐式声明 sqrt、用了 gets)时 Judge0 跑通了也带回 compile_output,拼进来
// 就成了「你的输出」的一部分,例子永远对不上;后台生成测试点时还会写进 .out
// - 开头的空格是输出的一部分(打印菱形、三角形),原来整体 trim() 会把它削掉,
// 对的代码被判成「只是空格不一样」
if (status === 3) return { status, output: stdout.trimEnd() }
// 没跑通:报错原文给人看,两段拼起来
return {
status,
output: [base64DecodeUtf8(data.compile_output), stdout].join("\n").trim(),
}
/** 判题机跑得了的语言。Flowchart、SQL 不是可执行代码 / 不走判题机,历史语言镜像里已经没有了 */
export function isRunnableLanguage(language: LANGUAGE): language is RunnableLanguage {
return language === "C" || language === "C++" || language === "Python"
}
+3
View File
@@ -23,6 +23,9 @@ REDIS_PASSWORD=
# 判题并发。受判题沙箱能力限制,不是越大越好。
# 服务器 2,机房机器宽裕可以给 4。
JUDGE_CONCURRENCY=2
# 试运行(运行例子 / 自己输入 / 后台生成测试点)同时占判题沙箱几路,和上面那个叠加。
# 不设时服务器 2、机房 4。
# TRIAL_CONCURRENCY=2
# DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。
AI_KEY=
+1
View File
@@ -145,6 +145,7 @@ services:
JUDGE_SERVER_URL: http://oj-judge:8080
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2}
TRIAL_CONCURRENCY: ${TRIAL_CONCURRENCY:-2}
AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 走 NPM 终止 TLS,浏览器侧是 https,Cookie 必须带 Secure
+1
View File
@@ -82,6 +82,7 @@ services:
JUDGE_SERVER_URL: http://oj-judge:8080
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-4}
TRIAL_CONCURRENCY: ${TRIAL_CONCURRENCY:-4}
AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传,
@@ -63,7 +63,10 @@
1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」和其他六项放在一起,不单独挪走)。
2. **题单入口隐藏**「我的提交」「本题提交」「统计」「点评」和标题下的「已解决 / 尝试过」标签 ——
不让学生看到加入题单之前的提交、抄到答案。
3. **「运行例子」先继续用 Judge0**,不另做判题机端点。
3. ~~「运行例子」先继续用 Judge0,不另做判题机端点。~~ **2026-09-29 改了**:「运行例子」「自己输入」、
后台「生成测试点」都改走本站判题机(`POST /trial-runs`,`apps/api/src/judge/trial.ts`),
和提交同一套编译命令、这道题的时间内存限制和代码模板;试运行在 api 进程里自己排队,
不进判题队列,全站同时跑的批数由 `TRIAL_CONCURRENCY` 定,每人最多两批。
4. **流程图题可以放进题单**;题单里流程图拿到 **A 或 S 级算这道题完成**。
5. 题单里**答案正确或流程图 A/S 后,1.5 秒自动回题单**,保留。
6. **课堂条里,流程图 A/S 也算做完。**
@@ -198,7 +201,7 @@ B 看得到。
| 管理员 | —— | 测试点表格原样(只有管理员有数据) |
**运行例子的判定**:两边都去掉末尾空白再比(现在只 trim 了运行输出,末尾带空格的样例永远「不通过」,
`ProblemContent.vue` 的 `test()`)。Judge0 返回的英文报错(Traceback、gcc 报错)**不能直接给学生看**:
`ProblemContent.vue` 的 `test()`)。判题机返回的英文报错(Traceback、gcc 报错)**不能直接给学生看**:
显示「程序运行出错了 / 编译没通过」加中文说明,原文折叠。
**「其他」类差异的文案要改**:现在是「对照一下你的输出和正确输出,找找从哪里开始不一样」,
@@ -243,7 +246,7 @@ B 看得到。
| 相似题 | ✓ | ✗(点进去会离开题单) | ✗ |
| 老师协作 | ✓ | ✓ | ✗(房间只记题号,比赛题号 1、2、3 和公开题撞号) |
| 课堂统计(教师) | ✓ | ✓ | ✗(统计接口按题号在公开题库里找题) |
| SQL 题 | 隐藏「运行例子」「自己输入」(Judge0 跑不了 SQL);数据表可横向滚动 | 同左 | 同左 |
| SQL 题 | 隐藏「运行例子」「自己输入」(SQL 不走判题机);数据表可横向滚动 | 同左 | 同左 |
一道题既在这节课里、又是从题单进来的:按入口路由,显示题单条。
@@ -321,8 +324,7 @@ B 看得到。
## 13. 不做 / 保持现状
- 「运行例子」不换成自己的判题机(决定 3)。代价:Judge0 的 C 是 GCC 9,参数也和判题机
(gcc-14 + `cLooseErrors`)不同,例子上的结果偶尔会和提交不一致。
- ~~「运行例子」不换成自己的判题机(决定 3)。~~ 已换,见决定 3。
- 强制点评的七个选项不改(决定 1)。
- 答案错误说明里给出的写法提示(如「Python 可以写 f"{x:.2f}"」)保持现状。
- AST 未通过按已解决记账、不放烟花 —— 和后端 `NON_FAILURE_RESULTS` 一致,保持。
+7
View File
@@ -37,7 +37,14 @@ export const judgeLanguageSchema = z.enum(["C", "C++", "Python", "Java", "JavaSc
*/
export const problemLanguageSchema = z.enum([...judgeLanguageSchema.options, "SQL", "Flowchart"])
/**
* 判题沙箱**现在**跑得了的语言,就是 `apps/api/src/judge/languages.ts` 里那三块。
* 试运行(`POST /trial-runs`)只收这几个:历史语言交上来只会在判题机那边报系统错误。
*/
export const runnableLanguageSchema = judgeLanguageSchema.extract(["C", "C++", "Python"])
export type JudgeLanguage = z.infer<typeof judgeLanguageSchema>
export type RunnableLanguage = z.infer<typeof runnableLanguageSchema>
export type ProblemLanguage = z.infer<typeof problemLanguageSchema>
/**
+59 -1
View File
@@ -2,7 +2,7 @@ import { z } from "zod"
import { paginatedSchema } from "./common"
import { judgeStatusSchema, type JudgeStatus } from "./judge-status"
import { problemLanguageSchema } from "./language"
import { problemLanguageSchema, runnableLanguageSchema } from "./language"
/**
* 判题机原始输出(`submission.info` 的 JSONB 原文)。**只是类型,不作运行时校验。**
@@ -454,6 +454,63 @@ export const formatCodeRequestSchema = z.object({
export const formatCodeResponseSchema = z.object({ code: z.string() })
/** 试运行一次最多几组。学生那边只有「运行例子」(一般两三个)和「自己输入」(一个) */
export const TRIAL_MAX_CASES = 10
/** 后台「生成测试点」一次跑的组数上限,只有管理员能用到 */
export const TRIAL_ADMIN_MAX_CASES = 50
/**
* 试运行:「运行例子」「自己输入」、后台「生成测试点」。走本站判题机,不落库、不算提交。
*
* - 带 `problemId`:按这道题的时间 / 内存限制和代码模板跑,和提交一个口径;
* - 不带:只有管理员能这么调(后台生成测试点时题目可能还没存)。
*
* `output` 是这组的正确输出,给了就由判题机比对(和提交一样去掉末尾空白再比),
* 结果是通过 / 答案错误;不给(「自己输入」、生成测试点)就只看有没有跑出错。
*/
export const trialRunRequestSchema = z.object({
problemId: z.number().int().positive().optional(),
contestId: z.number().int().positive().optional(),
language: runnableLanguageSchema,
code: z
.string()
.min(1)
.max(1024 * 1024),
cases: z
.array(
z.object({
input: z.string().max(1024 * 1024),
output: z
.string()
.max(1024 * 1024)
.optional(),
}),
)
.min(1)
.max(TRIAL_ADMIN_MAX_CASES),
})
/**
* - `done`:每组一条,顺序和请求里的 `cases` 一致。`result` 是判题机那套码;
* 没给正确输出的组,跑完没出错就是通过。`output` 里 stdout 和 stderr 是混在一起的
* (判题机把两者写进同一个文件),Python 的报错原文就在这里。运行时错误的组另带
* `runtimeError`:和提交的 `statistic_info.runtime_error` 同一个解析(第几行、什么异常),
* 解析不出来时没有这个字段。
* - `compile-error`:编译没过(Python 是语法错误),一组都没跑。
* - `too-much-output`:程序输出多到判题机回包超过上限,多半是死循环在不停打印。
*/
export type TrialRunResponse =
| {
status: "done"
cases: {
result: JudgeStatus
output: string
runtimeError?: NonNullable<StatisticInfo["runtime_error"]>
}[]
}
| { status: "compile-error"; message: string }
| { status: "too-much-output" }
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.infer<typeof createSubmissionRequestSchema>
@@ -473,3 +530,4 @@ export type CreateSubmissionResponse = z.infer<typeof createSubmissionResponseSc
export type FormatCodeResponse = z.infer<typeof formatCodeResponseSchema>
export type FormatCodeRequest = z.infer<typeof formatCodeRequestSchema>
export type TrialRunRequest = z.infer<typeof trialRunRequestSchema>