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:
2026-09-10 04:53:28 -06:00
parent 684f2d29a5
commit 7d15e6aeaa
9 changed files with 325 additions and 631 deletions

View File

@@ -1,11 +1,14 @@
import type { ExerciseType } from "@oj2/contract"
import { exerciseDataByType, type ExerciseType } from "@oj2/contract"
/**
* 练习题 `data` 的语义校验。
* 练习题 `data` 的校验。**这是唯一的校验点** —— 契约里 `data` 是
* `z.record(z.string(), z.unknown())`,七种题型的字段完全不同,用 zod 写成判别联合
* 会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema历史脏数据会把
* 整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行。
*
* 契约里 `data` 是 `z.record(z.string(), z.unknown())` —— 七种题型的字段完全不同,
* 用 zod 写成判别联合会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema
* 历史脏数据会把整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行
* 两层:先按 `exerciseDataByType` 查形状(键在不在、类型对不对),再走下面的语义
* 检查(选项够不够、下标越不越界)。形状那层是后补的 —— 之前只有语义检查
* 而它**一次都没查过 `question`**,一道没有题干的练习能存进库
*
* 为什么非校验不可:以前唯一的校验在前端 ExerciseManager 的 buildData(),而它对
* fill 和 mcq 几乎不查 —— 一道没有 `{{空位}}` 的填空题能存进库,学生端渲染出来是
@@ -17,6 +20,16 @@ export function exerciseDataError(
type: ExerciseType,
data: Record<string, unknown>,
): string | null {
const shape = exerciseDataByType[type]
if (!shape) return `未知的题型 ${type}`
const parsed = shape.safeParse(data)
if (!parsed.success) {
// 老师看到的是「题干必须是文字」这种话,不是 zod 的英文 issue
const issue = parsed.error.issues[0]!
// 只取第一段:数组项的 path 是 ["options", 0],老师要看的是「选项」
const field = String(issue.path[0] ?? "内容")
return `${FIELD_LABELS[field] ?? field}的格式不对(${issue.message}`
}
switch (type) {
case "mcq": {
const options = strings(data.options)
@@ -67,6 +80,20 @@ export function exerciseDataError(
}
}
/** zod 报的是键名,老师看的得是人话 */
const FIELD_LABELS: Record<string, string> = {
question: "题干",
options: "选项",
answer: "答案",
lines: "代码行",
code: "代码",
left: "左列",
right: "右列",
buckets: "分组",
items: "项目",
explanation: "解析",
}
function strings(value: unknown): string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string")
? (value as string[])

View File

@@ -83,23 +83,37 @@ through the dev server (see `vite.config.ts`).
### Contract guard (`utils/contract.ts`)
`@oj2/contract` 的 zod schema 是**前后端唯一的形状来源**`utils/types.ts` 只做
`z.infer` 派生与少量前端专有的收窄(都写了理由)。读接口应当走守卫:
`z.infer` 派生与少量前端专有的收窄(都写了理由)。
运行时闸门**只挂三处**:题目详情、提交详情、`shared/api.ts` 的用户资料 ——
原本就写了 `.parse()` 的那三条。留着它们的理由是**别抛错**,不是校验:
```ts
const endpoint = `problems/${encodeURIComponent(id)}`
return contract("GET /problems/:id", problemDetailSchema, await api.get<unknown>(endpoint))
// 原来是 problemDetailSchema.parse(v) as Problem —— `as` 让校验白做,
// 而 parse 抛错会让整个题目页白屏
return contract("GET /problems/:id", problemDetailSchema, value)
```
**失败策略是「记日志 + 放行原始数据」,不抛错。** 形状对不上时:控制台打一条带
端点和字段路径的记录、去重后记进 `window.__OJ2_CONTRACT_DRIFT__`、然后**返回原始
数据让页面继续渲染**。面向学生的生产站点,少一个字段的代价远小于白屏。
失败时记一条控制台日志再**放行原始数据**,页面照常渲染。
排查线上分歧就是打开控制台敲 `window.__OJ2_CONTRACT_DRIFT__`;某条路径长期为空之后,
那条路径可以升级成硬失败(直接 `schema.parse`),在那之前不要改。
**不要把它铺到更多端点上。** 试过一次41 个),收益是 41 次 safeParse 加一条
没人读的 console.error前后端同仓、共享同一份 schema「后端改字段前端不知道」
`tsc` 已经抓了。
改动 schema 时要记住**同一个 schema 后端也在 `parse`**(如
`submissionDetailSchema.parse` 在路由里),所以收紧一个字段前先用生产数据核一遍,
否则一条不符合的历史记录会让整个列表 500。
### 什么该收紧,什么不该
**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)不在读出侧
校验。** 它们的形状真相在写入侧 —— 判题机、`services/exercise.ts`。在读出侧再收
一遍的结果实测过两次:
- `info` 按采样键集收紧后124192 条提交里 9163 条RE、TLE、MLE 全中)对不上,
被 union 的空对象分支**静默剥成 `{}`**,管理员的测试点表格无声消失;
- `exercise.data` 按题型收紧后,后端读路径(`routes/content.ts` 硬 parse变成
一道闸,一行脏数据能让整条练习列表 500。
所以:**同一个 schema 后端也在 `parse`**`submissionDetailSchema` /
`exerciseSchema` / `contestRankItemSchema` 都是),收紧任何字段之前,拿根目录
那份生产备份把全量数据跑一遍,尤其要看**空值**而不只是键集合。
### Key Utilities

