refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
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:
2026-09-10 05:45:15 -06:00
parent b9a80d62bc
commit ab47e71d6f
36 changed files with 562 additions and 524 deletions

View File

@@ -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)
})