refactor(契约): 判题产物退回不校验,练一练的形状闸挪到写入侧,运行时闸门收回三处
前四轮把契约当成运行时闸门铺开,复盘下来三块里只有一块是赚的:类型收拢成一份
(语言联合、Problem/Message/ContestRank 的重复派生)留着;另外两块退回来。
## 判题产物:读出侧不再校验
judgeCaseResultSchema 按采样键集收紧的结果,用根目录那份生产备份全量跑了一遍:
124192 条提交里 9163 条对不上,**RE 8480/8480、TLE 338/338+26、MLE 1/1 全中**,
另有 270 条 WA、47 条 AC。原因不是键集合,是空值和 SQL 链路:
- 沙箱在非正常退出的测试点上写 `output_md5: null`,契约写的是 z.string();
- SQL 判题(judge/sql/engine.ts 的 CaseResult)根本没有 `output` 键;
- SQL 通过的测试点 `error_message` 是 null,契约写的是 z.string().optional()。
更糟的是失败方式:`info` 是 `union([完整形状, z.object({})])`,对不上的一律落进
第二支被剥成 `{}` 且 parse 成功 —— 管理员详情页的测试点表格**静默消失**,无日志。
JSONB 的形状真相在写入侧(判题机),读出侧再校验一遍只会在两边分叉时丢数据。
所以 `info` 回到 z.unknown(),形状改用 JudgeInfo / JudgeCaseResult 两个 TS 类型
描述(按判题机实际写的形状,不是采样出来的),取值处由 submissionCaseResults()
做唯一需要的运行时判断:有没有 data 数组。statisticInfo 换成 looseObject ——
所有键可选、不剥未知键,对任何对象都不会失败,它的作用是给类型不是当闸门。
## 练一练:形状闸从读路径挪到写路径
exerciseSchema 的 superRefine 挂在读路径上,而这个 schema 后端也在 parse
(routes/content.ts),等于一行脏数据就能让整条学生练习列表 500。同时写入侧的
exerciseDataError **一次都没查过 question**,两边严紧度不一致,脏数据进得来出不去。
exerciseDataByType 保留,改由 exerciseDataError 在写入前查,错误信息按字段翻成
中文给老师看;读路径回到不校验。
## 运行时闸门收回三处
contract() 从 41 个端点收回到题目详情 / 提交详情 / 用户资料 —— 原本就写了
.parse() 的那三条。留着的理由是「别抛错」(原来 parse 抛 ZodError 会白屏、
后面的 as 又让校验白做),不是校验:前后端同仓、共享同一份 schema,字段漂移
tsc 已经抓了。闸门本身也瘦掉了没人读的 window.__OJ2_CONTRACT_DRIFT__ 那套簿记。
## 验证
- 生产备份全量:124192 条提交过 submissionDetailSchema / submissionListItemSchema
零失败,其中 112144 条能拿到测试点明细(另外 12048 条本来就是 data:null);
151 道练习读路径 151/151、写入闸 151/151(老师改旧题不会被新闸挡);
- 反向验证写入闸:缺题干的排序题被拒并给出「题干的格式不对」;
- 本地实跑:种一条生产形状的 RE 提交(output_md5: null),管理员详情接口原样
返回 info.data(改之前是 {});库里塞一行没有 options 的 mcq,学生端练习列表
照常返回两条而不是 500;
- vue-tsc / tsc -p apps/api 均 exit 0,vite build 通过,check:routes 无遮蔽。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import type { ExerciseType } from "@oj2/contract"
|
||||
import { exerciseDataByType, type ExerciseType } from "@oj2/contract"
|
||||
|
||||
/**
|
||||
* 练习题 `data` 的语义校验。
|
||||
* 练习题 `data` 的校验。**这是唯一的校验点** —— 契约里 `data` 是
|
||||
* `z.record(z.string(), z.unknown())`,七种题型的字段完全不同,用 zod 写成判别联合
|
||||
* 会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema),历史脏数据会把
|
||||
* 整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行。
|
||||
*
|
||||
* 契约里 `data` 是 `z.record(z.string(), z.unknown())` —— 七种题型的字段完全不同,
|
||||
* 用 zod 写成判别联合会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema),
|
||||
* 历史脏数据会把整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行。
|
||||
* 两层:先按 `exerciseDataByType` 查形状(键在不在、类型对不对),再走下面的语义
|
||||
* 检查(选项够不够、下标越不越界)。形状那层是后补的 —— 之前只有语义检查,
|
||||
* 而它**一次都没查过 `question`**,一道没有题干的练习能存进库。
|
||||
*
|
||||
* 为什么非校验不可:以前唯一的校验在前端 ExerciseManager 的 buildData(),而它对
|
||||
* fill 和 mcq 几乎不查 —— 一道没有 `{{空位}}` 的填空题能存进库,学生端渲染出来是
|
||||
@@ -17,6 +20,16 @@ export function exerciseDataError(
|
||||
type: ExerciseType,
|
||||
data: Record<string, unknown>,
|
||||
): string | null {
|
||||
const shape = exerciseDataByType[type]
|
||||
if (!shape) return `未知的题型 ${type}`
|
||||
const parsed = shape.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
// 老师看到的是「题干必须是文字」这种话,不是 zod 的英文 issue
|
||||
const issue = parsed.error.issues[0]!
|
||||
// 只取第一段:数组项的 path 是 ["options", 0],老师要看的是「选项」
|
||||
const field = String(issue.path[0] ?? "内容")
|
||||
return `${FIELD_LABELS[field] ?? field}的格式不对(${issue.message})`
|
||||
}
|
||||
switch (type) {
|
||||
case "mcq": {
|
||||
const options = strings(data.options)
|
||||
@@ -67,6 +80,20 @@ export function exerciseDataError(
|
||||
}
|
||||
}
|
||||
|
||||
/** zod 报的是键名,老师看的得是人话 */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
question: "题干",
|
||||
options: "选项",
|
||||
answer: "答案",
|
||||
lines: "代码行",
|
||||
code: "代码",
|
||||
left: "左列",
|
||||
right: "右列",
|
||||
buckets: "分组",
|
||||
items: "项目",
|
||||
explanation: "解析",
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
? (value as string[])
|
||||
|
||||
@@ -83,23 +83,37 @@ through the dev server (see `vite.config.ts`).
|
||||
### Contract guard (`utils/contract.ts`)
|
||||
|
||||
`@oj2/contract` 的 zod schema 是**前后端唯一的形状来源**,`utils/types.ts` 只做
|
||||
`z.infer` 派生与少量前端专有的收窄(都写了理由)。读接口应当走守卫:
|
||||
`z.infer` 派生与少量前端专有的收窄(都写了理由)。
|
||||
|
||||
运行时闸门**只挂三处**:题目详情、提交详情、`shared/api.ts` 的用户资料 ——
|
||||
原本就写了 `.parse()` 的那三条。留着它们的理由是**别抛错**,不是校验:
|
||||
|
||||
```ts
|
||||
const endpoint = `problems/${encodeURIComponent(id)}`
|
||||
return contract("GET /problems/:id", problemDetailSchema, await api.get<unknown>(endpoint))
|
||||
// 原来是 problemDetailSchema.parse(v) as Problem —— `as` 让校验白做,
|
||||
// 而 parse 抛错会让整个题目页白屏
|
||||
return contract("GET /problems/:id", problemDetailSchema, value)
|
||||
```
|
||||
|
||||
**失败策略是「记日志 + 放行原始数据」,不抛错。** 形状对不上时:控制台打一条带
|
||||
端点和字段路径的记录、去重后记进 `window.__OJ2_CONTRACT_DRIFT__`、然后**返回原始
|
||||
数据让页面继续渲染**。面向学生的生产站点,少一个字段的代价远小于白屏。
|
||||
失败时记一条控制台日志再**放行原始数据**,页面照常渲染。
|
||||
|
||||
排查线上分歧就是打开控制台敲 `window.__OJ2_CONTRACT_DRIFT__`;某条路径长期为空之后,
|
||||
那条路径可以升级成硬失败(直接 `schema.parse`),在那之前不要改。
|
||||
**不要把它铺到更多端点上。** 试过一次(41 个),收益是 41 次 safeParse 加一条
|
||||
没人读的 console.error:前后端同仓、共享同一份 schema,「后端改字段前端不知道」
|
||||
`tsc` 已经抓了。
|
||||
|
||||
改动 schema 时要记住**同一个 schema 后端也在 `parse`**(如
|
||||
`submissionDetailSchema.parse` 在路由里),所以收紧一个字段前先用生产数据核一遍,
|
||||
否则一条不符合的历史记录会让整个列表 500。
|
||||
### 什么该收紧,什么不该
|
||||
|
||||
**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)不在读出侧
|
||||
校验。** 它们的形状真相在写入侧 —— 判题机、`services/exercise.ts`。在读出侧再收
|
||||
一遍的结果实测过两次:
|
||||
|
||||
- `info` 按采样键集收紧后,124192 条提交里 9163 条(RE、TLE、MLE 全中)对不上,
|
||||
被 union 的空对象分支**静默剥成 `{}`**,管理员的测试点表格无声消失;
|
||||
- `exercise.data` 按题型收紧后,后端读路径(`routes/content.ts` 硬 parse)变成
|
||||
一道闸,一行脏数据能让整条练习列表 500。
|
||||
|
||||
所以:**同一个 schema 后端也在 `parse`**(`submissionDetailSchema` /
|
||||
`exerciseSchema` / `contestRankItemSchema` 都是),收紧任何字段之前,拿根目录
|
||||
那份生产备份把全量数据跑一遍,尤其要看**空值**而不只是键集合。
|
||||
|
||||
### Key Utilities
|
||||
|
||||
|
||||
@@ -1,98 +1,86 @@
|
||||
import {
|
||||
type AiAnalysisRecord,
|
||||
type Contest as OjContest,
|
||||
type ContestAccess,
|
||||
type ContestList,
|
||||
type ActivityRankItem,
|
||||
type FormatCodeResponse,
|
||||
type Metrics,
|
||||
type TutorialSummary,
|
||||
type ClassComparisonResponse,
|
||||
type ClassRankItem,
|
||||
type ClassUserRank,
|
||||
type UserRank,
|
||||
type ProblemRank,
|
||||
type CreateSubmissionResponse,
|
||||
type ProblemAuthor,
|
||||
type ProblemListItem,
|
||||
type YearlyAc,
|
||||
type ProblemList,
|
||||
type CreateFlowchartResponse,
|
||||
type FlowchartCurrent,
|
||||
type FlowchartDetail,
|
||||
type FlowchartList,
|
||||
type FlowchartSubmission,
|
||||
type AiDetail,
|
||||
type DurationData,
|
||||
type HeatmapItem,
|
||||
type LoginSummary,
|
||||
type SolvedList,
|
||||
type ProblemSet,
|
||||
type ProblemSetBadge,
|
||||
type ProblemSetList,
|
||||
type ProblemSetProblem,
|
||||
type ProblemSetProgressList,
|
||||
type UserBadge,
|
||||
problemDetailSchema,
|
||||
problemListSchema,
|
||||
problemListItemSchema,
|
||||
submissionDetailSchema,
|
||||
submissionListSchema,
|
||||
submissionStatisticsSchema,
|
||||
submissionStatisticsItemsSchema,
|
||||
onlineCountSchema,
|
||||
websiteConfigSchema,
|
||||
contestListSchema,
|
||||
contestSchema,
|
||||
contestAccessSchema,
|
||||
contestRankSchema,
|
||||
announcementListSchema,
|
||||
announcementSchema,
|
||||
problemSetListSchema,
|
||||
problemSetSchema,
|
||||
problemSetBadgeSchema,
|
||||
userBadgeSchema,
|
||||
tutorialSchema,
|
||||
metricsSchema,
|
||||
activityRankItemSchema,
|
||||
problemRankSchema,
|
||||
userRankSchema,
|
||||
flowchartListSchema,
|
||||
flowchartDetailSchema,
|
||||
flowchartCurrentSchema,
|
||||
flowchartStatisticsSchema,
|
||||
flowchartSubmissionSchema,
|
||||
exerciseSchema,
|
||||
exerciseDataByType,
|
||||
problemSetProgressListSchema,
|
||||
problemSetProblemSchema,
|
||||
tutorialSummarySchema,
|
||||
tutorialProgressSchema,
|
||||
messageListSchema,
|
||||
yearlyAcSchema,
|
||||
aiDetailSchema,
|
||||
solvedListSchema,
|
||||
durationDataSchema,
|
||||
heatmapItemSchema,
|
||||
loginSummarySchema,
|
||||
aiAnalysisRecordSchema,
|
||||
type FlowchartStatistics,
|
||||
type SubmissionStatistics,
|
||||
type SubmissionStatisticsItems,
|
||||
} from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import { contract } from "utils/contract"
|
||||
import { filterResult } from "oj/transforms"
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementListItem,
|
||||
ContestRank,
|
||||
Profile,
|
||||
Message,
|
||||
SubmissionListItem,
|
||||
Exercise,
|
||||
Problem,
|
||||
ReactionKey,
|
||||
ReactionState,
|
||||
Submission,
|
||||
SubmissionListPayload,
|
||||
SubmitCodePayload,
|
||||
OnlineCount,
|
||||
WebsiteConfig,
|
||||
Tutorial,
|
||||
TutorialProgress,
|
||||
} from "utils/types"
|
||||
|
||||
/**
|
||||
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
|
||||
* 键名映射,抄漏一个字段就是静默 undefined。
|
||||
*
|
||||
* 走 `contract()` 而不是裸 `parse()`:这里原来是
|
||||
* `problemDetailSchema.parse(value) as Problem` —— `as` 把校验结果又断言回本地
|
||||
* 类型,等于校验白做。契约现在把 `languages` / `template` 都收进了联合,
|
||||
* `Problem` 不再需要额外窄化,`as` 也就没有存在的理由了。
|
||||
* 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as Problem`,
|
||||
* `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。
|
||||
* 现在形状不符时记一条控制台分歧再放行原始数据。
|
||||
*/
|
||||
function detailProblem(value: unknown): Problem {
|
||||
return contract("GET /problems/:id", problemDetailSchema, value)
|
||||
}
|
||||
|
||||
export async function getWebsiteConfig() {
|
||||
const endpoint = "site"
|
||||
return contract(
|
||||
"GET /site",
|
||||
websiteConfigSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getWebsiteConfig() {
|
||||
return api.get<WebsiteConfig>("site")
|
||||
}
|
||||
|
||||
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
|
||||
export async function getOnlineCount() {
|
||||
const endpoint = "site/online"
|
||||
return contract(
|
||||
"GET /site/online",
|
||||
onlineCountSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getOnlineCount() {
|
||||
return api.get<OnlineCount>("site/online")
|
||||
}
|
||||
|
||||
export async function getProblemList(
|
||||
@@ -100,14 +88,9 @@ export async function getProblemList(
|
||||
limit = 10,
|
||||
searchParams: Record<string, unknown> = {},
|
||||
) {
|
||||
const endpoint = "problems"
|
||||
const res = contract(
|
||||
"GET /problems",
|
||||
problemListSchema,
|
||||
await api.get<unknown>(endpoint, {
|
||||
params: { paging: true, offset, limit, ...searchParams },
|
||||
}),
|
||||
)
|
||||
const res = await api.get<ProblemList>("problems", {
|
||||
params: { paging: true, offset, limit, ...searchParams },
|
||||
})
|
||||
return {
|
||||
results: res.results.map(filterResult),
|
||||
total: res.total,
|
||||
@@ -132,13 +115,11 @@ export function getProblemBeatRate(problemID: number) {
|
||||
return api.get<string>(`problems/${problemID}/beat-count`)
|
||||
}
|
||||
|
||||
export async function getSubmission(id: string) {
|
||||
const endpoint = `submissions/${encodeURIComponent(id)}`
|
||||
return contract(
|
||||
"GET /submissions/:id",
|
||||
submissionDetailSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
export async function getSubmission(id: string): Promise<Submission> {
|
||||
const response = await api.get<unknown>(
|
||||
`submissions/${encodeURIComponent(id)}`,
|
||||
)
|
||||
return contract("GET /submissions/:id", submissionDetailSchema, response)
|
||||
}
|
||||
|
||||
export function submitCode(data: SubmitCodePayload) {
|
||||
@@ -162,36 +143,16 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
const endpoint = params.contestId
|
||||
? `contests/${encodeURIComponent(params.contestId)}/submissions`
|
||||
: "submissions"
|
||||
return getSubmissionPage(endpoint, params)
|
||||
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
|
||||
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
|
||||
return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
|
||||
// contestId 走的是路径,page 只有前端分页器用
|
||||
params: { ...params, contestId: undefined, page: undefined },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交列表。后端在 `submissionListItemSchema.parse` 上真的会抛 —— 它逐个列表项
|
||||
* 过 schema,所以这条链路上的分歧**后端自己就拦住了**,前端这层校验是第二道保险:
|
||||
* 主要防「后端加了字段但契约没跟上、前端类型声称有实际是 undefined」这类
|
||||
* 只在展示端出问题的偏差。
|
||||
*/
|
||||
async function getSubmissionPage(
|
||||
endpoint: string,
|
||||
params: Partial<SubmissionListPayload>,
|
||||
) {
|
||||
return contract(
|
||||
`GET /${endpoint}`,
|
||||
submissionListSchema,
|
||||
await api.get<unknown>(endpoint, {
|
||||
// contestId 走的是路径,page 只有前端分页器用
|
||||
params: { ...params, contestId: undefined, page: undefined },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRankOfProblem(problemId: string) {
|
||||
const endpoint = `problems/${encodeURIComponent(problemId)}/rank`
|
||||
return contract(
|
||||
"GET /problems/:id/rank",
|
||||
problemRankSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getRankOfProblem(problemId: string) {
|
||||
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
|
||||
}
|
||||
|
||||
export function getTodaySubmissionCount(language?: string) {
|
||||
@@ -208,56 +169,38 @@ export function adminRejudge(id: string) {
|
||||
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
|
||||
* 和上面那个按班级模糊匹配的不是一回事。
|
||||
*/
|
||||
export async function getSubmissionStatisticsItems(
|
||||
export function getSubmissionStatisticsItems(
|
||||
duration: { start?: string; end: string },
|
||||
username: string,
|
||||
problemID?: string,
|
||||
) {
|
||||
const endpoint = "submissions/statistics/items"
|
||||
return contract(
|
||||
"GET /submissions/statistics/items",
|
||||
submissionStatisticsItemsSchema,
|
||||
await api.get<unknown>(endpoint, {
|
||||
params: { ...duration, problemId: problemID, username },
|
||||
}),
|
||||
)
|
||||
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
|
||||
params: { ...duration, problemId: problemID, username },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSubmissionStatistics(
|
||||
export function getSubmissionStatistics(
|
||||
duration: { start?: string; end: string },
|
||||
problemID?: string,
|
||||
username?: string,
|
||||
) {
|
||||
const endpoint = "submissions/statistics"
|
||||
return contract(
|
||||
"GET /submissions/statistics",
|
||||
submissionStatisticsSchema,
|
||||
await api.get<unknown>(endpoint, {
|
||||
params: { ...duration, problemId: problemID, username },
|
||||
}),
|
||||
)
|
||||
return api.get<SubmissionStatistics>("submissions/statistics", {
|
||||
params: { ...duration, problemId: problemID, username },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
||||
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
||||
*/
|
||||
export async function getRank(offset: number, limit: number) {
|
||||
const endpoint = "rankings/users"
|
||||
return contract(
|
||||
"GET /rankings/users",
|
||||
userRankSchema,
|
||||
await api.get<unknown>(endpoint, { params: { offset, limit } }),
|
||||
)
|
||||
export function getRank(offset: number, limit: number) {
|
||||
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
|
||||
}
|
||||
|
||||
export async function getActivityRank(start: string) {
|
||||
const endpoint = "rankings/activity"
|
||||
return contract(
|
||||
"GET /rankings/activity",
|
||||
activityRankItemSchema.array(),
|
||||
await api.get<unknown>(endpoint, { params: { start } }),
|
||||
)
|
||||
export function getActivityRank(start: string) {
|
||||
return api.get<ActivityRankItem[]>("rankings/activity", {
|
||||
params: { start },
|
||||
})
|
||||
}
|
||||
|
||||
export function getClassRank(grade?: number | null) {
|
||||
@@ -286,37 +229,22 @@ export function getClassPK(
|
||||
})
|
||||
}
|
||||
|
||||
export async function getContestList(query: {
|
||||
export function getContestList(query: {
|
||||
offset: number
|
||||
limit: number
|
||||
keyword: string
|
||||
status: string
|
||||
tag: string
|
||||
}) {
|
||||
const endpoint = "contests"
|
||||
return contract(
|
||||
"GET /contests",
|
||||
contestListSchema,
|
||||
await api.get<unknown>(endpoint, { params: query }),
|
||||
)
|
||||
return api.get<ContestList>("contests", { params: query })
|
||||
}
|
||||
|
||||
export async function getContest(id: string) {
|
||||
const endpoint = `contests/${encodeURIComponent(id)}`
|
||||
return contract(
|
||||
"GET /contests/:id",
|
||||
contestSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getContest(id: string) {
|
||||
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export async function getContestAccess(id: string) {
|
||||
const endpoint = `contests/${encodeURIComponent(id)}/access`
|
||||
return contract(
|
||||
"GET /contests/:id/access",
|
||||
contestAccessSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getContestAccess(id: string) {
|
||||
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
|
||||
}
|
||||
|
||||
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
|
||||
@@ -330,29 +258,21 @@ export function checkContestPassword(contestID: string, password: string) {
|
||||
}
|
||||
|
||||
export async function getContestProblems(contestID: string) {
|
||||
const endpoint = `contests/${encodeURIComponent(contestID)}/problems`
|
||||
// 用 problemListItemSchema.array(),不是契约的 contestProblemsSchema ——
|
||||
// 后者是 `array(union([列表项, 详情]))`,联合类型会让 filterResult 的类型收窄
|
||||
// 落到详情分支上,而且学生侧这条接口只下发列表项。
|
||||
const res = contract(
|
||||
"GET /contests/:id/problems",
|
||||
problemListItemSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
const res = await api.get<ProblemListItem[]>(
|
||||
`contests/${encodeURIComponent(contestID)}/problems`,
|
||||
)
|
||||
return res.map(filterResult)
|
||||
}
|
||||
|
||||
export async function getContestRank(
|
||||
export function getContestRank(
|
||||
contestID: string,
|
||||
query: { limit: number; offset: number },
|
||||
) {
|
||||
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
|
||||
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank
|
||||
const endpoint = `contests/${encodeURIComponent(contestID)}/rank`
|
||||
return contract(
|
||||
"GET /contests/:id/rank",
|
||||
contestRankSchema,
|
||||
await api.get<unknown>(endpoint, { params: query }),
|
||||
return api.get<{ results: ContestRank[]; total: number }>(
|
||||
`contests/${encodeURIComponent(contestID)}/rank`,
|
||||
{ params: query },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,31 +288,21 @@ export function updateProfile(data: { realName: string; mood: string }) {
|
||||
return api.put<Profile>("me/profile", data)
|
||||
}
|
||||
|
||||
export async function getAnnouncementList(offset = 0, limit = 10) {
|
||||
const endpoint = "announcements"
|
||||
return contract(
|
||||
"GET /announcements",
|
||||
announcementListSchema,
|
||||
await api.get<unknown>(endpoint, { params: { limit, offset } }),
|
||||
)
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
|
||||
params: { limit, offset },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAnnouncement(id: number) {
|
||||
const endpoint = `announcements/${id}`
|
||||
return contract(
|
||||
"GET /announcements/:id",
|
||||
announcementSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getAnnouncement(id: number) {
|
||||
return api.get<Announcement>(`announcements/${id}`)
|
||||
}
|
||||
|
||||
export async function getMessageList(offset = 0, limit = 10) {
|
||||
const endpoint = "messages"
|
||||
return contract(
|
||||
"GET /messages",
|
||||
messageListSchema,
|
||||
await api.get<unknown>(endpoint, { params: { limit, offset } }),
|
||||
)
|
||||
export function getMessageList(offset = 0, limit = 10) {
|
||||
// language 的收窄同 getSubmissions,见那里的说明
|
||||
return api.get<{ results: Message[]; total: number }>("messages", {
|
||||
params: { limit, offset },
|
||||
})
|
||||
}
|
||||
|
||||
export function getReaction(problemID: number) {
|
||||
@@ -403,125 +313,71 @@ export function setReaction(problemID: number, type: ReactionKey) {
|
||||
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
|
||||
}
|
||||
|
||||
export async function getMetrics(userid: number) {
|
||||
const endpoint = `users/${userid}/metrics`
|
||||
return contract(
|
||||
"GET /users/:id/metrics",
|
||||
metricsSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getMetrics(userid: number) {
|
||||
return api.get<Metrics>(`users/${userid}/metrics`)
|
||||
}
|
||||
|
||||
export async function getTutorial(id: number) {
|
||||
const endpoint = `tutorials/${id}`
|
||||
return contract(
|
||||
"GET /tutorials/:id",
|
||||
tutorialSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getTutorial(id: number) {
|
||||
return api.get<Tutorial>(`tutorials/${id}`)
|
||||
}
|
||||
|
||||
export async function getTutorials(type: "python" | "c") {
|
||||
const endpoint = "tutorials"
|
||||
return contract(
|
||||
"GET /tutorials",
|
||||
tutorialSummarySchema.array(),
|
||||
await api.get<unknown>(endpoint, { params: { type } }),
|
||||
)
|
||||
export function getTutorials(type: "python" | "c") {
|
||||
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
|
||||
}
|
||||
|
||||
export async function getAIDetailData(
|
||||
start: string,
|
||||
end: string,
|
||||
username?: string,
|
||||
) {
|
||||
const endpoint = "ai/detail"
|
||||
return contract(
|
||||
"GET /ai/detail",
|
||||
aiDetailSchema,
|
||||
await api.get<unknown>(endpoint, { params: { start, end, username } }),
|
||||
)
|
||||
export function getAIDetailData(start: string, end: string, username?: string) {
|
||||
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
|
||||
}
|
||||
|
||||
export async function getAISolved(
|
||||
export function getAISolved(
|
||||
start: string,
|
||||
end: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
username?: string,
|
||||
) {
|
||||
const endpoint = "ai/solved"
|
||||
return contract(
|
||||
"GET /ai/solved",
|
||||
solvedListSchema,
|
||||
await api.get<unknown>(endpoint, {
|
||||
params: { start, end, offset, limit, username },
|
||||
}),
|
||||
)
|
||||
return api.get<SolvedList>("ai/solved", {
|
||||
params: { start, end, offset, limit, username },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAIDurationData(
|
||||
export function getAIDurationData(
|
||||
end: string,
|
||||
duration: string,
|
||||
username?: string,
|
||||
) {
|
||||
const endpoint = "ai/duration"
|
||||
return contract(
|
||||
"GET /ai/duration",
|
||||
durationDataSchema.array(),
|
||||
await api.get<unknown>(endpoint, { params: { end, duration, username } }),
|
||||
)
|
||||
return api.get<DurationData[]>("ai/duration", {
|
||||
params: { end, duration, username },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAIHeatmapData(username?: string) {
|
||||
const endpoint = "ai/heatmap"
|
||||
return contract(
|
||||
"GET /ai/heatmap",
|
||||
heatmapItemSchema.array(),
|
||||
await api.get<unknown>(endpoint, {
|
||||
params: username ? { username } : {},
|
||||
}),
|
||||
)
|
||||
export function getAIHeatmapData(username?: string) {
|
||||
return api.get<HeatmapItem[]>("ai/heatmap", {
|
||||
params: username ? { username } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAILoginSummary() {
|
||||
const endpoint = "ai/login-summary"
|
||||
return contract(
|
||||
"GET /ai/login-summary",
|
||||
loginSummarySchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getAILoginSummary() {
|
||||
return api.get<LoginSummary>("ai/login-summary")
|
||||
}
|
||||
|
||||
export async function getAIPinnedReport() {
|
||||
const endpoint = "ai/pinned"
|
||||
return contract(
|
||||
"GET /ai/pinned",
|
||||
aiAnalysisRecordSchema.nullable(),
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getAIPinnedReport() {
|
||||
return api.get<AiAnalysisRecord | null>("ai/pinned")
|
||||
}
|
||||
|
||||
// ==================== 相似题目推荐 ====================
|
||||
|
||||
export async function getSimilarProblems(problemId: string) {
|
||||
const endpoint = `problems/${encodeURIComponent(problemId)}/similar`
|
||||
const res = contract(
|
||||
"GET /problems/:id/similar",
|
||||
problemListItemSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
return res.map(filterResult)
|
||||
export function getSimilarProblems(problemId: string) {
|
||||
return api
|
||||
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
|
||||
.then((response) => response.map(filterResult))
|
||||
}
|
||||
|
||||
export type { YearlyAc as YearlyACData } from "@oj2/contract"
|
||||
|
||||
export async function getProblemYearlyAC(problemId: string) {
|
||||
const endpoint = `problems/${encodeURIComponent(problemId)}/yearly-ac`
|
||||
return contract(
|
||||
"GET /problems/:id/yearly-ac",
|
||||
yearlyAcSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
export function getProblemYearlyAC(problemId: string) {
|
||||
return api.get<YearlyAc[]>(
|
||||
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,16 +391,11 @@ export function submitFlowchart(data: {
|
||||
return api.post<CreateFlowchartResponse>("flowcharts", data)
|
||||
}
|
||||
|
||||
export async function getFlowchartSubmission(id: string) {
|
||||
const endpoint = `flowcharts/${encodeURIComponent(id)}`
|
||||
return contract(
|
||||
"GET /flowcharts/:id",
|
||||
flowchartSubmissionSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getFlowchartSubmission(id: string) {
|
||||
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export async function getFlowchartSubmissions(params: {
|
||||
export function getFlowchartSubmissions(params: {
|
||||
username?: string
|
||||
problemId?: string
|
||||
myself?: string
|
||||
@@ -553,27 +404,17 @@ export async function getFlowchartSubmissions(params: {
|
||||
today?: string
|
||||
grade?: string
|
||||
}) {
|
||||
const endpoint = "flowcharts"
|
||||
return contract(
|
||||
"GET /flowcharts",
|
||||
flowchartListSchema,
|
||||
await api.get<unknown>(endpoint, { params }),
|
||||
)
|
||||
return api.get<FlowchartList>("flowcharts", { params })
|
||||
}
|
||||
|
||||
export async function getFlowchartStatistics(
|
||||
export function getFlowchartStatistics(
|
||||
duration: { start?: string; end: string },
|
||||
problemID?: string,
|
||||
username?: string,
|
||||
) {
|
||||
const endpoint = "flowcharts/statistics"
|
||||
return contract(
|
||||
"GET /flowcharts/statistics",
|
||||
flowchartStatisticsSchema,
|
||||
await api.get<unknown>(endpoint, {
|
||||
params: { ...duration, problemId: problemID, username },
|
||||
}),
|
||||
)
|
||||
return api.get<FlowchartStatistics>("flowcharts/statistics", {
|
||||
params: { ...duration, problemId: problemID, username },
|
||||
})
|
||||
}
|
||||
|
||||
export function retryFlowchartSubmission(submissionId: string) {
|
||||
@@ -582,59 +423,36 @@ export function retryFlowchartSubmission(submissionId: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||
const endpoint = `problems/${problemId}/flowchart/current`
|
||||
return contract(
|
||||
"GET /problems/:id/flowchart/current",
|
||||
flowchartCurrentSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
|
||||
}
|
||||
|
||||
export async function getFlowchartSubmissionDetail(problemId: number, page = 0) {
|
||||
const endpoint = `problems/${problemId}/flowchart/history`
|
||||
return contract(
|
||||
"GET /problems/:id/flowchart/history",
|
||||
flowchartDetailSchema,
|
||||
await api.get<unknown>(endpoint, { params: { page } }),
|
||||
)
|
||||
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
|
||||
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
|
||||
params: { page },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 题单相关API ====================
|
||||
|
||||
export async function getProblemSetList(
|
||||
export function getProblemSetList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword = "",
|
||||
difficulty = "",
|
||||
status = "",
|
||||
) {
|
||||
const endpoint = "problem-sets"
|
||||
return contract(
|
||||
"GET /problem-sets",
|
||||
problemSetListSchema,
|
||||
await api.get<unknown>(endpoint, {
|
||||
params: { offset, limit, keyword, difficulty, status },
|
||||
}),
|
||||
)
|
||||
return api.get<ProblemSetList>("problem-sets", {
|
||||
params: { offset, limit, keyword, difficulty, status },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProblemSetDetail(id: number) {
|
||||
const endpoint = `problem-sets/${id}`
|
||||
return contract(
|
||||
"GET /problem-sets/:id",
|
||||
problemSetSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getProblemSetDetail(id: number) {
|
||||
return api.get<ProblemSet>(`problem-sets/${id}`)
|
||||
}
|
||||
|
||||
export async function getProblemSetProblems(problemSetId: number) {
|
||||
const endpoint = `problem-sets/${problemSetId}/problems`
|
||||
return contract(
|
||||
"GET /problem-sets/:id/problems",
|
||||
problemSetProblemSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
|
||||
}
|
||||
|
||||
export function joinProblemSet(problemSetId: number) {
|
||||
@@ -653,25 +471,17 @@ export function updateProblemSetProgress(
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserBadges(username?: string) {
|
||||
const endpoint = `users/${encodeURIComponent(username ?? "me")}/badges`
|
||||
return contract(
|
||||
"GET /users/:username/badges",
|
||||
userBadgeSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
export function getUserBadges(username?: string) {
|
||||
return api.get<UserBadge[]>(
|
||||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function getProblemSetBadges(problemSetId: number) {
|
||||
const endpoint = `problem-sets/${problemSetId}/badges`
|
||||
return contract(
|
||||
"GET /problem-sets/:id/badges",
|
||||
problemSetBadgeSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
|
||||
}
|
||||
|
||||
export async function getProblemSetUserProgress(
|
||||
export function getProblemSetUserProgress(
|
||||
problemSetId: number,
|
||||
params?: {
|
||||
limit?: number
|
||||
@@ -680,42 +490,14 @@ export async function getProblemSetUserProgress(
|
||||
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
||||
},
|
||||
) {
|
||||
const endpoint = `problem-sets/${problemSetId}/user-progress`
|
||||
return contract(
|
||||
"GET /problem-sets/:id/user-progress",
|
||||
problemSetProgressListSchema,
|
||||
await api.get<unknown>(endpoint, { params }),
|
||||
return api.get<ProblemSetProgressList>(
|
||||
`problem-sets/${problemSetId}/user-progress`,
|
||||
{ params },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getExercises(
|
||||
tutorialId: number,
|
||||
): Promise<Exercise[]> {
|
||||
const endpoint = `tutorials/${tutorialId}/exercises`
|
||||
// 外层走 exerciseSchema,内层 data 在这里按题型逐支校验:
|
||||
// `z.infer` 只能把 data 还原成 Record<string, unknown>(superRefine 无法把
|
||||
// 校验结果反映到推断类型上),所以那 7 个 Exercise*.vue 直接读
|
||||
// data.question / data.options 时本没有任何运行时保护。
|
||||
// 生产库 151 道练习题已确认七种题型的键集全部吻合。
|
||||
const rows = contract(
|
||||
"GET /tutorials/:id/exercises",
|
||||
exerciseSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
for (const row of rows) {
|
||||
const shape = exerciseDataByType[row.type]
|
||||
if (!shape) continue
|
||||
// 故意用同一个 contract():形状不符时它负责记日志并放行,不抛错
|
||||
contract(
|
||||
`GET /tutorials/:id/exercises(type=${row.type} 的 data)`,
|
||||
shape,
|
||||
row.data,
|
||||
)
|
||||
}
|
||||
// 类型上仍要收窄一次:契约推断出的 data 是宽松 record,组件要的是判别联合,
|
||||
// 两者不重叠,所以只能经过 unknown。**这个断言是有意的,不是假校验** ——
|
||||
// 上面那个循环已经在运行时按题型逐支验过;它只是把「运行时已确认」告诉 TS。
|
||||
return rows as unknown as Exercise[]
|
||||
export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -741,13 +523,8 @@ export function reportExerciseAttempt(
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function getLearnProgress(type: "python" | "c") {
|
||||
const endpoint = "learn/progress"
|
||||
return contract(
|
||||
"GET /learn/progress",
|
||||
tutorialProgressSchema.array(),
|
||||
await api.get<unknown>(endpoint, { params: { type } }),
|
||||
)
|
||||
export function getLearnProgress(type: "python" | "c") {
|
||||
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,116 +1,25 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
/**
|
||||
* 契约的运行时闸门。
|
||||
* 契约的运行时闸门。**只挂在三条路径上**:题目详情、提交详情、用户资料
|
||||
* (`shared/api.ts` 的 getProfile)—— 也就是原本就写了 `.parse()` 的那三处。
|
||||
*
|
||||
* ## 为什么要有这一层
|
||||
* ## 为什么只有三处
|
||||
*
|
||||
* `@oj2/contract` 的收益只有一半是类型:`z.infer` 给出编译期的形状,但**编译期
|
||||
* 管不了后端实际下发了什么**。改后端字段、drizzle 改名、序列化时漏一个键,
|
||||
* TypeScript 一概看不见,页面上表现为某个 `undefined` 静默渲染成空白。
|
||||
* 契约真正的价值在于同一份 schema 能在运行时把这种分歧当场抓出来。
|
||||
* 前后端同仓、同一次编译、共享同一份 schema,「后端改字段前端不知道」这种漂移
|
||||
* `tsc` 已经抓了,改了对不上当场编译不过。这里能多抓到的只有一种:**schema 与
|
||||
* 库里 JSONB 原文不符**,而那不是契约漂移,是 schema 写错了 —— 而且它的真相在
|
||||
* 写入侧,不是在这里。
|
||||
*
|
||||
* 原来只有三处调用 `.parse()`,而且**后面都紧跟一个 `as`** 把它重新断言回本地
|
||||
* 类型(`problemDetailSchema.parse(v) as Problem`)—— 校验结果被丢弃,等于没校验。
|
||||
* 曾经把它铺到 41 个端点上,收益是 41 次 safeParse 加一个没人读的 console.error;
|
||||
* 判题产物那次收紧还因此让 7.4% 的提交静默丢了测试点明细。所以退回三处。
|
||||
*
|
||||
* ## 失败策略:记日志 + 放行原始数据
|
||||
* ## 留着这三处的理由是「别抛错」,不是「校验」
|
||||
*
|
||||
* **不抛错。** 这是面向学生的生产站点,契约分歧的代价不该是白屏 —— 少了哪个
|
||||
* 字段,页面大体上照样能用,只是那处空着。所以解析失败时:
|
||||
*
|
||||
* 1. `console.error` 一条带端点和字段路径的记录,开发时一眼能看到;
|
||||
* 2. 记进 `window.__OJ2_CONTRACT_DRIFT__`(同一条只记一次),排查线上问题时
|
||||
* 可以直接在控制台敲这个变量看全部历史;
|
||||
* 3. **返回原始数据**,让页面继续渲染。
|
||||
*
|
||||
* 用 `safeParse` 而不是 `parse`:`parse` 抛出的 ZodError 会把调用方整个 async
|
||||
* 函数打断,`getProblem` 一失败,整个题目页就只剩白屏。
|
||||
*
|
||||
* ## 什么时候该升级成硬失败
|
||||
*
|
||||
* 等 `__OJ2_CONTRACT_DRIFT__` 在某条路径上稳定为空之后,那条路径就可以换成
|
||||
* 直接 `schema.parse()` —— 分歧修完了,剩下的任何分歧都是新引入的真 bug,
|
||||
* 那时白屏反而是对的。**在那之前不要硬失败**,机房上课时炸一个页面比字段空着严重得多。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 见过的分歧。只留前若干条实例,避免一个列表接口几百条记录把内存堆满 ——
|
||||
* 每条记录的形状问题是一样的,一条实例足够定位。
|
||||
*/
|
||||
interface DriftReport {
|
||||
/** 请求路径,带参数,方便直接复现 */
|
||||
endpoint: string
|
||||
/** zod 的 issue 摘要:路径 + 原因,多条用分号连 */
|
||||
detail: string
|
||||
/** 实际收到的数据。截断后的原始值,用来判断是字段缺失还是类型不同 */
|
||||
received: unknown
|
||||
/** 出现次数。同一个端点同一个 detail 只记一条,这里累加 */
|
||||
count: number
|
||||
}
|
||||
|
||||
const MAX_REPORTS = 200
|
||||
const MAX_RECEIVED_CHARS = 2000
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OJ2_CONTRACT_DRIFT__?: DriftReport[]
|
||||
}
|
||||
}
|
||||
|
||||
function collectDrift(endpoint: string, detail: string, received: unknown) {
|
||||
if (typeof window === "undefined") return
|
||||
const reports = (window.__OJ2_CONTRACT_DRIFT__ ??= [])
|
||||
|
||||
// 同一个端点 + 同一个原因只记一条,累加次数。列表接口一次几百条记录,
|
||||
// 不去重的话控制台会被同一句话刷屏,真正的新问题反而看不见。
|
||||
const existing = reports.find(
|
||||
(item) => item.endpoint === endpoint && item.detail === detail,
|
||||
)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
return
|
||||
}
|
||||
|
||||
if (reports.length >= MAX_REPORTS) return
|
||||
reports.push({
|
||||
endpoint,
|
||||
detail,
|
||||
received: truncate(received),
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/** 原始数据可能是一整个列表页,原样留着会占住大量内存;只用来判断形状,够看前 2KB 了 */
|
||||
function truncate(value: unknown) {
|
||||
try {
|
||||
const text = JSON.stringify(value)
|
||||
if (text === undefined) return value
|
||||
return text.length <= MAX_RECEIVED_CHARS
|
||||
? value
|
||||
: `${text.slice(0, MAX_RECEIVED_CHARS)}…(截断,共 ${text.length} 字符)`
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function describe(error: z.ZodError, endpoint: string) {
|
||||
const issues = error.issues.slice(0, 5).map((issue) => {
|
||||
const path = issue.path.length ? issue.path.join(".") : "(根)"
|
||||
return `${path}: ${issue.message}`
|
||||
})
|
||||
const more = error.issues.length > 5 ? `;另有 ${error.issues.length - 5} 处` : ""
|
||||
return `${endpoint} 的响应不符合契约 —— ${issues.join(";")}${more}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并返回响应。用 `unknown` 进来的数据出去就是契约类型,不需要再 `as`。
|
||||
*
|
||||
* ```ts
|
||||
* const data = await api.get<unknown>("problems", { params })
|
||||
* return contract("GET /problems", problemListSchema, data)
|
||||
* ```
|
||||
*
|
||||
* 端点字符串是手写的,刻意不让调用方漏掉 —— 它只用于日志和去重,写错不影响正确性。
|
||||
* 这三条原来是 `schema.parse(v) as T` —— `as` 把校验结果又断言回本地类型,等于
|
||||
* 没校验;而 `parse` 抛出的 ZodError 会打断整个 async 函数,一失败就是白屏。
|
||||
* 面向学生的生产站点,少一个字段页面照样能用,整页崩掉不行。所以这里:
|
||||
* 记一条带端点和字段路径的 `console.error`,然后**返回原始数据**。
|
||||
*/
|
||||
export function contract<T extends z.ZodType>(
|
||||
endpoint: string,
|
||||
@@ -120,13 +29,19 @@ export function contract<T extends z.ZodType>(
|
||||
const result = schema.safeParse(value)
|
||||
if (result.success) return result.data
|
||||
|
||||
collectDrift(endpoint, describe(result.error, endpoint), value)
|
||||
const issues = result.error.issues
|
||||
.slice(0, 5)
|
||||
.map((issue) => `${issue.path.join(".") || "(根)"}: ${issue.message}`)
|
||||
.join(";")
|
||||
const more =
|
||||
result.error.issues.length > 5
|
||||
? `;另有 ${result.error.issues.length - 5} 处`
|
||||
: ""
|
||||
console.error(
|
||||
`[契约] ${describe(result.error, endpoint)}\n` +
|
||||
" 已放行原始数据(页面照常渲染)。全部历史分歧见 window.__OJ2_CONTRACT_DRIFT__。\n" +
|
||||
" 契约在 packages/contract/src/,后端对不上的字段在 apps/api/src/routes/。",
|
||||
`[契约] ${endpoint} 的响应不符合契约 —— ${issues}${more}\n` +
|
||||
" 已放行原始数据(页面照常渲染)。契约在 packages/contract/src/。",
|
||||
)
|
||||
// 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是
|
||||
// 「能渲染的东西」而不是一个异常;分歧已经通过上面两条记录暴露出来了。
|
||||
// 「能渲染的东西」而不是一个异常,分歧已经通过上面那条日志暴露出来了。
|
||||
return value as z.infer<T>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -118,30 +118,16 @@ export const exerciseAttemptRequestSchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 练一练的内容,按题型分派。
|
||||
* 题型 → 内容形状。**只在写入侧校验**(`apps/api/src/services/exercise.ts` 的
|
||||
* `exerciseDataError`),读出侧不校验。
|
||||
*
|
||||
* 形状按**生产库 151 道练习题实测**得出,七种题型的键集逐个吻合、没有越界数据:
|
||||
* mcq 46 / fill 37 / sort 25 / predict 24 / debug 11 / match 6 / group 2。
|
||||
* 这张表曾经挂在 `exerciseSchema` 的 superRefine 上,于是学生端那条
|
||||
* `GET /tutorials/:id/exercises`(`routes/content.ts` 里硬 parse)变成了一道
|
||||
* 读闸:一行 data 对不上,整条练习列表 500 —— 坏的不是那一道,是整页。而写入侧
|
||||
* 当时并不检查 `question`,两边严紧度不一致,脏数据进得来、出不去。
|
||||
*
|
||||
* 为什么值得从 `Record<string, unknown>` 收紧:这七种题型各自被一个组件渲染,
|
||||
* 它们直接读 `data.question` / `data.options` / `data.lines` —— 结构对不上时
|
||||
* **渲染期才炸**,而炸的是整道题的组件。收紧之后这条路径由契约在响应边界上拦住。
|
||||
*
|
||||
* 注意 `exerciseSchema` **后端也在 parse**(`routes/content.ts` 的
|
||||
* `rows.map(... exerciseSchema.parse ...)`),所以这里的收紧同时是一道服务端闸门:
|
||||
* 数据对不上时整条练习列表 500,而不是渲染到一半崩。已用生产全量数据核验过
|
||||
* 151/151 通过,才敢这么收。
|
||||
*
|
||||
* `data` 里**没有**判别键 —— 题型的真相在**外层**的 `type` 上,所以只能在
|
||||
* superRefine 里拿 `type` 去挑对应的形状,不能用 discriminatedUnion
|
||||
* (那要求判别键存在于被判别对象内部)。
|
||||
*/
|
||||
/**
|
||||
* 题型 → 内容形状。导出是为了让**前端闸门**能逐题型校验 `data` ——
|
||||
* `z.infer` 只能把 `data` 还原成 `Record<string, unknown>`(superRefine
|
||||
* 无法把校验结果反映到推断出的类型上),所以前端那个更窄的判别联合
|
||||
* (`utils/types` 的 Exercise)在类型上仍然要自己收窄一次,但**运行时**
|
||||
* 走的就是这张表。
|
||||
* JSONB 的形状真相在写入侧,就在写入侧卡住:那里能给老师一句中文原因,
|
||||
* 也不会让历史数据把学生端打不开。
|
||||
*/
|
||||
export const exerciseDataByType: Record<string, z.ZodType> = {
|
||||
mcq: z.object({ question: z.string(), options: z.array(z.string()), answer: z.array(z.number()) }),
|
||||
@@ -154,36 +140,16 @@ export const exerciseDataByType: Record<string, z.ZodType> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 外层 `type` 与 `data` 的内容对不上时也算不通过,这正是要拦的情况:
|
||||
* `type: "mcq"` 配一份 `{question, code}` 会在渲染 mcq 组件时炸在 `data.options` 上。
|
||||
* 练一练。`data` 是 `Record<string, unknown>`,**读出侧刻意不按题型收紧** ——
|
||||
* 这个 schema 后端也在 parse(`routes/content.ts`),收紧它等于给学生端加一道
|
||||
* 会 500 的闸。七种题型各自的形状见上面的 `exerciseDataByType`,在写入时卡。
|
||||
*/
|
||||
export const exerciseSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
order: z.number().int(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
// type 是枚举,映射表覆盖了全部七个值;这里只是让 TS 收窄,顺带在
|
||||
// 将来加了新题型却忘了补形状时,以一条清楚的 issue 而不是 undefined 崩掉。
|
||||
const shape = exerciseDataByType[value.type]
|
||||
if (!shape) {
|
||||
ctx.addIssue({ code: "custom", path: ["type"], message: `题型 ${value.type} 没有对应的内容形状` })
|
||||
return
|
||||
}
|
||||
const parsed = shape.safeParse(value.data)
|
||||
if (!parsed.success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["data"],
|
||||
message: `type=${value.type} 的 data 形状不符:${parsed.error.issues
|
||||
.slice(0, 3)
|
||||
.map((issue) => `${issue.path.join(".") || "(根)"} ${issue.message}`)
|
||||
.join(";")}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
export const exerciseSchema = z.object({
|
||||
id: z.number().int(),
|
||||
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
order: z.number().int(),
|
||||
})
|
||||
|
||||
export type Message = z.infer<typeof messageSchema>
|
||||
export type MessageList = z.infer<typeof messageListSchema>
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -19,56 +19,51 @@ export const judgeStatusSchema = z.union([
|
||||
])
|
||||
|
||||
/**
|
||||
* 判题机原始输出(`submission.info` 的 JSONB 原文)。
|
||||
* 判题机原始输出(`submission.info` 的 JSONB 原文)。**只是类型,不作运行时校验。**
|
||||
*
|
||||
* 形状按**生产库 124191 条提交实测**得出,不是照着前端那份额外手抄的:
|
||||
* 这里曾经是一组 zod schema,按生产库实测的键集收紧过,结果是 124192 条提交里有
|
||||
* 9163 条(RE 8480/8480、TLE 338/338、MLE 1/1 全中)被判成不符:沙箱在非正常退出
|
||||
* 的测试点上写 `output_md5: null`,而 SQL 判题(`judge/sql/engine.ts` 的 CaseResult)
|
||||
* 压根没有 `output` 这个键、`error_message` 通过时是 null。收紧当时只对了键集合,
|
||||
* 没对空值。
|
||||
*
|
||||
* - `err` 实测 124191 条**全是 null**,从来没见过字符串 —— 但契约仍留 `string`,
|
||||
* 因为判题机层面它是有意义的通道,收紧成 `z.null()` 会在它第一次真的报错时炸。
|
||||
* - `data` 有 **12048 条是 null**(编译失败等没有逐测试点结果的情形),
|
||||
* 所以它必须 nullable。前端原来手抄的 `Info` 把 data 写成了非空数组,
|
||||
* 这 12048 条在类型上根本不成立,只是没有一处会去读它才没炸。
|
||||
* - 数组项比前端手抄的多三处:SQL 判题多带 `error_message`(201 个测试点)、
|
||||
* 部分带 `score`(10 个)。所以这里的字段一律可选,不用 strictObject。
|
||||
* 更糟的是失败方式:`info` 当时是 `union([完整形状, z.object({})])`,对不上的一律
|
||||
* 落进第二支被剥成 `{}` 且 parse 成功 —— 管理员的测试点表格**静默消失**。
|
||||
*
|
||||
* 结论:JSONB 的形状真相在**写入侧**(判题机、`judge/run.ts`),在读出侧再校验一遍
|
||||
* 只会在两边分叉时丢数据。所以 `info` 回到 `z.unknown()`,形状以下面的 TS 类型
|
||||
* 描述,取值处由 `submissionCaseResults()` 做一次真正需要的运行时判断(有没有
|
||||
* data 数组)。**改这里的字段时对着判题机改,不要对着采样出来的键集改。**
|
||||
*
|
||||
* 键名是**判题沙箱定的 snake_case**,不要跟着响应字段一起改。
|
||||
*/
|
||||
export const judgeCaseResultSchema = z.object({
|
||||
error: z.number(),
|
||||
memory: z.number(),
|
||||
output: z.string().nullable(),
|
||||
result: judgeStatusSchema,
|
||||
signal: z.number(),
|
||||
cpu_time: z.number(),
|
||||
exit_code: z.number(),
|
||||
real_time: z.number(),
|
||||
test_case: z.string(),
|
||||
output_md5: z.string(),
|
||||
/** SQL 判题会带上中文原因,沙箱判题没有这个键 */
|
||||
error_message: z.string().optional(),
|
||||
score: z.number().optional(),
|
||||
})
|
||||
|
||||
export const judgeInfoSchema = z.object({
|
||||
err: z.string().nullable(),
|
||||
data: z.array(judgeCaseResultSchema).nullable(),
|
||||
})
|
||||
export interface JudgeCaseResult {
|
||||
error: number
|
||||
memory: number
|
||||
/** SQL 判题没有这个键 */
|
||||
output?: string | null
|
||||
result: JudgeStatus
|
||||
signal: number
|
||||
cpu_time: number
|
||||
exit_code: number
|
||||
real_time: number
|
||||
test_case: string
|
||||
/** 非正常退出的测试点上是 null */
|
||||
output_md5: string | null
|
||||
/** SQL 判题会带上中文原因(通过的测试点是 null),沙箱判题没有这个键 */
|
||||
error_message?: string | null
|
||||
score?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* `info` 允许的两种取值,**不能只写成完整形状**:
|
||||
*
|
||||
* 1. 完整形状:判题机写的 JSONB 原文;
|
||||
* 2. **空对象**:后端对非管理员用 `info: {}` 下发的占位(`routes/submission.ts:841`
|
||||
* 的 `full ? row.submission.info : {}`),同一个空对象也是插入待判提交时的初值。
|
||||
*
|
||||
* 第 2 种是真实存在的合法取值,收紧成只认完整形状会让**每一条非管理员看的提交详情
|
||||
* 直接 500**(`submissionDetailSchema.parse` 在路由里抛,被 onError 兜成 internal-error)。
|
||||
* 这不是假想:收紧当天就在本地实测复现了。
|
||||
*
|
||||
* 换句话说,空对象表达的是「这条响应对你不含 info」,一个**权限投影**,
|
||||
* 而不是「字段缺失」—— 契约要如实描述它。
|
||||
* `info` 的完整形状。实际取值还有第三种:**空对象** —— 后端对非管理员下发
|
||||
* `info: {}`(`routes/submission.ts` 的 `full ? row.submission.info : {}`),
|
||||
* 也是插入待判提交时的初值。所以调用方不能直接 `.data`。
|
||||
*/
|
||||
export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
|
||||
export interface JudgeInfo {
|
||||
err: string | null
|
||||
data: JudgeCaseResult[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
|
||||
@@ -76,11 +71,11 @@ export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
|
||||
* 五个键全部可选,依据是生产库实测的出现次数:time_cost / memory_cost 各 112097、
|
||||
* score 3993、err_info 3153、ast_results 56,另有 27 条空对象。
|
||||
*
|
||||
* **不能用严格对象。** 有 8916 条历史记录里的 JSONB 原文内嵌了带转义的 shell
|
||||
* 输出、本身不是合法 JSON,后端 `objectValue()` 会把它兜成 `{ value: "<原串>" }`
|
||||
* 再下发 —— 严格 schema 会把这 8916 条判成契约分歧,而它们其实是正常的失败记录。
|
||||
* 用 `looseObject`:所有键可选 + 不剥未知键 = **对任何对象都不会失败、也不丢字段**,
|
||||
* 它在这里的作用是给前端一个能读 `err_info` 的类型,而不是一道闸门。判题产物的
|
||||
* 闸门在写入侧,理由见上面 `JudgeCaseResult`。
|
||||
*/
|
||||
export const statisticInfoSchema = z.object({
|
||||
export const statisticInfoSchema = z.looseObject({
|
||||
score: z.number().optional(),
|
||||
/** 判题机写进 statistic_info 的错误文本,教师面板的「最近一条错在哪」也读它 */
|
||||
err_info: z.string().optional(),
|
||||
@@ -129,8 +124,8 @@ export const submissionDetailSchema = z.object({
|
||||
username: z.string(),
|
||||
code: z.string(),
|
||||
result: judgeStatusSchema,
|
||||
/** 未判完或非管理员看时为 `{}`,见 submissionInfoSchema 的注释 */
|
||||
info: submissionInfoSchema,
|
||||
/** 判题机原文;未判完或非管理员看时为 `{}`,见 JudgeInfo 的注释 */
|
||||
info: z.unknown(),
|
||||
language: problemLanguageSchema,
|
||||
statisticInfo: statisticInfoSchema,
|
||||
contestId: z.number().int().nullable(),
|
||||
@@ -314,8 +309,6 @@ export const formatCodeRequestSchema = z.object({
|
||||
export const formatCodeResponseSchema = z.object({ code: z.string() })
|
||||
|
||||
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
|
||||
export type JudgeInfo = z.infer<typeof judgeInfoSchema>
|
||||
export type JudgeCaseResult = z.infer<typeof judgeCaseResultSchema>
|
||||
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
|
||||
export type CreateSubmissionRequest = z.infer<
|
||||
typeof createSubmissionRequestSchema
|
||||
|
||||
Reference in New Issue
Block a user