Deploy / deploy (push) Canceled after 0s
按外部分析报告(reports/analysis)挑出来做的几条: - CI 在编完前端之后跑 `bunx vp run verify`,有一项不过就不部署(必须排在编前端之后: 前端类型检查要的 auto-imports.d.ts / components.d.ts 是 vite build 时才生成的) - 连不上判题机(连接被拒、解析不到、被重置)时,正式判题等 2/5/10 秒重试,不再当场记成 系统错误;超时、判题机回错、页面上的运行和自测不重试 - worker 每 10 秒刷一次「还活着」,compose 加 `oj2-api healthcheck --worker`,deploy.sh 起栈后 也等 worker 健康(docker 不会因 unhealthy 自动重启,只是看得见、部署时拦得住) - 三套 compose 的容器日志限 10MB × 3 - `/public/%` 这类畸形编码回 404 而不是 500;找不到的接口回统一的 JSON 错误 - 删掉没人用的「停用判题机」接口,契约不再给 task_number / is_disabled(列留在库里) - 删掉腐化的 apps/web/tests/submitButtonState.test.ts;契约包 zod 范围对齐到 ^4.6.6 - 修几处和实际对不上的注释与文档(表数、编译并发、AST 语言数、deploy.sh 流程、校验处数) ⚠️ 改了 compose 的日志配置,这次部署会把 postgres / redis / 判题机也重建一遍,各停几秒 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
670 lines
30 KiB
TypeScript
670 lines
30 KiB
TypeScript
import { astRuleSchema, type ContestSubmissionInfo, type StatisticInfo } from '@oj2/contract'
|
||
import { and, eq, inArray } from 'drizzle-orm'
|
||
|
||
import { config } from '../config'
|
||
import { db, schema } from '../db'
|
||
import { publishAchievementNotification } from '../events'
|
||
import { updateAchievementsForSubmission } from '../services/achievements'
|
||
import { challengesFor, judgeChallenges, recordChallengeSuccesses, type ChallengeVerdict } from '../services/challenge'
|
||
import { recordSolvedAndNotify } from '../services/problemset'
|
||
import { checkAst, type AstRule } from './ast'
|
||
import { publishSubmissionUpdate } from './events'
|
||
import { asRecord } from '../routes/helpers'
|
||
import type { JudgeJobData } from './job'
|
||
import { isAccepted, JudgeStatus, type JudgeStatusValue } from './status'
|
||
import { parseProblemTemplate, shiftCompileLines } from './template'
|
||
import { withJudgeSlot } from './slots'
|
||
import { runSqlCase } from './sql'
|
||
import { nativeRuntimeError, parsePythonTraceback, type RuntimeErrorInfo } from './runtime-diagnosis'
|
||
import { compileError, judge, JudgeUnreachableError, type JudgeCase, type JudgeResponse } from './sandbox'
|
||
import { readInfo } from '../services/test-case'
|
||
import { readFile } from 'node:fs/promises'
|
||
import { resolve as resolvePath } from 'node:path'
|
||
|
||
export type { JudgeCase, JudgeResponse }
|
||
|
||
export function statusValue(value: number): JudgeStatusValue {
|
||
const statuses = new Set<number>(Object.values(JudgeStatus))
|
||
return statuses.has(value) ? (value as JudgeStatusValue) : JudgeStatus.SYSTEM_ERROR
|
||
}
|
||
|
||
export function templateForLanguage(value: unknown, language: string) {
|
||
const template = asRecord(value)[language]
|
||
return typeof template === 'string' ? template : null
|
||
}
|
||
|
||
/**
|
||
* 取某个语言的 AST 规则。逐条过 astRuleSchema 而不是整片 `as AstRule[]` 硬转 ——
|
||
* 这是从库里读出来的 JSONB,形状不对(比如 engine 是个 evaluateRule 不认识的值)
|
||
* 时丢掉那一条,行为和 evaluateRule 的 `default: return null` 一致,只是提前到了
|
||
* 这里、并且不再骗类型系统。
|
||
*/
|
||
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
|
||
const rules = asRecord(value)[language]
|
||
if (!Array.isArray(rules)) return []
|
||
return rules.flatMap((rule) => {
|
||
const parsed = astRuleSchema.safeParse(rule)
|
||
return parsed.success ? [parsed.data] : []
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 这份代码在判题机上编不编得过。给提交前的自动格式化用:clang-format 碰上编译不过的
|
||
* C 代码会把行并起来(`int n;` 和下一行、少了分号的两行),学生回头看到的就不是自己
|
||
* 写的代码了,报错的行号也对不上他记得的位置。api 容器里没有 gcc,而判题机的编译器、
|
||
* 编译参数本来就是判的标准,所以借它编一次。
|
||
*
|
||
* 判题机连不上、超时,一律当编得过 —— 那就和原来一样照常格式化。
|
||
*
|
||
* 这一下不进判题队列,但和运行例子共用交互池的名额(judge/slots.ts):服务器只有两核,
|
||
* 不排的话一个班 40 人一起交 C++ 能把判题机挤到每人 8 秒超时,还拖慢正式判题。
|
||
* 8 秒是连排队带编译的总预算,排不上也当编得过。
|
||
*/
|
||
export async function compileFails(language: string, source: string) {
|
||
const deadline = AbortSignal.timeout(8000)
|
||
try {
|
||
return await withJudgeSlot('interactive', { holdMs: 30_000, signal: deadline }, async () => (await compileError(language, source, deadline)) !== null)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判题机自己有编译、运行的限时,这里只防它卡死不回。不设的话一次卡死就永久占住一个
|
||
* worker(服务器上一共两个),而 BullMQ 会一直给它续锁、不当它卡住,全班停在「等待评分」。
|
||
* 判题机每组的墙钟上限是 CPU 时限的 3 倍、两组并行,现有题目全是 1 秒,二十组全超时
|
||
* 也就半分钟出头,两分钟足够;时限放宽的题按比例放宽。
|
||
*/
|
||
function judgeTimeoutMs(timeLimit: number) {
|
||
return Math.max(120_000, timeLimit * 60)
|
||
}
|
||
|
||
/**
|
||
* 连不上判题机时隔多久再试,一共再试这么多次。判题机容器重启(部署时重建、OOM 被拉起来)
|
||
* 要几秒,合计十几秒盖得住;原来是当场记成系统错误,那几秒里交的全班都得自己重交。
|
||
* 只重试「连不上」:超时(已经等了两分钟)、判题机回了错都不重试。等的时候不占名额
|
||
*/
|
||
const UNREACHABLE_RETRY_DELAYS_MS = [2_000, 5_000, 10_000]
|
||
|
||
async function requestJudge(
|
||
language: string,
|
||
code: string,
|
||
timeLimit: number,
|
||
memoryLimit: number,
|
||
testCase: string | { input: string; output: string }[],
|
||
output = false,
|
||
) {
|
||
const timeoutMs = judgeTimeoutMs(timeLimit)
|
||
for (let attempt = 0; ; attempt++) {
|
||
try {
|
||
// 超时从拿到名额才开始算:排队等名额不算判题机没回应
|
||
return await withJudgeSlot('judge', { holdMs: timeoutMs + 30_000 }, () =>
|
||
judge(language, code, timeLimit, memoryLimit, testCase, { output, signal: AbortSignal.timeout(timeoutMs) }),
|
||
)
|
||
} catch (error) {
|
||
// 这句会写进 err_info,学生能看到,别把 "The operation timed out." 原样给他
|
||
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
||
throw new Error(`判题机 ${timeoutMs / 1000} 秒没有回应`)
|
||
}
|
||
const delay = UNREACHABLE_RETRY_DELAYS_MS[attempt]
|
||
if (!(error instanceof JudgeUnreachableError) || delay === undefined) throw error
|
||
console.warn(`[judge] ${error.message},${delay / 1000} 秒后重试(第 ${attempt + 1} 次)`)
|
||
await Bun.sleep(delay)
|
||
}
|
||
}
|
||
}
|
||
|
||
async function persistResult(
|
||
submissionId: string,
|
||
problemId: number,
|
||
userId: number,
|
||
displayId: string,
|
||
result: JudgeStatusValue,
|
||
info: unknown,
|
||
statisticInfo: Record<string, unknown>,
|
||
contestId: number | null,
|
||
submissionCreateTime: string,
|
||
/** 重判之前**已经记过账**的结果;新提交、或原来没记过账(系统错误)时为 null */
|
||
counted: JudgeStatusValue | null,
|
||
) {
|
||
return db.transaction(async (tx) => {
|
||
const [currentSubmission] = await tx
|
||
.select({ result: schema.submission.result })
|
||
.from(schema.submission)
|
||
.where(eq(schema.submission.id, submissionId))
|
||
.for('update')
|
||
|
||
if (!currentSubmission || ![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(currentSubmission.result as 6 | 7)) {
|
||
return false
|
||
}
|
||
|
||
const [problem] = await tx
|
||
.select({
|
||
submissionNumber: schema.problem.submissionNumber,
|
||
acceptedNumber: schema.problem.acceptedNumber,
|
||
statisticInfo: schema.problem.statisticInfo,
|
||
})
|
||
.from(schema.problem)
|
||
.where(eq(schema.problem.id, problemId))
|
||
.for('update')
|
||
|
||
const [profile] = await tx.select().from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).for('update')
|
||
|
||
if (!problem || !profile) {
|
||
throw new Error('Submission dependencies disappeared during judging')
|
||
}
|
||
|
||
await tx.update(schema.submission).set({ result, info, statisticInfo }).where(eq(schema.submission.id, submissionId))
|
||
|
||
/**
|
||
* 重判只做差量:提交数不动,旧结果那一格减一、新结果那一格加一,通过数按新旧结果
|
||
* 调整。原来重判一律当新提交记账,老师上课对一条连点三次重判,这道题的提交数和
|
||
* 通过数就各多三(旧栈的 update_problem_status_rejudge 也是只做差量)
|
||
*/
|
||
const problemStatistics = asRecord(problem.statisticInfo)
|
||
const bump = (key: string, delta: number) => {
|
||
const value = problemStatistics[key]
|
||
problemStatistics[key] = Math.max(0, (typeof value === 'number' ? value : 0) + delta)
|
||
}
|
||
bump(String(result), 1)
|
||
if (counted !== null) bump(String(counted), -1)
|
||
const acceptedDelta = (isAccepted(result) ? 1 : 0) - (counted !== null && isAccepted(counted) ? 1 : 0)
|
||
|
||
await tx
|
||
.update(schema.problem)
|
||
.set({
|
||
submissionNumber: problem.submissionNumber + (counted === null ? 1 : 0),
|
||
acceptedNumber: Math.max(0, problem.acceptedNumber + acceptedDelta),
|
||
statisticInfo: problemStatistics,
|
||
})
|
||
.where(eq(schema.problem.id, problemId))
|
||
|
||
const acmStatus = asRecord(profile.acmProblemsStatus)
|
||
const statusKey = contestId === null ? 'problems' : 'contest_problems'
|
||
const problems = asRecord(acmStatus[statusKey])
|
||
const previous = asRecord(problems[String(problemId)])
|
||
const previousStatus = previous.status
|
||
const wasAccepted = typeof previousStatus === 'number' && isAccepted(previousStatus)
|
||
const acceptedNow = isAccepted(result)
|
||
|
||
if (previousStatus === undefined) {
|
||
problems[String(problemId)] = {
|
||
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
|
||
_id: displayId,
|
||
}
|
||
} else if (!wasAccepted) {
|
||
problems[String(problemId)] = {
|
||
...previous,
|
||
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
|
||
_id: displayId,
|
||
}
|
||
}
|
||
acmStatus[statusKey] = problems
|
||
|
||
await tx
|
||
.update(schema.userProfile)
|
||
.set({
|
||
// 重判不是又交了一次。个人的通过数只在「第一次做对」时加,重判改错了也不收回
|
||
// (他可能别的提交也对了),和 acm_problems_status 同一个口径
|
||
submissionNumber: profile.submissionNumber + (contestId === null && counted === null ? 1 : 0),
|
||
acceptedNumber: profile.acceptedNumber + (contestId === null && acceptedNow && !wasAccepted ? 1 : 0),
|
||
acmProblemsStatus: acmStatus,
|
||
})
|
||
.where(eq(schema.userProfile.id, profile.id))
|
||
|
||
if (contestId !== null) {
|
||
const [contest] = await tx.select({ startTime: schema.contest.startTime }).from(schema.contest).where(eq(schema.contest.id, contestId)).for('update')
|
||
if (!contest) throw new Error('Contest disappeared during judging')
|
||
|
||
await tx
|
||
.insert(schema.acmContestRank)
|
||
.values({
|
||
contestId,
|
||
userId,
|
||
submissionNumber: 0,
|
||
acceptedNumber: 0,
|
||
totalTime: 0,
|
||
submissionInfo: {},
|
||
})
|
||
.onConflictDoNothing({
|
||
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId],
|
||
})
|
||
|
||
const [rank] = await tx
|
||
.select()
|
||
.from(schema.acmContestRank)
|
||
.where(and(eq(schema.acmContestRank.contestId, contestId), eq(schema.acmContestRank.userId, userId)))
|
||
.for('update')
|
||
if (!rank) throw new Error('Contest rank could not be created')
|
||
|
||
const rankInfo = rank.submissionInfo
|
||
const previousInfo = rankInfo[String(problemId)]
|
||
const alreadyAccepted = previousInfo?.is_ac === true
|
||
if (!alreadyAccepted) {
|
||
const errorNumber = previousInfo?.error_number ?? 0
|
||
const nextInfo: ContestSubmissionInfo = {
|
||
is_ac: acceptedNow,
|
||
ac_time: 0,
|
||
error_number: errorNumber + (!acceptedNow && result !== JudgeStatus.COMPILE_ERROR ? 1 : 0),
|
||
is_first_ac: false,
|
||
}
|
||
let totalTime = rank.totalTime
|
||
let acceptedNumber = rank.acceptedNumber
|
||
if (acceptedNow) {
|
||
const acTime = Math.max(0, Math.floor((Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) / 1000))
|
||
nextInfo.ac_time = acTime
|
||
nextInfo.is_first_ac = problem.acceptedNumber === 0
|
||
acceptedNumber += 1
|
||
totalTime += acTime + errorNumber * 20 * 60
|
||
}
|
||
rankInfo[String(problemId)] = nextInfo
|
||
await tx
|
||
.update(schema.acmContestRank)
|
||
.set({
|
||
submissionNumber: rank.submissionNumber + 1,
|
||
acceptedNumber,
|
||
totalTime,
|
||
submissionInfo: rankInfo,
|
||
})
|
||
.where(eq(schema.acmContestRank.id, rank.id))
|
||
}
|
||
}
|
||
|
||
return true
|
||
})
|
||
}
|
||
|
||
async function markSystemError(submissionId: string, userId: number, error: unknown) {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
const updated = await db
|
||
.update(schema.submission)
|
||
.set({
|
||
result: JudgeStatus.SYSTEM_ERROR,
|
||
statisticInfo: { err_info: message, score: 0 },
|
||
})
|
||
.where(and(eq(schema.submission.id, submissionId), inArray(schema.submission.result, [JudgeStatus.PENDING, JudgeStatus.JUDGING])))
|
||
.returning({ id: schema.submission.id })
|
||
|
||
if (updated.length > 0) {
|
||
await publishSubmissionUpdate(userId, {
|
||
type: 'submission_update',
|
||
submissionId,
|
||
result: JudgeStatus.SYSTEM_ERROR,
|
||
status: 'error',
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判题任务在 `judgeSubmission` 之外失败时的兜底。
|
||
*
|
||
* 正常路径上的异常都被 judgeSubmission 自己的 try/catch 接住、落成 SYSTEM_ERROR
|
||
* 并且推给前端,所以能走到队列 `failed` 事件的只剩两种:取提交那一步就炸了,
|
||
* 以及**worker 进程中途死掉** —— 机房断电、容器 OOM 被杀、部署时重启。后一种
|
||
* BullMQ 会先按 stalled 重入队一次,再没人接就彻底放手;判题队列又没配 attempts,
|
||
* 失败即终局。没有这个兜底,那条提交就永远停在「等待评分」,学生看着转圈,
|
||
* 教师统计里它还占着一个「判题中」的名额。生产库里 3 条卡死的 PENDING
|
||
* (2022-11 / 2026-03 / 2026-04,都是旧栈时代留下的)就是这么来的。
|
||
*
|
||
* `markSystemError` 只动 PENDING / JUDGING 两个状态,所以判完了的、被重判改过的
|
||
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
|
||
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
|
||
*/
|
||
export async function failAbandonedSubmission(submissionId: string, error: unknown) {
|
||
const [row] = await db.select({ userId: schema.submission.userId }).from(schema.submission).where(eq(schema.submission.id, submissionId)).limit(1)
|
||
if (!row) return
|
||
await markSystemError(submissionId, row.userId, error)
|
||
}
|
||
|
||
/**
|
||
* 运行时错误的诊断,结果写进 `statistic_info.runtime_error`(字段说明见契约)。
|
||
*
|
||
* C / C++ 直接用失败测试点的信号和退出码。Python 要拿回溯,得**重跑那一个测试点**:
|
||
* 主判题不带回输出(每个点最多 16MB,见 sandbox.ts),所以这里读出第一个失败测试点的输入,
|
||
* 以内联测试点的形式单独再跑一次、打开 output。同样的代码、同样的输入,抛的是
|
||
* 同一个异常。只多跑一个点,而且只在 Python 判出运行时错误时才跑。
|
||
*
|
||
* 诊断失败(读不到测试点、判题机没回回溯)就不写这个字段,学生看到的和原来一样。
|
||
*/
|
||
export async function diagnoseRuntimeError(
|
||
row: {
|
||
submission: typeof schema.submission.$inferSelect
|
||
problem: typeof schema.problem.$inferSelect
|
||
},
|
||
source: string,
|
||
prependLines: number,
|
||
failed: JudgeCase,
|
||
): Promise<RuntimeErrorInfo | null> {
|
||
if (row.submission.language !== 'Python') return nativeRuntimeError(failed.signal, failed.exit_code)
|
||
|
||
const info = await readInfo(row.problem.testCaseId)
|
||
const inputName = info?.test_cases?.[failed.test_case]?.input_name
|
||
if (!inputName) return null
|
||
const input = await readFile(resolvePath(config.testCaseDirectory, row.problem.testCaseId, inputName), 'utf8')
|
||
const response = await requestJudge(row.submission.language, source, row.problem.timeLimit, row.problem.memoryLimit, [{ input, output: '' }], true)
|
||
const output = Array.isArray(response.data) ? response.data[0]?.output : null
|
||
if (typeof output !== 'string') return null
|
||
return parsePythonTraceback(output, row.submission.code, prependLines)
|
||
}
|
||
|
||
/** sample_check 里三段文本各自的上限。样例本身很短,截的主要是学生的输出(死循环打印) */
|
||
const SAMPLE_TEXT_LIMIT = 2000
|
||
|
||
type SampleCheck = NonNullable<StatisticInfo['sample_check']>
|
||
|
||
/**
|
||
* 答案错误时拿题目的**公开样例**重跑一次,记下第一个没过的样例和学生在它上面的输出,
|
||
* 写进 `statistic_info.sample_check`。
|
||
*
|
||
* 2025 秋以来的 6901 条答案错误在样例上重跑过:73% 在样例上就已经错了。样例写在题面上,
|
||
* 所以「输入 700,正确输出 7,你的输出 007」给学生看不泄露任何隐藏数据,而这正是他最
|
||
* 缺的线索 —— 原来答案错误只有四个字,连错在哪一类都不知道。其中约三分之一只差格式
|
||
* (多了提示文字、空格换行、中英文标点、小数位数),前端按差异类型再补一句中文提示。
|
||
*
|
||
* 和运行时错误的诊断一样,主判题不开 output;这里的样例是内联传的,输出只回这几个点。
|
||
*/
|
||
async function checkSamples(
|
||
row: {
|
||
submission: typeof schema.submission.$inferSelect
|
||
problem: typeof schema.problem.$inferSelect
|
||
},
|
||
source: string,
|
||
): Promise<SampleCheck | null> {
|
||
const samples = (Array.isArray(row.problem.samples) ? row.problem.samples : [])
|
||
.map((item) => asRecord(item))
|
||
.filter((item): item is { input: string; output: string } => typeof item.input === 'string' && typeof item.output === 'string')
|
||
if (!samples.length) return null
|
||
|
||
const response = await requestJudge(
|
||
row.submission.language,
|
||
source,
|
||
row.problem.timeLimit,
|
||
row.problem.memoryLimit,
|
||
// 题面上的样例输入常常没有结尾换行,而 input() 读到文件末尾没有换行照样能读,
|
||
// C 的 scanf 也不在乎。补一个是为了和测试点文件的样子一致
|
||
samples.map((item) => ({
|
||
input: item.input.endsWith('\n') ? item.input : `${item.input}\n`,
|
||
output: item.output,
|
||
})),
|
||
true,
|
||
)
|
||
// 在样例上就编译不过不会发生(正式判题刚编译过同一份代码),真遇到了就当没检查
|
||
if (response.err || !Array.isArray(response.data)) return null
|
||
const cases = [...(response.data as JudgeCase[])].sort((left, right) => Number(left.test_case) - Number(right.test_case))
|
||
const index = cases.findIndex((item) => item.result !== JudgeStatus.ACCEPTED)
|
||
if (index < 0) return { passed: true }
|
||
const sample = samples[index]!
|
||
const output = cases[index]!.output
|
||
return {
|
||
passed: false,
|
||
index,
|
||
input: sample.input.slice(0, SAMPLE_TEXT_LIMIT),
|
||
expected: sample.output.slice(0, SAMPLE_TEXT_LIMIT),
|
||
output: typeof output === 'string' ? output.slice(0, SAMPLE_TEXT_LIMIT) : '',
|
||
result: cases[index]!.result,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重判之前的结果**有没有记过账**。判完的结果都走过 persistResult、记过一次;
|
||
* 还在判的(卡在队列里被重判)和系统错误(markSystemError 只改结果、不动计数)没有,
|
||
* 重判时要当新提交记一次。判题机自己回的系统错误其实记过账,这里分不出来,按没记过算 ——
|
||
* 多记一次好过把别人的计数减掉
|
||
*/
|
||
function countedBefore(rejudgedFrom: number | undefined): JudgeStatusValue | null {
|
||
if (rejudgedFrom === undefined) return null
|
||
if (rejudgedFrom === JudgeStatus.PENDING || rejudgedFrom === JudgeStatus.JUDGING || rejudgedFrom === JudgeStatus.SYSTEM_ERROR) {
|
||
return null
|
||
}
|
||
return rejudgedFrom as JudgeStatusValue
|
||
}
|
||
|
||
export async function judgeSubmission(job: JudgeJobData) {
|
||
const [row] = await db
|
||
.select({
|
||
submission: schema.submission,
|
||
problem: schema.problem,
|
||
})
|
||
.from(schema.submission)
|
||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||
.where(and(eq(schema.submission.id, job.submissionId), eq(schema.problem.id, job.problemId)))
|
||
.limit(1)
|
||
|
||
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
|
||
if (![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(row.submission.result as 6 | 7)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await db.update(schema.submission).set({ result: JudgeStatus.JUDGING }).where(eq(schema.submission.id, row.submission.id))
|
||
await publishSubmissionUpdate(row.submission.userId, {
|
||
type: 'submission_update',
|
||
submissionId: row.submission.id,
|
||
result: JudgeStatus.JUDGING,
|
||
status: 'judging',
|
||
})
|
||
|
||
const rawTemplate = templateForLanguage(row.problem.template, row.submission.language)
|
||
const template = rawTemplate ? parseProblemTemplate(rawTemplate) : null
|
||
const source = template ? `${template.prepend}\n${row.submission.code}\n${template.append}` : row.submission.code
|
||
|
||
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
|
||
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
|
||
const response =
|
||
row.submission.language === 'SQL'
|
||
? await judgeSqlSubmission(row.problem, row.submission.code)
|
||
: await requestJudge(row.submission.language, source, row.problem.timeLimit, row.problem.memoryLimit, row.problem.testCaseId)
|
||
|
||
let result: JudgeStatusValue
|
||
let info: unknown = {}
|
||
let statisticInfo: Record<string, unknown> = {}
|
||
|
||
if (response.err) {
|
||
const errInfo = typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||
// 只有 CompileError 是学生的错。别的(JudgeClientError 的「Test case not found」
|
||
// 这类)是题目或判题机的问题:原来一律记成编译错误,学生看到一句英文、以为自己写错了,
|
||
// 全库 337 条,最近一条就在 2026-09。
|
||
if (response.err === 'CompileError') {
|
||
result = JudgeStatus.COMPILE_ERROR
|
||
statisticInfo = {
|
||
err_info: shiftCompileLines(errInfo, template ? template.prepend.split('\n').length : 0),
|
||
score: 0,
|
||
}
|
||
} else {
|
||
console.warn(`[judge] 判题机报错 ${row.submission.id}: ${response.err} ${errInfo}`)
|
||
result = JudgeStatus.SYSTEM_ERROR
|
||
statisticInfo = { err_info: errInfo, score: 0 }
|
||
}
|
||
} else {
|
||
if (!Array.isArray(response.data)) {
|
||
throw new Error('判题机回的结果看不懂')
|
||
}
|
||
const cases = [...response.data].sort((left, right) => Number(left.test_case) - Number(right.test_case))
|
||
info = { err: null, data: cases }
|
||
const firstFailure = cases.find((item) => item.result !== JudgeStatus.ACCEPTED)
|
||
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
|
||
statisticInfo = {
|
||
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
|
||
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
|
||
score: 0,
|
||
}
|
||
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
|
||
// error_message 里,而前端只读 statistic_info.err_info。把首个失败测试点的
|
||
// 提示提上来,否则学生只看到一个 WA 却不知道原因。对齐旧 sql_dispatcher。
|
||
const failedMessage = cases.find((item) => item.result !== JudgeStatus.ACCEPTED && item.error_message)?.error_message
|
||
if (typeof failedMessage === 'string') statisticInfo.err_info = failedMessage
|
||
|
||
// 比赛不诊断:ACM 只报对错,和「通过几个测试点」、AI 提示不给比赛提交同一个口径
|
||
if (result === JudgeStatus.RUNTIME_ERROR && firstFailure && row.submission.contestId === null) {
|
||
const prependLines = template ? template.prepend.split('\n').length : 0
|
||
const diagnosis = await diagnoseRuntimeError(row, source, prependLines, firstFailure).catch((error: unknown) => {
|
||
console.warn(`[judge] 运行时错误诊断失败 ${row.submission.id}:`, error)
|
||
return null
|
||
})
|
||
if (diagnosis) statisticInfo.runtime_error = diagnosis
|
||
}
|
||
|
||
// 同上,比赛不跑:比赛里有期末考试,格式提示在那里不该给
|
||
if (result === JudgeStatus.WRONG_ANSWER && row.submission.contestId === null && row.submission.language !== 'SQL') {
|
||
const check = await checkSamples(row, source).catch((error: unknown) => {
|
||
console.warn(`[judge] 样例重跑失败 ${row.submission.id}:`, error)
|
||
return null
|
||
})
|
||
if (check) statisticInfo.sample_check = check
|
||
}
|
||
|
||
if (result === JudgeStatus.ACCEPTED) {
|
||
const rules = astRulesForLanguage(row.problem.astRules, row.submission.language)
|
||
if (rules.length > 0) {
|
||
const ast = await checkAst(row.submission.code, row.submission.language, rules)
|
||
if (!ast.passed) {
|
||
result = JudgeStatus.AST_CHECK_FAILED
|
||
statisticInfo.ast_results = ast.results
|
||
}
|
||
}
|
||
}
|
||
|
||
// 挑战:做对了就拿这道题的每条挑战各判一遍(比赛不判,同上)。结果全存下来,
|
||
// 前端在挑战中时按 id 取它正在挑战的那条;做到的下面落库
|
||
if (result === JudgeStatus.ACCEPTED && row.submission.contestId === null) {
|
||
const challenges = challengesFor(row.problem.challenges, row.submission.language)
|
||
if (challenges.length > 0) {
|
||
statisticInfo.challenges = await judgeChallenges(row.submission.code, row.submission.language, challenges)
|
||
}
|
||
}
|
||
}
|
||
|
||
const counted = countedBefore(job.rejudgedFrom)
|
||
const saved = await persistResult(
|
||
row.submission.id,
|
||
row.problem.id,
|
||
row.submission.userId,
|
||
row.problem.displayId,
|
||
result,
|
||
info,
|
||
statisticInfo,
|
||
row.submission.contestId,
|
||
row.submission.createTime,
|
||
counted,
|
||
)
|
||
if (!saved) return
|
||
|
||
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
|
||
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
|
||
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
|
||
// 进度已经落库,跳回题单页看到的就是新数据。
|
||
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
|
||
if (row.submission.contestId === null && isAccepted(result)) {
|
||
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
|
||
}
|
||
|
||
if (Array.isArray(statisticInfo.challenges)) {
|
||
await recordChallengeSuccesses(
|
||
(statisticInfo.challenges as ChallengeVerdict[])
|
||
.filter((verdict) => verdict.passed)
|
||
.map((verdict) => ({
|
||
userId: row.submission.userId,
|
||
problemId: row.problem.id,
|
||
challengeId: verdict.id,
|
||
submissionId: row.submission.id,
|
||
createdAt: row.submission.createTime,
|
||
})),
|
||
).catch((error: unknown) => console.error(`[judge] 挑战记录失败 ${row.submission.id}:`, error))
|
||
}
|
||
|
||
try {
|
||
const unlocked = await updateAchievementsForSubmission(row.submission.id, counted)
|
||
await publishAchievementNotification(
|
||
row.submission.userId,
|
||
unlocked.map((achievement) => ({
|
||
id: achievement.id,
|
||
name: achievement.name,
|
||
description: achievement.description,
|
||
icon: achievement.icon,
|
||
rarity: achievement.rarity,
|
||
kind: 'achievement',
|
||
})),
|
||
)
|
||
} catch (error) {
|
||
console.error(`Failed to update achievements for ${row.submission.id}`, error)
|
||
}
|
||
|
||
await publishSubmissionUpdate(row.submission.userId, {
|
||
type: 'submission_update',
|
||
submissionId: row.submission.id,
|
||
result,
|
||
status: 'finished',
|
||
score: typeof statisticInfo.score === 'number' ? statisticInfo.score : undefined,
|
||
})
|
||
} catch (error) {
|
||
console.error(`Failed to judge submission ${row.submission.id}`, error)
|
||
await markSystemError(row.submission.id, row.submission.userId, error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
|
||
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
|
||
* 对齐旧 `judge/sql_dispatcher.py`。
|
||
*/
|
||
async function judgeSqlSubmission(problem: typeof schema.problem.$inferSelect, studentSql: string): Promise<JudgeResponse> {
|
||
const sqlConfig = asRecord(problem.sqlConfig)
|
||
const mode = sqlConfig.mode
|
||
if (mode !== 'query' && mode !== 'modify') {
|
||
throw new Error('题目缺少 SQL 配置(题型)')
|
||
}
|
||
const answers = Array.isArray(problem.answers) ? problem.answers : []
|
||
const refSql = answers.map((item) => asRecord(item)).find((item) => item.language === 'SQL' && typeof item.code === 'string' && item.code.trim())?.code
|
||
if (typeof refSql !== 'string') throw new Error('题目缺少 SQL 标准答案')
|
||
|
||
const info = await readInfo(problem.testCaseId)
|
||
if (!info) throw new Error('测试点信息读取失败')
|
||
if (!info.sql) throw new Error('测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包')
|
||
|
||
// 按 "1","2",… 的数字序遍历,保证测试点顺序稳定
|
||
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
|
||
if (keys.length === 0) throw new Error('题目没有任何测试点')
|
||
|
||
const cases: JudgeCase[] = []
|
||
for (const [index, key] of keys.entries()) {
|
||
const inputName = info.test_cases![key]!.input_name
|
||
const initSql = await readFile(resolvePath(config.testCaseDirectory, problem.testCaseId, inputName), 'utf8').catch(() => {
|
||
throw new Error(`测试点脚本 ${inputName} 读取失败`)
|
||
})
|
||
|
||
const outcome = await runSqlCase({
|
||
kind: 'judge',
|
||
initSql,
|
||
refSql,
|
||
studentSql,
|
||
mode,
|
||
orderSensitive: sqlConfig.order_sensitive === true,
|
||
timeLimitMs: problem.timeLimit,
|
||
memoryLimitMb: problem.memoryLimit,
|
||
})
|
||
if (!outcome.ok) {
|
||
// 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR
|
||
if (outcome.result === JudgeStatus.SYSTEM_ERROR) throw new Error(outcome.message)
|
||
// 子进程被杀(超时/内存)也走这里,按学生错误记成一个测试点
|
||
cases.push({
|
||
test_case: String(index + 1),
|
||
result: outcome.result,
|
||
cpu_time: 0,
|
||
real_time: 0,
|
||
memory: 0,
|
||
error_message: outcome.message,
|
||
} as unknown as JudgeCase)
|
||
break
|
||
}
|
||
const value = outcome.value
|
||
value.test_case = String(index + 1)
|
||
// 语法错误与数据无关,首个测试点即可确认,整题按编译错误处理
|
||
// (ACM 不罚时,前端展示 err_info)
|
||
if (index === 0 && value.result === JudgeStatus.COMPILE_ERROR) {
|
||
return { err: 'CompileError', data: value.error_message }
|
||
}
|
||
cases.push(value as unknown as JudgeCase)
|
||
}
|
||
return { err: null, data: cases }
|
||
}
|