refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Some checks failed
Deploy / deploy (push) Has been cancelled
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
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
import {
|
||||
addContestProblemRequestSchema,
|
||||
adminProblemListItemSchema,
|
||||
adminProblemListSchema,
|
||||
adminProblemSchema,
|
||||
createProblemRequestSchema,
|
||||
makeProblemPublicRequestSchema,
|
||||
updateProblemRequestSchema,
|
||||
generateSqlTestCaseRequestSchema,
|
||||
generateSqlTestCaseResponseSchema,
|
||||
makeProblemPublicRequestSchema,
|
||||
sqlPreviewRequestSchema,
|
||||
sqlTestCaseScriptSchema,
|
||||
uploadTestCaseResponseSchema,
|
||||
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"
|
||||
@@ -30,7 +31,7 @@ import { config } from "../../config"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { resolve } from "node:path"
|
||||
import { getTopReactions } from "../../services/reaction"
|
||||
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
|
||||
import { objectValue, queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminProblemRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -130,7 +131,7 @@ async function serialize(row: ProblemRow) {
|
||||
.where(eq(schema.user.id, row.createdById)).limit(1),
|
||||
tagNames(row.id),
|
||||
])
|
||||
return adminProblemSchema.parse({
|
||||
return {
|
||||
id: row.id,
|
||||
_id: row.displayId,
|
||||
title: row.title,
|
||||
@@ -141,8 +142,8 @@ async function serialize(row: ProblemRow) {
|
||||
testCaseId: row.testCaseId,
|
||||
testCaseScore: Array.isArray(row.testCaseScore) ? row.testCaseScore : [],
|
||||
hint: row.hint,
|
||||
languages: stringArray(row.languages),
|
||||
template: objectValue(row.template),
|
||||
languages: row.languages,
|
||||
template: row.template,
|
||||
createTime: row.createTime,
|
||||
lastUpdateTime: row.lastUpdateTime,
|
||||
timeLimit: row.timeLimit,
|
||||
@@ -164,9 +165,9 @@ async function serialize(row: ProblemRow) {
|
||||
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,
|
||||
})
|
||||
sqlConfig: row.sqlConfig,
|
||||
sqlDisplay: row.sqlDisplay,
|
||||
} satisfies AdminProblem
|
||||
}
|
||||
|
||||
/** 公共校验,对齐旧 `ProblemBase.common_checks` */
|
||||
@@ -205,7 +206,7 @@ async function generateSqlDisplay(
|
||||
testCaseId: string,
|
||||
answers: Record<string, unknown>[],
|
||||
sqlConfig: SqlConfig,
|
||||
): Promise<{ error: string } | { display: unknown }> {
|
||||
): Promise<{ error: string } | { display: SqlDisplay }> {
|
||||
const info = await readInfo(testCaseId)
|
||||
if (!info) return { error: "测试点信息读取失败,请重新上传测试点" }
|
||||
if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" }
|
||||
@@ -294,9 +295,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
|
||||
// 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致
|
||||
const problemIds = rows.map(({ problem }) => problem.id)
|
||||
const [topReactions, tags] = await Promise.all([getTopReactions(problemIds), tagNamesFor(problemIds)])
|
||||
return success(c, adminProblemListSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ problem, user: creator, realName }) =>
|
||||
adminProblemListItemSchema.parse({
|
||||
({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
@@ -309,9 +310,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
|
||||
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) => {
|
||||
@@ -331,7 +332,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
|
||||
let sqlDisplay: unknown = null
|
||||
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)
|
||||
@@ -388,7 +389,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
|
||||
|
||||
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
|
||||
let sqlDisplay: unknown = null
|
||||
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)
|
||||
@@ -467,9 +468,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
|
||||
.where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
const tags = await tagNamesFor(rows.map(({ problem }) => problem.id))
|
||||
return success(c, adminProblemListSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ problem, user: creator, realName }) =>
|
||||
adminProblemListItemSchema.parse({
|
||||
({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
@@ -482,9 +483,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
|
||||
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) => {
|
||||
@@ -500,7 +501,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
|
||||
let sqlDisplay: unknown = null
|
||||
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)
|
||||
@@ -652,10 +653,10 @@ adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
|
||||
const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? ""))
|
||||
try {
|
||||
const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql })
|
||||
return success(c, uploadTestCaseResponseSchema.parse({
|
||||
return success(c, {
|
||||
id: result.testCaseId,
|
||||
info: result.info,
|
||||
}), 201)
|
||||
} 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)
|
||||
@@ -701,7 +702,7 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
|
||||
if (!info.sql) return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型")
|
||||
try {
|
||||
const scripts = await readSqlScripts(problem.testCaseId)
|
||||
return success(c, scripts.map((script) => sqlTestCaseScriptSchema.parse(script)))
|
||||
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", "测试点脚本读取失败")
|
||||
@@ -735,7 +736,7 @@ SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。
|
||||
请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`,
|
||||
`题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`,
|
||||
)
|
||||
return success(c, generateSqlTestCaseResponseSchema.parse({ sql }))
|
||||
return success(c, { sql } satisfies GenerateSqlTestCaseResponse)
|
||||
} catch (error) {
|
||||
console.error("SQL test case generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
|
||||
Reference in New Issue
Block a user