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:
xuyueandClaude Opus 5 committed 2026-09-10 04:53:28 -06:00
1 parent 684f2d29a5
commit 7d15e6aeaa
9 files changed
+325 -631

No files matched your search

+25 -110
View File
@@ -1,116 +1,25 @@
import type { z } from "zod"
/**
* 契约的运行时闸门。
* 契约的运行时闸门。**只挂在三条路径上**:题目详情、提交详情、用户资料
* (`shared/api.ts` 的 getProfile)—— 也就是原本就写了 `.parse()` 的那三处。
*
* ## 为什么要有这一层
* ## 为什么只有三处
*
* `@oj2/contract` 的收益只有一半是类型:`z.infer` 给出编译期的形状,但**编译期
* 管不了后端实际下发了什么**。改后端字段、drizzle 改名、序列化时漏一个键,
* TypeScript 一概看不见,页面上表现为某个 `undefined` 静默渲染成空白。
* 契约真正的价值在于同一份 schema 能在运行时把这种分歧当场抓出来。
* 前后端同仓、同一次编译、共享同一份 schema,「后端改字段前端不知道」这种漂移
* `tsc` 已经抓了,改了对不上当场编译不过。这里能多抓到的只有一种:**schema 与
* 库里 JSONB 原文不符**,而那不是契约漂移,是 schema 写错了 —— 而且它的真相在
* 写入侧,不是在这里。
*
* 原来只有三处调用 `.parse()`,而且**后面都紧跟一个 `as`** 把它重新断言回本地
* 类型(`problemDetailSchema.parse(v) as Problem`)—— 校验结果被丢弃,等于没校验。
* 曾经把它铺到 41 个端点上,收益是 41 次 safeParse 加一个没人读的 console.error;
* 判题产物那次收紧还因此让 7.4% 的提交静默丢了测试点明细。所以退回三处。
*
* ## 失败策略:记日志 + 放行原始数据
* ## 留着这三处的理由是「别抛错」,不是「校验」
*
* **不抛错。** 这是面向学生的生产站点,契约分歧的代价不该是白屏 —— 少了哪个
* 字段,页面大体上照样能用,只是那处空着。所以解析失败时:
*
* 1. `console.error` 一条带端点和字段路径的记录,开发时一眼能看到;
* 2. 记进 `window.__OJ2_CONTRACT_DRIFT__`(同一条只记一次),排查线上问题时
* 可以直接在控制台敲这个变量看全部历史;
* 3. **返回原始数据**,让页面继续渲染。
*
* 用 `safeParse` 而不是 `parse`:`parse` 抛出的 ZodError 会把调用方整个 async
* 函数打断,`getProblem` 一失败,整个题目页就只剩白屏。
*
* ## 什么时候该升级成硬失败
*
* 等 `__OJ2_CONTRACT_DRIFT__` 在某条路径上稳定为空之后,那条路径就可以换成
* 直接 `schema.parse()` —— 分歧修完了,剩下的任何分歧都是新引入的真 bug,
* 那时白屏反而是对的。**在那之前不要硬失败**,机房上课时炸一个页面比字段空着严重得多。
*/
/**
* 见过的分歧。只留前若干条实例,避免一个列表接口几百条记录把内存堆满 ——
* 每条记录的形状问题是一样的,一条实例足够定位。
*/
interface DriftReport {
/** 请求路径,带参数,方便直接复现 */
endpoint: string
/** zod 的 issue 摘要:路径 + 原因,多条用分号连 */
detail: string
/** 实际收到的数据。截断后的原始值,用来判断是字段缺失还是类型不同 */
received: unknown
/** 出现次数。同一个端点同一个 detail 只记一条,这里累加 */
count: number
}
const MAX_REPORTS = 200
const MAX_RECEIVED_CHARS = 2000
declare global {
interface Window {
__OJ2_CONTRACT_DRIFT__?: DriftReport[]
}
}
function collectDrift(endpoint: string, detail: string, received: unknown) {
if (typeof window === "undefined") return
const reports = (window.__OJ2_CONTRACT_DRIFT__ ??= [])
// 同一个端点 + 同一个原因只记一条,累加次数。列表接口一次几百条记录,
// 不去重的话控制台会被同一句话刷屏,真正的新问题反而看不见。
const existing = reports.find(
(item) => item.endpoint === endpoint && item.detail === detail,
)
if (existing) {
existing.count += 1
return
}
if (reports.length >= MAX_REPORTS) return
reports.push({
endpoint,
detail,
received: truncate(received),
count: 1,
})
}
/** 原始数据可能是一整个列表页,原样留着会占住大量内存;只用来判断形状,够看前 2KB 了 */
function truncate(value: unknown) {
try {
const text = JSON.stringify(value)
if (text === undefined) return value
return text.length <= MAX_RECEIVED_CHARS
? value
: `${text.slice(0, MAX_RECEIVED_CHARS)}…(截断,共 ${text.length} 字符)`
} catch {
return String(value)
}
}
function describe(error: z.ZodError, endpoint: string) {
const issues = error.issues.slice(0, 5).map((issue) => {
const path = issue.path.length ? issue.path.join(".") : "(根)"
return `${path}: ${issue.message}`
})
const more = error.issues.length > 5 ? `;另有 ${error.issues.length - 5} 处` : ""
return `${endpoint} 的响应不符合契约 —— ${issues.join(";")}${more}`
}
/**
* 校验并返回响应。用 `unknown` 进来的数据出去就是契约类型,不需要再 `as`。
*
* ```ts
* const data = await api.get<unknown>("problems", { params })
* return contract("GET /problems", problemListSchema, data)
* ```
*
* 端点字符串是手写的,刻意不让调用方漏掉 —— 它只用于日志和去重,写错不影响正确性。
* 这三条原来是 `schema.parse(v) as T` —— `as` 把校验结果又断言回本地类型,等于
* 没校验;而 `parse` 抛出的 ZodError 会打断整个 async 函数,一失败就是白屏。
* 面向学生的生产站点,少一个字段页面照样能用,整页崩掉不行。所以这里:
* 记一条带端点和字段路径的 `console.error`,然后**返回原始数据**。
*/
export function contract<T extends z.ZodType>(
endpoint: string,
@@ -120,13 +29,19 @@ export function contract<T extends z.ZodType>(
const result = schema.safeParse(value)
if (result.success) return result.data
collectDrift(endpoint, describe(result.error, endpoint), value)
const issues = result.error.issues
.slice(0, 5)
.map((issue) => `${issue.path.join(".") || "(根)"}: ${issue.message}`)
.join(";")
const more =
result.error.issues.length > 5
? `;另有 ${result.error.issues.length - 5} 处`
: ""
console.error(
`[契约] ${describe(result.error, endpoint)}\n` +
" 已放行原始数据(页面照常渲染)。全部历史分歧见 window.__OJ2_CONTRACT_DRIFT__。\n" +
" 契约在 packages/contract/src/,后端对不上的字段在 apps/api/src/routes/。",
`[契约] ${endpoint} 的响应不符合契约 —— ${issues}${more}\n` +
" 已放行原始数据(页面照常渲染)。契约在 packages/contract/src/。",
)
// 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是
// 「能渲染的东西」而不是一个异常;分歧已经通过上面两条记录暴露出来了。
// 「能渲染的东西」而不是一个异常,分歧已经通过上面那条日志暴露出来了。
return value as z.infer<T>
}
+11 -11
View File
@@ -1,5 +1,5 @@
import { toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, SubmissionDetail } from "@oj2/contract"
import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types"
import { USER_TYPE } from "./constants"
@@ -23,18 +23,18 @@ function calculateACRate(acCount: number, totalCount: number): string {
/**
* 从 `submission.info` 里取测试点明细,取不到就返回空数组。
*
* 契约里 `info` 是**联合类型**:判题机写的完整形状,或者空对象 —— 后者是后端对
* 非管理员下发的权限投影(`routes/submission.ts` 的 `full ? row.submission.info : {}`),
* 也是待判提交的初值。所以调用方不能直接 `.data`,得先在这里收口。
* `info` 在契约里是 `z.unknown()`(判题产物不在读出侧校验,见契约那边的说明),
* 它有三种真实取值:判题机写的完整形状、**空对象**(后端对非管理员下发的
* `info: {}`,也是待判提交的初值)、以及 `data: null`(编译失败等没有逐测试点
* 结果的情形)。三种「没有」在这里一并归成空数组。
*
* 另外 `data` 本身也可能是 null:生产库 124191 条提交里有 12048 条是编译失败之类
* 没有逐测试点结果的情形。两种「没有」在这里一并归成空数组。
* **这是判题产物在前端唯一需要的运行时判断** —— 有没有 data 数组。数组项的形状
* 直接信判题机(`JudgeCaseResult`)。
*/
export function submissionCaseResults(
info: SubmissionDetail["info"] | null | undefined,
): JudgeCaseResult[] {
if (!info || !("data" in info) || !info.data) return []
return info.data
export function submissionCaseResults(info: unknown): JudgeCaseResult[] {
if (!info || typeof info !== "object" || !("data" in info)) return []
const data = (info as JudgeInfo).data
return Array.isArray(data) ? data : []
}
export function getACRate(acCount: number, totalCount: number): string {
+4 -3
View File
@@ -265,9 +265,10 @@ export type {
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
/**
* 提交详情。**info / statisticInfo / language 三处窄化都搬进契约了**
* (`judgeInfoSchema` / `statisticInfoSchema` / `problemLanguageSchema`),
* 依据是生产库 124191 条提交的实测分布,见 packages/contract/src/submission.ts。
* 提交详情。`statisticInfo` / `language` 的窄化在契约里
* (`statisticInfoSchema` / `problemLanguageSchema`);`info` 在契约里是
* `z.unknown()`,形状由 `JudgeInfo` / `JudgeCaseResult` 两个 TS 类型描述,
* 取值统一走 `utils/functions` 的 `submissionCaseResults()`(原因见契约那边)。
*
* 前端仍要保留一处:`result` 多一个 9 —— 点了提交、还没拿到结果时前端本地先填的
* 伪状态,后端永远不会下发,见 constants.ts 的 SubmissionStatus.submitting。