feat(后台): 编程题出题页重做——左边写,右边是学生看到的样子;例子和测试数据的输出都由标准答案跑出来,改老题能直接看、改测试数据

- 例子就是测试数据里勾了「当例子」的几组,不再手抄输出;老题的例子不在测试数据里的补到最后
- 改了答案,输出变了的那组标黄、写清原来存的是什么;答案在某组上报错就挡住保存
- 没存标准答案的老题输出手写;太大的测试数据(>100 组或单文件 >32KB)只能导入 / 下载 zip
- 预制代码只写学生看得到的那段,前后藏起来的代码收进折叠;语法要求先拿标准答案自测
- 流程图两个互斥开关改成三选一;时间 / 内存限制能改了;草稿只给新建页
- 后端:读回测试点原文、语法要求自测、AI 想测试输入三个接口

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 20:07:34 +08:00
1 parent 033a245abb
commit ba5b9ada0e
15 files changed
+3043 -1068

No files matched your search

+99 -1
View File
@@ -1,6 +1,10 @@
import {
addContestProblemRequestSchema,
astCheckRequestSchema,
generateTestInputsRequestSchema,
isSqlProblem,
TEST_CASE_EDIT_MAX_CASES,
TEST_CASE_EDIT_MAX_FILE_BYTES,
createProblemRequestSchema,
generateSqlTestCaseRequestSchema,
makeProblemPublicRequestSchema,
@@ -9,11 +13,14 @@ import {
type AdminProblem,
type AdminProblemList,
type AdminProblemListItem,
type AstCheckResponse,
type AstRules,
type GenerateTestInputsResponse,
type GenerateSqlTestCaseResponse,
type SqlConfig,
type SqlDisplay,
type SqlTestCaseScript,
type TestCaseFiles,
type UploadTestCaseResponse,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, not, or, sql } from "drizzle-orm"
@@ -23,15 +30,17 @@ import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema, type DbOrTx } from "../../db"
import { failure, parseBody, success } from "../../http"
import { astRulesError, pickAstRules } from "../../judge/ast"
import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast"
import { buildSqlDisplay } from "../../judge/sql"
import { completeChat } from "../../services/ai"
import { plainText } from "../../services/hint-diagnosis"
import { contestStatus } from "../../services/contest"
import {
packTestCaseZip,
processTestCaseZip,
readInfo,
readSqlScripts,
readTestCaseFiles,
TestCaseError,
} from "../../services/test-case"
import { config } from "../../config"
@@ -863,6 +872,95 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
}
})
/**
* 回显编程题已有的测试点原文,出题页就地改。太大的(见契约 TEST_CASE_EDIT_*)只给数量和大小。
* SQL 题的测试点是脚本,走上面的 sql-scripts。
*/
adminProblemRoutes.get("/problems/:id/test-case-files", requireProblemPermission, async (c) => {
const [problem] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const info = await readInfo(problem.testCaseId)
if (info?.sql) return failure(c, 409, "sql-test-case", "该题的测试点是 SQL 脚本")
try {
const files = await readTestCaseFiles(problem.testCaseId, {
maxCases: TEST_CASE_EDIT_MAX_CASES,
maxFileBytes: TEST_CASE_EDIT_MAX_FILE_BYTES,
})
return success(c, files satisfies TestCaseFiles)
} catch (error) {
if (error instanceof TestCaseError)
return failure(c, 404, "test-case-not-found", "测试点文件不存在")
throw error
}
})
/** 语法要求:每条规则学生看到的那句话 + 标准答案过不过,和判题时同一个 checkAst */
adminProblemRoutes.post("/ast-check", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, astCheckRequestSchema)
if (!parsed.success) return parsed.response
const { language, code, rules } = parsed.data
const error = astRulesError({ [language]: rules })
if (error) return failure(c, 400, "invalid-ast-rules", error)
const checked = []
for (const rule of rules) {
const description = describeAstRule(rule, language)
if (!code.trim()) {
checked.push({ description, passed: null })
continue
}
const [result] = (await checkAst(code, language, [rule])).results
checked.push({ description, passed: result?.passed ?? null, actual: result?.actual })
}
return success(c, { rules: checked } satisfies AstCheckResponse)
})
/**
* AI 按题面和标准答案想几组测试输入。只要输入:输出一律由标准答案在判题机上跑,
* AI 编的输出不可信。
*/
adminProblemRoutes.post("/test-inputs/generate", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, generateTestInputsRequestSchema)
if (!parsed.success) return parsed.response
const body = parsed.data
const existing = body.existing.length
? body.existing.map((input, i) => `第 ${i + 1} 组:\n${input}`).join("\n\n")
: "(还没有)"
try {
const raw = await completeChat(
`你是编程题出题助手,帮老师补测试数据。老师会给你题目描述、输入说明、标准答案和已有的测试输入。
请再想 5 组新的测试输入,要和已有的不重复,重点覆盖边界情况(最小值、最大值、0、负数、
重复元素、只有一个元素、各个分支都要走到……),但必须严格符合输入说明的格式,标准答案要能正常处理。
数据规模保持小,每组不超过 20 行。
只回 json:{"inputs": ["第一组输入", "第二组输入", ...]},每组输入是一个字符串,多行用 \\n 分隔。`,
[
`题目描述:${plainText(body.description).slice(0, 3000)}`,
`输入说明:${plainText(body.inputDescription).slice(0, 1000) || "无"}`,
`标准答案(${body.language}):\n${body.answer}`,
`已有的测试输入:\n${existing.slice(0, 4000)}`,
].join("\n\n"),
{ json: true },
)
const value: unknown = JSON.parse(raw)
const list =
value && typeof value === "object" && Array.isArray((value as { inputs?: unknown }).inputs)
? ((value as { inputs: unknown[] }).inputs.filter((x) => typeof x === "string") as string[])
: []
const known = new Set(body.existing.map((input) => input.trim()))
const inputs = list.filter((input) => !known.has(input.trim())).slice(0, 10)
return success(c, { inputs } satisfies GenerateTestInputsResponse)
} catch (error) {
console.error("Test input generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
}
})
/** SQL 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */
adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, sqlPreviewRequestSchema)
+1 -1
View File
@@ -88,7 +88,7 @@ function errInfo(row: HintRow) {
* 输出说明里的 `&lt;`、`&nbsp;` 恰恰可能就是格式要求本身,不解码模型会读歪。
* 图片直接丢掉(模型看不到图),只留一个占位,让它知道那里原本有东西。
*/
function plainText(html: string) {
export function plainText(html: string) {
return (
html
.replace(/<img\b[^>]*>/gi, "[图片]")
+38 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"
import { mkdir, chmod, readdir, readFile, writeFile } from "node:fs/promises"
import { mkdir, chmod, readdir, readFile, stat, writeFile } from "node:fs/promises"
import { resolve } from "node:path"
import { unzipSync, zipSync } from "fflate"
@@ -250,6 +250,43 @@ export async function readSqlScripts(testCaseId: string) {
return scripts
}
/**
* 读回编程题测试点的原文(出题页回显、就地改)。超过 `limits` 的只报数量和大小,不读内容。
* 目录不存在时抛 TestCaseError。
*/
export async function readTestCaseFiles(
testCaseId: string,
limits: { maxCases: number; maxFileBytes: number },
): Promise<
| { editable: true; cases: { input: string; output: string }[] }
| { editable: false; count: number; totalBytes: number }
> {
const directory = resolve(config.testCaseDirectory, testCaseId)
let entries: string[]
try {
entries = await readdir(directory)
} catch {
throw new TestCaseError("Test case does not exist")
}
const pairs = collectPairs(new Set(entries))
let totalBytes = 0
let tooLarge = pairs.length > limits.maxCases
for (const name of pairs.flat()) {
const size = (await stat(resolve(directory, name))).size
totalBytes += size
if (size > limits.maxFileBytes) tooLarge = true
}
if (tooLarge) return { editable: false, count: pairs.length, totalBytes }
const cases: { input: string; output: string }[] = []
for (const [input, output] of pairs) {
cases.push({
input: await readFile(resolve(directory, input), "utf8"),
output: await readFile(resolve(directory, output), "utf8"),
})
}
return { editable: true, cases }
}
function randomId() {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
const bytes = new Uint8Array(32)