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:
@@ -2,13 +2,13 @@ import { randomBytes } from "node:crypto"
|
||||
|
||||
import {
|
||||
createFlowchartRequestSchema,
|
||||
createFlowchartResponseSchema,
|
||||
flowchartCurrentSchema,
|
||||
flowchartDetailSchema,
|
||||
flowchartListItemSchema,
|
||||
flowchartListSchema,
|
||||
flowchartStatisticsSchema,
|
||||
flowchartSubmissionSchema,
|
||||
type CreateFlowchartResponse,
|
||||
type FlowchartCurrent,
|
||||
type FlowchartDetail,
|
||||
type FlowchartList,
|
||||
type FlowchartListItem,
|
||||
type FlowchartStatistics,
|
||||
type FlowchartSubmission,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -45,7 +45,7 @@ function flowchartData(
|
||||
flowchart: typeof schema.flowchartSubmission.$inferSelect,
|
||||
username: string,
|
||||
) {
|
||||
return flowchartSubmissionSchema.parse({
|
||||
return {
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problemId: flowchart.problemId,
|
||||
@@ -62,7 +62,7 @@ function flowchartData(
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
})
|
||||
} satisfies FlowchartSubmission
|
||||
}
|
||||
|
||||
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
@@ -106,7 +106,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
|
||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||||
}
|
||||
return success(c, createFlowchartResponseSchema.parse({ submissionId: id, status: "pending" }), 201)
|
||||
return success(c, { submissionId: id, status: "pending" } satisfies CreateFlowchartResponse, 201)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
@@ -121,7 +121,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
|
||||
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
|
||||
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
|
||||
return success(c, flowchartListSchema.parse({ results: [], total: 0 }))
|
||||
return success(c, { results: [], total: 0 } satisfies FlowchartList)
|
||||
}
|
||||
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
|
||||
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||||
@@ -136,8 +136,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, flowchartListSchema.parse({
|
||||
results: rows.map(({ flowchart, username, problem }) => flowchartListItemSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ flowchart, username, problem }) => ({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problem: problem.displayId,
|
||||
@@ -151,9 +151,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
showLink: canView(user, flowchart, problem),
|
||||
})),
|
||||
} satisfies FlowchartListItem)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies FlowchartList)
|
||||
})
|
||||
|
||||
const FLOWCHART_COMPLETED = 2
|
||||
@@ -242,7 +242,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
})),
|
||||
}
|
||||
if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty))
|
||||
if (rows.length === 0) return success(c, empty satisfies FlowchartStatistics)
|
||||
|
||||
const gradeDistribution: Record<string, number> = {}
|
||||
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
|
||||
@@ -289,7 +289,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max }
|
||||
}
|
||||
|
||||
return success(c, flowchartStatisticsSchema.parse({
|
||||
return success(c, {
|
||||
totalCount: rows.length,
|
||||
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
|
||||
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
|
||||
@@ -304,7 +304,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
})),
|
||||
}))
|
||||
} satisfies FlowchartStatistics)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
@@ -351,7 +351,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||||
}
|
||||
return success(c, createFlowchartResponseSchema.parse({ submissionId: row.flowchart.id, status: "pending" }))
|
||||
return success(c, { submissionId: row.flowchart.id, status: "pending" } satisfies CreateFlowchartResponse)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||
@@ -359,7 +359,7 @@ flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) =>
|
||||
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
|
||||
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
return success(c, flowchartCurrentSchema.parse({ count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" }))
|
||||
return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
||||
@@ -371,5 +371,5 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) =>
|
||||
.orderBy(asc(schema.flowchartSubmission.createTime))
|
||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
||||
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
|
||||
return success(c, flowchartDetailSchema.parse({ submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length }))
|
||||
return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user