原来试运行走另一台 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:
1 parent
8834536135
commit
eb32676de4
27 files changed
+691
-169
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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
|
||||
@@ -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() {
|
||||
|
||||
Vendored
-1
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -23,6 +23,9 @@ REDIS_PASSWORD=
|
||||
# 判题并发。受判题沙箱能力限制,不是越大越好。
|
||||
# 服务器 2,机房机器宽裕可以给 4。
|
||||
JUDGE_CONCURRENCY=2
|
||||
# 试运行(运行例子 / 自己输入 / 后台生成测试点)同时占判题沙箱几路,和上面那个叠加。
|
||||
# 不设时服务器 2、机房 4。
|
||||
# TRIAL_CONCURRENCY=2
|
||||
|
||||
# DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。
|
||||
AI_KEY=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 一致,保持。
|
||||
|
||||
@@ -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>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user