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)