契约在前端一直只当类型包用: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:
1 parent
a475cac128
commit
aab0404ed7
11 files changed
+414
-115
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
/**
|
||||
* 契约的运行时闸门。
|
||||
*
|
||||
* ## 为什么要有这一层
|
||||
*
|
||||
* `@oj2/contract` 的收益只有一半是类型:`z.infer` 给出编译期的形状,但**编译期
|
||||
* 管不了后端实际下发了什么**。改后端字段、drizzle 改名、序列化时漏一个键,
|
||||
* TypeScript 一概看不见,页面上表现为某个 `undefined` 静默渲染成空白。
|
||||
* 契约真正的价值在于同一份 schema 能在运行时把这种分歧当场抓出来。
|
||||
*
|
||||
* 原来只有三处调用 `.parse()`,而且**后面都紧跟一个 `as`** 把它重新断言回本地
|
||||
* 类型(`problemDetailSchema.parse(v) as Problem`)—— 校验结果被丢弃,等于没校验。
|
||||
*
|
||||
* ## 失败策略:记日志 + 放行原始数据
|
||||
*
|
||||
* **不抛错。** 这是面向学生的生产站点,契约分歧的代价不该是白屏 —— 少了哪个
|
||||
* 字段,页面大体上照样能用,只是那处空着。所以解析失败时:
|
||||
*
|
||||
* 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)
|
||||
* ```
|
||||
*
|
||||
* 端点字符串是手写的,刻意不让调用方漏掉 —— 它只用于日志和去重,写错不影响正确性。
|
||||
*/
|
||||
export function contract<T extends z.ZodType>(
|
||||
endpoint: string,
|
||||
schema: T,
|
||||
value: unknown,
|
||||
): z.infer<T> {
|
||||
const result = schema.safeParse(value)
|
||||
if (result.success) return result.data
|
||||
|
||||
collectDrift(endpoint, describe(result.error, endpoint), value)
|
||||
console.error(
|
||||
`[契约] ${describe(result.error, endpoint)}\n` +
|
||||
" 已放行原始数据(页面照常渲染)。全部历史分歧见 window.__OJ2_CONTRACT_DRIFT__。\n" +
|
||||
" 契约在 packages/contract/src/,后端对不上的字段在 apps/api/src/routes/。",
|
||||
)
|
||||
// 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是
|
||||
// 「能渲染的东西」而不是一个异常;分歧已经通过上面两条记录暴露出来了。
|
||||
return value as z.infer<T>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { toAdminType } from "@oj2/contract"
|
||||
import type { JudgeCaseResult, SubmissionDetail } from "@oj2/contract"
|
||||
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
|
||||
import { User } from "./types"
|
||||
import { USER_TYPE } from "./constants"
|
||||
@@ -19,6 +20,23 @@ function calculateACRate(acCount: number, totalCount: number): string {
|
||||
return ((acCount / totalCount) * 100).toFixed(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 `submission.info` 里取测试点明细,取不到就返回空数组。
|
||||
*
|
||||
* 契约里 `info` 是**联合类型**:判题机写的完整形状,或者空对象 —— 后者是后端对
|
||||
* 非管理员下发的权限投影(`routes/submission.ts` 的 `full ? row.submission.info : {}`),
|
||||
* 也是待判提交的初值。所以调用方不能直接 `.data`,得先在这里收口。
|
||||
*
|
||||
* 另外 `data` 本身也可能是 null:生产库 124191 条提交里有 12048 条是编译失败之类
|
||||
* 没有逐测试点结果的情形。两种「没有」在这里一并归成空数组。
|
||||
*/
|
||||
export function submissionCaseResults(
|
||||
info: SubmissionDetail["info"] | null | undefined,
|
||||
): JudgeCaseResult[] {
|
||||
if (!info || !("data" in info) || !info.data) return []
|
||||
return info.data
|
||||
}
|
||||
|
||||
export function getACRate(acCount: number, totalCount: number): string {
|
||||
return `${calculateACRate(acCount, totalCount)}%`
|
||||
}
|
||||
|
||||
+26
-73
@@ -15,6 +15,7 @@ import type {
|
||||
JudgeStatus,
|
||||
AstRules,
|
||||
CreateAnnouncementRequest,
|
||||
ProblemLanguage,
|
||||
} from "@oj2/contract"
|
||||
|
||||
/**
|
||||
@@ -45,16 +46,17 @@ export type User = AdminUser & {
|
||||
password?: string
|
||||
}
|
||||
|
||||
export type LANGUAGE =
|
||||
| "C"
|
||||
| "C++"
|
||||
| "Python2"
|
||||
| "Python3"
|
||||
| "Java"
|
||||
| "JavaScript"
|
||||
| "Golang"
|
||||
| "Flowchart"
|
||||
| "SQL"
|
||||
/**
|
||||
* 语言联合。**从契约派生,不再手抄** —— 原来这里手写了一份 9 个值的联合,
|
||||
* 而后端 `judge/languages.ts` 与生产库各有自己的答案,三份真相各自演进:
|
||||
* 手抄那份漏了 `SQL`,于是生产库里 91 条 SQL 提交的 `language` 在类型上是
|
||||
* `undefined`(提交列表、表情组件都按它渲染)。现在唯一来源是契约的
|
||||
* `problemLanguageSchema`,见 packages/contract/src/language.ts。
|
||||
*
|
||||
* constants.ts 的 SOURCES / LANGUAGE_FORMAT_VALUE / LANGUAGE_SHOW_VALUE
|
||||
* 都以它为键 —— 契约里加一种语言而那边没补映射,会当场编译不过。
|
||||
*/
|
||||
export type LANGUAGE = ProblemLanguage
|
||||
|
||||
/**
|
||||
* SQL 题的配置与展示数据。形状在契约里 —— 原来这里手抄了一份,
|
||||
@@ -262,74 +264,25 @@ export type {
|
||||
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
|
||||
|
||||
/**
|
||||
* 判题机原始输出。契约里是 `info: z.unknown()` —— 后端不校验沙箱产物,
|
||||
* 这些键名是沙箱定的,**保持 snake_case**,不要跟着响应字段一起改名。
|
||||
* 提交详情。**info / statisticInfo / language 三处窄化都搬进契约了**
|
||||
* (`judgeInfoSchema` / `statisticInfoSchema` / `problemLanguageSchema`),
|
||||
* 依据是生产库 124191 条提交的实测分布,见 packages/contract/src/submission.ts。
|
||||
*
|
||||
* 前端仍要保留一处:`result` 多一个 9 —— 点了提交、还没拿到结果时前端本地先填的
|
||||
* 伪状态,后端永远不会下发,见 constants.ts 的 SubmissionStatus.submitting。
|
||||
* 这是「前端自己造的状态」,不属于契约能描述的东西。
|
||||
*/
|
||||
interface Info {
|
||||
err: string | null
|
||||
data: {
|
||||
error: number
|
||||
memory: number
|
||||
output: null
|
||||
result: SUBMISSION_RESULT
|
||||
signal: number
|
||||
cpu_time: number
|
||||
exit_code: number
|
||||
real_time: number
|
||||
test_case: string
|
||||
output_md5: string
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题产出的统计。**键名保持 snake_case** —— 这是 submission.statistic_info
|
||||
* JSONB 的原文,判题机按这套键名写进去,不能跟着响应字段一起改名。
|
||||
*/
|
||||
export interface StatisticInfo {
|
||||
score?: number
|
||||
err_info?: string
|
||||
time_cost?: number
|
||||
memory_cost?: number
|
||||
ast_results?: Array<{
|
||||
description: string
|
||||
passed: boolean
|
||||
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
|
||||
actual?: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交详情。以契约的 SubmissionDetail 为准,只窄化两处 unknown:
|
||||
* `info` 是判题沙箱原始输出,`statisticInfo` 是判题写的 JSONB —— 两者内部都是 snake。
|
||||
*/
|
||||
export type Submission = Omit<
|
||||
SubmissionDetail,
|
||||
"info" | "statisticInfo" | "language" | "result"
|
||||
> & {
|
||||
info: Info
|
||||
statisticInfo: StatisticInfo
|
||||
language: LANGUAGE
|
||||
// 比契约多一个 9:点了提交、还没拿到结果时前端本地先填这个伪状态,
|
||||
// 见 constants.ts 的 SubmissionStatus.submitting
|
||||
export type Submission = Omit<SubmissionDetail, "result"> & {
|
||||
result: SUBMISSION_RESULT
|
||||
}
|
||||
|
||||
/** 站内信里嵌的提交:problem 是展示题号而非数字 id,且不含 info / ip / contestId */
|
||||
export type EmbeddedSubmission = Omit<
|
||||
ContractEmbeddedSubmission,
|
||||
"statisticInfo" | "language"
|
||||
> & {
|
||||
statisticInfo: StatisticInfo
|
||||
language: LANGUAGE
|
||||
}
|
||||
/**
|
||||
* 站内信里嵌的提交:problem 是展示题号而非数字 id,且不含 info / ip / contestId。
|
||||
* 契约里已经窄化好了(`embeddedSubmissionSchema`),这里不再重复 Omit + 增补。
|
||||
*/
|
||||
export type EmbeddedSubmission = ContractEmbeddedSubmission
|
||||
|
||||
export type SubmissionListItem = Omit<
|
||||
ContractSubmissionListItem,
|
||||
"statisticInfo" | "language"
|
||||
> & {
|
||||
statisticInfo: StatisticInfo
|
||||
language: LANGUAGE
|
||||
}
|
||||
export type SubmissionListItem = ContractSubmissionListItem
|
||||
|
||||
export interface SubmissionListPayload {
|
||||
myself?: "1" | "0"
|
||||
|
||||
Reference in new issue
Block a user