From 925cecb389603528ec38f01f3c31044ea66d3145 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 15:07:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E9=A2=98=E7=9B=AE):=20=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=20/=20=E5=A4=8D=E5=88=B6=E9=A2=98=E7=9B=AE=E6=94=B6?= =?UTF-8?q?=E6=88=90=20validateProblem=E3=80=81insertProblem=E3=80=81copyP?= =?UTF-8?q?roblem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin/problem.ts 里四处 insert(schema.problem) 其实是两对重复: - 新建公开题 / 新建比赛题:公共校验 + SQL 展示数据 + 插入 + 写标签,逐行一样, 只差 contestId。编辑题目那条也抄了同一段校验。收成 validateProblem(校验 + 展示数据)和 insertProblem(插入 + 标签,一个事务)。比赛题当初就是抄漏了 sqlDisplay,现在三条路径只有一份 - 比赛题转公开 / 从公开题库拉进比赛:复制整行 + 统计清零 + 复制标签,收成 copyProblem 顺带 db/index.ts 加 DbOrTx 类型,四处 `tx as unknown as typeof db` 去掉。 唯一的行为差异:编辑题目时 SQL 展示数据的生成挪到了题号查重之前(和新建一致), 两项同时出错时报的是前一个。 实跑核过:新建公开题、编辑、重复题号 409、新建比赛题、比赛题转公开(不可见、 标签跟过去)、从公开题库拉进比赛(可见、标签跟过去),SQL 题测试点缺失时照样拦下。 测试数据已删。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/db/index.ts | 8 + apps/api/src/routes/admin/problem.ts | 282 ++++++++++++--------------- apps/api/src/routes/admin/tag.ts | 2 +- 3 files changed, 139 insertions(+), 153 deletions(-) diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 95735c1..f8c5dfd 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -10,6 +10,14 @@ const client = postgres(config.databaseUrl) export const db = drizzle(client, { schema }) +/** + * 「库或事务」都能接的参数类型。辅助函数既要能单独调、又要能在事务里调时用它, + * 别再写 `tx as unknown as typeof db` —— 那个断言把两者的差异(事务上没有 + * `$client`、`batch` 等)一并抹掉了。 + */ +export type DbOrTx = + typeof db | Parameters[0]>[0] + /** * 读出来的时刻统一成 ISO 8601 UTC,和写侧的 `new Date().toISOString()` 同形状。 * diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index c481d60..15dee31 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -31,7 +31,7 @@ import { Hono } from "hono" import { requireProblemPermission, type AppEnv } from "../../auth/middleware" import type { AuthUser } from "../../auth/session" -import { db, schema } from "../../db" +import { db, schema, type DbOrTx } from "../../db" import { failure, parseBody, success } from "../../http" import { astRulesError, pickAstRules } from "../../judge/ast" import { buildSqlDisplay } from "../../judge/sql" @@ -120,7 +120,7 @@ export function normalizeTagNames(names: string[]) { * 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。 * 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。 */ -export async function findTagsByName(tx: typeof db, names: string[]) { +export async function findTagsByName(tx: DbOrTx, names: string[]) { const map = new Map() if (names.length === 0) return map const rows = await tx @@ -137,7 +137,7 @@ export async function findTagsByName(tx: typeof db, names: string[]) { } /** 把标签名解析成 id:去空格、大小写不敏感复用已有标签,没有才新建。对齐旧 resolve_tags */ -async function resolveTags(tx: typeof db, names: string[]) { +async function resolveTags(tx: DbOrTx, names: string[]) { const wanted = normalizeTagNames(names) if (wanted.length === 0) return [] const existing = await findTagsByName(tx, wanted) @@ -154,7 +154,7 @@ async function resolveTags(tx: typeof db, names: string[]) { .filter((id) => id !== undefined) } -async function setTags(tx: typeof db, problemId: number, names: string[]) { +async function setTags(tx: DbOrTx, problemId: number, names: string[]) { const ids = await resolveTags(tx, names) await tx .delete(schema.problemTags) @@ -332,6 +332,99 @@ function problemValues( } } +type ProblemInput = ReturnType + +/** + * 保存前的全部校验:公共校验 + SQL 题的展示数据。新建公开题、新建比赛题、编辑题目 + * 三条路径共用 —— 原来各抄一份,比赛题那份还把算好的 sqlDisplay 丢了(见 insertProblem)。 + * + * SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上。 + */ +async function validateProblem( + data: ProblemInput, +): Promise< + { error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null } +> { + const checked = commonChecks(data) + if ("error" in checked) return checked + if (!checked.sql) return { sql: false, sqlDisplay: null } + const built = await generateSqlDisplay( + data.testCaseId, + data.answers, + data.sqlConfig!, + ) + if ("error" in built) return built + return { sql: true, sqlDisplay: built.display } +} + +/** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */ +async function insertProblem( + data: ProblemInput, + validated: { sql: boolean; sqlDisplay: SqlDisplay | null }, + owner: { contestId: number | null; createdById: number }, +) { + const now = new Date().toISOString() + return db.transaction(async (tx) => { + const [row] = await tx + .insert(schema.problem) + .values({ + ...problemValues(data, validated.sql), + ...owner, + createTime: now, + lastUpdateTime: now, + submissionNumber: 0, + acceptedNumber: 0, + statisticInfo: {}, + isPublic: false, + // 比赛题原来这里写死 null,结果比赛里的 SQL 题打开后看不到示例数据表和期望结果 + sqlDisplay: validated.sqlDisplay, + }) + .returning() + await setTags(tx, row!.id, data.tags) + return row! + }) +} + +/** + * 把一道已有的题复制一份(连同标签),统计清零。比赛题转公开、从公开题库拉进比赛 + * 都是这个动作,只是落到哪、可见性怎么设不同,由 `overrides` 给。 + */ +async function copyProblem( + tx: DbOrTx, + source: ProblemRow, + overrides: Pick< + typeof schema.problem.$inferInsert, + "contestId" | "displayId" | "visible" | "isPublic" + >, +) { + const now = new Date().toISOString() + const { id: _old, ...rest } = source + const [copy] = await tx + .insert(schema.problem) + .values({ + ...rest, + ...overrides, + 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, source.id)) + if (tags.length) { + await tx + .insert(schema.problemTags) + .values( + tags.map((tag) => ({ problemId: copy!.id, problemtagId: tag.tagId })), + ) + } + return copy! +} + // ---------------------------------------------------------------- 公开题目 adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { @@ -434,19 +527,9 @@ adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => { adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => { const parsed = await parseBody(c, createProblemRequestSchema) if (!parsed.success) return parsed.response - 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 validated = await validateProblem(parsed.data) + if ("error" in validated) + return failure(c, 400, "invalid-problem", validated.error) const [duplicate] = await db .select({ id: schema.problem.id }) @@ -461,25 +544,9 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => { 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! + const created = await insertProblem(parsed.data, validated, { + contestId: null, + createdById: c.get("user")!.id, }) return success(c, await serialize(created), 201) }) @@ -498,9 +565,9 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => { 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 validated = await validateProblem(parsed.data) + if ("error" in validated) + return failure(c, 400, "invalid-problem", validated.error) // 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一 const [duplicate] = await db @@ -519,29 +586,17 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => { 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, + ...problemValues(parsed.data, validated.sql), + sqlDisplay: validated.sqlDisplay, lastUpdateTime: new Date().toISOString(), }) .where(eq(schema.problem.id, id)) .returning() - await setTags(tx as unknown as typeof db, id, parsed.data.tags) + await setTags(tx, id, parsed.data.tags) return row! }) return success(c, await serialize(updated)) @@ -686,20 +741,9 @@ adminProblemRoutes.post( } const parsed = await parseBody(c, createProblemRequestSchema) if (!parsed.success) return parsed.response - 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 validated = await validateProblem(parsed.data) + if ("error" in validated) + return failure(c, 400, "invalid-problem", validated.error) const [duplicate] = await db .select({ id: schema.problem.id }) @@ -714,27 +758,9 @@ adminProblemRoutes.post( 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! + const created = await insertProblem(parsed.data, validated, { + contestId, + createdById: user.id, }) return success(c, await serialize(created), 201) }, @@ -782,43 +808,19 @@ adminProblemRoutes.post( 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 copyProblem(tx, problem, { + contestId: null, + displayId: parsed.data.displayId, + visible: false, + isPublic: true, + }) }) return success(c, await serialize(created), 201) }, @@ -881,38 +883,14 @@ adminProblemRoutes.post( "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! - }) + const created = await db.transaction((tx) => + copyProblem(tx, problem, { + contestId, + displayId: parsed.data.displayId, + visible: true, + isPublic: true, + }), + ) return success(c, await serialize(created), 201) }, ) diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index 3c5a579..f09d8f1 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -194,7 +194,7 @@ adminTagRoutes.post( const wanted = normalizeTagNames(parsed.data.tagNames) const tagIds = await db.transaction(async (tx) => { - const existing = await findTagsByName(tx as unknown as typeof db, wanted) + const existing = await findTagsByName(tx, wanted) // 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签 if (parsed.data.action === "add") { const missing = wanted.filter(