Files
OJ2/packages/contract/src/contest.ts
yuetsh 7d15e6aeaa 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
2026-09-10 04:53:28 -06:00

74 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { problemDetailSchema, problemListItemSchema } from "./problem"
export const contestStatusSchema = z.enum(["-1", "0", "1"])
export const contestSchema = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
tag: z.string(),
startTime: z.string(),
endTime: z.string(),
createTime: z.string(),
lastUpdateTime: z.string(),
createdBy: sampleUserSchema,
status: contestStatusSchema,
contestType: z.enum(["Public", "Password Protected"]),
now: z.string().optional(),
})
export const contestListSchema = paginatedSchema(contestSchema)
export const contestPasswordRequestSchema = z.object({
password: z.string().min(1).max(128),
})
export const contestAccessSchema = z.object({ access: z.boolean() })
export const contestProblemsSchema = z.array(z.union([problemListItemSchema, problemDetailSchema]))
/**
* `acm_contest_rank.submission_info` 的 JSONB 原文。
*
* 键名是**判题链路写进去的 snake_case**(历史比赛的榜单行也是这个形状),
* 不要跟着响应字段一起改成 camelCase。只有真正提交过的题目才会有自己的键
* 键一旦存在,前四个字段判题链路一定会写全;`checked` 是前端本地标「已看」时补的,
* 所以只有它可选。生产库 2401 行榜单实测全部符合。
*
* 原来契约这里是 `z.record(z.string(), z.unknown())`,于是前端不得不
* 自己再声明一份 `SubmissionInfo` 去覆盖它utils/types 的 ContestRank
* 形状搬进来之后那个覆盖就没有内容了。
*/
export const contestSubmissionInfoSchema = z.object({
is_ac: z.boolean(),
ac_time: z.number(),
is_first_ac: z.boolean(),
error_number: z.number().int(),
checked: z.boolean().optional(),
})
export const contestRankItemSchema = z.object({
id: z.number().int(),
user: sampleUserSchema,
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
totalTime: z.number().int(),
submissionInfo: z.record(z.string(), contestSubmissionInfoSchema),
contestId: z.number().int(),
})
export const contestRankSchema = paginatedSchema(contestRankItemSchema)
export type Contest = z.infer<typeof contestSchema>
export type ContestList = z.infer<typeof contestListSchema>
export type ContestRankItem = z.infer<typeof contestRankItemSchema>
export type ContestRank = z.infer<typeof contestRankSchema>
export type ContestAccess = z.infer<typeof contestAccessSchema>
export type ContestSubmissionInfo = z.infer<typeof contestSubmissionInfoSchema>
export type ContestStatus = z.infer<typeof contestStatusSchema>
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
export type ContestProblems = z.infer<typeof contestProblemsSchema>