refactor(契约): 语言与判题产物的形状收进契约,学生端高频响应接上运行时校验
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
契约在前端一直只当类型包用:45 处引用里几乎全是 import type,三个 .parse() 后面
还都紧跟一个 as 把校验结果断言回去,等于没校验。这一轮把形状的来源收拢。
## 语言:三份真相并成一份
前端 utils/types.ts 手写了一份 9 值的语言联合,后端 judge/languages.ts 有自己的一套,
生产库又有一套。手抄那份**漏了 SQL**,而生产库 961 道题里有 9 道 SQL 题、
124191 条提交里有 91 条 SQL 提交 —— 这些提交的 language 在前端类型上是 undefined。
现在唯一来源是契约的 problemLanguageSchema,constants.ts 的显示映射以它为键,
契约里加语言而那边没补映射会当场编译不过。
## 判题产物:按生产数据实测收紧
judgeInfoSchema / statisticInfoSchema 的形状来自 124191 条提交的实测,不是手抄:
- info.data 有 12048 条是 null(编译失败等没有逐测试点结果),前端手抄的 Info
却把 data 写成非空数组 —— 这 12048 条在类型上根本不成立;
- info 还允许**空对象**:非管理员看提交详情时后端下发 info: {}(权限投影)。
收紧时必须把它算进去,否则每条非管理员看的提交详情直接 500 —— 本地实测复现过;
- statistic_info 的五个键按出现次数定成全部可选;另有 8916 条 JSONB 原文因内嵌
带转义的 shell 输出不是合法 JSON,被后端 objectValue() 兜成 { value: ... },
所以不能用严格对象,否则这 8916 条会被误判成分歧。
## 运行时闸门
新增 utils/contract.ts:safeParse 失败时记一条分歧(去重、控制台可见、
window.__OJ2_CONTRACT_DRIFT__ 可查)后**放行原始数据**,不白屏 —— 面向学生的
生产站点,字段空着比整页崩掉可接受。接在 7 条高频链路上:/site、/site/online、
/problems、/problems/:id、/submissions、/submissions/:id、/me。
提交详情的 info 是联合类型,调用方不再直接取 .data,统一走
utils/functions.ts 的 submissionCaseResults()。
## 验证
- vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;
- 契约 schema 直跑真实接口:7/7 通过(自包含脚本,从登录到详情全链路);
- 用生产备份复核收紧后的约束:961 题的 languages 无越界值,template 只出现
C/Python3 两个键 —— 不会因为这次收紧在生产上抛错。
This commit is contained in:
@@ -8,6 +8,7 @@ export * from "./common"
|
||||
export * from "./content"
|
||||
export * from "./contest"
|
||||
export * from "./flowchart"
|
||||
export * from "./language"
|
||||
export * from "./problem"
|
||||
export * from "./problemset"
|
||||
export * from "./roles"
|
||||
|
||||
45
packages/contract/src/language.ts
Normal file
45
packages/contract/src/language.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* 判题沙箱认得的语言。**这是全仓唯一的语言集合定义处。**
|
||||
*
|
||||
* 键名必须与 `apps/api/src/judge/languages.ts` 的 `languageConfigs` 一致 ——
|
||||
* 沙箱的编译/运行命令按这些键查表,查不到就是 `Unsupported judge language`。
|
||||
* 后端那边是 `Record<string, …>`(判题机要按名字取配置,不能窄化成联合),
|
||||
* 所以这个联合是**手写**的,改 languages.ts 时两边一起改。
|
||||
*
|
||||
* 这 6 种是**现在还能提交**的语言。含历史值 `Python2`:判题机已经没有它的
|
||||
* 编译配置了,但生产库里有 3 条当年用 Python2 提交的记录,提交列表要能渲染出来。
|
||||
*/
|
||||
export const judgeLanguageSchema = z.enum([
|
||||
"Python2",
|
||||
"Python3",
|
||||
"C",
|
||||
"C++",
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Golang",
|
||||
])
|
||||
|
||||
/**
|
||||
* 题目可以挂的语言 = 沙箱语言 + 两种非沙箱题型。
|
||||
*
|
||||
* - `SQL` 走 `judge/sql/` 那条独立链路(子进程 + sql.js),不经过沙箱;
|
||||
* - `Flowchart` 走 AI 评分(flowchart/run.ts),也不经过沙箱。
|
||||
*
|
||||
* **两者都是真实可选、真实有历史数据的**,不是预留值:生产库 961 道题里有
|
||||
* 9 道 SQL 题、124191 条提交里有 91 条 SQL。前端原来手抄的语言联合漏了 SQL,
|
||||
* 于是那 91 条提交的 `language` 在类型上是 `undefined` —— 这就是两份真相
|
||||
* 各自演进的代价,现在并成一份。
|
||||
*/
|
||||
export const problemLanguageSchema = z.enum([
|
||||
...judgeLanguageSchema.options,
|
||||
"SQL",
|
||||
"Flowchart",
|
||||
])
|
||||
|
||||
/** 沙箱语言组成的数组(顺序即提权顺序,前端用它排语言 tab) */
|
||||
export const JUDGE_LANGUAGES = judgeLanguageSchema.options
|
||||
|
||||
export type JudgeLanguage = z.infer<typeof judgeLanguageSchema>
|
||||
export type ProblemLanguage = z.infer<typeof problemLanguageSchema>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||
import { problemLanguageSchema } from "./language"
|
||||
|
||||
/**
|
||||
* 题目难度。生产库 956 道题只有这三个值(旧 Django 的 Problem.difficulty choices
|
||||
@@ -296,8 +297,16 @@ export const problemDetailSchema = z.object({
|
||||
}),
|
||||
),
|
||||
hint: z.string().nullable(),
|
||||
languages: z.array(z.string()),
|
||||
template: z.record(z.string(), z.string()),
|
||||
languages: z.array(problemLanguageSchema),
|
||||
/**
|
||||
* 语言 → 代码模板。**用 partialRecord 让键受语言联合约束** ——
|
||||
* 原来这里是 `z.record(z.string(), z.string())`,等价于 `Record<string, string>`,
|
||||
* 前端按语言查模板时拿不到任何键名保护(`template["Pytho3"]` 也是合法表达式)。
|
||||
*
|
||||
* partialRecord 而非 record:没配模板的语言不该出现该键(`template: {}` 是常态),
|
||||
* 用 record 会要求每一个语言键都存在。
|
||||
*/
|
||||
template: z.partialRecord(problemLanguageSchema, z.string()),
|
||||
createTime: z.string(),
|
||||
lastUpdateTime: z.string().nullable(),
|
||||
timeLimit: z.number().int(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { paginatedSchema } from "./common"
|
||||
import { problemLanguageSchema } from "./language"
|
||||
|
||||
export const judgeStatusSchema = z.union([
|
||||
z.literal(-2),
|
||||
@@ -17,9 +18,93 @@ export const judgeStatusSchema = z.union([
|
||||
z.literal(10),
|
||||
])
|
||||
|
||||
/**
|
||||
* 判题机原始输出(`submission.info` 的 JSONB 原文)。
|
||||
*
|
||||
* 形状按**生产库 124191 条提交实测**得出,不是照着前端那份额外手抄的:
|
||||
*
|
||||
* - `err` 实测 124191 条**全是 null**,从来没见过字符串 —— 但契约仍留 `string`,
|
||||
* 因为判题机层面它是有意义的通道,收紧成 `z.null()` 会在它第一次真的报错时炸。
|
||||
* - `data` 有 **12048 条是 null**(编译失败等没有逐测试点结果的情形),
|
||||
* 所以它必须 nullable。前端原来手抄的 `Info` 把 data 写成了非空数组,
|
||||
* 这 12048 条在类型上根本不成立,只是没有一处会去读它才没炸。
|
||||
* - 数组项比前端手抄的多三处:SQL 判题多带 `error_message`(201 个测试点)、
|
||||
* 部分带 `score`(10 个)。所以这里的字段一律可选,不用 strictObject。
|
||||
*
|
||||
* 键名是**判题沙箱定的 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(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `info` 允许的两种取值,**不能只写成完整形状**:
|
||||
*
|
||||
* 1. 完整形状:判题机写的 JSONB 原文;
|
||||
* 2. **空对象**:后端对非管理员用 `info: {}` 下发的占位(`routes/submission.ts:841`
|
||||
* 的 `full ? row.submission.info : {}`),同一个空对象也是插入待判提交时的初值。
|
||||
*
|
||||
* 第 2 种是真实存在的合法取值,收紧成只认完整形状会让**每一条非管理员看的提交详情
|
||||
* 直接 500**(`submissionDetailSchema.parse` 在路由里抛,被 onError 兜成 internal-error)。
|
||||
* 这不是假想:收紧当天就在本地实测复现了。
|
||||
*
|
||||
* 换句话说,空对象表达的是「这条响应对你不含 info」,一个**权限投影**,
|
||||
* 而不是「字段缺失」—— 契约要如实描述它。
|
||||
*/
|
||||
export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
|
||||
|
||||
/**
|
||||
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
|
||||
*
|
||||
* 五个键全部可选,依据是生产库实测的出现次数:time_cost / memory_cost 各 112097、
|
||||
* score 3993、err_info 3153、ast_results 56,另有 27 条空对象。
|
||||
*
|
||||
* **不能用严格对象。** 有 8916 条历史记录里的 JSONB 原文内嵌了带转义的 shell
|
||||
* 输出、本身不是合法 JSON,后端 `objectValue()` 会把它兜成 `{ value: "<原串>" }`
|
||||
* 再下发 —— 严格 schema 会把这 8916 条判成契约分歧,而它们其实是正常的失败记录。
|
||||
*/
|
||||
export const statisticInfoSchema = z.object({
|
||||
score: z.number().optional(),
|
||||
/** 判题机写进 statistic_info 的错误文本,教师面板的「最近一条错在哪」也读它 */
|
||||
err_info: z.string().optional(),
|
||||
time_cost: z.number().optional(),
|
||||
memory_cost: z.number().optional(),
|
||||
ast_results: z.array(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
passed: z.boolean(),
|
||||
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
|
||||
actual: z.number().optional(),
|
||||
}),
|
||||
).optional(),
|
||||
})
|
||||
|
||||
export const createSubmissionRequestSchema = z.object({
|
||||
problemId: z.number().int().positive(),
|
||||
language: z.string().min(1).max(32),
|
||||
/**
|
||||
* 提交的语言。用题目语言的联合而不是 `z.string()` —— 学生能选的语言就是题目
|
||||
* `languages` 里列出的那些,写宽松了的话,前端把语言拼错(`"C++"`、`"python3"`
|
||||
* 大小写)会一路走到判题机才以 `Unsupported judge language` 报系统错误,
|
||||
* 学生看到的是「系统错误」而不是「语言不对」。
|
||||
*/
|
||||
language: problemLanguageSchema,
|
||||
code: z.string().min(1).max(1024 * 1024),
|
||||
contestId: z.number().int().positive().optional(),
|
||||
/**
|
||||
@@ -44,9 +129,10 @@ export const submissionDetailSchema = z.object({
|
||||
username: z.string(),
|
||||
code: z.string(),
|
||||
result: judgeStatusSchema,
|
||||
info: z.unknown(),
|
||||
language: z.string(),
|
||||
statisticInfo: z.record(z.string(), z.unknown()),
|
||||
/** 未判完或非管理员看时为 `{}`,见 submissionInfoSchema 的注释 */
|
||||
info: submissionInfoSchema,
|
||||
language: problemLanguageSchema,
|
||||
statisticInfo: statisticInfoSchema,
|
||||
contestId: z.number().int().nullable(),
|
||||
problemId: z.number().int(),
|
||||
/**
|
||||
@@ -99,8 +185,8 @@ export const submissionListItemSchema = z.object({
|
||||
userId: z.number().int(),
|
||||
username: z.string(),
|
||||
result: judgeStatusSchema,
|
||||
language: z.string(),
|
||||
statisticInfo: z.record(z.string(), z.unknown()),
|
||||
language: problemLanguageSchema,
|
||||
statisticInfo: statisticInfoSchema,
|
||||
/**
|
||||
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null(比赛题不会进题单)。
|
||||
* 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission
|
||||
@@ -228,6 +314,9 @@ 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