Files
OJ2/packages/contract/src/submission.ts
yuetsh ab47e71d6f
Some checks failed
Deploy / deploy (push) Has been cancelled
refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
## 出参改 satisfies

出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:

- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
  的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
  z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
  submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
  problem;列表为空时才碰巧不炸,所以一直没人报。

两个都是读出侧校验自己造出来的故障,不是它拦住的故障。

## 校验责任挪回写入侧

- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
  .language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
  .sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
  tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
  problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
  不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
  problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
  difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
  会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
  在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
  报文是真边界,失败返回 null 而不是 500。

顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。

## 文档

CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。

## 验证

- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
  数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
  前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
  建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
  (error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
  后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
  problemDisplayId 收到 undefined,改动后同样两个响应均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 18:14:05 -06:00

338 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod"
import { paginatedSchema } from "./common"
import { problemLanguageSchema } from "./language"
export const judgeStatusSchema = z.union([
z.literal(-2),
z.literal(-1),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
z.literal(8),
z.literal(10),
])
/**
* 判题机原始输出(`submission.info` 的 JSONB 原文)。**只是类型,不作运行时校验。**
*
* 这里曾经是一组 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。收紧当时只对了键集合
* 没对空值。
*
* 更糟的是失败方式:`info` 当时是 `union([完整形状, z.object({})])`,对不上的一律
* 落进第二支被剥成 `{}` 且 parse 成功 —— 管理员的测试点表格**静默消失**。
*
* 结论JSONB 的形状真相在**写入侧**(判题机、`judge/run.ts`),在读出侧再校验一遍
* 只会在两边分叉时丢数据。所以 `info` 回到 `z.unknown()`,形状以下面的 TS 类型
* 描述,取值处由 `submissionCaseResults()` 做一次真正需要的运行时判断(有没有
* data 数组)。**改这里的字段时对着判题机改,不要对着采样出来的键集改。**
*
* 键名是**判题沙箱定的 snake_case**,不要跟着响应字段一起改。
*/
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` 的完整形状。实际取值还有第三种:**空对象** —— 后端对非管理员下发
* `info: {}``routes/submission.ts` 的 `full ? row.submission.info : {}`
* 也是插入待判提交时的初值。所以调用方不能直接 `.data`。
*/
export interface JudgeInfo {
err: string | null
data: JudgeCaseResult[] | null
}
/**
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
*
* 五个键全部可选依据是生产库实测的出现次数time_cost / memory_cost 各 112097、
* score 3993、err_info 3153、ast_results 56另有 27 条空对象。
*
* 用 `looseObject`:所有键可选 + 不剥未知键 = **对任何对象都不会失败、也不丢字段**
* 它在这里的作用是给前端一个能读 `err_info` 的类型,而不是一道闸门。判题产物的
* 闸门在写入侧,理由见上面 `JudgeCaseResult`。
*/
export const statisticInfoSchema = z.looseObject({
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({
problemId: z.number().int().positive(),
/**
* 提交的语言。用题目语言的联合而不是 `z.string()` —— 学生能选的语言就是题目
* `languages` 里列出的那些,写宽松了的话,前端把语言拼错(`"C"`、`"python3"`
* 大小写)会一路走到判题机才以 `Unsupported judge language` 报系统错误,
* 学生看到的是「系统错误」而不是「语言不对」。
*/
language: problemLanguageSchema,
code: z.string().min(1).max(1024 * 1024),
contestId: z.number().int().positive().optional(),
/**
* 来源题单。学生从 `/problemset/:id/problem/:pid` 那个入口提交时前端带上,
* 后端落进 `submission.problemset_id`,提交列表据此标出「这条是刷题单刷出来的」。
*
* 只是**来源标记**,不参与判题、也不参与题单进度记账 —— 进度由判完之后的
* `recordSolvedProblem` 记进所有已加入且含这道题的题单,和从哪个入口进来无关。
* 所以这里带错了顶多是标记不准,不会影响成绩。
*/
problemSetId: z.number().int().positive().optional(),
})
export const createSubmissionResponseSchema = z.object({
submissionId: z.string(),
})
export const submissionDetailSchema = z.object({
id: z.string(),
createTime: z.string(),
userId: z.number().int(),
username: z.string(),
code: z.string(),
result: judgeStatusSchema,
/** 判题机原文;未判完或非管理员看时为 `{}`,见 JudgeInfo 的注释 */
info: z.unknown(),
language: problemLanguageSchema,
statisticInfo: statisticInfoSchema,
contestId: z.number().int().nullable(),
problemId: z.number().int(),
/**
* 题目的展示编号problem._id。**独立的 /submission/:id 页面要靠它** ——
* 那条路由只喂 submissionID组件拿不到 display id而「复制回到题目」要用它
* 拼路由。原来只给内部数字 id于是那个按钮在这条路由上一点就抛
* `Missing required param "problemID"`。
*/
problemDisplayId: z.string(),
showLink: z.boolean(),
})
/**
* 内嵌在别处(目前只有站内信)的提交对象。对齐旧后端的
* `SubmissionSafeModelSerializer(exclude=("info", "contest", "ip"))` ——
* 这些键**根本不出现**,而不是出现但值为空。(`ip` 已随 IP 功能整体删除。)
*
* 独立成一个 schema 而不是复用 submissionDetailSchema 传空值:形状一致了,
* 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这些字段。
*/
export const embeddedSubmissionSchema = submissionDetailSchema
// problemDisplayId 也要去掉:下面的 problem 就是它,同一个值留两份,
// 而路由只填了 problem —— 这里漏 omit 的那阵子,凡是收到过站内信的人
// 打开消息页都是 500parse 抛在缺失的 problemDisplayId 上,列表为空时才碰巧不炸)。
.omit({ info: true, contestId: true, problemId: true, problemDisplayId: true })
// 旧 SubmissionSafeModelSerializer 里 problem 是
// `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。
// 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。
.extend({ problem: z.string() })
/**
* 判题进度推送。**只带前端真正要用的东西**:靠 submissionId 认领、靠 result /
* status 决定是继续等还是去拉详情。
*
* 这里曾经还带着 time_cost / memory_cost / err_info —— 从 statistic_info 原样
* 抄一份出来,前端一处都没读过。耗时和错误信息在提交详情里本来就有,判完了去
* 拉一次就是了,不必让推送顺带背一份 JSONB 的形状。
*/
export const submissionUpdateSchema = z.object({
type: z.literal("submission_update"),
submissionId: z.string(),
result: judgeStatusSchema,
status: z.enum(["pending", "judging", "finished", "error"]),
score: z.number().optional(),
})
export const submissionListItemSchema = z.object({
id: z.string(),
problem: z.string(),
problemTitle: z.string(),
showLink: z.boolean(),
createTime: z.string(),
userId: z.number().int(),
username: z.string(),
result: judgeStatusSchema,
language: problemLanguageSchema,
statisticInfo: statisticInfoSchema,
/**
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null比赛题不会进题单
* 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission
* 回填的就是这些,其余老提交无从判断入口,一律留空。
*/
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
})
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
/**
* **一条都没交**的学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
* 不是 user.real_name 列 —— 与 F2「真名默认不下发」不冲突这里只有教师能看到
* 且教师面板的用途正是点名谁没做。
*
* 注意它不是「未完成」的全部:交了但一次没对的学生在 `dataAttempted` 里。
*/
export const unacceptedStudentSchema = z.object({
username: z.string(),
realName: z.string(),
})
/**
* **交了但一次没对**的学生。这批人原来两栏都不在 —— 不在「完成人数」(没 AC
* 也不在「未完成」名单(那一栏只收一条没交的),于是课堂上最该去看一眼的人
* 反而从屏幕上消失了。`submissionCount` 是窗口内的提交次数,教师据此判断
* 「卡了多久」。
*/
export const attemptedStudentSchema = unacceptedStudentSchema.extend({
submissionCount: z.number().int(),
/**
* 已经解决的题数。查多道题时这一栏里混着「一道没对」和「三道做出两道」两种人,
* 差几道决定了老师先管谁 —— 所以名字后面要缀 `2/3`。
*/
solvedCount: z.number().int(),
/**
* 最近一条提交错在哪。教师点名字就能看到「是编译错了还是答案错了」,
* 不必再切去提交列表翻这个人。`error` 是判题机写进 statistic_info 的 err_info
* 已截断;没有错误文本(比如答案错误那种)时为 null。
*/
lastFailure: z
.object({
id: z.string(),
/** 题目的展示编号,用来告诉老师错在哪道题 */
problem: z.string(),
result: judgeStatusSchema,
error: z.string().nullable(),
})
.nullable(),
})
export const submissionStatisticsUserSchema = z.object({
username: z.string(),
className: z.string().nullable(),
submissionCount: z.number().int(),
/** 通过的**提交条数**。correctRate 的分子就是它 */
acceptedCount: z.number().int(),
/**
* 解决的**题数**(同一道题重复 AC 只算一道)。表格「已解决」那一列显示的是它 ——
* 不指定题号查「这节课全班」时,条数和题数能差出好几倍。
*/
solvedCount: z.number().int(),
/**
* 「答案对了但语法没按要求写」且**最后也没改对**的题数。这些题算在 solvedCount 里
* AST_CHECK_FAILED 全站都算通过),单列出来只是让教师看得见教学上没达标的那几个。
*/
astOnlyCount: z.number().int(),
/** 这个人还在判题队列里的条数。`submissionCount` 含它,`correctRate` 的分母不含 */
judgingCount: z.number().int(),
// 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。
correctRate: z.number(),
/**
* 这个人在本次查询的口径下做完了没有(查了 N 道题就要 N 道都解决)。
*
* `data` 里**没做完的人也在**,教师才能在同一张表里展开看他错在哪;「完成人数」
* 和完成度算的是 `done` 为真的那些,不是 `data.length`。
*/
done: z.boolean(),
})
/**
* 展开行的明细,**按需拉**GET /submissions/statistics/items
*
* 原来是随统计一起给每个人各带一份,可表格一次只展开一行 —— 生产快照上那是
* 4.9 万行没人看的数据。`truncated` 为真时前端要说明「只显示最近 N 条」,
* 免得老师以为这人就交了这么多。
*/
export const submissionStatisticsItemsSchema = z.object({
items: z.array(z.object({ id: z.string(), result: judgeStatusSchema })),
truncated: z.boolean(),
})
export const submissionStatisticsSchema = z.object({
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
/**
* 还没判完的条数PENDING / JUDGING。`submissionCount` 把它算在内,
* `correctRate` 的分母不算 —— 全班同时交卷的那几秒,分母涨了分子没涨,
* 正确率会凭空掉一截。下发它是为了让教师看得出「那几条还在判」。
*/
judgingCount: z.number().int(),
correctRate: z.number(),
// 花名册人数(未禁用的普通用户)。**只有这一个分母下发**:完成度由前端算,
// 因为「请假隐藏」会把请假的人从分母里减掉,那是后端不知道的浏览器本地状态。
personCount: z.number().int(),
/** 窗口里交过东西的所有人(做没做完看 `done`),按提交数倒序 */
data: z.array(submissionStatisticsUserSchema),
/** 一条都没交的(花名册里的人减去有提交的人) */
dataUnaccepted: z.array(unacceptedStudentSchema),
/**
* 交了但没做完的(一道没对,或者查三道只做出两道)。
*
* 传了用户名时按花名册取,和 dataUnaccepted 同一个范围;不传用户名时没有花名册,
* 退回「窗口内有提交但没做完的全部普通学生」—— 否则这批人两栏都不在,看起来
* 就像统计只认成功的提交。dataUnaccepted 没有花名册就真的算不出来,仍然为空。
*/
dataAttempted: z.array(attemptedStudentSchema),
})
export const formatCodeRequestSchema = z.object({
code: z.string().max(1024 * 1024),
language: z.enum(["python", "c", "cpp", "sql"]),
})
export const formatCodeResponseSchema = z.object({ code: z.string() })
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema
>
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
export type SubmissionStatisticsUser = z.infer<
typeof submissionStatisticsUserSchema
>
export type SubmissionStatisticsItems = z.infer<
typeof submissionStatisticsItemsSchema
>
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>
export type AttemptedStudent = z.infer<typeof attemptedStudentSchema>
export type SubmissionListItem = z.infer<typeof submissionListItemSchema>
export type SubmissionList = z.infer<typeof submissionListSchema>
export type EmbeddedSubmission = z.infer<typeof embeddedSubmissionSchema>
export type CreateSubmissionResponse = z.infer<typeof createSubmissionResponseSchema>
export type FormatCodeResponse = z.infer<typeof formatCodeResponseSchema>
export type FormatCodeRequest = z.infer<typeof formatCodeRequestSchema>