feat(阶段4): 题目管理 / 比赛题目 / 比赛题⇄公开题互转

GET/POST          admin/problems
  GET/PUT/DELETE    admin/problems/:id
  POST              admin/problems/:id/make-public
  GET/POST          admin/contests/:contestId/problems
  POST              admin/contests/:contestId/problems/from-public

合并了旧后端拆开的两套路由:旧 admin/problem 与 admin/contest/problem 的
GET 详情、PUT、DELETE 都只按题目 id 取、比赛是从题目推导出来的,分开没有意义,
还逼前端多传一个它未必知道的 contestId。现在共用 /admin/problems/:id。

归属判断按旧后端的口径分开:**比赛题看比赛的创建者,公开题看题目自己的创建者**
(旧 ensure_created_by(problem.contest, user) vs ensure_created_by(problem, user))。
一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
题号唯一性的作用域也跟着走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一。

删题不删测试用例目录,与旧后端一致(它把 rmtree 注释掉了):删错了还能从磁盘捞回来,
而误删的测试数据没有别处备份;孤儿目录另有清理入口。有提交记录的题目拒绝删除。

## SQL 题目前只能编辑、不能新建

新后端**没有 SQL 判题链路** —— 旧 judge/sql_runner.py(378 行)+ sql_dispatcher.py
(113 行)无对应实现,languages.ts 里也只有 C/C++/Java/Golang/JavaScript/Python3 六种。
题目页给学生看的表结构与期望结果(sql_display)要跑 SQLite 现算,算不出来就没法建题。

因此:新建 SQL 题返回 501 并说明原因;**编辑已有 SQL 题时原样保留 sqlDisplay 不动** ——
不动它比生成一个错的安全,它直接决定学生看到的期望结果。SQL 的前置校验
(必须是唯一语言、要有 sql_config、要有 SQL 标准答案)照旧执行。

实测:学生 403;缺样例/缺输入描述/SQL 混语言三条校验文案与旧后端一致;
题号重复 409、跨比赛题号互不影响;公开题加入比赛后计数归零且标签带过来;
比赛题转公开后 visible=false、contestId=null、重复转 409;删除级联正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:37:20 -06:00
parent 764e0d28cc
commit e396d78a07
5 changed files with 702 additions and 33 deletions

View File

@@ -487,3 +487,102 @@ export const acTrendSchema = z.object({
export const generateFlowchartRequestSchema = z.object({ python: z.string().min(1).max(64 * 1024) })
export const generateFlowchartResponseSchema = z.object({ flowchart: z.string() })
// ---------------------------------------------------------------- 题目管理
export const adminProblemListItemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
createdBy: sampleUserSchema,
visible: z.boolean(),
createTime: z.string(),
difficulty: z.string(),
tags: z.array(z.string()),
hasAstRules: z.boolean(),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),
topReaction: z.string().nullable(),
})
export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema)
/** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */
export const adminProblemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
description: z.string(),
inputDescription: z.string(),
outputDescription: z.string(),
samples: z.array(z.record(z.string(), z.unknown())),
testCaseId: z.string(),
testCaseScore: z.array(z.record(z.string(), z.unknown())),
hint: z.string().nullable(),
languages: z.array(z.string()),
template: z.record(z.string(), z.string()),
createTime: z.string(),
lastUpdateTime: z.string(),
timeLimit: z.number().int(),
memoryLimit: z.number().int(),
visible: z.boolean(),
difficulty: z.string(),
source: z.string().nullable(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
statisticInfo: z.record(z.string(), z.unknown()),
shareSubmission: z.boolean(),
contestId: z.number().int().nullable(),
createdBy: sampleUserSchema,
isPublic: z.boolean(),
tags: z.array(z.string()),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),
mermaidCode: z.string().nullable(),
flowchartHint: z.string().nullable(),
astRules: z.unknown(),
answers: z.array(z.record(z.string(), z.unknown())),
prompt: z.string().nullable(),
sqlConfig: z.record(z.string(), z.unknown()).nullable(),
sqlDisplay: z.record(z.string(), z.unknown()).nullable(),
})
export const createProblemRequestSchema = z.object({
_id: z.string().trim().min(1).max(32),
title: z.string().trim().min(1).max(1024),
description: z.string(),
inputDescription: z.string(),
outputDescription: z.string(),
samples: z.array(z.record(z.string(), z.unknown())),
testCaseId: z.string().regex(/^[a-zA-Z0-9]+$/).max(32),
testCaseScore: z.array(z.record(z.string(), z.unknown())),
timeLimit: z.number().int().min(1).max(1000 * 60),
memoryLimit: z.number().int().min(1).max(1024),
languages: z.array(z.string()).min(1),
template: z.record(z.string(), z.string()),
visible: z.boolean(),
difficulty: z.enum(["Low", "Mid", "High"]),
tags: z.array(z.string().max(32)).min(1),
hint: z.string().nullable().default(null),
source: z.string().max(256).nullable().default(null),
prompt: z.string().nullable().default(null),
answers: z.array(z.record(z.string(), z.unknown())).default([]),
shareSubmission: z.boolean(),
allowFlowchart: z.boolean().default(false),
showFlowchart: z.boolean().default(false),
mermaidCode: z.string().nullable().default(null),
flowchartHint: z.string().nullable().default(null),
astRules: z.unknown().default(null),
sqlConfig: z.record(z.string(), z.unknown()).nullable().default(null),
})
export const updateProblemRequestSchema = createProblemRequestSchema
export const makeProblemPublicRequestSchema = z.object({
displayId: z.string().trim().min(1).max(32),
})
export const addContestProblemRequestSchema = z.object({
problemId: z.number().int().positive(),
displayId: z.string().trim().min(1).max(32),
})