refactor(题目): 新建 / 复制题目收成 validateProblem、insertProblem、copyProblem

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 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:07:13 +08:00
1 parent f2ca34fd4b
commit 925cecb389
3 files changed
+139 -153

No files matched your search

+8
View File
@@ -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<Parameters<typeof db.transaction>[0]>[0]
/**
* 读出来的时刻统一成 ISO 8601 UTC,和写侧的 `new Date().toISOString()` 同形状。
*
+130 -152
View File
@@ -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<string, number>()
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<typeof createProblemRequestSchema.parse>
/**
* 保存前的全部校验:公共校验 + 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)
},
)
+1 -1
View File
@@ -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(