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:
@@ -6,7 +6,7 @@ export function success<T>(c: Context, data: T, status = 200) {
|
|||||||
|
|
||||||
export function failure(
|
export function failure(
|
||||||
c: Context,
|
c: Context,
|
||||||
status: 400 | 401 | 403 | 404 | 409 | 429 | 500 | 502,
|
status: 400 | 401 | 403 | 404 | 409 | 429 | 500 | 501 | 502,
|
||||||
code: string,
|
code: string,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { adminAchievementRoutes } from "./achievement"
|
|||||||
import { adminAiRoutes } from "./ai"
|
import { adminAiRoutes } from "./ai"
|
||||||
import { adminConfRoutes } from "./conf"
|
import { adminConfRoutes } from "./conf"
|
||||||
import { adminContestRoutes } from "./contest"
|
import { adminContestRoutes } from "./contest"
|
||||||
|
import { adminProblemRoutes } from "./problem"
|
||||||
import { adminProblemSetRoutes } from "./problemset"
|
import { adminProblemSetRoutes } from "./problemset"
|
||||||
import { adminTagRoutes } from "./tag"
|
import { adminTagRoutes } from "./tag"
|
||||||
import { adminAnnouncementRoutes } from "./announcement"
|
import { adminAnnouncementRoutes } from "./announcement"
|
||||||
@@ -26,6 +27,7 @@ adminRoutes.route("/", adminAchievementRoutes)
|
|||||||
adminRoutes.route("/", adminAiRoutes)
|
adminRoutes.route("/", adminAiRoutes)
|
||||||
adminRoutes.route("/", adminConfRoutes)
|
adminRoutes.route("/", adminConfRoutes)
|
||||||
adminRoutes.route("/", adminContestRoutes)
|
adminRoutes.route("/", adminContestRoutes)
|
||||||
|
adminRoutes.route("/", adminProblemRoutes)
|
||||||
adminRoutes.route("/", adminProblemSetRoutes)
|
adminRoutes.route("/", adminProblemSetRoutes)
|
||||||
adminRoutes.route("/", adminTagRoutes)
|
adminRoutes.route("/", adminTagRoutes)
|
||||||
adminRoutes.route("/", adminAnnouncementRoutes)
|
adminRoutes.route("/", adminAnnouncementRoutes)
|
||||||
|
|||||||
528
apps/api/src/routes/admin/problem.ts
Normal file
528
apps/api/src/routes/admin/problem.ts
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import {
|
||||||
|
addContestProblemRequestSchema,
|
||||||
|
adminProblemListItemSchema,
|
||||||
|
adminProblemListSchema,
|
||||||
|
adminProblemSchema,
|
||||||
|
createProblemRequestSchema,
|
||||||
|
makeProblemPublicRequestSchema,
|
||||||
|
updateProblemRequestSchema,
|
||||||
|
} 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 { contestStatus } from "../../services/contest"
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非 SQL 题的公共校验,对齐旧 `ProblemBase.common_checks` 的 else 分支。
|
||||||
|
*
|
||||||
|
* SQL 分支在这里**只做前置校验、不生成 sqlDisplay** —— 生成需要跑 SQLite,
|
||||||
|
* 而新后端还没有 SQL 判题链路(旧 judge/sql_runner.py 378 行无对应实现)。
|
||||||
|
* 因此新建 SQL 题会被拒绝,编辑 SQL 题则保留原有 sqlDisplay 不动,不会把已有数据弄坏。
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQL_NOT_SUPPORTED =
|
||||||
|
"新后端尚未实现 SQL 判题链路(题目页展示数据需要跑 SQLite 生成),暂不能新建 SQL 题。已有 SQL 题可以正常编辑,其展示数据保持不变。"
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (checked.sql) return failure(c, 501, "sql-not-supported", SQL_NOT_SUPPORTED)
|
||||||
|
|
||||||
|
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, false),
|
||||||
|
contestId: null,
|
||||||
|
createdById: c.get("user")!.id,
|
||||||
|
createTime: now,
|
||||||
|
lastUpdateTime: now,
|
||||||
|
submissionNumber: 0,
|
||||||
|
acceptedNumber: 0,
|
||||||
|
statisticInfo: {},
|
||||||
|
isPublic: false,
|
||||||
|
sqlDisplay: null,
|
||||||
|
}).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")
|
||||||
|
|
||||||
|
const updated = await db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx.update(schema.problem).set({
|
||||||
|
...problemValues(parsed.data, checked.sql),
|
||||||
|
lastUpdateTime: new Date().toISOString(),
|
||||||
|
// SQL 题的 sqlDisplay 原样保留:重新生成需要跑 SQLite,新后端还没有那条链路。
|
||||||
|
// 不动它比生成一个错的更安全 —— 它直接决定题目页给学生看的表结构与期望结果。
|
||||||
|
}).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)
|
||||||
|
if (checked.sql) return failure(c, 501, "sql-not-supported", SQL_NOT_SUPPORTED)
|
||||||
|
|
||||||
|
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, false),
|
||||||
|
contestId,
|
||||||
|
createdById: user.id,
|
||||||
|
createTime: now,
|
||||||
|
lastUpdateTime: now,
|
||||||
|
submissionNumber: 0,
|
||||||
|
acceptedNumber: 0,
|
||||||
|
statisticInfo: {},
|
||||||
|
isPublic: false,
|
||||||
|
sqlDisplay: null,
|
||||||
|
}).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")
|
||||||
|
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)
|
||||||
|
if (!contest || !problem) return failure(c, 404, "not-found", "Contest or Problem does not exist")
|
||||||
|
const user = c.get("user")!
|
||||||
|
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) {
|
||||||
|
return failure(c, 404, "contest-not-found", "Contest 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)
|
||||||
|
})
|
||||||
@@ -38,21 +38,13 @@ export async function getProblemList(
|
|||||||
contestID?: string,
|
contestID?: string,
|
||||||
tagId?: number,
|
tagId?: number,
|
||||||
) {
|
) {
|
||||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
const endpoint = contestID
|
||||||
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
? `admin/contests/${contestID}/problems`
|
||||||
endpoint,
|
: "admin/problems"
|
||||||
{
|
const res = await legacyResponse<{
|
||||||
params: {
|
results: AdminProblem[]
|
||||||
paging: true,
|
total: number
|
||||||
offset,
|
}>(api2.get(endpoint, { params: { offset, limit, keyword, author, tagId } }))
|
||||||
limit,
|
|
||||||
keyword,
|
|
||||||
author,
|
|
||||||
contest_id: contestID,
|
|
||||||
tag_id: tagId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
results: res.data.results.map(toProblemListItem),
|
results: res.data.results.map(toProblemListItem),
|
||||||
total: res.data.total,
|
total: res.data.total,
|
||||||
@@ -60,15 +52,21 @@ export async function getProblemList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteProblem(id: number) {
|
export function deleteProblem(id: number) {
|
||||||
return http.delete("admin/problem", { params: { id } })
|
return api2.delete(`admin/problems/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 比赛题与公开题共用一条删除路由,比赛由后端从题目推导
|
||||||
export function deleteContestProblem(id: number) {
|
export function deleteContestProblem(id: number) {
|
||||||
return http.delete("admin/contest/problem", { params: { id } })
|
return api2.delete(`admin/problems/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editProblem(problem: AdminProblem | BlankProblem) {
|
export function editProblem(problem: AdminProblem | BlankProblem) {
|
||||||
return http.put("admin/problem", problem)
|
return legacyResponse(
|
||||||
|
api2.put(
|
||||||
|
`admin/problems/${(problem as AdminProblem).id}`,
|
||||||
|
toProblemBody(problem),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleProblemVisible(problemID: number) {
|
export function toggleProblemVisible(problemID: number) {
|
||||||
@@ -80,15 +78,20 @@ export function generateFlowchartFromPythonCode(python: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function editContestProblem(problem: AdminProblem | BlankProblem) {
|
export function editContestProblem(problem: AdminProblem | BlankProblem) {
|
||||||
return http.put("admin/contest/problem", problem)
|
return legacyResponse(
|
||||||
|
api2.put(
|
||||||
|
`admin/problems/${(problem as AdminProblem).id}`,
|
||||||
|
toProblemBody(problem),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblem(id: string | number) {
|
export function getProblem(id: string | number) {
|
||||||
return http.get<AdminProblem>("admin/problem", { params: { id } })
|
return legacyResponse<AdminProblem>(api2.get(`admin/problems/${id}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContestProblem(id: number) {
|
export function getContestProblem(id: number) {
|
||||||
return http.get("admin/contest/problem", { params: { id } })
|
return legacyResponse<AdminProblem>(api2.get(`admin/problems/${id}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标签管理
|
// 标签管理
|
||||||
@@ -239,12 +242,49 @@ export function generateSQLTestcase(data: {
|
|||||||
return http.post<{ sql: string }>("admin/sql_test_case_ai_gen", data)
|
return http.post<{ sql: string }>("admin/sql_test_case_ai_gen", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组件里的题目对象是 snake_case,出站转成新后端要的 camelCase */
|
||||||
|
function toProblemBody(problem: AdminProblem | BlankProblem) {
|
||||||
|
const p = problem as Record<string, any>
|
||||||
|
return {
|
||||||
|
_id: p._id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.description,
|
||||||
|
inputDescription: p.input_description ?? "",
|
||||||
|
outputDescription: p.output_description ?? "",
|
||||||
|
samples: p.samples ?? [],
|
||||||
|
testCaseId: p.test_case_id,
|
||||||
|
testCaseScore: p.test_case_score ?? [],
|
||||||
|
timeLimit: p.time_limit,
|
||||||
|
memoryLimit: p.memory_limit,
|
||||||
|
languages: p.languages ?? [],
|
||||||
|
template: p.template ?? {},
|
||||||
|
visible: p.visible,
|
||||||
|
difficulty: p.difficulty,
|
||||||
|
tags: p.tags ?? [],
|
||||||
|
hint: p.hint ?? null,
|
||||||
|
source: p.source ?? null,
|
||||||
|
prompt: p.prompt ?? null,
|
||||||
|
answers: p.answers ?? [],
|
||||||
|
shareSubmission: p.share_submission ?? false,
|
||||||
|
allowFlowchart: p.allow_flowchart ?? false,
|
||||||
|
showFlowchart: p.show_flowchart ?? false,
|
||||||
|
mermaidCode: p.mermaid_code ?? null,
|
||||||
|
flowchartHint: p.flowchart_hint ?? null,
|
||||||
|
astRules: p.ast_rules ?? null,
|
||||||
|
sqlConfig: p.sql_config ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createProblem(problem: BlankProblem) {
|
export function createProblem(problem: BlankProblem) {
|
||||||
return http.post("admin/problem", problem)
|
return legacyResponse(api2.post("admin/problems", toProblemBody(problem)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createContestProblem(problem: BlankProblem) {
|
export function createContestProblem(problem: BlankProblem) {
|
||||||
return http.post("admin/contest/problem", problem)
|
// contest_id 由 detail.vue 在提交前写进 problem 对象
|
||||||
|
const contestID = (problem as Record<string, any>).contest_id
|
||||||
|
return legacyResponse(
|
||||||
|
api2.post(`admin/contests/${contestID}/problems`, toProblemBody(problem)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */
|
/** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */
|
||||||
@@ -289,11 +329,12 @@ export function addProblemForContest(
|
|||||||
problemID: number,
|
problemID: number,
|
||||||
displayID: string,
|
displayID: string,
|
||||||
) {
|
) {
|
||||||
return http.post("admin/contest/add_problem_from_public", {
|
return legacyResponse(
|
||||||
contest_id: contestID,
|
api2.post(`admin/contests/${contestID}/problems/from-public`, {
|
||||||
problem_id: problemID,
|
problemId: problemID,
|
||||||
display_id: displayID,
|
displayId: displayID,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWebsite() {
|
export function getWebsite() {
|
||||||
@@ -451,10 +492,9 @@ export function deleteExercise(id: number) {
|
|||||||
|
|
||||||
// 将竞赛题目转为公开题目
|
// 将竞赛题目转为公开题目
|
||||||
export function makeProblemPublic(id: number, display_id: string) {
|
export function makeProblemPublic(id: number, display_id: string) {
|
||||||
return http.post("admin/contest_problem/make_public", {
|
return legacyResponse(
|
||||||
id,
|
api2.post(`admin/problems/${id}/make-public`, { displayId: display_id }),
|
||||||
display_id,
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 比赛辅助检查
|
// 比赛辅助检查
|
||||||
|
|||||||
@@ -487,3 +487,102 @@ export const acTrendSchema = z.object({
|
|||||||
|
|
||||||
export const generateFlowchartRequestSchema = z.object({ python: z.string().min(1).max(64 * 1024) })
|
export const generateFlowchartRequestSchema = z.object({ python: z.string().min(1).max(64 * 1024) })
|
||||||
export const generateFlowchartResponseSchema = z.object({ flowchart: z.string() })
|
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),
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user