feat(阶段2补课): SQL 判题链路 + 最后两个后台端点
新后端此前完全没有 SQL 判题(旧 judge/sql_runner.py 378 行 + sql_dispatcher.py 113 行无对应实现),阶段 2 纵切时漏了这条与沙箱完全不同的路径。 judge/sql/engine.ts 判题核心,移植自 sql_runner.py,判定口径逐条对齐 judge/sql/child.ts 子进程入口 judge/sql/index.ts 父进程:spawn + 硬超时 judge/run.ts language === "SQL" 时分流,不经判题沙箱 POST admin/sql-test-cases/preview 题目页展示数据预览 POST admin/sql-test-cases/generate AI 按标准答案倒推初始化脚本 题目保存时重新生成 sqlDisplay(对齐旧 generate_sql_display):取测试点 1 的初始化 脚本 + 标准答案跑一遍,失败一律拦下不让保存 —— 展示数据直接决定学生看到的表结构 与期望结果,宁可不保存也不能存错的。 ## 防护换了实现,逐条实测 bun:sqlite 没有 authorizer / progress_handler / setlimit,且实测 Worker.terminate() 杀不掉跑飞的查询(原生代码占着线程)。改用「WASM 引擎 + 独立子进程」: ATTACH → WASM 无宿主文件系统绑定,结构上够不到(比旧的 authorizer 更强) 查询题只读 → PRAGMA query_only=1 超时 → 子进程外部 SIGKILL 单值内存 → 子进程 ulimit -d 八条提交实测:正确→Accepted;列少一个/漏过滤→Wrong Answer;语法错误→Compile Error; 查询题里 INSERT→运行错误并说明;递归 CTE 死循环→CPU 超时;hex(zeroblob(2e8))→内存超限; attach '/etc/passwd'→打不开。 ## 踩到的两个坑(已写进 docs/specs/phase3-coverage.md) 1. ulimit 必须用 -d 不能用 -v。-v 限虚拟地址空间而 JS 引擎预留巨量地址,实测 -v 之下 Bun 退出时有概率 panic(SIGILL),结果早已写出但进程异常终止,父进程读到空串 误判成超时 —— 6 次里坏 2 次。换 -d 后 12/12 稳定。 2. 子进程写完结果直接 SIGKILL 自己,不走 process.exit()——后者仍有清理会撞限额。 至此 admin/api.ts 已无任何指向旧后端的调用。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,12 +16,18 @@ import {
|
||||
type JudgeStatusValue,
|
||||
} from "./status"
|
||||
import { parseProblemTemplate } from "./template"
|
||||
import { runSqlCase } from "./sql"
|
||||
import { readInfo } from "../services/test-case"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { resolve as resolvePath } from "node:path"
|
||||
|
||||
interface JudgeCase {
|
||||
cpu_time: number
|
||||
memory: number
|
||||
result: number
|
||||
test_case: string
|
||||
/** SQL 判题会带上中文原因,沙箱判题没有这个字段 */
|
||||
error_message?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -350,13 +356,17 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
? `${template.prepend}\n${row.submission.code}\n${template.append}`
|
||||
: row.submission.code
|
||||
|
||||
const response = await requestJudge(
|
||||
row.submission.language,
|
||||
source,
|
||||
row.problem.timeLimit,
|
||||
row.problem.memoryLimit,
|
||||
row.problem.testCaseId,
|
||||
)
|
||||
// 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 = {}
|
||||
@@ -386,6 +396,13 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
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
|
||||
|
||||
if (result === JudgeStatus.ACCEPTED) {
|
||||
const rules = astRulesForLanguage(
|
||||
@@ -454,3 +471,76 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
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 = objectValue(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) => objectValue(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 }
|
||||
}
|
||||
|
||||
88
apps/api/src/judge/sql/child.ts
Normal file
88
apps/api/src/judge/sql/child.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* SQL 判题子进程的入口。
|
||||
*
|
||||
* 单独起进程的唯一理由是**能被杀掉**:SQLite 的查询跑在原生代码里,
|
||||
* 实测 Worker.terminate() 抢占不了,只有 OS 的 SIGKILL 可靠。
|
||||
* 父进程见 `./index.ts`。
|
||||
*
|
||||
* 协议:stdin 读一段 JSON 作业,stdout 写一段 JSON 结果;
|
||||
* 阶段标记写 stderr,父进程在超时杀掉本进程后据此判断卡在哪一阶段。
|
||||
*/
|
||||
|
||||
import { writeSync } from "node:fs"
|
||||
|
||||
import { buildDisplay, runCase, SqlCaseError } from "./engine"
|
||||
import { JudgeStatus } from "../status"
|
||||
|
||||
export type SqlJob =
|
||||
| {
|
||||
kind: "judge"
|
||||
initSql: string
|
||||
refSql: string
|
||||
studentSql: string
|
||||
mode: "query" | "modify"
|
||||
orderSensitive: boolean
|
||||
timeLimitMs: number
|
||||
memoryLimitMb: number
|
||||
}
|
||||
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
|
||||
|
||||
function markPhase(phase: string) {
|
||||
process.stderr.write(`@phase:${phase}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写完结果立刻硬退出,不走 Bun 的正常 teardown。
|
||||
*
|
||||
* 实测在 `ulimit -v` 之下,Bun 退出时的清理有概率撞上地址空间上限而 panic
|
||||
* (SIGILL,exit 132)—— 结果其实已经写到 stdout 了,但进程异常终止会让父进程
|
||||
* 读到空串,进而误判成超时。这个 panic 是不确定的,同样的输入时好时坏,
|
||||
* 正是最难查的那种。这里跳过 teardown:子进程本来就是一次性的,没有要优雅关闭的资源。
|
||||
*/
|
||||
function finish(payload: unknown): never {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload))
|
||||
// 直接写 fd 1 并确认写完,再自杀。不用 process.exit():实测它仍会走一段清理,
|
||||
// 在 ulimit 之下有概率 panic(SIGILL,6 次里 2 次),而结果早已写出,
|
||||
// 父进程却因进程异常终止读到空串、误判成超时 —— 时好时坏,最难查的那种。
|
||||
let written = 0
|
||||
while (written < bytes.length) {
|
||||
written += writeSync(1, bytes, written, bytes.length - written)
|
||||
}
|
||||
process.kill(process.pid, "SIGKILL")
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const raw = await new Response(Bun.stdin.stream()).text()
|
||||
const job = JSON.parse(raw) as SqlJob
|
||||
try {
|
||||
if (job.kind === "display") {
|
||||
markPhase("display")
|
||||
const display = await buildDisplay(job.initSql, job.refSql, job.mode)
|
||||
finish({ ok: true, display })
|
||||
}
|
||||
markPhase("judge")
|
||||
const result = await runCase(job.initSql, job.refSql, job.studentSql, {
|
||||
mode: job.mode,
|
||||
orderSensitive: job.orderSensitive,
|
||||
timeLimitMs: job.timeLimitMs,
|
||||
memoryLimitMb: job.memoryLimitMb,
|
||||
})
|
||||
finish({ ok: true, case: result })
|
||||
} catch (error) {
|
||||
if (error instanceof SqlCaseError) {
|
||||
finish({ ok: false, result: error.result, message: error.detail })
|
||||
}
|
||||
// WASM 堆触顶时 emscripten 抛的是普通 Error("Aborted"/"out of memory"),
|
||||
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
|
||||
const message = String((error as Error)?.message ?? error)
|
||||
const memoryish = message.includes("out of memory") || message.includes("Aborted")
|
||||
finish({
|
||||
ok: false,
|
||||
result: memoryish ? JudgeStatus.MEMORY_LIMIT_EXCEEDED : JudgeStatus.SYSTEM_ERROR,
|
||||
message: memoryish ? "内存超出限制" : message.slice(0, 200),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
467
apps/api/src/judge/sql/engine.ts
Normal file
467
apps/api/src/judge/sql/engine.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* SQL 题判题核心:在 WASM SQLite 里分别执行标准答案和学生 SQL 并比对结果。
|
||||
* 移植自旧后端 `judge/sql_runner.py`,判定口径逐条对齐。
|
||||
*
|
||||
* 查询题(mode="query")比对最后一条 SELECT 的结果集;
|
||||
* 增删改题(mode="modify")比对执行后所有用户表的最终状态。
|
||||
*
|
||||
* ## 防护为什么和旧实现不一样
|
||||
*
|
||||
* 旧实现用 Python sqlite3 的 set_authorizer / set_progress_handler / setlimit 三件套。
|
||||
* bun:sqlite 一个都没有,且实测 Worker.terminate() 杀不掉跑飞的查询(原生代码占着线程)。
|
||||
* 所以改成「WASM 引擎 + 独立子进程」,逐条替代:
|
||||
*
|
||||
* | 旧防护 | 新做法 |
|
||||
* |---|---|
|
||||
* | authorizer 禁 ATTACH(防读写服务器任意 SQLite 文件) | WASM 没有宿主文件系统绑定,ATTACH **结构上**够不到宿主,只能碰随进程消失的虚拟 FS |
|
||||
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1`,SQLite 原生只读开关 |
|
||||
* | progress_handler 墙钟超时 | 子进程外部 SIGKILL(OS 级,比指令计数更硬)+ 语句间 deadline 检查 |
|
||||
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -v`,触顶时 WASM 抛可捕获错误 |
|
||||
* | max_page_count | 原样保留 |
|
||||
*/
|
||||
|
||||
import initSqlJs, { type Database, type SqlJsStatic } from "sql.js"
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
||||
|
||||
/** 单结果集/单表最大行数,防 CROSS JOIN 撑爆内存 */
|
||||
const ROW_LIMIT = 10_000
|
||||
/** 题目页展示的行数上限(示例数据/期望结果) */
|
||||
const DISPLAY_ROW_LIMIT = 20
|
||||
const ERROR_MESSAGE_MAX_LEN = 200
|
||||
|
||||
/** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */
|
||||
const SYNTAX_ERROR_MARKERS = ["syntax error", "unrecognized token", "incomplete input"]
|
||||
|
||||
export class SqlCaseError extends Error {
|
||||
constructor(readonly result: JudgeStatusValue, readonly detail: string) {
|
||||
super(detail)
|
||||
}
|
||||
}
|
||||
|
||||
let cached: SqlJsStatic | null = null
|
||||
|
||||
export async function sqlEngine() {
|
||||
if (cached) return cached
|
||||
const binary = readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm"))
|
||||
// @types/sql.js 把 wasmBinary 标成 ArrayBuffer,实际 emscripten 接受 TypedArray;
|
||||
// 这里传 Uint8Array 是运行时正确的写法,类型上断言掉
|
||||
cached = await initSqlJs({ wasmBinary: binary as unknown as ArrayBuffer })
|
||||
return cached
|
||||
}
|
||||
|
||||
function truncate(message: string) {
|
||||
return message.length > ERROR_MESSAGE_MAX_LEN
|
||||
? `${message.slice(0, ERROR_MESSAGE_MAX_LEN)}...`
|
||||
: message
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 值归一化
|
||||
|
||||
type Canonical = string
|
||||
|
||||
/**
|
||||
* 值归一化并打类型标签,防止 NULL/"NULL"、1/"1" 碰撞;数值统一比对
|
||||
* (1 == 1.0,浮点保留 6 位有效数字)。与旧 `_canonical_value` 同口径。
|
||||
*/
|
||||
function canonicalValue(value: unknown): Canonical {
|
||||
if (value === null || value === undefined) return "null"
|
||||
if (value instanceof Uint8Array) return `blob:${Buffer.from(value).toString("hex")}`
|
||||
if (typeof value === "number") {
|
||||
if (Number.isInteger(value) && Math.abs(value) < 2 ** 53) return `num:${value}`
|
||||
// Python 的 format(v, ".6g")
|
||||
return `num:${formatG6(value)}`
|
||||
}
|
||||
if (typeof value === "bigint") return `num:${value}`
|
||||
return `str:${String(value)}`
|
||||
}
|
||||
|
||||
/** 等价于 Python 的 format(v, ".6g") */
|
||||
function formatG6(value: number) {
|
||||
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
|
||||
if (exponent < -4 || exponent >= 6) {
|
||||
return value.toExponential(5).replace(/\.?0+e/, "e").replace(/e([+-])(\d)$/, "e$10$2")
|
||||
}
|
||||
const text = value.toPrecision(6)
|
||||
return text.includes(".") ? text.replace(/\.?0+$/, "") : text
|
||||
}
|
||||
|
||||
function canonicalRow(row: unknown[]) {
|
||||
return row.map(canonicalValue).join("")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 执行
|
||||
|
||||
interface ResultSet {
|
||||
columns: number
|
||||
rows: string[]
|
||||
}
|
||||
|
||||
function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) {
|
||||
const db = new SQL.Database()
|
||||
const limit = Math.max(Math.trunc(memoryLimitMb), 1)
|
||||
db.run("PRAGMA page_size=4096")
|
||||
// 4096B/页 × 256 页/MB,超限报 "database or disk is full"
|
||||
db.run(`PRAGMA max_page_count=${limit * 256}`)
|
||||
return db
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。
|
||||
*
|
||||
* 用 sql.js 的 iterateStatements(底层是 sqlite3_prepare_v2 逐条推进),
|
||||
* 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。
|
||||
*/
|
||||
function executeStatements(db: Database, script: string, deadline: number): ResultSet | null {
|
||||
let last: ResultSet | null = null
|
||||
for (const statement of (db as unknown as {
|
||||
iterateStatements(sql: string): Iterable<{
|
||||
step(): boolean
|
||||
get(): unknown[]
|
||||
getColumnNames(): string[]
|
||||
free(): void
|
||||
}>
|
||||
}).iterateStatements(script)) {
|
||||
if (Date.now() > deadline) {
|
||||
statement.free()
|
||||
throw new Error("interrupted")
|
||||
}
|
||||
try {
|
||||
const names = statement.getColumnNames()
|
||||
if (names.length > 0) {
|
||||
const rows: string[] = []
|
||||
while (statement.step()) {
|
||||
rows.push(canonicalRow(statement.get()))
|
||||
if (rows.length > ROW_LIMIT) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT} 行`)
|
||||
}
|
||||
}
|
||||
last = { columns: names.length, rows }
|
||||
} else {
|
||||
while (statement.step()) { /* 无结果集语句,推进到结束 */ }
|
||||
}
|
||||
} finally {
|
||||
statement.free()
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
|
||||
function dumpTables(db: Database) {
|
||||
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
const state: Record<string, { columns: number; rows: string[] }> = {}
|
||||
for (const table of names) {
|
||||
const quoted = String(table).replaceAll('"', '""')
|
||||
const result = db.exec(`SELECT * FROM "${quoted}"`)
|
||||
const first = result[0]
|
||||
const rows = (first?.values ?? []).map((row) => canonicalRow(row as unknown[]))
|
||||
if (rows.length > ROW_LIMIT) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `表 ${table} 超过 ${ROW_LIMIT} 行`)
|
||||
}
|
||||
state[String(table)] = {
|
||||
// 空表 exec 不返回结果,列数用 table_info 兜底
|
||||
columns: first?.columns.length ?? tableColumnCount(db, quoted),
|
||||
rows: rows.sort(),
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function tableColumnCount(db: Database, quotedTable: string) {
|
||||
return db.exec(`PRAGMA table_info("${quotedTable}")`)[0]?.values.length ?? 0
|
||||
}
|
||||
|
||||
function queryColumn(db: Database, sql: string) {
|
||||
return (db.exec(sql)[0]?.values ?? []).map((row) => row[0])
|
||||
}
|
||||
|
||||
function trustedErrorText(message: string) {
|
||||
if (message.includes("interrupted")) return "超时"
|
||||
return truncate(message)
|
||||
}
|
||||
|
||||
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
|
||||
function executeTrusted(db: Database, script: string, deadline: number, prefix: string) {
|
||||
try {
|
||||
return executeStatements(db, script, deadline)
|
||||
} catch (error) {
|
||||
if (error instanceof SqlCaseError) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${error.detail}`)
|
||||
}
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${trustedErrorText(String((error as Error).message))}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 带防护执行学生 SQL,异常映射为学生级 JudgeStatus */
|
||||
function runStudent(db: Database, script: string, mode: string, deadline: number) {
|
||||
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
|
||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||
try {
|
||||
const last = executeStatements(db, script, deadline)
|
||||
if (mode === "query") return last
|
||||
return dumpTables(db)
|
||||
} catch (error) {
|
||||
if (error instanceof SqlCaseError) throw error
|
||||
const message = String((error as Error).message)
|
||||
if (message.includes("interrupted")) {
|
||||
throw new SqlCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时")
|
||||
}
|
||||
if (message.includes("database or disk is full")) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制")
|
||||
}
|
||||
// WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限
|
||||
if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
|
||||
}
|
||||
if (message.includes("readonly database")) {
|
||||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)")
|
||||
}
|
||||
if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) {
|
||||
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
|
||||
}
|
||||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
|
||||
} finally {
|
||||
if (mode === "query") {
|
||||
try { db.run("PRAGMA query_only=0") } catch { /* 连接可能已不可用 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compare(
|
||||
expected: unknown,
|
||||
actual: unknown,
|
||||
mode: string,
|
||||
orderSensitive: boolean,
|
||||
) {
|
||||
if (mode === "query") {
|
||||
const exp = expected as ResultSet
|
||||
const act = actual as ResultSet
|
||||
if (exp.columns !== act.columns) return false
|
||||
if (orderSensitive) return exp.rows.join("") === act.rows.join("")
|
||||
return [...exp.rows].sort().join("") === [...act.rows].sort().join("")
|
||||
}
|
||||
return JSON.stringify(expected) === JSON.stringify(actual)
|
||||
}
|
||||
|
||||
export interface RunCaseOptions {
|
||||
mode: "query" | "modify"
|
||||
orderSensitive: boolean
|
||||
timeLimitMs: number
|
||||
memoryLimitMb: number
|
||||
}
|
||||
|
||||
export interface CaseResult {
|
||||
test_case: string
|
||||
result: JudgeStatusValue
|
||||
cpu_time: number
|
||||
real_time: number
|
||||
memory: number
|
||||
signal: number
|
||||
exit_code: number
|
||||
error: number
|
||||
output_md5: string
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判一个测试点,返回与外部 judger 单测试点同构的结构。
|
||||
* 学生错误(CE/WA/TLE/MLE/RE)体现在返回值里;出题配置错误抛 SqlCaseError(SYSTEM_ERROR)。
|
||||
*/
|
||||
export async function runCase(
|
||||
initSql: string,
|
||||
refSql: string,
|
||||
studentSql: string,
|
||||
options: RunCaseOptions,
|
||||
): Promise<CaseResult> {
|
||||
const SQL = await sqlEngine()
|
||||
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞
|
||||
const trustedLimitMs = Math.max(options.timeLimitMs * 5, 10_000)
|
||||
|
||||
let expected: unknown
|
||||
const refDb = newDatabase(SQL, options.memoryLimitMb)
|
||||
try {
|
||||
executeTrusted(refDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
|
||||
const last = executeTrusted(refDb, refSql, Date.now() + trustedLimitMs, "标准答案执行失败")
|
||||
if (options.mode === "query") {
|
||||
expected = last
|
||||
} else {
|
||||
try {
|
||||
expected = dumpTables(refDb)
|
||||
} catch (error) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
refDb.close()
|
||||
}
|
||||
if (options.mode === "query" && expected === null) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
|
||||
}
|
||||
|
||||
const result: CaseResult = {
|
||||
test_case: "",
|
||||
result: JudgeStatus.ACCEPTED,
|
||||
cpu_time: 0,
|
||||
real_time: 0,
|
||||
memory: 0,
|
||||
signal: 0,
|
||||
exit_code: 0,
|
||||
error: 0,
|
||||
output_md5: "",
|
||||
error_message: null,
|
||||
}
|
||||
|
||||
const studentDb = newDatabase(SQL, options.memoryLimitMb)
|
||||
let actual: unknown
|
||||
let elapsed = 0
|
||||
try {
|
||||
executeTrusted(studentDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
|
||||
const start = Date.now()
|
||||
try {
|
||||
actual = runStudent(studentDb, studentSql, options.mode, start + options.timeLimitMs)
|
||||
} catch (error) {
|
||||
elapsed = Date.now() - start
|
||||
const failure = error as SqlCaseError
|
||||
return { ...result, result: failure.result, error_message: failure.detail, cpu_time: elapsed, real_time: elapsed }
|
||||
}
|
||||
elapsed = Date.now() - start
|
||||
} finally {
|
||||
studentDb.close()
|
||||
}
|
||||
|
||||
result.cpu_time = elapsed
|
||||
result.real_time = elapsed
|
||||
if (options.mode === "query" && (actual === null || actual === undefined)) {
|
||||
result.result = JudgeStatus.WRONG_ANSWER
|
||||
result.error_message = "提交的 SQL 未产生查询结果集"
|
||||
} else if (!compare(expected, actual, options.mode, options.orderSensitive)) {
|
||||
result.result = JudgeStatus.WRONG_ANSWER
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 题目页展示数据
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
if (value instanceof Uint8Array) return Buffer.from(value).toString("hex")
|
||||
return value as string | number | null
|
||||
}
|
||||
|
||||
interface DisplayTable {
|
||||
name: string
|
||||
columns: { name: string; type: string }[]
|
||||
rows: (string | number | null)[][]
|
||||
total_rows: number
|
||||
truncated: boolean
|
||||
dropped?: boolean
|
||||
}
|
||||
|
||||
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
|
||||
function dumpDisplayTables(db: Database, only?: Set<string>): DisplayTable[] {
|
||||
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
const tables: DisplayTable[] = []
|
||||
for (const raw of names) {
|
||||
const name = String(raw)
|
||||
if (only && !only.has(name)) continue
|
||||
const quoted = name.replaceAll('"', '""')
|
||||
const columns = (db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []).map((row) => ({
|
||||
name: String(row[1]),
|
||||
type: String(row[2] ?? ""),
|
||||
}))
|
||||
const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0)
|
||||
const rows = (db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]?.values ?? [])
|
||||
.map((row) => (row as unknown[]).map(displayValue))
|
||||
tables.push({ name, columns, rows, total_rows: total, truncated: total > DISPLAY_ROW_LIMIT })
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
/**
|
||||
* 给查询结果的列名标上类型:按列名回查数据表的声明类型,与数据表展示同源(如 VARCHAR(20))。
|
||||
* 表达式/聚合列(COUNT(*)、别名等)在数据表里无同名列,类型留空(前端隐藏)。
|
||||
*/
|
||||
function queryResultColumns(names: string[], tables: DisplayTable[]) {
|
||||
const types = new Map<string, string>()
|
||||
for (const table of tables) {
|
||||
for (const column of table.columns) types.set(column.name, column.type)
|
||||
}
|
||||
return names.map((name) => ({ name, type: types.get(name) ?? "" }))
|
||||
}
|
||||
|
||||
/** 生成题目页展示数据:初始数据表 + 期望结果。失败一律抛 SqlCaseError(出题配置问题) */
|
||||
export async function buildDisplay(
|
||||
initSql: string,
|
||||
refSql: string,
|
||||
mode: "query" | "modify",
|
||||
memoryLimitMb = 64,
|
||||
) {
|
||||
const SQL = await sqlEngine()
|
||||
const db = newDatabase(SQL, memoryLimitMb)
|
||||
const deadline = Date.now() + 10_000
|
||||
try {
|
||||
executeTrusted(db, initSql, deadline, "初始化脚本执行失败")
|
||||
const tables = dumpDisplayTables(db)
|
||||
|
||||
if (mode === "query") {
|
||||
let expected: unknown = null
|
||||
try {
|
||||
for (const statement of (db as unknown as {
|
||||
iterateStatements(sql: string): Iterable<{
|
||||
step(): boolean; get(): unknown[]; getColumnNames(): string[]; free(): void
|
||||
}>
|
||||
}).iterateStatements(refSql)) {
|
||||
try {
|
||||
const names = statement.getColumnNames()
|
||||
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
|
||||
const rows: unknown[][] = []
|
||||
while (statement.step()) {
|
||||
rows.push(statement.get())
|
||||
if (rows.length > ROW_LIMIT) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT} 行`)
|
||||
}
|
||||
}
|
||||
expected = {
|
||||
columns: queryResultColumns(names, tables),
|
||||
rows: rows.slice(0, DISPLAY_ROW_LIMIT).map((row) => row.map(displayValue)),
|
||||
total_rows: rows.length,
|
||||
truncated: rows.length > DISPLAY_ROW_LIMIT,
|
||||
}
|
||||
} finally {
|
||||
statement.free()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SqlCaseError) throw error
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`)
|
||||
}
|
||||
if (expected === null) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
|
||||
}
|
||||
return { tables, expected }
|
||||
}
|
||||
|
||||
const before = dumpTables(db)
|
||||
executeTrusted(db, refSql, Date.now() + 10_000, "标准答案执行失败")
|
||||
const after = dumpTables(db)
|
||||
const changed = new Set<string>()
|
||||
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
|
||||
}
|
||||
if (changed.size === 0) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
|
||||
}
|
||||
const changedTables = dumpDisplayTables(db, changed)
|
||||
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
|
||||
const existing = new Set(changedTables.map((table) => table.name))
|
||||
for (const table of tables) {
|
||||
if (changed.has(table.name) && !existing.has(table.name)) {
|
||||
changedTables.push({ ...table, rows: [], total_rows: 0, truncated: false, dropped: true })
|
||||
}
|
||||
}
|
||||
return { tables, expected: { changed_tables: changedTables } }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
88
apps/api/src/judge/sql/index.ts
Normal file
88
apps/api/src/judge/sql/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { resolve } from "node:path"
|
||||
|
||||
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
||||
import type { CaseResult } from "./engine"
|
||||
import type { SqlJob } from "./child"
|
||||
|
||||
/**
|
||||
* SQL 作业的父进程侧:起一个短命子进程跑,到点 SIGKILL。
|
||||
*
|
||||
* 为什么非要子进程:SQLite 查询在原生代码里执行,实测 Worker.terminate() 抢占不了
|
||||
* (递归 CTE 死循环能把整个 worker 卡死),只有 OS 的 SIGKILL 可靠。
|
||||
*
|
||||
* 内存交给 OS:`ulimit -d`(数据段)给子进程封顶,触顶时 WASM 抛可捕获错误,
|
||||
* 子进程照常回报 MEMORY_LIMIT_EXCEEDED。
|
||||
*
|
||||
* **必须用 -d 不能用 -v。** -v 限的是虚拟地址空间,而 JS 引擎会预留巨量地址,
|
||||
* 实测 -v 之下 Bun 退出时有概率 panic(SIGILL)—— 结果早已写出但进程异常终止,
|
||||
* 父进程读到空串误判成超时,时好时坏。-d 限的是实际提交的内存(Linux 4.7 起
|
||||
* 也覆盖匿名 mmap),512MB 下正常判题稳定、`hex(zeroblob(2e8))` 被拦。
|
||||
*/
|
||||
|
||||
/** 子进程数据段上限(KB)。低于 512MB Bun 自己起不来 */
|
||||
const CHILD_DATA_LIMIT_KB = 512 * 1024
|
||||
/** 父进程的兜底墙钟。比作业自报的时限宽裕,只负责杀掉真正跑飞的进程 */
|
||||
const HARD_TIMEOUT_SLACK_MS = 15_000
|
||||
|
||||
export interface SqlJobFailure {
|
||||
ok: false
|
||||
result: JudgeStatusValue
|
||||
message: string
|
||||
}
|
||||
|
||||
type SqlJobOutcome<T> = { ok: true; value: T } | SqlJobFailure
|
||||
|
||||
async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T>> {
|
||||
const entry = resolve(import.meta.dir, "child.ts")
|
||||
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口
|
||||
const child = Bun.spawn(
|
||||
["sh", "-c", `ulimit -d ${CHILD_DATA_LIMIT_KB}; exec "$0" run "$1"`, process.execPath, entry],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
child.stdin.write(JSON.stringify(job))
|
||||
await child.stdin.end()
|
||||
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), budgetMs + HARD_TIMEOUT_SLACK_MS)
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
try {
|
||||
;[stdout, stderr] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
await child.exited
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
// 子进程没来得及写结果就没了 —— 要么被我们 SIGKILL,要么被内核 OOM 掉。
|
||||
// 用 stderr 里的阶段标记区分:卡在受信脚本是出题问题,卡在学生 SQL 是超时。
|
||||
const phase = stderr.includes("@phase:") ? stderr.split("@phase:")[1]?.split("\n")[0] : null
|
||||
if (phase === "display") {
|
||||
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "生成展示数据超时或内存超限,请检查初始化脚本与标准答案" }
|
||||
}
|
||||
return { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as
|
||||
| { ok: true; case?: CaseResult; display?: unknown }
|
||||
| SqlJobFailure
|
||||
if (!parsed.ok) return parsed
|
||||
return { ok: true, value: (parsed.case ?? parsed.display) as T }
|
||||
} catch {
|
||||
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "SQL 判题子进程返回了无法解析的结果" }
|
||||
}
|
||||
}
|
||||
|
||||
export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
|
||||
return runJob<CaseResult>(job, Math.max(job.timeLimitMs * 5, 10_000))
|
||||
}
|
||||
|
||||
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
|
||||
return runJob<{ tables: unknown[]; expected: unknown }>(
|
||||
{ kind: "display", initSql, refSql, mode },
|
||||
10_000,
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
createProblemRequestSchema,
|
||||
makeProblemPublicRequestSchema,
|
||||
updateProblemRequestSchema,
|
||||
generateSqlTestCaseRequestSchema,
|
||||
generateSqlTestCaseResponseSchema,
|
||||
sqlPreviewRequestSchema,
|
||||
sqlTestCaseScriptSchema,
|
||||
uploadTestCaseResponseSchema,
|
||||
} from "@oj2/contract"
|
||||
@@ -16,8 +19,13 @@ import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { buildSqlDisplay } from "../../judge/sql"
|
||||
import { completeChat } from "../../services/ai"
|
||||
import { contestStatus } from "../../services/contest"
|
||||
import { packTestCaseZip, processTestCaseZip, readInfo, readSqlScripts, TestCaseError } from "../../services/test-case"
|
||||
import { config } from "../../config"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { resolve } from "node:path"
|
||||
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
|
||||
|
||||
export const adminProblemRoutes = new Hono<AppEnv>()
|
||||
@@ -123,13 +131,7 @@ async function serialize(row: ProblemRow) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 非 SQL 题的公共校验,对齐旧 `ProblemBase.common_checks` 的 else 分支。
|
||||
*
|
||||
* SQL 分支在这里**只做前置校验、不生成 sqlDisplay** —— 生成需要跑 SQLite,
|
||||
* 而新后端还没有 SQL 判题链路(旧 judge/sql_runner.py 378 行无对应实现)。
|
||||
* 因此新建 SQL 题会被拒绝,编辑 SQL 题则保留原有 sqlDisplay 不动,不会把已有数据弄坏。
|
||||
*/
|
||||
/** 公共校验,对齐旧 `ProblemBase.common_checks` */
|
||||
function commonChecks(data: {
|
||||
languages: string[]
|
||||
inputDescription: string
|
||||
@@ -153,8 +155,38 @@ function commonChecks(data: {
|
||||
return { sql: false }
|
||||
}
|
||||
|
||||
const SQL_NOT_SUPPORTED =
|
||||
"新后端尚未实现 SQL 判题链路(题目页展示数据需要跑 SQLite 生成),暂不能新建 SQL 题。已有 SQL 题可以正常编辑,其展示数据保持不变。"
|
||||
/**
|
||||
* SQL 题保存时生成题目页展示数据(数据表 + 期望结果)。
|
||||
* 对齐旧 `problem/utils.py:generate_sql_display`:取**测试点 1** 的初始化脚本 + 标准答案跑一遍。
|
||||
* 失败一律拦下不让保存 —— 展示数据直接决定学生看到的表结构与期望结果,宁可不保存也不能存错的。
|
||||
*/
|
||||
async function generateSqlDisplay(
|
||||
testCaseId: string,
|
||||
answers: Record<string, unknown>[],
|
||||
sqlConfig: Record<string, unknown>,
|
||||
): Promise<{ error: string } | { display: unknown }> {
|
||||
const info = await readInfo(testCaseId)
|
||||
if (!info) return { error: "测试点信息读取失败,请重新上传测试点" }
|
||||
if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" }
|
||||
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
|
||||
if (keys.length === 0) return { error: "题目没有任何测试点" }
|
||||
const inputName = info.test_cases![keys[0]!]?.input_name
|
||||
if (!inputName) return { error: "测试点信息损坏,请重新上传测试点" }
|
||||
let initSql: string
|
||||
try {
|
||||
initSql = await readFile(resolve(config.testCaseDirectory, testCaseId, inputName), "utf8")
|
||||
} catch {
|
||||
return { error: `测试点脚本 ${inputName} 读取失败` }
|
||||
}
|
||||
const refSql = answers.find(
|
||||
(item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim(),
|
||||
)?.code
|
||||
if (typeof refSql !== "string") return { error: "题目缺少 SQL 标准答案" }
|
||||
const mode = sqlConfig.mode === "modify" ? "modify" as const : "query" as const
|
||||
const outcome = await buildSqlDisplay(initSql, refSql, mode)
|
||||
if (!outcome.ok) return { error: `SQL 展示数据生成失败: ${outcome.message}` }
|
||||
return { display: outcome.value }
|
||||
}
|
||||
|
||||
function problemValues(data: ReturnType<typeof createProblemRequestSchema.parse>, isSql: boolean) {
|
||||
return {
|
||||
@@ -257,7 +289,12 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
|
||||
if (checked.sql) return failure(c, 501, "sql-not-supported", SQL_NOT_SUPPORTED)
|
||||
let sqlDisplay: unknown = null
|
||||
if (checked.sql) {
|
||||
const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!)
|
||||
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
|
||||
sqlDisplay = built.display
|
||||
}
|
||||
|
||||
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(eq(schema.problem.displayId, parsed.data._id), isNull(schema.problem.contestId))).limit(1)
|
||||
@@ -266,7 +303,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
const now = new Date().toISOString()
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx.insert(schema.problem).values({
|
||||
...problemValues(parsed.data, false),
|
||||
...problemValues(parsed.data, checked.sql),
|
||||
contestId: null,
|
||||
createdById: c.get("user")!.id,
|
||||
createTime: now,
|
||||
@@ -275,7 +312,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
isPublic: false,
|
||||
sqlDisplay: null,
|
||||
sqlDisplay,
|
||||
}).returning()
|
||||
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
|
||||
return row!
|
||||
@@ -308,12 +345,19 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
)).limit(1)
|
||||
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
|
||||
|
||||
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
|
||||
let sqlDisplay: unknown = null
|
||||
if (checked.sql) {
|
||||
const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!)
|
||||
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
|
||||
sqlDisplay = built.display
|
||||
}
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const [row] = await tx.update(schema.problem).set({
|
||||
...problemValues(parsed.data, checked.sql),
|
||||
sqlDisplay,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
// SQL 题的 sqlDisplay 原样保留:重新生成需要跑 SQLite,新后端还没有那条链路。
|
||||
// 不动它比生成一个错的更安全 —— 它直接决定题目页给学生看的表结构与期望结果。
|
||||
}).where(eq(schema.problem.id, id)).returning()
|
||||
await setTags(tx as unknown as typeof db, id, parsed.data.tags)
|
||||
return row!
|
||||
@@ -411,7 +455,12 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
|
||||
if (checked.sql) return failure(c, 501, "sql-not-supported", SQL_NOT_SUPPORTED)
|
||||
let sqlDisplay: unknown = null
|
||||
if (checked.sql) {
|
||||
const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!)
|
||||
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
|
||||
sqlDisplay = built.display
|
||||
}
|
||||
|
||||
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(eq(schema.problem.displayId, parsed.data._id), eq(schema.problem.contestId, contestId))).limit(1)
|
||||
@@ -420,7 +469,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
|
||||
const now = new Date().toISOString()
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx.insert(schema.problem).values({
|
||||
...problemValues(parsed.data, false),
|
||||
...problemValues(parsed.data, checked.sql),
|
||||
contestId,
|
||||
createdById: user.id,
|
||||
createTime: now,
|
||||
@@ -594,3 +643,37 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
|
||||
return failure(c, 500, "test-case-error", "测试点脚本读取失败")
|
||||
}
|
||||
})
|
||||
|
||||
/** SQL 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */
|
||||
adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => {
|
||||
const parsed = sqlPreviewRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const outcome = await buildSqlDisplay(parsed.data.initSql, parsed.data.refSql, parsed.data.mode)
|
||||
if (!outcome.ok) return failure(c, 400, "sql-preview-failed", outcome.message)
|
||||
return success(c, outcome.value)
|
||||
})
|
||||
|
||||
/** AI 按标准答案倒推表结构、生成一份自洽的初始化脚本 */
|
||||
adminProblemRoutes.post("/sql-test-cases/generate", requireProblemPermission, async (c) => {
|
||||
const parsed = generateSqlTestCaseRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
try {
|
||||
const sql = await completeChat(
|
||||
`你是一个 SQL 出题助手。用户会给你一道 SQL 题的标准答案(查询题的
|
||||
SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。
|
||||
请你推断出该标准答案所需要的表结构,生成一份自洽的 SQLite 兼容初始化脚本,
|
||||
包含 CREATE TABLE 和若干条 INSERT 语句,插入的数据要足够让标准答案跑出有意义的结果
|
||||
(比如查询题要有能被筛选出来和被过滤掉的行;增删改题要有能被改动和不受影响的行)。
|
||||
请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`,
|
||||
`题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`,
|
||||
)
|
||||
return success(c, generateSqlTestCaseResponseSchema.parse({ sql }))
|
||||
} catch (error) {
|
||||
console.error("SQL test case generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user