From aab0404ed7ed9fa1abc6295ef0e250d4602f9bb7 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 10 Sep 2026 04:19:41 -0600 Subject: [PATCH] =?UTF-8?q?refactor(=E5=A5=91=E7=BA=A6):=20=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E4=B8=8E=E5=88=A4=E9=A2=98=E4=BA=A7=E7=89=A9=E7=9A=84?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E6=94=B6=E8=BF=9B=E5=A5=91=E7=BA=A6=EF=BC=8C?= =?UTF-8?q?=E5=AD=A6=E7=94=9F=E7=AB=AF=E9=AB=98=E9=A2=91=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=8E=A5=E4=B8=8A=E8=BF=90=E8=A1=8C=E6=97=B6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约在前端一直只当类型包用: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 两个键 —— 不会因为这次收紧在生产上抛错。 --- apps/web/src/oj/api.ts | 81 ++++++++--- .../problem/components/SubmissionResult.vue | 12 +- apps/web/src/oj/submission/detail.vue | 14 +- apps/web/src/shared/api.ts | 13 +- apps/web/src/utils/contract.ts | 132 ++++++++++++++++++ apps/web/src/utils/functions.ts | 18 +++ apps/web/src/utils/types.ts | 99 ++++--------- packages/contract/src/index.ts | 1 + packages/contract/src/language.ts | 45 ++++++ packages/contract/src/problem.ts | 13 +- packages/contract/src/submission.ts | 101 +++++++++++++- 11 files changed, 414 insertions(+), 115 deletions(-) create mode 100644 apps/web/src/utils/contract.ts create mode 100644 packages/contract/src/language.ts diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index a67657e..90835ed 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -16,7 +16,6 @@ import { type ProblemAuthor, type ProblemListItem, type YearlyAc, - type ProblemList, type CreateFlowchartResponse, type FlowchartCurrent, type FlowchartDetail, @@ -34,12 +33,17 @@ import { type ProblemSetProgressList, type UserBadge, problemDetailSchema, + problemListSchema, submissionDetailSchema, + submissionListSchema, + onlineCountSchema, + websiteConfigSchema, 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, @@ -47,16 +51,12 @@ import type { ContestRank, Profile, Message, - SubmissionListItem, Exercise, Problem, ReactionKey, ReactionState, - Submission, SubmissionListPayload, SubmitCodePayload, - OnlineCount, - WebsiteConfig, Tutorial, TutorialProgress, } from "utils/types" @@ -64,18 +64,33 @@ import type { /** * 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的 * 键名映射,抄漏一个字段就是静默 undefined。 + * + * 走 `contract()` 而不是裸 `parse()`:这里原来是 + * `problemDetailSchema.parse(value) as Problem` —— `as` 把校验结果又断言回本地 + * 类型,等于校验白做。契约现在把 `languages` / `template` 都收进了联合, + * `Problem` 不再需要额外窄化,`as` 也就没有存在的理由了。 */ function detailProblem(value: unknown): Problem { - return problemDetailSchema.parse(value) as Problem + return contract("GET /problems/:id", problemDetailSchema, value) } -export function getWebsiteConfig() { - return api.get("site") +export async function getWebsiteConfig() { + const endpoint = "site" + return contract( + "GET /site", + websiteConfigSchema, + await api.get(endpoint), + ) } /** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */ -export function getOnlineCount() { - return api.get("site/online") +export async function getOnlineCount() { + const endpoint = "site/online" + return contract( + "GET /site/online", + onlineCountSchema, + await api.get(endpoint), + ) } export async function getProblemList( @@ -83,9 +98,14 @@ export async function getProblemList( limit = 10, searchParams: Record = {}, ) { - const res = await api.get("problems", { - params: { paging: true, offset, limit, ...searchParams }, - }) + const endpoint = "problems" + const res = contract( + "GET /problems", + problemListSchema, + await api.get(endpoint, { + params: { paging: true, offset, limit, ...searchParams }, + }), + ) return { results: res.results.map(filterResult), total: res.total, @@ -111,10 +131,12 @@ export function getProblemBeatRate(problemID: number) { } export async function getSubmission(id: string) { - const response = await api.get( - `submissions/${encodeURIComponent(id)}`, + const endpoint = `submissions/${encodeURIComponent(id)}` + return contract( + "GET /submissions/:id", + submissionDetailSchema, + await api.get(endpoint), ) - return submissionDetailSchema.parse(response) as Submission } export function submitCode(data: SubmitCodePayload) { @@ -138,12 +160,27 @@ export function getSubmissions(params: Partial) { const endpoint = params.contestId ? `contests/${encodeURIComponent(params.contestId)}/submissions` : "submissions" - // 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让 - // 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE - return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, { - // contestId 走的是路径,page 只有前端分页器用 - params: { ...params, contestId: undefined, page: undefined }, - }) + return getSubmissionPage(endpoint, params) +} + +/** + * 提交列表。后端在 `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 function getRankOfProblem(problemId: string) { diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index 319c187..03f88cc 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -2,8 +2,10 @@ import { Icon } from "@iconify/vue" import { useThemeVars } from "naive-ui" import { HINT_MIN_FAILURES } from "@oj2/contract" +import type { JudgeCaseResult } from "@oj2/contract" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" import { + submissionCaseResults, submissionMemoryFormat, submissionTimeFormat, } from "utils/functions" @@ -124,9 +126,12 @@ async function fetchHint(submissionId: string) { // 测试用例表格数据(只在部分通过时显示) const infoTable = computed(() => { - if (!props.submission?.info?.data?.length) return [] + const submission = props.submission + if (!submission) return [] + const data = submissionCaseResults(submission.info) + if (!data.length) return [] - const result = props.submission.result + const result = submission.result // AC、编译错误、运行时错误不显示测试用例表格 if ( result === SubmissionStatus.accepted || @@ -137,13 +142,12 @@ const infoTable = computed(() => { return [] } - const data = props.submission.info.data // 只有存在失败的测试用例时才显示 return data.some((item) => item.result === 0) ? data : [] }) // 测试用例表格列配置 -const columns: DataTableColumn[] = [ +const columns: DataTableColumn[] = [ { title: "测试用例", key: "test_case" }, { title: "测试状态", diff --git a/apps/web/src/oj/submission/detail.vue b/apps/web/src/oj/submission/detail.vue index 14677e0..d4f2ad2 100644 --- a/apps/web/src/oj/submission/detail.vue +++ b/apps/web/src/oj/submission/detail.vue @@ -1,5 +1,6 @@