Some checks failed
Deploy / deploy (push) Has been cancelled
## 出参改 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
745 lines
34 KiB
TypeScript
745 lines
34 KiB
TypeScript
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", "生成失败,请稍后再试")
|
||
}
|
||
})
|