diff --git a/apps/api/src/services/exercise.ts b/apps/api/src/services/exercise.ts index f8e103b..4445c90 100644 --- a/apps/api/src/services/exercise.ts +++ b/apps/api/src/services/exercise.ts @@ -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 | 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 = { + 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[]) diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 8b6d7b5..75dad79 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -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(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 diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 6e8c526..60b81cb 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -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(endpoint), - ) +export function getWebsiteConfig() { + return api.get("site") } /** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */ -export async function getOnlineCount() { - const endpoint = "site/online" - return contract( - "GET /site/online", - onlineCountSchema, - await api.get(endpoint), - ) +export function getOnlineCount() { + return api.get("site/online") } export async function getProblemList( @@ -100,14 +88,9 @@ export async function getProblemList( limit = 10, searchParams: Record = {}, ) { - const endpoint = "problems" - const res = contract( - "GET /problems", - problemListSchema, - await api.get(endpoint, { - params: { paging: true, offset, limit, ...searchParams }, - }), - ) + const res = await api.get("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(`problems/${problemID}/beat-count`) } -export async function getSubmission(id: string) { - const endpoint = `submissions/${encodeURIComponent(id)}` - return contract( - "GET /submissions/:id", - submissionDetailSchema, - await api.get(endpoint), +export async function getSubmission(id: string): Promise { + const response = await api.get( + `submissions/${encodeURIComponent(id)}`, ) + return contract("GET /submissions/:id", submissionDetailSchema, response) } export function submitCode(data: SubmitCodePayload) { @@ -162,36 +143,16 @@ export function getSubmissions(params: Partial) { 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, -) { - return contract( - `GET /${endpoint}`, - submissionListSchema, - await api.get(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(endpoint), - ) +export function getRankOfProblem(problemId: string) { + return api.get(`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(endpoint, { - params: { ...duration, problemId: problemID, username }, - }), - ) + return api.get("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(endpoint, { - params: { ...duration, problemId: problemID, username }, - }), - ) + return api.get("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(endpoint, { params: { offset, limit } }), - ) +export function getRank(offset: number, limit: number) { + return api.get("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(endpoint, { params: { start } }), - ) +export function getActivityRank(start: string) { + return api.get("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(endpoint, { params: query }), - ) + return api.get("contests", { params: query }) } -export async function getContest(id: string) { - const endpoint = `contests/${encodeURIComponent(id)}` - return contract( - "GET /contests/:id", - contestSchema, - await api.get(endpoint), - ) +export function getContest(id: string) { + return api.get(`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(endpoint), - ) +export function getContestAccess(id: string) { + return api.get(`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(endpoint), + const res = await api.get( + `contests/${encodeURIComponent(contestID)}/problems`, ) return res.map(filterResult) } -export async function getContestRank( +export function getContestRank( contestID: string, query: { limit: number; offset: number }, ) { // submissionInfo 在契约里是 Record(JSONB 原文), // 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank - const endpoint = `contests/${encodeURIComponent(contestID)}/rank` - return contract( - "GET /contests/:id/rank", - contestRankSchema, - await api.get(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("me/profile", data) } -export async function getAnnouncementList(offset = 0, limit = 10) { - const endpoint = "announcements" - return contract( - "GET /announcements", - announcementListSchema, - await api.get(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(endpoint), - ) +export function getAnnouncement(id: number) { + return api.get(`announcements/${id}`) } -export async function getMessageList(offset = 0, limit = 10) { - const endpoint = "messages" - return contract( - "GET /messages", - messageListSchema, - await api.get(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(`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(endpoint), - ) +export function getMetrics(userid: number) { + return api.get(`users/${userid}/metrics`) } -export async function getTutorial(id: number) { - const endpoint = `tutorials/${id}` - return contract( - "GET /tutorials/:id", - tutorialSchema, - await api.get(endpoint), - ) +export function getTutorial(id: number) { + return api.get(`tutorials/${id}`) } -export async function getTutorials(type: "python" | "c") { - const endpoint = "tutorials" - return contract( - "GET /tutorials", - tutorialSummarySchema.array(), - await api.get(endpoint, { params: { type } }), - ) +export function getTutorials(type: "python" | "c") { + return api.get("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(endpoint, { params: { start, end, username } }), - ) +export function getAIDetailData(start: string, end: string, username?: string) { + return api.get("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(endpoint, { - params: { start, end, offset, limit, username }, - }), - ) + return api.get("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(endpoint, { params: { end, duration, username } }), - ) + return api.get("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(endpoint, { - params: username ? { username } : {}, - }), - ) +export function getAIHeatmapData(username?: string) { + return api.get("ai/heatmap", { + params: username ? { username } : {}, + }) } -export async function getAILoginSummary() { - const endpoint = "ai/login-summary" - return contract( - "GET /ai/login-summary", - loginSummarySchema, - await api.get(endpoint), - ) +export function getAILoginSummary() { + return api.get("ai/login-summary") } -export async function getAIPinnedReport() { - const endpoint = "ai/pinned" - return contract( - "GET /ai/pinned", - aiAnalysisRecordSchema.nullable(), - await api.get(endpoint), - ) +export function getAIPinnedReport() { + return api.get("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(endpoint), - ) - return res.map(filterResult) +export function getSimilarProblems(problemId: string) { + return api + .get(`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(endpoint), +export function getProblemYearlyAC(problemId: string) { + return api.get( + `problems/${encodeURIComponent(problemId)}/yearly-ac`, ) } @@ -535,16 +391,11 @@ export function submitFlowchart(data: { return api.post("flowcharts", data) } -export async function getFlowchartSubmission(id: string) { - const endpoint = `flowcharts/${encodeURIComponent(id)}` - return contract( - "GET /flowcharts/:id", - flowchartSubmissionSchema, - await api.get(endpoint), - ) +export function getFlowchartSubmission(id: string) { + return api.get(`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(endpoint, { params }), - ) + return api.get("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(endpoint, { - params: { ...duration, problemId: problemID, username }, - }), - ) + return api.get("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(endpoint), - ) +export function getCurrentProblemFlowchartSubmission(problemId: number) { + return api.get(`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(endpoint, { params: { page } }), - ) +export function getFlowchartSubmissionDetail(problemId: number, page = 0) { + return api.get(`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(endpoint, { - params: { offset, limit, keyword, difficulty, status }, - }), - ) + return api.get("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(endpoint), - ) +export function getProblemSetDetail(id: number) { + return api.get(`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(endpoint), - ) +export function getProblemSetProblems(problemSetId: number) { + return api.get(`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(endpoint), +export function getUserBadges(username?: string) { + return api.get( + `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(endpoint), - ) +export function getProblemSetBadges(problemSetId: number) { + return api.get(`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(endpoint, { params }), + return api.get( + `problem-sets/${problemSetId}/user-progress`, + { params }, ) } -export async function getExercises( - tutorialId: number, -): Promise { - const endpoint = `tutorials/${tutorialId}/exercises` - // 外层走 exerciseSchema,内层 data 在这里按题型逐支校验: - // `z.infer` 只能把 data 还原成 Record(superRefine 无法把 - // 校验结果反映到推断类型上),所以那 7 个 Exercise*.vue 直接读 - // data.question / data.options 时本没有任何运行时保护。 - // 生产库 151 道练习题已确认七种题型的键集全部吻合。 - const rows = contract( - "GET /tutorials/:id/exercises", - exerciseSchema.array(), - await api.get(endpoint), - ) - for (const row of rows) { - const shape = exerciseDataByType[row.type] - if (!shape) continue - // 故意用同一个 contract():形状不符时它负责记日志并放行,不抛错 - contract( - `GET /tutorials/:id/exercises(type=${row.type} 的 data)`, - shape, - row.data, - ) - } - // 类型上仍要收窄一次:契约推断出的 data 是宽松 record,组件要的是判别联合, - // 两者不重叠,所以只能经过 unknown。**这个断言是有意的,不是假校验** —— - // 上面那个循环已经在运行时按题型逐支验过;它只是把「运行时已确认」告诉 TS。 - return rows as unknown as Exercise[] +export function getExercises(tutorialId: number): Promise { + return api.get(`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(endpoint, { params: { type } }), - ) +export function getLearnProgress(type: "python" | "c") { + return api.get("learn/progress", { params: { type } }) } /** diff --git a/apps/web/src/utils/contract.ts b/apps/web/src/utils/contract.ts index 3d4b21f..5ba1173 100644 --- a/apps/web/src/utils/contract.ts +++ b/apps/web/src/utils/contract.ts @@ -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("problems", { params }) - * return contract("GET /problems", problemListSchema, data) - * ``` - * - * 端点字符串是手写的,刻意不让调用方漏掉 —— 它只用于日志和去重,写错不影响正确性。 + * 这三条原来是 `schema.parse(v) as T` —— `as` 把校验结果又断言回本地类型,等于 + * 没校验;而 `parse` 抛出的 ZodError 会打断整个 async 函数,一失败就是白屏。 + * 面向学生的生产站点,少一个字段页面照样能用,整页崩掉不行。所以这里: + * 记一条带端点和字段路径的 `console.error`,然后**返回原始数据**。 */ export function contract( endpoint: string, @@ -120,13 +29,19 @@ export function contract( 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 } diff --git a/apps/web/src/utils/functions.ts b/apps/web/src/utils/functions.ts index a66591e..8c7d6bb 100644 --- a/apps/web/src/utils/functions.ts +++ b/apps/web/src/utils/functions.ts @@ -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 { diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index b9a73f4..2b0d67d 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -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。 diff --git a/packages/contract/src/content.ts b/packages/contract/src/content.ts index 5b0ae80..66fe79a 100644 --- a/packages/contract/src/content.ts +++ b/packages/contract/src/content.ts @@ -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` 收紧:这七种题型各自被一个组件渲染, - * 它们直接读 `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`(superRefine - * 无法把校验结果反映到推断出的类型上),所以前端那个更窄的判别联合 - * (`utils/types` 的 Exercise)在类型上仍然要自己收窄一次,但**运行时** - * 走的就是这张表。 + * JSONB 的形状真相在写入侧,就在写入侧卡住:那里能给老师一句中文原因, + * 也不会让历史数据把学生端打不开。 */ export const exerciseDataByType: Record = { mcq: z.object({ question: z.string(), options: z.array(z.string()), answer: z.array(z.number()) }), @@ -154,36 +140,16 @@ export const exerciseDataByType: Record = { } /** - * 外层 `type` 与 `data` 的内容对不上时也算不通过,这正是要拦的情况: - * `type: "mcq"` 配一份 `{question, code}` 会在渲染 mcq 组件时炸在 `data.options` 上。 + * 练一练。`data` 是 `Record`,**读出侧刻意不按题型收紧** —— + * 这个 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 export type MessageList = z.infer diff --git a/packages/contract/src/contest.ts b/packages/contract/src/contest.ts index b73034b..3f63a9f 100644 --- a/packages/contract/src/contest.ts +++ b/packages/contract/src/contest.ts @@ -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)。 diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index 7a77a4a..d4b48cf 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -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 -export type JudgeInfo = z.infer -export type JudgeCaseResult = z.infer export type StatisticInfo = z.infer export type CreateSubmissionRequest = z.infer< typeof createSubmissionRequestSchema