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:
@@ -1,11 +1,14 @@
|
||||
import type { ExerciseType } from "@oj2/contract"
|
||||
import { exerciseDataByType, type ExerciseType } from "@oj2/contract"
|
||||
|
||||
/**
|
||||
* 练习题 `data` 的语义校验。
|
||||
* 练习题 `data` 的校验。**这是唯一的校验点** —— 契约里 `data` 是
|
||||
* `z.record(z.string(), z.unknown())`,七种题型的字段完全不同,用 zod 写成判别联合
|
||||
* 会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema),历史脏数据会把
|
||||
* 整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行。
|
||||
*
|
||||
* 契约里 `data` 是 `z.record(z.string(), z.unknown())` —— 七种题型的字段完全不同,
|
||||
* 用 zod 写成判别联合会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema),
|
||||
* 历史脏数据会把整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行。
|
||||
* 两层:先按 `exerciseDataByType` 查形状(键在不在、类型对不对),再走下面的语义
|
||||
* 检查(选项够不够、下标越不越界)。形状那层是后补的 —— 之前只有语义检查,
|
||||
* 而它**一次都没查过 `question`**,一道没有题干的练习能存进库。
|
||||
*
|
||||
* 为什么非校验不可:以前唯一的校验在前端 ExerciseManager 的 buildData(),而它对
|
||||
* fill 和 mcq 几乎不查 —— 一道没有 `{{空位}}` 的填空题能存进库,学生端渲染出来是
|
||||
@@ -17,6 +20,16 @@ export function exerciseDataError(
|
||||
type: ExerciseType,
|
||||
data: Record<string, unknown>,
|
||||
): string | null {
|
||||
const shape = exerciseDataByType[type]
|
||||
if (!shape) return `未知的题型 ${type}`
|
||||
const parsed = shape.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
// 老师看到的是「题干必须是文字」这种话,不是 zod 的英文 issue
|
||||
const issue = parsed.error.issues[0]!
|
||||
// 只取第一段:数组项的 path 是 ["options", 0],老师要看的是「选项」
|
||||
const field = String(issue.path[0] ?? "内容")
|
||||
return `${FIELD_LABELS[field] ?? field}的格式不对(${issue.message})`
|
||||
}
|
||||
switch (type) {
|
||||
case "mcq": {
|
||||
const options = strings(data.options)
|
||||
@@ -67,6 +80,20 @@ export function exerciseDataError(
|
||||
}
|
||||
}
|
||||
|
||||
/** zod 报的是键名,老师看的得是人话 */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
question: "题干",
|
||||
options: "选项",
|
||||
answer: "答案",
|
||||
lines: "代码行",
|
||||
code: "代码",
|
||||
left: "左列",
|
||||
right: "右列",
|
||||
buckets: "分组",
|
||||
items: "项目",
|
||||
explanation: "解析",
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
? (value as string[])
|
||||
|
||||
Reference in New Issue
Block a user