Files
OJ2/apps/api/src/routes/admin/problem.ts
yuetsh ab47e71d6f
Some checks failed
Deploy / deploy (push) Has been cancelled
refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
## 出参改 satisfies

出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:

- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
  的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
  z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
  submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
  problem;列表为空时才碰巧不炸,所以一直没人报。

两个都是读出侧校验自己造出来的故障,不是它拦住的故障。

## 校验责任挪回写入侧

- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
  .language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
  .sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
  tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
  problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
  不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
  problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
  difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
  会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
  在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
  报文是真边界,失败返回 null 而不是 500。

顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。

## 文档

CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。

## 验证

- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
  数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
  前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
  建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
  (error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
  后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
  problemDisplayId 收到 undefined,改动后同样两个响应均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 18:14:05 -06:00

745 lines
34 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,
createProblemRequestSchema,
generateSqlTestCaseRequestSchema,
makeProblemPublicRequestSchema,
sqlPreviewRequestSchema,
updateProblemRequestSchema,
type AdminProblem,
type AdminProblemList,
type AdminProblemListItem,
type AstRules,
type GenerateSqlTestCaseResponse,
type SqlConfig,
type SqlDisplay,
type SqlTestCaseScript,
type UploadTestCaseResponse,
} 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 { astRulesError, pickAstRules } from "../../judge/ast"
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 { getTopReactions } from "../../services/reaction"
import { objectValue, queryInteger, sampleUser } 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) {
return (await tagNamesFor([problemId])).get(problemId) ?? []
}
/** 批量版:列表接口一定要走这个,按行调 tagNames 就是 N+1 */
async function tagNamesFor(problemIds: number[]) {
const result = new Map<number, string[]>()
if (problemIds.length === 0) return result
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(inArray(schema.problemTags.problemId, problemIds))
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
return result
}
/**
* 把一批标签名去空格、去重(大小写不敏感),保留每个名字第一次出现时的原始大小写。
* 解析和批量打标签两条路都用它,口径对齐旧 resolve_tags。
*/
export function normalizeTagNames(names: string[]) {
const wanted: string[] = []
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())
wanted.push(name)
}
return wanted
}
/**
* 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。
* 以前是每个名字一条 SELECT一次改十个标签就是十次往返。
*/
export async function findTagsByName(tx: typeof db, names: string[]) {
const map = new Map<string, number>()
if (names.length === 0) return map
const rows = await tx.select({ id: schema.problemTag.id, name: schema.problemTag.name })
.from(schema.problemTag)
.where(inArray(sql`lower(${schema.problemTag.name})`, names.map((name) => name.toLowerCase())))
for (const row of rows) map.set(row.name.toLowerCase(), row.id)
return map
}
/** 把标签名解析成 id去空格、大小写不敏感复用已有标签没有才新建。对齐旧 resolve_tags */
async function resolveTags(tx: typeof db, names: string[]) {
const wanted = normalizeTagNames(names)
if (wanted.length === 0) return []
const existing = await findTagsByName(tx, wanted)
const missing = wanted.filter((name) => !existing.has(name.toLowerCase()))
if (missing.length) {
const created = await tx.insert(schema.problemTag).values(missing.map((name) => ({ name })))
.returning({ id: schema.problemTag.id, name: schema.problemTag.name })
for (const row of created) existing.set(row.name.toLowerCase(), row.id)
}
return wanted.map((name) => existing.get(name.toLowerCase())!).filter((id) => id !== undefined)
}
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 {
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: row.languages,
template: 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),
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,
sqlDisplay: row.sqlDisplay,
} satisfies AdminProblem
}
/** 公共校验,对齐旧 `ProblemBase.common_checks` */
function commonChecks(data: {
languages: string[]
inputDescription: string
outputDescription: string
samples: unknown[]
sqlConfig: SqlConfig | null
answers: Record<string, unknown>[]
astRules: AstRules | null
}): { error: string } | { sql: boolean } {
const astError = astRulesError(pickAstRules(data.astRules, data.languages))
if (astError) return { error: astError }
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: SqlConfig,
): Promise<{ error: string } | { display: SqlDisplay }> {
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 outcome = await buildSqlDisplay(initSql, refSql, sqlConfig.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,
allowFlowchart: data.allowFlowchart,
showFlowchart: data.showFlowchart,
mermaidCode: data.mermaidCode,
flowchartHint: data.flowchartHint,
astRules: pickAstRules(data.astRules, data.languages),
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),
])
// 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致
const problemIds = rows.map(({ problem }) => problem.id)
const [topReactions, tags] = await Promise.all([getTopReactions(problemIds), tagNamesFor(problemIds)])
return success(c, {
results: rows.map(({ problem, user: creator, realName }) =>
({
id: problem.id,
_id: problem.displayId,
title: problem.title,
createdBy: sampleUser(creator, realName),
visible: problem.visible,
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [],
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: topReactions.get(problem.id) ?? null,
} satisfies AdminProblemListItem)),
total: totalRow[0]?.value ?? 0,
} satisfies AdminProblemList)
})
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: SqlDisplay | null = 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: SqlDisplay | null = 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)
})
/**
* 删题的共用实现。
*
* 「有提交就不让删」这道守卫**不能去掉**submission.problem_id 是本次唯一**没有**
* 改成 CASCADE 的题目外键0010就是为了让 12 万条历史提交不会被一次误删带走。
* 守卫先拦,报的是人话;库级外键是最后一道保险。
* 其余子表problem_tags / problemset_problem / problemset_submission / reaction /
* flowchart_submission全部交给 CASCADE原先这里手抄了四条 delete、且漏了
* problemset_submission —— 那条漏清被上面的守卫挡着,一直没能真的发作。
*
* 测试用例目录**不删** —— 与旧后端一致(它把 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.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),
])
const tags = await tagNamesFor(rows.map(({ problem }) => problem.id))
return success(c, {
results: rows.map(({ problem, user: creator, realName }) =>
({
id: problem.id,
_id: problem.displayId,
title: problem.title,
createdBy: sampleUser(creator, realName),
visible: problem.visible,
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [],
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
} satisfies AdminProblemListItem)),
total: totalRow[0]?.value ?? 0,
} satisfies AdminProblemList)
})
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: SqlDisplay | null = 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, {
id: result.testCaseId,
info: result.info,
} satisfies UploadTestCaseResponse, 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 satisfies SqlTestCaseScript[])
} 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, { sql } satisfies GenerateSqlTestCaseResponse)
} catch (error) {
console.error("SQL test case generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
}
})