View File

@@ -1,98 +1,86 @@
import {
type AiAnalysisRecord,
type Contest as OjContest,
type ContestAccess,
type ContestList,
type ActivityRankItem,
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type UserRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemList,
type CreateFlowchartResponse,
type FlowchartCurrent,
type FlowchartDetail,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
problemDetailSchema,
problemListSchema,
problemListItemSchema,
submissionDetailSchema,
submissionListSchema,
submissionStatisticsSchema,
submissionStatisticsItemsSchema,
onlineCountSchema,
websiteConfigSchema,
contestListSchema,
contestSchema,
contestAccessSchema,
contestRankSchema,
announcementListSchema,
announcementSchema,
problemSetListSchema,
problemSetSchema,
problemSetBadgeSchema,
userBadgeSchema,
tutorialSchema,
metricsSchema,
activityRankItemSchema,
problemRankSchema,
userRankSchema,
flowchartListSchema,
flowchartDetailSchema,
flowchartCurrentSchema,
flowchartStatisticsSchema,
flowchartSubmissionSchema,
exerciseSchema,
exerciseDataByType,
problemSetProgressListSchema,
problemSetProblemSchema,
tutorialSummarySchema,
tutorialProgressSchema,
messageListSchema,
yearlyAcSchema,
aiDetailSchema,
solvedListSchema,
durationDataSchema,
heatmapItemSchema,
loginSummarySchema,
aiAnalysisRecordSchema,
type FlowchartStatistics,
type SubmissionStatistics,
type SubmissionStatisticsItems,
} from "@oj2/contract"
import api from "utils/api"
import { contract } from "utils/contract"
import { filterResult } from "oj/transforms"
import type {
Announcement,
AnnouncementListItem,
ContestRank,
Profile,
Message,
SubmissionListItem,
Exercise,
Problem,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
OnlineCount,
WebsiteConfig,
Tutorial,
TutorialProgress,
} from "utils/types"
/**
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
* 键名映射,抄漏一个字段就是静默 undefined。
*
* 走 `contract()` 而不是裸 `parse()`这里原来是
* `problemDetailSchema.parse(value) as Problem` —— `as` 把校验结果又断言回本地
* 类型,等于校验白做。契约现在把 `languages` / `template` 都收进了联合,
* `Problem` 不再需要额外窄化,`as` 也就没有存在的理由了。
* 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as Problem`
* `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。
* 现在形状不符时记一条控制台分歧再放行原始数据。
*/
function detailProblem(value: unknown): Problem {
return contract("GET /problems/:id", problemDetailSchema, value)
}
export async function getWebsiteConfig() {
const endpoint = "site"
return contract(
"GET /site",
websiteConfigSchema,
await api.get<unknown>(endpoint),
)
export function getWebsiteConfig() {
return api.get<WebsiteConfig>("site")
}
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export async function getOnlineCount() {
const endpoint = "site/online"
return contract(
"GET /site/online",
onlineCountSchema,
await api.get<unknown>(endpoint),
)
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
}
export async function getProblemList(
@@ -100,14 +88,9 @@ export async function getProblemList(
limit = 10,
searchParams: Record<string, unknown> = {},
) {
const endpoint = "problems"
const res = contract(
"GET /problems",
problemListSchema,
await api.get<unknown>(endpoint, {
params: { paging: true, offset, limit, ...searchParams },
}),
)
const res = await api.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.results.map(filterResult),
total: res.total,
@@ -132,13 +115,11 @@ export function getProblemBeatRate(problemID: number) {
return api.get<string>(`problems/${problemID}/beat-count`)
}
export async function getSubmission(id: string) {
const endpoint = `submissions/${encodeURIComponent(id)}`
return contract(
"GET /submissions/:id",
submissionDetailSchema,
await api.get<unknown>(endpoint),
export async function getSubmission(id: string): Promise<Submission> {
const response = await api.get<unknown>(
`submissions/${encodeURIComponent(id)}`,
)
return contract("GET /submissions/:id", submissionDetailSchema, response)
}
export function submitCode(data: SubmitCodePayload) {
@@ -162,36 +143,16 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = params.contestId
? `contests/${encodeURIComponent(params.contestId)}/submissions`
: "submissions"
return getSubmissionPage(endpoint, params)
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
// contestId 走的是路径page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
})
}
/**
* 提交列表。后端在 `submissionListItemSchema.parse` 上真的会抛 —— 它逐个列表项
* 过 schema所以这条链路上的分歧**后端自己就拦住了**,前端这层校验是第二道保险:
* 主要防「后端加了字段但契约没跟上、前端类型声称有实际是 undefined」这类
* 只在展示端出问题的偏差。
*/
async function getSubmissionPage(
endpoint: string,
params: Partial<SubmissionListPayload>,
) {
return contract(
`GET /${endpoint}`,
submissionListSchema,
await api.get<unknown>(endpoint, {
// contestId 走的是路径page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
}),
)
}
export async function getRankOfProblem(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/rank`
return contract(
"GET /problems/:id/rank",
problemRankSchema,
await api.get<unknown>(endpoint),
)
export function getRankOfProblem(problemId: string) {
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
}
export function getTodaySubmissionCount(language?: string) {
@@ -208,56 +169,38 @@ export function adminRejudge(id: string) {
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
* 和上面那个按班级模糊匹配的不是一回事。
*/
export async function getSubmissionStatisticsItems(
export function getSubmissionStatisticsItems(
duration: { start?: string; end: string },
username: string,
problemID?: string,
) {
const endpoint = "submissions/statistics/items"
return contract(
"GET /submissions/statistics/items",
submissionStatisticsItemsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
params: { ...duration, problemId: problemID, username },
})
}
export async function getSubmissionStatistics(
export function getSubmissionStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
const endpoint = "submissions/statistics"
return contract(
"GET /submissions/statistics",
submissionStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
return api.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
/**
* 全服榜单。上限100 名)由服务端定,调用方只管翻页 ——
* 「全服 Top10」就是这个榜的第一页取 limit=10 即可,不需要另一个上限参数。
*/
export async function getRank(offset: number, limit: number) {
const endpoint = "rankings/users"
return contract(
"GET /rankings/users",
userRankSchema,
await api.get<unknown>(endpoint, { params: { offset, limit } }),
)
export function getRank(offset: number, limit: number) {
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
}
export async function getActivityRank(start: string) {
const endpoint = "rankings/activity"
return contract(
"GET /rankings/activity",
activityRankItemSchema.array(),
await api.get<unknown>(endpoint, { params: { start } }),
)
export function getActivityRank(start: string) {
return api.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
})
}
export function getClassRank(grade?: number | null) {
@@ -286,37 +229,22 @@ export function getClassPK(
})
}
export async function getContestList(query: {
export function getContestList(query: {
offset: number
limit: number
keyword: string
status: string
tag: string
}) {
const endpoint = "contests"
return contract(
"GET /contests",
contestListSchema,
await api.get<unknown>(endpoint, { params: query }),
)
return api.get<ContestList>("contests", { params: query })
}
export async function getContest(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}`
return contract(
"GET /contests/:id",
contestSchema,
await api.get<unknown>(endpoint),
)
export function getContest(id: string) {
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
}
export async function getContestAccess(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}/access`
return contract(
"GET /contests/:id/access",
contestAccessSchema,
await api.get<unknown>(endpoint),
)
export function getContestAccess(id: string) {
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
}
// 注意和 GET /access 不一样:这个返回裸 true密码错是 403 走 catch
@@ -330,29 +258,21 @@ export function checkContestPassword(contestID: string, password: string) {
}
export async function getContestProblems(contestID: string) {
const endpoint = `contests/${encodeURIComponent(contestID)}/problems`
// 用 problemListItemSchema.array(),不是契约的 contestProblemsSchema ——
// 后者是 `array(union([列表项, 详情]))`,联合类型会让 filterResult 的类型收窄
// 落到详情分支上,而且学生侧这条接口只下发列表项。
const res = contract(
"GET /contests/:id/problems",
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
const res = await api.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
)
return res.map(filterResult)
}
export async function getContestRank(
export function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
// submissionInfo 在契约里是 Record<string, unknown>JSONB 原文),
// 前端在这里收窄成 SubmissionInfo见 utils/types 的 ContestRank
const endpoint = `contests/${encodeURIComponent(contestID)}/rank`
return contract(
"GET /contests/:id/rank",
contestRankSchema,
await api.get<unknown>(endpoint, { params: query }),
return api.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`,
{ params: query },
)
}
@@ -368,31 +288,21 @@ export function updateProfile(data: { realName: string; mood: string }) {
return api.put<Profile>("me/profile", data)
}
export async function getAnnouncementList(offset = 0, limit = 10) {
const endpoint = "announcements"
return contract(
"GET /announcements",
announcementListSchema,
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
params: { limit, offset },
})
}
export async function getAnnouncement(id: number) {
const endpoint = `announcements/${id}`
return contract(
"GET /announcements/:id",
announcementSchema,
await api.get<unknown>(endpoint),
)
export function getAnnouncement(id: number) {
return api.get<Announcement>(`announcements/${id}`)
}
export async function getMessageList(offset = 0, limit = 10) {
const endpoint = "messages"
return contract(
"GET /messages",
messageListSchema,
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
export function getMessageList(offset = 0, limit = 10) {
// language 的收窄同 getSubmissions见那里的说明
return api.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
}
export function getReaction(problemID: number) {
@@ -403,125 +313,71 @@ export function setReaction(problemID: number, type: ReactionKey) {
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
}
export async function getMetrics(userid: number) {
const endpoint = `users/${userid}/metrics`
return contract(
"GET /users/:id/metrics",
metricsSchema,
await api.get<unknown>(endpoint),
)
export function getMetrics(userid: number) {
return api.get<Metrics>(`users/${userid}/metrics`)
}
export async function getTutorial(id: number) {
const endpoint = `tutorials/${id}`
return contract(
"GET /tutorials/:id",
tutorialSchema,
await api.get<unknown>(endpoint),
)
export function getTutorial(id: number) {
return api.get<Tutorial>(`tutorials/${id}`)
}
export async function getTutorials(type: "python" | "c") {
const endpoint = "tutorials"
return contract(
"GET /tutorials",
tutorialSummarySchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
export function getTutorials(type: "python" | "c") {
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
}
export async function getAIDetailData(
start: string,
end: string,
username?: string,
) {
const endpoint = "ai/detail"
return contract(
"GET /ai/detail",
aiDetailSchema,
await api.get<unknown>(endpoint, { params: { start, end, username } }),
)
export function getAIDetailData(start: string, end: string, username?: string) {
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
}
export async function getAISolved(
export function getAISolved(
start: string,
end: string,
offset: number,
limit: number,
username?: string,
) {
const endpoint = "ai/solved"
return contract(
"GET /ai/solved",
solvedListSchema,
await api.get<unknown>(endpoint, {
params: { start, end, offset, limit, username },
}),
)
return api.get<SolvedList>("ai/solved", {
params: { start, end, offset, limit, username },
})
}
export async function getAIDurationData(
export function getAIDurationData(
end: string,
duration: string,
username?: string,
) {
const endpoint = "ai/duration"
return contract(
"GET /ai/duration",
durationDataSchema.array(),
await api.get<unknown>(endpoint, { params: { end, duration, username } }),
)
return api.get<DurationData[]>("ai/duration", {
params: { end, duration, username },
})
}
export async function getAIHeatmapData(username?: string) {
const endpoint = "ai/heatmap"
return contract(
"GET /ai/heatmap",
heatmapItemSchema.array(),
await api.get<unknown>(endpoint, {
params: username ? { username } : {},
}),
)
export function getAIHeatmapData(username?: string) {
return api.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export async function getAILoginSummary() {
const endpoint = "ai/login-summary"
return contract(
"GET /ai/login-summary",
loginSummarySchema,
await api.get<unknown>(endpoint),
)
export function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary")
}
export async function getAIPinnedReport() {
const endpoint = "ai/pinned"
return contract(
"GET /ai/pinned",
aiAnalysisRecordSchema.nullable(),
await api.get<unknown>(endpoint),
)
export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export async function getSimilarProblems(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/similar`
const res = contract(
"GET /problems/:id/similar",
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
)
return res.map(filterResult)
export function getSimilarProblems(problemId: string) {
return api
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map(filterResult))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export async function getProblemYearlyAC(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/yearly-ac`
return contract(
"GET /problems/:id/yearly-ac",
yearlyAcSchema.array(),
await api.get<unknown>(endpoint),
export function getProblemYearlyAC(problemId: string) {
return api.get<YearlyAc[]>(
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
)
}
@@ -535,16 +391,11 @@ export function submitFlowchart(data: {
return api.post<CreateFlowchartResponse>("flowcharts", data)
}
export async function getFlowchartSubmission(id: string) {
const endpoint = `flowcharts/${encodeURIComponent(id)}`
return contract(
"GET /flowcharts/:id",
flowchartSubmissionSchema,
await api.get<unknown>(endpoint),
)
export function getFlowchartSubmission(id: string) {
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
}
export async function getFlowchartSubmissions(params: {
export function getFlowchartSubmissions(params: {
username?: string
problemId?: string
myself?: string
@@ -553,27 +404,17 @@ export async function getFlowchartSubmissions(params: {
today?: string
grade?: string
}) {
const endpoint = "flowcharts"
return contract(
"GET /flowcharts",
flowchartListSchema,
await api.get<unknown>(endpoint, { params }),
)
return api.get<FlowchartList>("flowcharts", { params })
}
export async function getFlowchartStatistics(
export function getFlowchartStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
const endpoint = "flowcharts/statistics"
return contract(
"GET /flowcharts/statistics",
flowchartStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
return api.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
export function retryFlowchartSubmission(submissionId: string) {
@@ -582,59 +423,36 @@ export function retryFlowchartSubmission(submissionId: string) {
)
}
export async function getCurrentProblemFlowchartSubmission(problemId: number) {
const endpoint = `problems/${problemId}/flowchart/current`
return contract(
"GET /problems/:id/flowchart/current",
flowchartCurrentSchema,
await api.get<unknown>(endpoint),
)
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
}
export async function getFlowchartSubmissionDetail(problemId: number, page = 0) {
const endpoint = `problems/${problemId}/flowchart/history`
return contract(
"GET /problems/:id/flowchart/history",
flowchartDetailSchema,
await api.get<unknown>(endpoint, { params: { page } }),
)
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
params: { page },
})
}
// ==================== 题单相关API ====================
export async function getProblemSetList(
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
const endpoint = "problem-sets"
return contract(
"GET /problem-sets",
problemSetListSchema,
await api.get<unknown>(endpoint, {
params: { offset, limit, keyword, difficulty, status },
}),
)
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
}
export async function getProblemSetDetail(id: number) {
const endpoint = `problem-sets/${id}`
return contract(
"GET /problem-sets/:id",
problemSetSchema,
await api.get<unknown>(endpoint),
)
export function getProblemSetDetail(id: number) {
return api.get<ProblemSet>(`problem-sets/${id}`)
}
export async function getProblemSetProblems(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/problems`
return contract(
"GET /problem-sets/:id/problems",
problemSetProblemSchema.array(),
await api.get<unknown>(endpoint),
)
export function getProblemSetProblems(problemSetId: number) {
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
@@ -653,25 +471,17 @@ export function updateProblemSetProgress(
})
}
export async function getUserBadges(username?: string) {
const endpoint = `users/${encodeURIComponent(username ?? "me")}/badges`
return contract(
"GET /users/:username/badges",
userBadgeSchema.array(),
await api.get<unknown>(endpoint),
export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`,
)
}
export async function getProblemSetBadges(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/badges`
return contract(
"GET /problem-sets/:id/badges",
problemSetBadgeSchema.array(),
await api.get<unknown>(endpoint),
)
export function getProblemSetBadges(problemSetId: number) {
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
}
export async function getProblemSetUserProgress(
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
@@ -680,42 +490,14 @@ export async function getProblemSetUserProgress(
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
const endpoint = `problem-sets/${problemSetId}/user-progress`
return contract(
"GET /problem-sets/:id/user-progress",
problemSetProgressListSchema,
await api.get<unknown>(endpoint, { params }),
return api.get<ProblemSetProgressList>(
`problem-sets/${problemSetId}/user-progress`,
{ params },
)
}
export async function getExercises(
tutorialId: number,
): Promise<Exercise[]> {
const endpoint = `tutorials/${tutorialId}/exercises`
// 外层走 exerciseSchema内层 data 在这里按题型逐支校验:
// `z.infer` 只能把 data 还原成 Record<string, unknown>superRefine 无法把
// 校验结果反映到推断类型上),所以那 7 个 Exercise*.vue 直接读
// data.question / data.options 时本没有任何运行时保护。
// 生产库 151 道练习题已确认七种题型的键集全部吻合。
const rows = contract(
"GET /tutorials/:id/exercises",
exerciseSchema.array(),
await api.get<unknown>(endpoint),
)
for (const row of rows) {
const shape = exerciseDataByType[row.type]
if (!shape) continue
// 故意用同一个 contract():形状不符时它负责记日志并放行,不抛错
contract(
`GET /tutorials/:id/exercisestype=${row.type} 的 data`,
shape,
row.data,
)
}
// 类型上仍要收窄一次:契约推断出的 data 是宽松 record组件要的是判别联合
// 两者不重叠,所以只能经过 unknown。**这个断言是有意的,不是假校验** ——
// 上面那个循环已经在运行时按题型逐支验过;它只是把「运行时已确认」告诉 TS。
return rows as unknown as Exercise[]
export function getExercises(tutorialId: number): Promise<Exercise[]> {
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
}
/**
@@ -741,13 +523,8 @@ export function reportExerciseAttempt(
}).catch(() => undefined)
}
export async function getLearnProgress(type: "python" | "c") {
const endpoint = "learn/progress"
return contract(
"GET /learn/progress",
tutorialProgressSchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
export function getLearnProgress(type: "python" | "c") {
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
}
/**

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>
}

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 {

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。

View File

@@ -118,30 +118,16 @@ export const exerciseAttemptRequestSchema = z.object({
})
/**
* 练一练的内容,按题型分派。
* 题型 → 内容形状。**只在写入侧校验**`apps/api/src/services/exercise.ts` 的
* `exerciseDataError`),读出侧不校验。
*
* 形状按**生产库 151 道练习题实测**得出,七种题型的键集逐个吻合、没有越界数据:
* mcq 46 / fill 37 / sort 25 / predict 24 / debug 11 / match 6 / group 2。
* 这张表曾经挂在 `exerciseSchema` 的 superRefine 上,于是学生端那条
* `GET /tutorials/:id/exercises``routes/content.ts` 里硬 parse变成了一道
* 读闸:一行 data 对不上,整条练习列表 500 —— 坏的不是那一道,是整页。而写入侧
* 当时并不检查 `question`,两边严紧度不一致,脏数据进得来、出不去。
*
* 为什么值得从 `Record<string, unknown>` 收紧:这七种题型各自被一个组件渲染
* 它们直接读 `data.question` / `data.options` / `data.lines` —— 结构对不上时
* **渲染期才炸**,而炸的是整道题的组件。收紧之后这条路径由契约在响应边界上拦住。
*
* 注意 `exerciseSchema` **后端也在 parse**`routes/content.ts` 的
* `rows.map(... exerciseSchema.parse ...)`),所以这里的收紧同时是一道服务端闸门:
* 数据对不上时整条练习列表 500而不是渲染到一半崩。已用生产全量数据核验过
* 151/151 通过,才敢这么收。
*
* `data` 里**没有**判别键 —— 题型的真相在**外层**的 `type` 上,所以只能在
* superRefine 里拿 `type` 去挑对应的形状,不能用 discriminatedUnion
* (那要求判别键存在于被判别对象内部)。
*/
/**
* 题型 → 内容形状。导出是为了让**前端闸门**能逐题型校验 `data` ——
* `z.infer` 只能把 `data` 还原成 `Record<string, unknown>`superRefine
* 无法把校验结果反映到推断出的类型上),所以前端那个更窄的判别联合
* `utils/types` 的 Exercise在类型上仍然要自己收窄一次但**运行时**
* 走的就是这张表。
* JSONB 的形状真相在写入侧,就在写入侧卡住:那里能给老师一句中文原因
* 也不会让历史数据把学生端打不开。
*/
export const exerciseDataByType: Record<string, z.ZodType> = {
mcq: z.object({ question: z.string(), options: z.array(z.string()), answer: z.array(z.number()) }),
@@ -154,36 +140,16 @@ export const exerciseDataByType: Record<string, z.ZodType> = {
}
/**
* 外层 `type` `data` 的内容对不上时也算不通过,这正是要拦的情况:
* `type: "mcq"` 配一份 `{question, code}` 会在渲染 mcq 组件时炸在 `data.options` 上。
* 练一练。`data` `Record<string, unknown>`**读出侧刻意不按题型收紧** ——
* 这个 schema 后端也在 parse`routes/content.ts`),收紧它等于给学生端加一道
* 会 500 的闸。七种题型各自的形状见上面的 `exerciseDataByType`,在写入时卡。
*/
export const exerciseSchema = z
.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
.superRefine((value, ctx) => {
// type 是枚举,映射表覆盖了全部七个值;这里只是让 TS 收窄,顺带在
// 将来加了新题型却忘了补形状时,以一条清楚的 issue 而不是 undefined 崩掉。
const shape = exerciseDataByType[value.type]
if (!shape) {
ctx.addIssue({ code: "custom", path: ["type"], message: `题型 ${value.type} 没有对应的内容形状` })
return
}
const parsed = shape.safeParse(value.data)
if (!parsed.success) {
ctx.addIssue({
code: "custom",
path: ["data"],
message: `type=${value.type} 的 data 形状不符:${parsed.error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "(根)"} ${issue.message}`)
.join("")}`,
})
}
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>

View File

@@ -33,8 +33,9 @@ export const contestProblemsSchema = z.array(z.union([problemListItemSchema, pro
* `acm_contest_rank.submission_info` 的 JSONB 原文。
*
* 键名是**判题链路写进去的 snake_case**(历史比赛的榜单行也是这个形状),
* 不要跟着响应字段一起改成 camelCase。字段全部可选:只有真正提交过的题目
* 才会出现,`checked` 是前端本地标「已看」时补的
* 不要跟着响应字段一起改成 camelCase。只有真正提交过的题目才会有自己的键,
* 键一旦存在,前四个字段判题链路一定会写全;`checked` 是前端本地标「已看」时补的
* 所以只有它可选。生产库 2401 行榜单实测全部符合。
*
* 原来契约这里是 `z.record(z.string(), z.unknown())`,于是前端不得不
* 自己再声明一份 `SubmissionInfo` 去覆盖它utils/types 的 ContestRank

View File

@@ -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