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:
@@ -83,23 +83,37 @@ through the dev server (see `vite.config.ts`).
|
||||
### Contract guard (`utils/contract.ts`)
|
||||
|
||||
`@oj2/contract` 的 zod schema 是**前后端唯一的形状来源**,`utils/types.ts` 只做
|
||||
`z.infer` 派生与少量前端专有的收窄(都写了理由)。读接口应当走守卫:
|
||||
`z.infer` 派生与少量前端专有的收窄(都写了理由)。
|
||||
|
||||
运行时闸门**只挂三处**:题目详情、提交详情、`shared/api.ts` 的用户资料 ——
|
||||
原本就写了 `.parse()` 的那三条。留着它们的理由是**别抛错**,不是校验:
|
||||
|
||||
```ts
|
||||
const endpoint = `problems/${encodeURIComponent(id)}`
|
||||
return contract("GET /problems/:id", problemDetailSchema, await api.get<unknown>(endpoint))
|
||||
// 原来是 problemDetailSchema.parse(v) as Problem —— `as` 让校验白做,
|
||||
// 而 parse 抛错会让整个题目页白屏
|
||||
return contract("GET /problems/:id", problemDetailSchema, value)
|
||||
```
|
||||
|
||||
**失败策略是「记日志 + 放行原始数据」,不抛错。** 形状对不上时:控制台打一条带
|
||||
端点和字段路径的记录、去重后记进 `window.__OJ2_CONTRACT_DRIFT__`、然后**返回原始
|
||||
数据让页面继续渲染**。面向学生的生产站点,少一个字段的代价远小于白屏。
|
||||
失败时记一条控制台日志再**放行原始数据**,页面照常渲染。
|
||||
|
||||
排查线上分歧就是打开控制台敲 `window.__OJ2_CONTRACT_DRIFT__`;某条路径长期为空之后,
|
||||
那条路径可以升级成硬失败(直接 `schema.parse`),在那之前不要改。
|
||||
**不要把它铺到更多端点上。** 试过一次(41 个),收益是 41 次 safeParse 加一条
|
||||
没人读的 console.error:前后端同仓、共享同一份 schema,「后端改字段前端不知道」
|
||||
`tsc` 已经抓了。
|
||||
|
||||
改动 schema 时要记住**同一个 schema 后端也在 `parse`**(如
|
||||
`submissionDetailSchema.parse` 在路由里),所以收紧一个字段前先用生产数据核一遍,
|
||||
否则一条不符合的历史记录会让整个列表 500。
|
||||
### 什么该收紧,什么不该
|
||||
|
||||
**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)不在读出侧
|
||||
校验。** 它们的形状真相在写入侧 —— 判题机、`services/exercise.ts`。在读出侧再收
|
||||
一遍的结果实测过两次:
|
||||
|
||||
- `info` 按采样键集收紧后,124192 条提交里 9163 条(RE、TLE、MLE 全中)对不上,
|
||||
被 union 的空对象分支**静默剥成 `{}`**,管理员的测试点表格无声消失;
|
||||
- `exercise.data` 按题型收紧后,后端读路径(`routes/content.ts` 硬 parse)变成
|
||||
一道闸,一行脏数据能让整条练习列表 500。
|
||||
|
||||
所以:**同一个 schema 后端也在 `parse`**(`submissionDetailSchema` /
|
||||
`exerciseSchema` / `contestRankItemSchema` 都是),收紧任何字段之前,拿根目录
|
||||
那份生产备份把全量数据跑一遍,尤其要看**空值**而不只是键集合。
|
||||
|
||||
### Key Utilities
|
||||
|
||||
|
||||
Reference in New Issue
Block a user