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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user