Files
OJ2/apps/api/src/routes/admin/problem.ts
yuetsh cafa92a102 fix(阶段4评审收尾): 清掉三条 Minor,顺带一个真 bug
## M4 禁用账号会把学生卡在登录死循环里(唯一学生会撞上的)

`getSessionUser` 对禁用用户返回 null,于是落到 401 `login-required`,
而前端拦截器见到这个码就弹登录框 —— 一个上课上到一半被禁用的学生会陷入
「弹登录框 → 登进去 → 又被弹」,完全看不出发生了什么。

会话解析改成返回 `{ user } | { user: null, reason: "anonymous" | "disabled" }`,
禁用报 403 `account-disabled`(凭证有效、是账号不让用了,和 login 接口对禁用
账号的回法一致)。会话照删,禁用立即生效。前端补一支:清登录态 + 明确提示,
**不弹登录框**。

实测:会话中途 UPDATE is_disabled=true → 同一会话下一个请求
403 `account-disabled`「账号已被禁用,请联系老师」。

## M3 三个端点的守卫写在 handler 体内

submissions/statistics、submissions/:id/rejudge、flowcharts/statistics 的档位
本来就是对的,但写成 handler 里的 if,违背了「守卫要从注册行上看得出来」的约定,
下一个人加同类端点容易漏掉那个 if。改用 requireTeacher / requireSuperAdmin。

实测档位没变:普通学生三个都 403;教师统计接口 200、重判仍 403。

## M2 from-public 的错误码构成比赛存在性预言机

比赛不存在回 `not-found`、存在但不属于你回 `contest-not-found`,带一个已知
有效的 problemId 就能靠错误码枚举出哪些 contestId 真实存在。统一成
`contest-not-found`,和全仓其余跨租户路径一致。

实测:两种情况现在都是 404 contest-not-found。

## 顺带:比赛里的 SQL 题看不到示例数据

改 M2 时 tsc 报 `sqlDisplay` 声明了没用到 —— 查下去是真 bug:
`POST /contests/:id/problems` 把展示数据算出来了,却往库里写死 null
(公开题那两条路径都是对的)。于是比赛里的 SQL 题打开后没有示例数据表和期望结果。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:23:23 -06:00

