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

@@ -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[])