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:
@@ -19,56 +19,51 @@ export const judgeStatusSchema = z.union([
|
||||
])
|
||||
|
||||
/**
|
||||
* 判题机原始输出(`submission.info` 的 JSONB 原文)。
|
||||
* 判题机原始输出(`submission.info` 的 JSONB 原文)。**只是类型,不作运行时校验。**
|
||||
*
|
||||
* 形状按**生产库 124191 条提交实测**得出,不是照着前端那份额外手抄的:
|
||||
* 这里曾经是一组 zod schema,按生产库实测的键集收紧过,结果是 124192 条提交里有
|
||||
* 9163 条(RE 8480/8480、TLE 338/338、MLE 1/1 全中)被判成不符:沙箱在非正常退出
|
||||
* 的测试点上写 `output_md5: null`,而 SQL 判题(`judge/sql/engine.ts` 的 CaseResult)
|
||||
* 压根没有 `output` 这个键、`error_message` 通过时是 null。收紧当时只对了键集合,
|
||||
* 没对空值。
|
||||
*
|
||||
* - `err` 实测 124191 条**全是 null**,从来没见过字符串 —— 但契约仍留 `string`,
|
||||
* 因为判题机层面它是有意义的通道,收紧成 `z.null()` 会在它第一次真的报错时炸。
|
||||
* - `data` 有 **12048 条是 null**(编译失败等没有逐测试点结果的情形),
|
||||
* 所以它必须 nullable。前端原来手抄的 `Info` 把 data 写成了非空数组,
|
||||
* 这 12048 条在类型上根本不成立,只是没有一处会去读它才没炸。
|
||||
* - 数组项比前端手抄的多三处:SQL 判题多带 `error_message`(201 个测试点)、
|
||||
* 部分带 `score`(10 个)。所以这里的字段一律可选,不用 strictObject。
|
||||
* 更糟的是失败方式:`info` 当时是 `union([完整形状, z.object({})])`,对不上的一律
|
||||
* 落进第二支被剥成 `{}` 且 parse 成功 —— 管理员的测试点表格**静默消失**。
|
||||
*
|
||||
* 结论:JSONB 的形状真相在**写入侧**(判题机、`judge/run.ts`),在读出侧再校验一遍
|
||||
* 只会在两边分叉时丢数据。所以 `info` 回到 `z.unknown()`,形状以下面的 TS 类型
|
||||
* 描述,取值处由 `submissionCaseResults()` 做一次真正需要的运行时判断(有没有
|
||||
* data 数组)。**改这里的字段时对着判题机改,不要对着采样出来的键集改。**
|
||||
*
|
||||
* 键名是**判题沙箱定的 snake_case**,不要跟着响应字段一起改。
|
||||
*/
|
||||
export const judgeCaseResultSchema = z.object({
|
||||
error: z.number(),
|
||||
memory: z.number(),
|
||||
output: z.string().nullable(),
|
||||
result: judgeStatusSchema,
|
||||
signal: z.number(),
|
||||
cpu_time: z.number(),
|
||||
exit_code: z.number(),
|
||||
real_time: z.number(),
|
||||
test_case: z.string(),
|
||||
output_md5: z.string(),
|
||||
/** SQL 判题会带上中文原因,沙箱判题没有这个键 */
|
||||
error_message: z.string().optional(),
|
||||
score: z.number().optional(),
|
||||
})
|
||||
|
||||
export const judgeInfoSchema = z.object({
|
||||
err: z.string().nullable(),
|
||||
data: z.array(judgeCaseResultSchema).nullable(),
|
||||
})
|
||||
export interface JudgeCaseResult {
|
||||
error: number
|
||||
memory: number
|
||||
/** SQL 判题没有这个键 */
|
||||
output?: string | null
|
||||
result: JudgeStatus
|
||||
signal: number
|
||||
cpu_time: number
|
||||
exit_code: number
|
||||
real_time: number
|
||||
test_case: string
|
||||
/** 非正常退出的测试点上是 null */
|
||||
output_md5: string | null
|
||||
/** SQL 判题会带上中文原因(通过的测试点是 null),沙箱判题没有这个键 */
|
||||
error_message?: string | null
|
||||
score?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* `info` 允许的两种取值,**不能只写成完整形状**:
|
||||
*
|
||||
* 1. 完整形状:判题机写的 JSONB 原文;
|
||||
* 2. **空对象**:后端对非管理员用 `info: {}` 下发的占位(`routes/submission.ts:841`
|
||||
* 的 `full ? row.submission.info : {}`),同一个空对象也是插入待判提交时的初值。
|
||||
*
|
||||
* 第 2 种是真实存在的合法取值,收紧成只认完整形状会让**每一条非管理员看的提交详情
|
||||
* 直接 500**(`submissionDetailSchema.parse` 在路由里抛,被 onError 兜成 internal-error)。
|
||||
* 这不是假想:收紧当天就在本地实测复现了。
|
||||
*
|
||||
* 换句话说,空对象表达的是「这条响应对你不含 info」,一个**权限投影**,
|
||||
* 而不是「字段缺失」—— 契约要如实描述它。
|
||||
* `info` 的完整形状。实际取值还有第三种:**空对象** —— 后端对非管理员下发
|
||||
* `info: {}`(`routes/submission.ts` 的 `full ? row.submission.info : {}`),
|
||||
* 也是插入待判提交时的初值。所以调用方不能直接 `.data`。
|
||||
*/
|
||||
export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
|
||||
export interface JudgeInfo {
|
||||
err: string | null
|
||||
data: JudgeCaseResult[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
|
||||
@@ -76,11 +71,11 @@ export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
|
||||
* 五个键全部可选,依据是生产库实测的出现次数:time_cost / memory_cost 各 112097、
|
||||
* score 3993、err_info 3153、ast_results 56,另有 27 条空对象。
|
||||
*
|
||||
* **不能用严格对象。** 有 8916 条历史记录里的 JSONB 原文内嵌了带转义的 shell
|
||||
* 输出、本身不是合法 JSON,后端 `objectValue()` 会把它兜成 `{ value: "<原串>" }`
|
||||
* 再下发 —— 严格 schema 会把这 8916 条判成契约分歧,而它们其实是正常的失败记录。
|
||||
* 用 `looseObject`:所有键可选 + 不剥未知键 = **对任何对象都不会失败、也不丢字段**,
|
||||
* 它在这里的作用是给前端一个能读 `err_info` 的类型,而不是一道闸门。判题产物的
|
||||
* 闸门在写入侧,理由见上面 `JudgeCaseResult`。
|
||||
*/
|
||||
export const statisticInfoSchema = z.object({
|
||||
export const statisticInfoSchema = z.looseObject({
|
||||
score: z.number().optional(),
|
||||
/** 判题机写进 statistic_info 的错误文本,教师面板的「最近一条错在哪」也读它 */
|
||||
err_info: z.string().optional(),
|
||||
@@ -129,8 +124,8 @@ export const submissionDetailSchema = z.object({
|
||||
username: z.string(),
|
||||
code: z.string(),
|
||||
result: judgeStatusSchema,
|
||||
/** 未判完或非管理员看时为 `{}`,见 submissionInfoSchema 的注释 */
|
||||
info: submissionInfoSchema,
|
||||
/** 判题机原文;未判完或非管理员看时为 `{}`,见 JudgeInfo 的注释 */
|
||||
info: z.unknown(),
|
||||
language: problemLanguageSchema,
|
||||
statisticInfo: statisticInfoSchema,
|
||||
contestId: z.number().int().nullable(),
|
||||
@@ -314,8 +309,6 @@ export const formatCodeRequestSchema = z.object({
|
||||
export const formatCodeResponseSchema = z.object({ code: z.string() })
|
||||
|
||||
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
|
||||
export type JudgeInfo = z.infer<typeof judgeInfoSchema>
|
||||
export type JudgeCaseResult = z.infer<typeof judgeCaseResultSchema>
|
||||
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
|
||||
export type CreateSubmissionRequest = z.infer<
|
||||
typeof createSubmissionRequestSchema
|
||||
|
||||
Reference in New Issue
Block a user