refactor(契约): 语言与判题产物的形状收进契约,学生端高频响应接上运行时校验
Some checks failed
Deploy / deploy (push) Has been cancelled

契约在前端一直只当类型包用:45 处引用里几乎全是 import type,三个 .parse() 后面
还都紧跟一个 as 把校验结果断言回去,等于没校验。这一轮把形状的来源收拢。

## 语言:三份真相并成一份

前端 utils/types.ts 手写了一份 9 值的语言联合,后端 judge/languages.ts 有自己的一套,
生产库又有一套。手抄那份**漏了 SQL**,而生产库 961 道题里有 9 道 SQL 题、
124191 条提交里有 91 条 SQL 提交 —— 这些提交的 language 在前端类型上是 undefined。
现在唯一来源是契约的 problemLanguageSchema,constants.ts 的显示映射以它为键,
契约里加语言而那边没补映射会当场编译不过。

## 判题产物:按生产数据实测收紧

judgeInfoSchema / statisticInfoSchema 的形状来自 124191 条提交的实测,不是手抄:

- info.data 有 12048 条是 null(编译失败等没有逐测试点结果),前端手抄的 Info
  却把 data 写成非空数组 —— 这 12048 条在类型上根本不成立;
- info 还允许**空对象**:非管理员看提交详情时后端下发 info: {}(权限投影)。
  收紧时必须把它算进去,否则每条非管理员看的提交详情直接 500 —— 本地实测复现过;
- statistic_info 的五个键按出现次数定成全部可选;另有 8916 条 JSONB 原文因内嵌
  带转义的 shell 输出不是合法 JSON,被后端 objectValue() 兜成 { value: ... },
  所以不能用严格对象,否则这 8916 条会被误判成分歧。

## 运行时闸门

新增 utils/contract.ts:safeParse 失败时记一条分歧(去重、控制台可见、
window.__OJ2_CONTRACT_DRIFT__ 可查)后**放行原始数据**,不白屏 —— 面向学生的
生产站点,字段空着比整页崩掉可接受。接在 7 条高频链路上:/site、/site/online、
/problems、/problems/:id、/submissions、/submissions/:id、/me。

提交详情的 info 是联合类型,调用方不再直接取 .data,统一走
utils/functions.ts 的 submissionCaseResults()。

## 验证

- vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;
- 契约 schema 直跑真实接口:7/7 通过(自包含脚本,从登录到详情全链路);
- 用生产备份复核收紧后的约束:961 题的 languages 无越界值,template 只出现
  C/Python3 两个键 —— 不会因为这次收紧在生产上抛错。
This commit is contained in:
2026-09-10 04:19:41 -06:00
parent a475cac128
commit aab0404ed7
11 changed files with 414 additions and 115 deletions

View File

@@ -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<WebsiteConfig>("site")
export async function getWebsiteConfig() {
const endpoint = "site"
return contract(
"GET /site",
websiteConfigSchema,
await api.get<unknown>(endpoint),
)
}
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
export async function getOnlineCount() {
const endpoint = "site/online"
return contract(
"GET /site/online",
onlineCountSchema,
await api.get<unknown>(endpoint),
)
}
export async function getProblemList(
@@ -83,9 +98,14 @@ export async function getProblemList(
limit = 10,
searchParams: Record<string, unknown> = {},
) {
const res = await api.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
const endpoint = "problems"
const res = contract(
"GET /problems",
problemListSchema,
await api.get<unknown>(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<unknown>(
`submissions/${encodeURIComponent(id)}`,
const endpoint = `submissions/${encodeURIComponent(id)}`
return contract(
"GET /submissions/:id",
submissionDetailSchema,
await api.get<unknown>(endpoint),
)
return submissionDetailSchema.parse(response) as Submission
}
export function submitCode(data: SubmitCodePayload) {
@@ -138,12 +160,27 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
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<SubmissionListPayload>,
) {
return contract(
`GET /${endpoint}`,
submissionListSchema,
await api.get<unknown>(endpoint, {
// contestId 走的是路径page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
}),
)
}
export function getRankOfProblem(problemId: string) {

View File

@@ -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<Submission["info"]["data"][number]>[] = [
const columns: DataTableColumn<JudgeCaseResult>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { getSubmission } from "oj/api"
import type { JudgeCaseResult } from "@oj2/contract"
import {
JUDGE_STATUS,
LANGUAGE_FORMAT_VALUE,
@@ -7,6 +8,7 @@ import {
} from "utils/constants"
import {
parseTime,
submissionCaseResults,
submissionMemoryFormat,
submissionTimeFormat,
utoa,
@@ -36,6 +38,12 @@ const { isMobile, isDesktop } = useBreakpoints()
const submission = ref<Submission>()
const loading = ref(false)
/**
* 测试点明细。`info` 在契约里是「完整形状或空对象」的联合(非管理员拿到的是空对象),
* `data` 本身也可能为 null —— 两种情况都由这个访问器归成空数组,模板里不再直接取。
*/
const caseResults = computed(() => submissionCaseResults(submission.value?.info))
async function init() {
submission.value = props.submission
if (submission.value) return
@@ -45,7 +53,7 @@ async function init() {
loading.value = false
}
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
const columns: DataTableColumn<JudgeCaseResult>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",
@@ -149,9 +157,9 @@ onMounted(init)
/>
</n-card>
<n-data-table
v-if="!hideList && submission.info && submission.info.data"
v-if="!hideList && caseResults.length"
:columns="columns"
:data="submission.info.data"
:data="caseResults"
/>
</n-flex>
<n-spin v-else :show="loading" class="loading-container"> </n-spin>