refactor(契约): 判题产物退回不校验,练一练的形状闸挪到写入侧,运行时闸门收回三处

前四轮把契约当成运行时闸门铺开,复盘下来三块里只有一块是赚的:类型收拢成一份
(语言联合、Problem/Message/ContestRank 的重复派生)留着;另外两块退回来。

## 判题产物:读出侧不再校验

judgeCaseResultSchema 按采样键集收紧的结果,用根目录那份生产备份全量跑了一遍:
124192 条提交里 9163 条对不上,**RE 8480/8480、TLE 338/338+26、MLE 1/1 全中**,
另有 270 条 WA、47 条 AC。原因不是键集合,是空值和 SQL 链路:

- 沙箱在非正常退出的测试点上写 `output_md5: null`,契约写的是 z.string();
- SQL 判题(judge/sql/engine.ts 的 CaseResult)根本没有 `output` 键;
- SQL 通过的测试点 `error_message` 是 null,契约写的是 z.string().optional()。

更糟的是失败方式:`info` 是 `union([完整形状, z.object({})])`,对不上的一律落进
第二支被剥成 `{}` 且 parse 成功 —— 管理员详情页的测试点表格**静默消失**,无日志。

JSONB 的形状真相在写入侧(判题机),读出侧再校验一遍只会在两边分叉时丢数据。
所以 `info` 回到 z.unknown(),形状改用 JudgeInfo / JudgeCaseResult 两个 TS 类型
描述(按判题机实际写的形状,不是采样出来的),取值处由 submissionCaseResults()
做唯一需要的运行时判断:有没有 data 数组。statisticInfo 换成 looseObject ——
所有键可选、不剥未知键,对任何对象都不会失败,它的作用是给类型不是当闸门。

## 练一练:形状闸从读路径挪到写路径

exerciseSchema 的 superRefine 挂在读路径上,而这个 schema 后端也在 parse
(routes/content.ts),等于一行脏数据就能让整条学生练习列表 500。同时写入侧的
exerciseDataError **一次都没查过 question**,两边严紧度不一致,脏数据进得来出不去。

exerciseDataByType 保留,改由 exerciseDataError 在写入前查,错误信息按字段翻成
中文给老师看;读路径回到不校验。

## 运行时闸门收回三处

contract() 从 41 个端点收回到题目详情 / 提交详情 / 用户资料 —— 原本就写了
.parse() 的那三条。留着的理由是「别抛错」(原来 parse 抛 ZodError 会白屏、
后面的 as 又让校验白做),不是校验:前后端同仓、共享同一份 schema,字段漂移
tsc 已经抓了。闸门本身也瘦掉了没人读的 window.__OJ2_CONTRACT_DRIFT__ 那套簿记。

## 验证

- 生产备份全量:124192 条提交过 submissionDetailSchema / submissionListItemSchema
  零失败,其中 112144 条能拿到测试点明细(另外 12048 条本来就是 data:null);
  151 道练习读路径 151/151、写入闸 151/151(老师改旧题不会被新闸挡);
- 反向验证写入闸:缺题干的排序题被拒并给出「题干的格式不对」;
- 本地实跑:种一条生产形状的 RE 提交(output_md5: null),管理员详情接口原样
  返回 info.data(改之前是 {});库里塞一行没有 options 的 mcq,学生端练习列表
  照常返回两条而不是 500;
- vue-tsc / tsc -p apps/api 均 exit 0,vite build 通过,check:routes 无遮蔽。

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 04:53:28 -06:00
parent 684f2d29a5
commit 7d15e6aeaa
9 changed files with 325 additions and 631 deletions

View File

@@ -118,30 +118,16 @@ export const exerciseAttemptRequestSchema = z.object({
})
/**
* 练一练的内容,按题型分派。
* 题型 → 内容形状。**只在写入侧校验**`apps/api/src/services/exercise.ts` 的
* `exerciseDataError`),读出侧不校验。
*
* 形状按**生产库 151 道练习题实测**得出,七种题型的键集逐个吻合、没有越界数据:
* mcq 46 / fill 37 / sort 25 / predict 24 / debug 11 / match 6 / group 2。
* 这张表曾经挂在 `exerciseSchema` 的 superRefine 上,于是学生端那条
* `GET /tutorials/:id/exercises``routes/content.ts` 里硬 parse变成了一道
* 读闸:一行 data 对不上,整条练习列表 500 —— 坏的不是那一道,是整页。而写入侧
* 当时并不检查 `question`,两边严紧度不一致,脏数据进得来、出不去。
*
* 为什么值得从 `Record<string, unknown>` 收紧:这七种题型各自被一个组件渲染
* 它们直接读 `data.question` / `data.options` / `data.lines` —— 结构对不上时
* **渲染期才炸**,而炸的是整道题的组件。收紧之后这条路径由契约在响应边界上拦住。
*
* 注意 `exerciseSchema` **后端也在 parse**`routes/content.ts` 的
* `rows.map(... exerciseSchema.parse ...)`),所以这里的收紧同时是一道服务端闸门:
* 数据对不上时整条练习列表 500而不是渲染到一半崩。已用生产全量数据核验过
* 151/151 通过,才敢这么收。
*
* `data` 里**没有**判别键 —— 题型的真相在**外层**的 `type` 上,所以只能在
* superRefine 里拿 `type` 去挑对应的形状,不能用 discriminatedUnion
* (那要求判别键存在于被判别对象内部)。
*/
/**
* 题型 → 内容形状。导出是为了让**前端闸门**能逐题型校验 `data` ——
* `z.infer` 只能把 `data` 还原成 `Record<string, unknown>`superRefine
* 无法把校验结果反映到推断出的类型上),所以前端那个更窄的判别联合
* `utils/types` 的 Exercise在类型上仍然要自己收窄一次但**运行时**
* 走的就是这张表。
* JSONB 的形状真相在写入侧,就在写入侧卡住:那里能给老师一句中文原因
* 也不会让历史数据把学生端打不开。
*/
export const exerciseDataByType: Record<string, z.ZodType> = {
mcq: z.object({ question: z.string(), options: z.array(z.string()), answer: z.array(z.number()) }),
@@ -154,36 +140,16 @@ export const exerciseDataByType: Record<string, z.ZodType> = {
}
/**
* 外层 `type` `data` 的内容对不上时也算不通过,这正是要拦的情况:
* `type: "mcq"` 配一份 `{question, code}` 会在渲染 mcq 组件时炸在 `data.options` 上。
* 练一练。`data` `Record<string, unknown>`**读出侧刻意不按题型收紧** ——
* 这个 schema 后端也在 parse`routes/content.ts`),收紧它等于给学生端加一道
* 会 500 的闸。七种题型各自的形状见上面的 `exerciseDataByType`,在写入时卡。
*/
export const exerciseSchema = z
.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
.superRefine((value, ctx) => {
// type 是枚举,映射表覆盖了全部七个值;这里只是让 TS 收窄,顺带在
// 将来加了新题型却忘了补形状时,以一条清楚的 issue 而不是 undefined 崩掉。
const shape = exerciseDataByType[value.type]
if (!shape) {
ctx.addIssue({ code: "custom", path: ["type"], message: `题型 ${value.type} 没有对应的内容形状` })
return
}
const parsed = shape.safeParse(value.data)
if (!parsed.success) {
ctx.addIssue({
code: "custom",
path: ["data"],
message: `type=${value.type} 的 data 形状不符:${parsed.error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "(根)"} ${issue.message}`)
.join("")}`,
})
}
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>