699 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
addContestProblemRequestSchema,
adminProblemListItemSchema,
adminProblemListSchema,
adminProblemSchema,
createProblemRequestSchema,
makeProblemPublicRequestSchema,
updateProblemRequestSchema,
generateSqlTestCaseRequestSchema,
generateSqlTestCaseResponseSchema,
sqlPreviewRequestSchema,
sqlTestCaseScriptSchema,
uploadTestCaseResponseSchema,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
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>()
type ProblemRow = typeof schema.problem.$inferSelect
function canManageAll(user: AuthUser) {
return user.adminType === "Super Admin" || user.problemPermission === "All"
}
/**
* 题目归属判断。比赛题看**比赛**的创建者,公开题看题目自己的创建者 ——
* 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。
* 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
*/
async function canEdit(user: AuthUser, problem: ProblemRow) {
if (user.adminType === "Super Admin") return true
if (problem.contestId === null) {
return canManageAll(user) || problem.createdById === user.id
}
const [contest] = await db.select({ createdById: schema.contest.createdById })
.from(schema.contest).where(eq(schema.contest.id, problem.contestId)).limit(1)
return Boolean(contest && contest.createdById === user.id)
}
async function tagNames(problemId: number) {
const rows = await db.select({ name: schema.problemTag.name }).from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTags.problemId, problemId))
return rows.map((row) => row.name)
}
/** 把标签名解析成 id去空格、大小写不敏感复用已有标签没有才新建。对齐旧 resolve_tags */
async function resolveTags(tx: typeof db, names: string[]) {
const ids: number[] = []
const seen = new Set<string>()
for (const raw of names) {
const name = raw.trim()
if (!name || seen.has(name.toLowerCase())) continue
seen.add(name.toLowerCase())
const [existing] = await tx.select({ id: schema.problemTag.id }).from(schema.problemTag)
.where(sql`lower(${schema.problemTag.name}) = lower(${name})`).limit(1)
if (existing) { ids.push(existing.id); continue }
const [created] = await tx.insert(schema.problemTag).values({ name })
.returning({ id: schema.problemTag.id })
ids.push(created!.id)
}
return ids
}
async function setTags(tx: typeof db, problemId: number, names: string[]) {
const ids = await resolveTags(tx, names)
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemId, problemId))
if (ids.length) {
await tx.insert(schema.problemTags).values(ids.map((problemtagId) => ({ problemId, problemtagId })))
}
}
async function serialize(row: ProblemRow) {
const [[creator], tags] = await Promise.all([
db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.id, row.createdById)).limit(1),
tagNames(row.id),
])
return adminProblemSchema.parse({
id: row.id,
_id: row.displayId,
title: row.title,
description: row.description,
inputDescription: row.inputDescription,
outputDescription: row.outputDescription,
samples: Array.isArray(row.samples) ? row.samples : [],
testCaseId: row.testCaseId,
testCaseScore: Array.isArray(row.testCaseScore) ? row.testCaseScore : [],
hint: row.hint,
languages: stringArray(row.languages),
template: objectValue(row.template),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
timeLimit: row.timeLimit,
memoryLimit: row.memoryLimit,
visible: row.visible,
difficulty: row.difficulty,
source: row.source,
submissionNumber: row.submissionNumber,
acceptedNumber: row.acceptedNumber,
statisticInfo: objectValue(row.statisticInfo),
shareSubmission: row.shareSubmission,
contestId: row.contestId,
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
isPublic: row.isPublic,
tags,
allowFlowchart: row.allowFlowchart,
showFlowchart: row.showFlowchart,
mermaidCode: row.mermaidCode,
flowchartHint: row.flowchartHint,
astRules: row.astRules,
answers: Array.isArray(row.answers) ? row.answers : [],
prompt: row.prompt,
sqlConfig: row.sqlConfig ? objectValue(row.sqlConfig) : null,
sqlDisplay: row.sqlDisplay ? objectValue(row.sqlDisplay) : null,
})
}
/** 公共校验,对齐旧 `ProblemBase.common_checks` */
function commonChecks(data: {
languages: string[]
inputDescription: string
outputDescription: string
samples: unknown[]
sqlConfig: Record<string, unknown> | null
answers: Record<string, unknown>[]
}): { error: string } | { sql: boolean } {
if (data.languages.includes("SQL")) {
if (data.languages.length !== 1) return { error: "SQL problem cannot be mixed with other languages" }
if (!data.sqlConfig) return { error: "SQL problem requires sql_config" }
const hasAnswer = data.answers.some((item) =>
item.language === "SQL" && typeof item.code === "string" && item.code.trim())
if (!hasAnswer) return { error: "SQL problem requires a SQL reference answer" }
return { sql: true }
}
if (!data.inputDescription || !data.outputDescription) {
return { error: "Input and output description are required" }
}
if (data.samples.length === 0) return { error: "Samples are required" }
return { sql: false }
}
/**
* 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 {
displayId: data._id,
title: data.title,
description: data.description,
inputDescription: data.inputDescription,
outputDescription: data.outputDescription,
samples: data.samples,
testCaseId: data.testCaseId,
testCaseScore: data.testCaseScore,
hint: data.hint,
languages: data.languages,
template: data.template,
timeLimit: data.timeLimit,
memoryLimit: data.memoryLimit,
visible: data.visible,
difficulty: data.difficulty,
source: data.source,
shareSubmission: data.shareSubmission,
allowFlowchart: data.allowFlowchart,
showFlowchart: data.showFlowchart,
mermaidCode: data.mermaidCode,
flowchartHint: data.flowchartHint,
astRules: data.astRules ?? null,
answers: data.answers,
prompt: data.prompt,
// 防脏数据:非 SQL 题不应携带 SQL 配置,对齐旧 common_checks
sqlConfig: isSql ? data.sqlConfig : null,
}
}
// ---------------------------------------------------------------- 公开题目
adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const user = c.get("user")!
const filters = [isNull(schema.problem.contestId)]
if (!canManageAll(user)) filters.push(eq(schema.problem.createdById, user.id))
const author = c.req.query("author")?.trim()
const keyword = c.req.query("keyword")?.trim()
const tagId = c.req.query("tagId")?.trim()
if (author) filters.push(eq(schema.user.username, author))
if (keyword) {
filters.push(or(
ilike(schema.problem.title, `%${keyword}%`),
ilike(schema.problem.displayId, `%${keyword}%`),
)!)
}
if (tagId) {
filters.push(inArray(schema.problem.id,
db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
.where(eq(schema.problemTags.problemtagId, Number(tagId)))))
}
const where = and(...filters)
const [totalRow, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)).where(where),
db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset),
])
return success(c, adminProblemListSchema.parse({
results: await Promise.all(rows.map(async ({ problem, user: creator, realName }) =>
adminProblemListItemSchema.parse({
id: problem.id,
_id: problem.displayId,
title: problem.title,
createdBy: sampleUser(creator, realName),
visible: problem.visible,
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: await tagNames(problem.id),
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
}))),
total: totalRow[0]?.value ?? 0,
}))
})
adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
const [row] = await db.select().from(schema.problem)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, row))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
return success(c, await serialize(row))
})
adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
const parsed = createProblemRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const checked = commonChecks(parsed.data)
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
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)
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
const [row] = await tx.insert(schema.problem).values({
...problemValues(parsed.data, checked.sql),
contestId: null,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
isPublic: false,
sqlDisplay,
}).returning()
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
return row!
})
return success(c, await serialize(created), 201)
})
adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateProblemRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [existing] = await db.select().from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const checked = commonChecks(parsed.data)
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)
.where(and(
eq(schema.problem.displayId, parsed.data._id),
existing.contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, existing.contestId),
ne(schema.problem.id, id),
)).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(),
}).where(eq(schema.problem.id, id)).returning()
await setTags(tx as unknown as typeof db, id, parsed.data.tags)
return row!
})
return success(c, await serialize(updated))
})
// 公开题与比赛题共用一条删除路由。旧接口分成两个admin/problem 与
// admin/contest/problem但两边都只按题目 id 取、比赛是从题目推导出来的,
// 分开没有意义,还逼前端多传一个它未必知道的 contestId。
adminProblemRoutes.delete("/problems/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db.select().from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exists")
if (!(await canEdit(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exists")
}
return deleteProblem(c, id)
})
/**
* 删题的共用实现。子表全是 NO ACTION 外键Django 的级联在应用层,得手工清。
* 测试用例目录**不删** —— 与旧后端一致(它把 rmtree 注释掉了)。
* 删错了还能从磁盘捞回来,而误删的测试数据没有别处备份;孤儿目录另有清理入口。
*/
async function deleteProblem(c: Parameters<typeof success>[0], id: number) {
const [submissions] = await db.select({ value: count() }).from(schema.submission)
.where(eq(schema.submission.problemId, id))
if ((submissions?.value ?? 0) > 0) {
return failure(c, 409, "problem-has-submissions", "该题目已有提交记录,不能删除")
}
await db.transaction(async (tx) => {
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemId, id))
await tx.delete(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemId, id))
await tx.delete(schema.flowchartSubmission).where(eq(schema.flowchartSubmission.problemId, id))
await tx.delete(schema.reaction).where(eq(schema.reaction.problemId, id))
await tx.delete(schema.problem).where(eq(schema.problem.id, id))
})
return success(c, null)
}
// ---------------------------------------------------------------- 比赛题目
adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission, async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.problem.contestId, contestId)]
const keyword = c.req.query("keyword")?.trim()
if (keyword) filters.push(ilike(schema.problem.title, `%${keyword}%`))
const where = and(...filters)
const [totalRow, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problem).where(where),
db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset),
])
return success(c, adminProblemListSchema.parse({
results: await Promise.all(rows.map(async ({ problem, user: creator, realName }) =>
adminProblemListItemSchema.parse({
id: problem.id,
_id: problem.displayId,
title: problem.title,
createdBy: sampleUser(creator, realName),
visible: problem.visible,
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: await tagNames(problem.id),
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
}))),
total: totalRow[0]?.value ?? 0,
}))
})
adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermission, async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const parsed = createProblemRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const checked = commonChecks(parsed.data)
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
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)
if (duplicate) return failure(c, 409, "display-id-exists", "Duplicate Display id")
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
const [row] = await tx.insert(schema.problem).values({
...problemValues(parsed.data, checked.sql),
contestId,
createdById: user.id,
createTime: now,
lastUpdateTime: now,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
isPublic: false,
// 上面 generateSqlDisplay 已经把展示数据算好了,之前这里写死 null
// 结果比赛里的 SQL 题打开后看不到示例数据表和期望结果(公开题那两条路径都是对的)
sqlDisplay,
}).returning()
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
return row!
})
return success(c, await serialize(created), 201)
})
// ---------------------------------------------------------------- 比赛题 ⇄ 公开题
adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = makeProblemPublicRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "displayId 不能为空")
const [problem] = await db.select().from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
// 归属校验不能少:这个接口会把整道题(含 answers 标准答案)复制出来并回传,
// 没有它,任何有出题权的人拿别人比赛题的 id 就能把题面和答案整份拿走。
// 旧后端同样缺这个校验,但它只 `return self.success()` 不带数据,泄露面比这里小。
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (!problem.contestId || problem.isPublic) {
return failure(c, 409, "already-public", "Already be a public problem")
}
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)
.where(and(eq(schema.problem.displayId, parsed.data.displayId), isNull(schema.problem.contestId))).limit(1)
if (duplicate) return failure(c, 409, "display-id-exists", "Duplicate display ID")
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
// 原比赛题标记成「已转公开」,避免同一道题被转两次
await tx.update(schema.problem).set({ isPublic: true }).where(eq(schema.problem.id, id))
const { id: _old, ...rest } = problem
const [copy] = await tx.insert(schema.problem).values({
...rest,
contestId: null,
displayId: parsed.data.displayId,
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
visible: false,
isPublic: true,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createTime: now,
lastUpdateTime: now,
}).returning()
const tags = await tx.select({ tagId: schema.problemTags.problemtagId })
.from(schema.problemTags).where(eq(schema.problemTags.problemId, id))
if (tags.length) {
await tx.insert(schema.problemTags).values(tags.map((tag) => ({
problemId: copy!.id, problemtagId: tag.tagId,
})))
}
return copy!
})
return success(c, await serialize(created), 201)
})
adminProblemRoutes.post("/contests/:contestId/problems/from-public", requireProblemPermission, async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const parsed = addContestProblemRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
const [problem] = await db.select().from(schema.problem)
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
const user = c.get("user")!
// 「比赛不存在」和「比赛存在但不是你的」必须回同一个码。分开报的话,带一个已知有效的
// problemId 就能靠错误码差异枚举出哪些 contestId 真实存在。全仓其余跨租户路径都是
// 统一码contest 系列一律 contest-not-found这里对齐。
const denyContest =
!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)
if (denyContest) return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
// 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 ——
// 于是能把别人比赛里的题(或别人尚未公开的草稿)拖进自己比赛,进而读到 answers。
if (problem.contestId !== null) {
return failure(c, 400, "not-a-public-problem", "只能从公开题库添加题目")
}
if (!problem.visible && !(await canEdit(user, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (contestStatus(contest) === "-1") return failure(c, 409, "contest-ended", "Contest has ended")
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)
.where(and(eq(schema.problem.contestId, contestId), eq(schema.problem.displayId, parsed.data.displayId))).limit(1)
if (duplicate) return failure(c, 409, "display-id-exists", "Duplicate display id in this contest")
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
const { id: _old, ...rest } = problem
const [copy] = await tx.insert(schema.problem).values({
...rest,
contestId,
isPublic: true,
visible: true,
displayId: parsed.data.displayId,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createTime: now,
lastUpdateTime: now,
}).returning()
const tags = await tx.select({ tagId: schema.problemTags.problemtagId })
.from(schema.problemTags).where(eq(schema.problemTags.problemId, problem.id))
if (tags.length) {
await tx.insert(schema.problemTags).values(tags.map((tag) => ({
problemId: copy!.id, problemtagId: tag.tagId,
})))
}
return copy!
})
return success(c, await serialize(created), 201)
})
// ---------------------------------------------------------------- 测试用例
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
const form = await c.req.formData().catch(() => null)
const file = form?.get("file")
if (!(file instanceof File)) return failure(c, 400, "invalid-request", "Upload failed")
const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? ""))
try {
const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql })
return success(c, uploadTestCaseResponseSchema.parse({
id: result.testCaseId,
info: result.info,
}), 201)
} catch (error) {
if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message)
console.error("Failed to process test case zip", error)
return failure(c, 500, "test-case-error", "测试点处理失败")
}
})
adminProblemRoutes.get("/problems/:id/test-cases", 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 exists")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exists")
}
try {
const archive = await packTestCaseZip(problem.testCaseId)
return new Response(archive, {
headers: {
"content-type": "application/zip",
"content-disposition": `attachment; filename=problem_${problem.id}_test_cases.zip`,
},
})
} catch (error) {
if (error instanceof TestCaseError) return failure(c, 404, "test-case-not-found", error.message)
throw error
}
})
/**
* 回显 SQL 题已上传的测试点脚本内容。只读磁盘上的 N.sql不需要 SQL 引擎 ——
* 同组的 sql-preview / sql-ai-gen 要跑 SQLite 生成展示数据,新后端还没有那条链路,
* 那两个仍在旧后端上。
*/
adminProblemRoutes.get("/problems/:id/sql-scripts", 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 exists")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exists")
}
const info = await readInfo(problem.testCaseId)
if (!info) return failure(c, 404, "test-case-info-unreadable", "测试点信息读取失败")
if (!info.sql) return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型")
try {
const scripts = await readSqlScripts(problem.testCaseId)
return success(c, scripts.map((script) => sqlTestCaseScriptSchema.parse(script)))
} catch (error) {
console.error("Failed to read SQL test case scripts", error)
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", "生成失败,请稍后再试")
}
})