refactor(契约): 语言与判题产物的形状收进契约,学生端高频响应接上运行时校验
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
@@ -16,7 +16,6 @@ import {
|
|||||||
type ProblemAuthor,
|
type ProblemAuthor,
|
||||||
type ProblemListItem,
|
type ProblemListItem,
|
||||||
type YearlyAc,
|
type YearlyAc,
|
||||||
type ProblemList,
|
|
||||||
type CreateFlowchartResponse,
|
type CreateFlowchartResponse,
|
||||||
type FlowchartCurrent,
|
type FlowchartCurrent,
|
||||||
type FlowchartDetail,
|
type FlowchartDetail,
|
||||||
@@ -34,12 +33,17 @@ import {
|
|||||||
type ProblemSetProgressList,
|
type ProblemSetProgressList,
|
||||||
type UserBadge,
|
type UserBadge,
|
||||||
problemDetailSchema,
|
problemDetailSchema,
|
||||||
|
problemListSchema,
|
||||||
submissionDetailSchema,
|
submissionDetailSchema,
|
||||||
|
submissionListSchema,
|
||||||
|
onlineCountSchema,
|
||||||
|
websiteConfigSchema,
|
||||||
type FlowchartStatistics,
|
type FlowchartStatistics,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
type SubmissionStatisticsItems,
|
type SubmissionStatisticsItems,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import api from "utils/api"
|
import api from "utils/api"
|
||||||
|
import { contract } from "utils/contract"
|
||||||
import { filterResult } from "oj/transforms"
|
import { filterResult } from "oj/transforms"
|
||||||
import type {
|
import type {
|
||||||
Announcement,
|
Announcement,
|
||||||
@@ -47,16 +51,12 @@ import type {
|
|||||||
ContestRank,
|
ContestRank,
|
||||||
Profile,
|
Profile,
|
||||||
Message,
|
Message,
|
||||||
SubmissionListItem,
|
|
||||||
Exercise,
|
Exercise,
|
||||||
Problem,
|
Problem,
|
||||||
ReactionKey,
|
ReactionKey,
|
||||||
ReactionState,
|
ReactionState,
|
||||||
Submission,
|
|
||||||
SubmissionListPayload,
|
SubmissionListPayload,
|
||||||
SubmitCodePayload,
|
SubmitCodePayload,
|
||||||
OnlineCount,
|
|
||||||
WebsiteConfig,
|
|
||||||
Tutorial,
|
Tutorial,
|
||||||
TutorialProgress,
|
TutorialProgress,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
@@ -64,18 +64,33 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
|
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
|
||||||
* 键名映射,抄漏一个字段就是静默 undefined。
|
* 键名映射,抄漏一个字段就是静默 undefined。
|
||||||
|
*
|
||||||
|
* 走 `contract()` 而不是裸 `parse()`:这里原来是
|
||||||
|
* `problemDetailSchema.parse(value) as Problem` —— `as` 把校验结果又断言回本地
|
||||||
|
* 类型,等于校验白做。契约现在把 `languages` / `template` 都收进了联合,
|
||||||
|
* `Problem` 不再需要额外窄化,`as` 也就没有存在的理由了。
|
||||||
*/
|
*/
|
||||||
function detailProblem(value: unknown): Problem {
|
function detailProblem(value: unknown): Problem {
|
||||||
return problemDetailSchema.parse(value) as Problem
|
return contract("GET /problems/:id", problemDetailSchema, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWebsiteConfig() {
|
export async function getWebsiteConfig() {
|
||||||
return api.get<WebsiteConfig>("site")
|
const endpoint = "site"
|
||||||
|
return contract(
|
||||||
|
"GET /site",
|
||||||
|
websiteConfigSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
|
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
|
||||||
export function getOnlineCount() {
|
export async function getOnlineCount() {
|
||||||
return api.get<OnlineCount>("site/online")
|
const endpoint = "site/online"
|
||||||
|
return contract(
|
||||||
|
"GET /site/online",
|
||||||
|
onlineCountSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProblemList(
|
export async function getProblemList(
|
||||||
@@ -83,9 +98,14 @@ export async function getProblemList(
|
|||||||
limit = 10,
|
limit = 10,
|
||||||
searchParams: Record<string, unknown> = {},
|
searchParams: Record<string, unknown> = {},
|
||||||
) {
|
) {
|
||||||
const res = await api.get<ProblemList>("problems", {
|
const endpoint = "problems"
|
||||||
params: { paging: true, offset, limit, ...searchParams },
|
const res = contract(
|
||||||
})
|
"GET /problems",
|
||||||
|
problemListSchema,
|
||||||
|
await api.get<unknown>(endpoint, {
|
||||||
|
params: { paging: true, offset, limit, ...searchParams },
|
||||||
|
}),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
results: res.results.map(filterResult),
|
results: res.results.map(filterResult),
|
||||||
total: res.total,
|
total: res.total,
|
||||||
@@ -111,10 +131,12 @@ export function getProblemBeatRate(problemID: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSubmission(id: string) {
|
export async function getSubmission(id: string) {
|
||||||
const response = await api.get<unknown>(
|
const endpoint = `submissions/${encodeURIComponent(id)}`
|
||||||
`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) {
|
export function submitCode(data: SubmitCodePayload) {
|
||||||
@@ -138,12 +160,27 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
|||||||
const endpoint = params.contestId
|
const endpoint = params.contestId
|
||||||
? `contests/${encodeURIComponent(params.contestId)}/submissions`
|
? `contests/${encodeURIComponent(params.contestId)}/submissions`
|
||||||
: "submissions"
|
: "submissions"
|
||||||
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
|
return getSubmissionPage(endpoint, params)
|
||||||
// 新加的语言在后端 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 function getRankOfProblem(problemId: string) {
|
export function getRankOfProblem(problemId: string) {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { HINT_MIN_FAILURES } from "@oj2/contract"
|
import { HINT_MIN_FAILURES } from "@oj2/contract"
|
||||||
|
import type { JudgeCaseResult } from "@oj2/contract"
|
||||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||||
import {
|
import {
|
||||||
|
submissionCaseResults,
|
||||||
submissionMemoryFormat,
|
submissionMemoryFormat,
|
||||||
submissionTimeFormat,
|
submissionTimeFormat,
|
||||||
} from "utils/functions"
|
} from "utils/functions"
|
||||||
@@ -124,9 +126,12 @@ async function fetchHint(submissionId: string) {
|
|||||||
|
|
||||||
// 测试用例表格数据(只在部分通过时显示)
|
// 测试用例表格数据(只在部分通过时显示)
|
||||||
const infoTable = computed(() => {
|
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、编译错误、运行时错误不显示测试用例表格
|
// AC、编译错误、运行时错误不显示测试用例表格
|
||||||
if (
|
if (
|
||||||
result === SubmissionStatus.accepted ||
|
result === SubmissionStatus.accepted ||
|
||||||
@@ -137,13 +142,12 @@ const infoTable = computed(() => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = props.submission.info.data
|
|
||||||
// 只有存在失败的测试用例时才显示
|
// 只有存在失败的测试用例时才显示
|
||||||
return data.some((item) => item.result === 0) ? 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: "测试用例", key: "test_case" },
|
||||||
{
|
{
|
||||||
title: "测试状态",
|
title: "测试状态",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getSubmission } from "oj/api"
|
import { getSubmission } from "oj/api"
|
||||||
|
import type { JudgeCaseResult } from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
JUDGE_STATUS,
|
JUDGE_STATUS,
|
||||||
LANGUAGE_FORMAT_VALUE,
|
LANGUAGE_FORMAT_VALUE,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
} from "utils/constants"
|
} from "utils/constants"
|
||||||
import {
|
import {
|
||||||
parseTime,
|
parseTime,
|
||||||
|
submissionCaseResults,
|
||||||
submissionMemoryFormat,
|
submissionMemoryFormat,
|
||||||
submissionTimeFormat,
|
submissionTimeFormat,
|
||||||
utoa,
|
utoa,
|
||||||
@@ -36,6 +38,12 @@ const { isMobile, isDesktop } = useBreakpoints()
|
|||||||
const submission = ref<Submission>()
|
const submission = ref<Submission>()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试点明细。`info` 在契约里是「完整形状或空对象」的联合(非管理员拿到的是空对象),
|
||||||
|
* `data` 本身也可能为 null —— 两种情况都由这个访问器归成空数组,模板里不再直接取。
|
||||||
|
*/
|
||||||
|
const caseResults = computed(() => submissionCaseResults(submission.value?.info))
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
submission.value = props.submission
|
submission.value = props.submission
|
||||||
if (submission.value) return
|
if (submission.value) return
|
||||||
@@ -45,7 +53,7 @@ async function init() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||||
{ title: "测试用例", key: "test_case" },
|
{ title: "测试用例", key: "test_case" },
|
||||||
{
|
{
|
||||||
title: "测试状态",
|
title: "测试状态",
|
||||||
@@ -149,9 +157,9 @@ onMounted(init)
|
|||||||
/>
|
/>
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
v-if="!hideList && submission.info && submission.info.data"
|
v-if="!hideList && caseResults.length"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="submission.info.data"
|
:data="caseResults"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
|
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { userProfileSchema, type Quote } from "@oj2/contract"
|
import { userProfileSchema, type Quote } from "@oj2/contract"
|
||||||
import api from "utils/api"
|
import api from "utils/api"
|
||||||
|
import { contract } from "utils/contract"
|
||||||
import type { Profile, Tag } from "utils/types"
|
import type { Profile, Tag } from "utils/types"
|
||||||
|
|
||||||
export function login(data: { username: string; password: string }) {
|
export function login(data: { username: string; password: string }) {
|
||||||
@@ -21,12 +22,14 @@ export function logout() {
|
|||||||
export async function getProfile(
|
export async function getProfile(
|
||||||
username: string = "",
|
username: string = "",
|
||||||
): Promise<Profile | null> {
|
): Promise<Profile | null> {
|
||||||
const response = await api.get<unknown>(
|
const endpoint = username ? `profiles/${encodeURIComponent(username)}` : "me"
|
||||||
username ? `profiles/${encodeURIComponent(username)}` : "me",
|
const response = await api.get<unknown>(endpoint)
|
||||||
)
|
|
||||||
if (response === null) return null
|
if (response === null) return null
|
||||||
// 形状与契约一致,不再逐字段搬运;zod 解析仍保留,形状对不上要当场炸
|
// 形状与契约一致,不再逐字段搬运。走契约闸门而不是裸 parse():原来这里是
|
||||||
return userProfileSchema.parse(response) as Profile
|
// `userProfileSchema.parse(response) as Profile`,`as` 把校验结果又断言回本地
|
||||||
|
// 类型(Profile 把 user 收窄成 SessionUser、acmProblemsStatus 收窄成具体形状),
|
||||||
|
// 形状对不上时页面白屏。现在记一条分歧日志后放行原始数据。
|
||||||
|
return contract("GET /profiles/:username", userProfileSchema, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemTagList() {
|
export function getProblemTagList() {
|
||||||
|
|||||||
132
apps/web/src/utils/contract.ts
Normal file
132
apps/web/src/utils/contract.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 契约的运行时闸门。
|
||||||
|
*
|
||||||
|
* ## 为什么要有这一层
|
||||||
|
*
|
||||||
|
* `@oj2/contract` 的收益只有一半是类型:`z.infer` 给出编译期的形状,但**编译期
|
||||||
|
* 管不了后端实际下发了什么**。改后端字段、drizzle 改名、序列化时漏一个键,
|
||||||
|
* TypeScript 一概看不见,页面上表现为某个 `undefined` 静默渲染成空白。
|
||||||
|
* 契约真正的价值在于同一份 schema 能在运行时把这种分歧当场抓出来。
|
||||||
|
*
|
||||||
|
* 原来只有三处调用 `.parse()`,而且**后面都紧跟一个 `as`** 把它重新断言回本地
|
||||||
|
* 类型(`problemDetailSchema.parse(v) as Problem`)—— 校验结果被丢弃,等于没校验。
|
||||||
|
*
|
||||||
|
* ## 失败策略:记日志 + 放行原始数据
|
||||||
|
*
|
||||||
|
* **不抛错。** 这是面向学生的生产站点,契约分歧的代价不该是白屏 —— 少了哪个
|
||||||
|
* 字段,页面大体上照样能用,只是那处空着。所以解析失败时:
|
||||||
|
*
|
||||||
|
* 1. `console.error` 一条带端点和字段路径的记录,开发时一眼能看到;
|
||||||
|
* 2. 记进 `window.__OJ2_CONTRACT_DRIFT__`(同一条只记一次),排查线上问题时
|
||||||
|
* 可以直接在控制台敲这个变量看全部历史;
|
||||||
|
* 3. **返回原始数据**,让页面继续渲染。
|
||||||
|
*
|
||||||
|
* 用 `safeParse` 而不是 `parse`:`parse` 抛出的 ZodError 会把调用方整个 async
|
||||||
|
* 函数打断,`getProblem` 一失败,整个题目页就只剩白屏。
|
||||||
|
*
|
||||||
|
* ## 什么时候该升级成硬失败
|
||||||
|
*
|
||||||
|
* 等 `__OJ2_CONTRACT_DRIFT__` 在某条路径上稳定为空之后,那条路径就可以换成
|
||||||
|
* 直接 `schema.parse()` —— 分歧修完了,剩下的任何分歧都是新引入的真 bug,
|
||||||
|
* 那时白屏反而是对的。**在那之前不要硬失败**,机房上课时炸一个页面比字段空着严重得多。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 见过的分歧。只留前若干条实例,避免一个列表接口几百条记录把内存堆满 ——
|
||||||
|
* 每条记录的形状问题是一样的,一条实例足够定位。
|
||||||
|
*/
|
||||||
|
interface DriftReport {
|
||||||
|
/** 请求路径,带参数,方便直接复现 */
|
||||||
|
endpoint: string
|
||||||
|
/** zod 的 issue 摘要:路径 + 原因,多条用分号连 */
|
||||||
|
detail: string
|
||||||
|
/** 实际收到的数据。截断后的原始值,用来判断是字段缺失还是类型不同 */
|
||||||
|
received: unknown
|
||||||
|
/** 出现次数。同一个端点同一个 detail 只记一条,这里累加 */
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_REPORTS = 200
|
||||||
|
const MAX_RECEIVED_CHARS = 2000
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OJ2_CONTRACT_DRIFT__?: DriftReport[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDrift(endpoint: string, detail: string, received: unknown) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const reports = (window.__OJ2_CONTRACT_DRIFT__ ??= [])
|
||||||
|
|
||||||
|
// 同一个端点 + 同一个原因只记一条,累加次数。列表接口一次几百条记录,
|
||||||
|
// 不去重的话控制台会被同一句话刷屏,真正的新问题反而看不见。
|
||||||
|
const existing = reports.find(
|
||||||
|
(item) => item.endpoint === endpoint && item.detail === detail,
|
||||||
|
)
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reports.length >= MAX_REPORTS) return
|
||||||
|
reports.push({
|
||||||
|
endpoint,
|
||||||
|
detail,
|
||||||
|
received: truncate(received),
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原始数据可能是一整个列表页,原样留着会占住大量内存;只用来判断形状,够看前 2KB 了 */
|
||||||
|
function truncate(value: unknown) {
|
||||||
|
try {
|
||||||
|
const text = JSON.stringify(value)
|
||||||
|
if (text === undefined) return value
|
||||||
|
return text.length <= MAX_RECEIVED_CHARS
|
||||||
|
? value
|
||||||
|
: `${text.slice(0, MAX_RECEIVED_CHARS)}…(截断,共 ${text.length} 字符)`
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(error: z.ZodError, endpoint: string) {
|
||||||
|
const issues = error.issues.slice(0, 5).map((issue) => {
|
||||||
|
const path = issue.path.length ? issue.path.join(".") : "(根)"
|
||||||
|
return `${path}: ${issue.message}`
|
||||||
|
})
|
||||||
|
const more = error.issues.length > 5 ? `;另有 ${error.issues.length - 5} 处` : ""
|
||||||
|
return `${endpoint} 的响应不符合契约 —— ${issues.join(";")}${more}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并返回响应。用 `unknown` 进来的数据出去就是契约类型,不需要再 `as`。
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const data = await api.get<unknown>("problems", { params })
|
||||||
|
* return contract("GET /problems", problemListSchema, data)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 端点字符串是手写的,刻意不让调用方漏掉 —— 它只用于日志和去重,写错不影响正确性。
|
||||||
|
*/
|
||||||
|
export function contract<T extends z.ZodType>(
|
||||||
|
endpoint: string,
|
||||||
|
schema: T,
|
||||||
|
value: unknown,
|
||||||
|
): z.infer<T> {
|
||||||
|
const result = schema.safeParse(value)
|
||||||
|
if (result.success) return result.data
|
||||||
|
|
||||||
|
collectDrift(endpoint, describe(result.error, endpoint), value)
|
||||||
|
console.error(
|
||||||
|
`[契约] ${describe(result.error, endpoint)}\n` +
|
||||||
|
" 已放行原始数据(页面照常渲染)。全部历史分歧见 window.__OJ2_CONTRACT_DRIFT__。\n" +
|
||||||
|
" 契约在 packages/contract/src/,后端对不上的字段在 apps/api/src/routes/。",
|
||||||
|
)
|
||||||
|
// 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是
|
||||||
|
// 「能渲染的东西」而不是一个异常;分歧已经通过上面两条记录暴露出来了。
|
||||||
|
return value as z.infer<T>
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { toAdminType } from "@oj2/contract"
|
import { toAdminType } from "@oj2/contract"
|
||||||
|
import type { JudgeCaseResult, SubmissionDetail } from "@oj2/contract"
|
||||||
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
|
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
|
||||||
import { User } from "./types"
|
import { User } from "./types"
|
||||||
import { USER_TYPE } from "./constants"
|
import { USER_TYPE } from "./constants"
|
||||||
@@ -19,6 +20,23 @@ function calculateACRate(acCount: number, totalCount: number): string {
|
|||||||
return ((acCount / totalCount) * 100).toFixed(2)
|
return ((acCount / totalCount) * 100).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 `submission.info` 里取测试点明细,取不到就返回空数组。
|
||||||
|
*
|
||||||
|
* 契约里 `info` 是**联合类型**:判题机写的完整形状,或者空对象 —— 后者是后端对
|
||||||
|
* 非管理员下发的权限投影(`routes/submission.ts` 的 `full ? row.submission.info : {}`),
|
||||||
|
* 也是待判提交的初值。所以调用方不能直接 `.data`,得先在这里收口。
|
||||||
|
*
|
||||||
|
* 另外 `data` 本身也可能是 null:生产库 124191 条提交里有 12048 条是编译失败之类
|
||||||
|
* 没有逐测试点结果的情形。两种「没有」在这里一并归成空数组。
|
||||||
|
*/
|
||||||
|
export function submissionCaseResults(
|
||||||
|
info: SubmissionDetail["info"] | null | undefined,
|
||||||
|
): JudgeCaseResult[] {
|
||||||
|
if (!info || !("data" in info) || !info.data) return []
|
||||||
|
return info.data
|
||||||
|
}
|
||||||
|
|
||||||
export function getACRate(acCount: number, totalCount: number): string {
|
export function getACRate(acCount: number, totalCount: number): string {
|
||||||
return `${calculateACRate(acCount, totalCount)}%`
|
return `${calculateACRate(acCount, totalCount)}%`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
JudgeStatus,
|
JudgeStatus,
|
||||||
AstRules,
|
AstRules,
|
||||||
CreateAnnouncementRequest,
|
CreateAnnouncementRequest,
|
||||||
|
ProblemLanguage,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,16 +46,17 @@ export type User = AdminUser & {
|
|||||||
password?: string
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LANGUAGE =
|
/**
|
||||||
| "C"
|
* 语言联合。**从契约派生,不再手抄** —— 原来这里手写了一份 9 个值的联合,
|
||||||
| "C++"
|
* 而后端 `judge/languages.ts` 与生产库各有自己的答案,三份真相各自演进:
|
||||||
| "Python2"
|
* 手抄那份漏了 `SQL`,于是生产库里 91 条 SQL 提交的 `language` 在类型上是
|
||||||
| "Python3"
|
* `undefined`(提交列表、表情组件都按它渲染)。现在唯一来源是契约的
|
||||||
| "Java"
|
* `problemLanguageSchema`,见 packages/contract/src/language.ts。
|
||||||
| "JavaScript"
|
*
|
||||||
| "Golang"
|
* constants.ts 的 SOURCES / LANGUAGE_FORMAT_VALUE / LANGUAGE_SHOW_VALUE
|
||||||
| "Flowchart"
|
* 都以它为键 —— 契约里加一种语言而那边没补映射,会当场编译不过。
|
||||||
| "SQL"
|
*/
|
||||||
|
export type LANGUAGE = ProblemLanguage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SQL 题的配置与展示数据。形状在契约里 —— 原来这里手抄了一份,
|
* SQL 题的配置与展示数据。形状在契约里 —— 原来这里手抄了一份,
|
||||||
@@ -262,74 +264,25 @@ export type {
|
|||||||
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
|
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判题机原始输出。契约里是 `info: z.unknown()` —— 后端不校验沙箱产物,
|
* 提交详情。**info / statisticInfo / language 三处窄化都搬进契约了**
|
||||||
* 这些键名是沙箱定的,**保持 snake_case**,不要跟着响应字段一起改名。
|
* (`judgeInfoSchema` / `statisticInfoSchema` / `problemLanguageSchema`),
|
||||||
|
* 依据是生产库 124191 条提交的实测分布,见 packages/contract/src/submission.ts。
|
||||||
|
*
|
||||||
|
* 前端仍要保留一处:`result` 多一个 9 —— 点了提交、还没拿到结果时前端本地先填的
|
||||||
|
* 伪状态,后端永远不会下发,见 constants.ts 的 SubmissionStatus.submitting。
|
||||||
|
* 这是「前端自己造的状态」,不属于契约能描述的东西。
|
||||||
*/
|
*/
|
||||||
interface Info {
|
export type Submission = Omit<SubmissionDetail, "result"> & {
|
||||||
err: string | null
|
|
||||||
data: {
|
|
||||||
error: number
|
|
||||||
memory: number
|
|
||||||
output: null
|
|
||||||
result: SUBMISSION_RESULT
|
|
||||||
signal: number
|
|
||||||
cpu_time: number
|
|
||||||
exit_code: number
|
|
||||||
real_time: number
|
|
||||||
test_case: string
|
|
||||||
output_md5: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判题产出的统计。**键名保持 snake_case** —— 这是 submission.statistic_info
|
|
||||||
* JSONB 的原文,判题机按这套键名写进去,不能跟着响应字段一起改名。
|
|
||||||
*/
|
|
||||||
export interface StatisticInfo {
|
|
||||||
score?: number
|
|
||||||
err_info?: string
|
|
||||||
time_cost?: number
|
|
||||||
memory_cost?: number
|
|
||||||
ast_results?: Array<{
|
|
||||||
description: string
|
|
||||||
passed: boolean
|
|
||||||
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
|
|
||||||
actual?: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交详情。以契约的 SubmissionDetail 为准,只窄化两处 unknown:
|
|
||||||
* `info` 是判题沙箱原始输出,`statisticInfo` 是判题写的 JSONB —— 两者内部都是 snake。
|
|
||||||
*/
|
|
||||||
export type Submission = Omit<
|
|
||||||
SubmissionDetail,
|
|
||||||
"info" | "statisticInfo" | "language" | "result"
|
|
||||||
> & {
|
|
||||||
info: Info
|
|
||||||
statisticInfo: StatisticInfo
|
|
||||||
language: LANGUAGE
|
|
||||||
// 比契约多一个 9:点了提交、还没拿到结果时前端本地先填这个伪状态,
|
|
||||||
// 见 constants.ts 的 SubmissionStatus.submitting
|
|
||||||
result: SUBMISSION_RESULT
|
result: SUBMISSION_RESULT
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 站内信里嵌的提交:problem 是展示题号而非数字 id,且不含 info / ip / contestId */
|
/**
|
||||||
export type EmbeddedSubmission = Omit<
|
* 站内信里嵌的提交:problem 是展示题号而非数字 id,且不含 info / ip / contestId。
|
||||||
ContractEmbeddedSubmission,
|
* 契约里已经窄化好了(`embeddedSubmissionSchema`),这里不再重复 Omit + 增补。
|
||||||
"statisticInfo" | "language"
|
*/
|
||||||
> & {
|
export type EmbeddedSubmission = ContractEmbeddedSubmission
|
||||||
statisticInfo: StatisticInfo
|
|
||||||
language: LANGUAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SubmissionListItem = Omit<
|
export type SubmissionListItem = ContractSubmissionListItem
|
||||||
ContractSubmissionListItem,
|
|
||||||
"statisticInfo" | "language"
|
|
||||||
> & {
|
|
||||||
statisticInfo: StatisticInfo
|
|
||||||
language: LANGUAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmissionListPayload {
|
export interface SubmissionListPayload {
|
||||||
myself?: "1" | "0"
|
myself?: "1" | "0"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * from "./common"
|
|||||||
export * from "./content"
|
export * from "./content"
|
||||||
export * from "./contest"
|
export * from "./contest"
|
||||||
export * from "./flowchart"
|
export * from "./flowchart"
|
||||||
|
export * from "./language"
|
||||||
export * from "./problem"
|
export * from "./problem"
|
||||||
export * from "./problemset"
|
export * from "./problemset"
|
||||||
export * from "./roles"
|
export * from "./roles"
|
||||||
|
|||||||
45
packages/contract/src/language.ts
Normal file
45
packages/contract/src/language.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判题沙箱认得的语言。**这是全仓唯一的语言集合定义处。**
|
||||||
|
*
|
||||||
|
* 键名必须与 `apps/api/src/judge/languages.ts` 的 `languageConfigs` 一致 ——
|
||||||
|
* 沙箱的编译/运行命令按这些键查表,查不到就是 `Unsupported judge language`。
|
||||||
|
* 后端那边是 `Record<string, …>`(判题机要按名字取配置,不能窄化成联合),
|
||||||
|
* 所以这个联合是**手写**的,改 languages.ts 时两边一起改。
|
||||||
|
*
|
||||||
|
* 这 6 种是**现在还能提交**的语言。含历史值 `Python2`:判题机已经没有它的
|
||||||
|
* 编译配置了,但生产库里有 3 条当年用 Python2 提交的记录,提交列表要能渲染出来。
|
||||||
|
*/
|
||||||
|
export const judgeLanguageSchema = z.enum([
|
||||||
|
"Python2",
|
||||||
|
"Python3",
|
||||||
|
"C",
|
||||||
|
"C++",
|
||||||
|
"Java",
|
||||||
|
"JavaScript",
|
||||||
|
"Golang",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目可以挂的语言 = 沙箱语言 + 两种非沙箱题型。
|
||||||
|
*
|
||||||
|
* - `SQL` 走 `judge/sql/` 那条独立链路(子进程 + sql.js),不经过沙箱;
|
||||||
|
* - `Flowchart` 走 AI 评分(flowchart/run.ts),也不经过沙箱。
|
||||||
|
*
|
||||||
|
* **两者都是真实可选、真实有历史数据的**,不是预留值:生产库 961 道题里有
|
||||||
|
* 9 道 SQL 题、124191 条提交里有 91 条 SQL。前端原来手抄的语言联合漏了 SQL,
|
||||||
|
* 于是那 91 条提交的 `language` 在类型上是 `undefined` —— 这就是两份真相
|
||||||
|
* 各自演进的代价,现在并成一份。
|
||||||
|
*/
|
||||||
|
export const problemLanguageSchema = z.enum([
|
||||||
|
...judgeLanguageSchema.options,
|
||||||
|
"SQL",
|
||||||
|
"Flowchart",
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 沙箱语言组成的数组(顺序即提权顺序,前端用它排语言 tab) */
|
||||||
|
export const JUDGE_LANGUAGES = judgeLanguageSchema.options
|
||||||
|
|
||||||
|
export type JudgeLanguage = z.infer<typeof judgeLanguageSchema>
|
||||||
|
export type ProblemLanguage = z.infer<typeof problemLanguageSchema>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
|
import { problemLanguageSchema } from "./language"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目难度。生产库 956 道题只有这三个值(旧 Django 的 Problem.difficulty choices
|
* 题目难度。生产库 956 道题只有这三个值(旧 Django 的 Problem.difficulty choices
|
||||||
@@ -296,8 +297,16 @@ export const problemDetailSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
hint: z.string().nullable(),
|
hint: z.string().nullable(),
|
||||||
languages: z.array(z.string()),
|
languages: z.array(problemLanguageSchema),
|
||||||
template: z.record(z.string(), z.string()),
|
/**
|
||||||
|
* 语言 → 代码模板。**用 partialRecord 让键受语言联合约束** ——
|
||||||
|
* 原来这里是 `z.record(z.string(), z.string())`,等价于 `Record<string, string>`,
|
||||||
|
* 前端按语言查模板时拿不到任何键名保护(`template["Pytho3"]` 也是合法表达式)。
|
||||||
|
*
|
||||||
|
* partialRecord 而非 record:没配模板的语言不该出现该键(`template: {}` 是常态),
|
||||||
|
* 用 record 会要求每一个语言键都存在。
|
||||||
|
*/
|
||||||
|
template: z.partialRecord(problemLanguageSchema, z.string()),
|
||||||
createTime: z.string(),
|
createTime: z.string(),
|
||||||
lastUpdateTime: z.string().nullable(),
|
lastUpdateTime: z.string().nullable(),
|
||||||
timeLimit: z.number().int(),
|
timeLimit: z.number().int(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { paginatedSchema } from "./common"
|
import { paginatedSchema } from "./common"
|
||||||
|
import { problemLanguageSchema } from "./language"
|
||||||
|
|
||||||
export const judgeStatusSchema = z.union([
|
export const judgeStatusSchema = z.union([
|
||||||
z.literal(-2),
|
z.literal(-2),
|
||||||
@@ -17,9 +18,93 @@ export const judgeStatusSchema = z.union([
|
|||||||
z.literal(10),
|
z.literal(10),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判题机原始输出(`submission.info` 的 JSONB 原文)。
|
||||||
|
*
|
||||||
|
* 形状按**生产库 124191 条提交实测**得出,不是照着前端那份额外手抄的:
|
||||||
|
*
|
||||||
|
* - `err` 实测 124191 条**全是 null**,从来没见过字符串 —— 但契约仍留 `string`,
|
||||||
|
* 因为判题机层面它是有意义的通道,收紧成 `z.null()` 会在它第一次真的报错时炸。
|
||||||
|
* - `data` 有 **12048 条是 null**(编译失败等没有逐测试点结果的情形),
|
||||||
|
* 所以它必须 nullable。前端原来手抄的 `Info` 把 data 写成了非空数组,
|
||||||
|
* 这 12048 条在类型上根本不成立,只是没有一处会去读它才没炸。
|
||||||
|
* - 数组项比前端手抄的多三处:SQL 判题多带 `error_message`(201 个测试点)、
|
||||||
|
* 部分带 `score`(10 个)。所以这里的字段一律可选,不用 strictObject。
|
||||||
|
*
|
||||||
|
* 键名是**判题沙箱定的 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(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `info` 允许的两种取值,**不能只写成完整形状**:
|
||||||
|
*
|
||||||
|
* 1. 完整形状:判题机写的 JSONB 原文;
|
||||||
|
* 2. **空对象**:后端对非管理员用 `info: {}` 下发的占位(`routes/submission.ts:841`
|
||||||
|
* 的 `full ? row.submission.info : {}`),同一个空对象也是插入待判提交时的初值。
|
||||||
|
*
|
||||||
|
* 第 2 种是真实存在的合法取值,收紧成只认完整形状会让**每一条非管理员看的提交详情
|
||||||
|
* 直接 500**(`submissionDetailSchema.parse` 在路由里抛,被 onError 兜成 internal-error)。
|
||||||
|
* 这不是假想:收紧当天就在本地实测复现了。
|
||||||
|
*
|
||||||
|
* 换句话说,空对象表达的是「这条响应对你不含 info」,一个**权限投影**,
|
||||||
|
* 而不是「字段缺失」—— 契约要如实描述它。
|
||||||
|
*/
|
||||||
|
export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
|
||||||
|
*
|
||||||
|
* 五个键全部可选,依据是生产库实测的出现次数:time_cost / memory_cost 各 112097、
|
||||||
|
* score 3993、err_info 3153、ast_results 56,另有 27 条空对象。
|
||||||
|
*
|
||||||
|
* **不能用严格对象。** 有 8916 条历史记录里的 JSONB 原文内嵌了带转义的 shell
|
||||||
|
* 输出、本身不是合法 JSON,后端 `objectValue()` 会把它兜成 `{ value: "<原串>" }`
|
||||||
|
* 再下发 —— 严格 schema 会把这 8916 条判成契约分歧,而它们其实是正常的失败记录。
|
||||||
|
*/
|
||||||
|
export const statisticInfoSchema = z.object({
|
||||||
|
score: z.number().optional(),
|
||||||
|
/** 判题机写进 statistic_info 的错误文本,教师面板的「最近一条错在哪」也读它 */
|
||||||
|
err_info: z.string().optional(),
|
||||||
|
time_cost: z.number().optional(),
|
||||||
|
memory_cost: z.number().optional(),
|
||||||
|
ast_results: z.array(
|
||||||
|
z.object({
|
||||||
|
description: z.string(),
|
||||||
|
passed: z.boolean(),
|
||||||
|
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
|
||||||
|
actual: z.number().optional(),
|
||||||
|
}),
|
||||||
|
).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const createSubmissionRequestSchema = z.object({
|
export const createSubmissionRequestSchema = z.object({
|
||||||
problemId: z.number().int().positive(),
|
problemId: z.number().int().positive(),
|
||||||
language: z.string().min(1).max(32),
|
/**
|
||||||
|
* 提交的语言。用题目语言的联合而不是 `z.string()` —— 学生能选的语言就是题目
|
||||||
|
* `languages` 里列出的那些,写宽松了的话,前端把语言拼错(`"C++"`、`"python3"`
|
||||||
|
* 大小写)会一路走到判题机才以 `Unsupported judge language` 报系统错误,
|
||||||
|
* 学生看到的是「系统错误」而不是「语言不对」。
|
||||||
|
*/
|
||||||
|
language: problemLanguageSchema,
|
||||||
code: z.string().min(1).max(1024 * 1024),
|
code: z.string().min(1).max(1024 * 1024),
|
||||||
contestId: z.number().int().positive().optional(),
|
contestId: z.number().int().positive().optional(),
|
||||||
/**
|
/**
|
||||||
@@ -44,9 +129,10 @@ export const submissionDetailSchema = z.object({
|
|||||||
username: z.string(),
|
username: z.string(),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
result: judgeStatusSchema,
|
result: judgeStatusSchema,
|
||||||
info: z.unknown(),
|
/** 未判完或非管理员看时为 `{}`,见 submissionInfoSchema 的注释 */
|
||||||
language: z.string(),
|
info: submissionInfoSchema,
|
||||||
statisticInfo: z.record(z.string(), z.unknown()),
|
language: problemLanguageSchema,
|
||||||
|
statisticInfo: statisticInfoSchema,
|
||||||
contestId: z.number().int().nullable(),
|
contestId: z.number().int().nullable(),
|
||||||
problemId: z.number().int(),
|
problemId: z.number().int(),
|
||||||
/**
|
/**
|
||||||
@@ -99,8 +185,8 @@ export const submissionListItemSchema = z.object({
|
|||||||
userId: z.number().int(),
|
userId: z.number().int(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
result: judgeStatusSchema,
|
result: judgeStatusSchema,
|
||||||
language: z.string(),
|
language: problemLanguageSchema,
|
||||||
statisticInfo: z.record(z.string(), z.unknown()),
|
statisticInfo: statisticInfoSchema,
|
||||||
/**
|
/**
|
||||||
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null(比赛题不会进题单)。
|
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null(比赛题不会进题单)。
|
||||||
* 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission
|
* 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission
|
||||||
@@ -228,6 +314,9 @@ export const formatCodeRequestSchema = z.object({
|
|||||||
export const formatCodeResponseSchema = z.object({ code: z.string() })
|
export const formatCodeResponseSchema = z.object({ code: z.string() })
|
||||||
|
|
||||||
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
|
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<
|
export type CreateSubmissionRequest = z.infer<
|
||||||
typeof createSubmissionRequestSchema
|
typeof createSubmissionRequestSchema
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user