Files
OJ2/docs/contract.md
T
xuyueandClaude Opus 5.5 b046af4ba0
Deploy / deploy (push) Canceled after 0s
chore(部署): push 后先过检查再部署;判题机重启那几秒交的题不再变成系统错误;worker 挂了看得出来
按外部分析报告(reports/analysis)挑出来做的几条:
- CI 在编完前端之后跑 `bunx vp run verify`,有一项不过就不部署(必须排在编前端之后:
  前端类型检查要的 auto-imports.d.ts / components.d.ts 是 vite build 时才生成的)
- 连不上判题机(连接被拒、解析不到、被重置)时,正式判题等 2/5/10 秒重试,不再当场记成
  系统错误;超时、判题机回错、页面上的运行和自测不重试
- worker 每 10 秒刷一次「还活着」,compose 加 `oj2-api healthcheck --worker`,deploy.sh 起栈后
  也等 worker 健康(docker 不会因 unhealthy 自动重启,只是看得见、部署时拦得住)
- 三套 compose 的容器日志限 10MB × 3
- `/public/%` 这类畸形编码回 404 而不是 500;找不到的接口回统一的 JSON 错误
- 删掉没人用的「停用判题机」接口,契约不再给 task_number / is_disabled(列留在库里)
- 删掉腐化的 apps/web/tests/submitButtonState.test.ts;契约包 zod 范围对齐到 ^4.6.6
- 修几处和实际对不上的注释与文档(表数、编译并发、AST 语言数、deploy.sh 流程、校验处数)

⚠️ 改了 compose 的日志配置,这次部署会把 postgres / redis / 判题机也重建一遍,各停几秒

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:59:19 +08:00

48 lines
3.1 KiB
Markdown
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.
# 契约:闸门设在写入侧的来龙去脉
规矩在 `apps/api/CLAUDE.md`「出参不 `parse`,用 `satisfies`」一节,前端那侧
(`utils/contract.ts` 为什么只挂三处)在 `apps/web/CLAUDE.md`。这里是证据。
## 读出侧校验自己造出来的四次故障
后端出参原来有 136 处 `xxxSchema.parse({...})`,全部撤成 `satisfies`。撤的时候当场炸出
两个一直存在的线上 500:
- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time`
是全库唯一可空的列(961 道题里 470 道是 NULL)——**后台打开任何一道没编辑过的老题都是 500**;
- `embeddedSubmissionSchema` 从 `submissionDetailSchema` 继承了 `problemDisplayId` 却没
omit,而路由只填了同义的 `problem` ——**凡是收到过站内信的人,消息页都打不开**
(列表为空时才碰巧不炸,所以一直没人报)。
历史上还有两次同类:
- `exerciseSchema` 按题型收紧后,一行脏数据让整条练习列表 500;
- `info` 写成 `union([完整形状, z.object({})])` 后,对不上的一律落进空对象那支且
parse **成功**,管理员详情页的测试点表格静默消失。全量核出 9163/124192 条中招,
RE 8480/8480 全中 —— 沙箱在非正常退出的测试点上写 `output_md5: null`,
而契约写的是 `z.string()`。
四次都是「读出侧校验」自己造出来的故障,不是它拦住的故障。出参是后端自己刚拼出来的
字面量,TS 已经在编译期校验过;再 parse 一遍拿不到任何新信息,唯一可能失败的输入是
**库里的历史数据**,而失败的代价是 500。
## 闸门的三处形态
1. **入参 `safeParse`**(请求体一律走 `http.ts` 的 `parseBody`,全部保留)—— 请求体进来的那一刻校验,对不上回 400。
2. **`db/schema.ts` 的 `.$type<>()`** —— 枚举型的列(`submission.result` / `.language`、
`problem.difficulty` / `.languages`、`achievement.rarity`、`exercise.type`…)和几个形状
确定的 JSONB(`problem.template` / `.astRules` / `.sqlConfig` / `.sqlDisplay`、
`acm_contest_rank.submission_info`)直接在列上收窄,只影响 TS、不产生任何 SQL。
这些断言**逐列拿生产备份核过**(12.4 万条提交的 `result` 全在 `-2..6,10`、961 道题的
`languages` 全是合法数组、10050 条榜单条目形状全对)。**加这类断言前先照样核一遍,别凭直觉。**
3. **语义校验函数** —— `astRulesError()`、`services/exercise.ts` 的 `exerciseDataError`。
**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)仍然一律放行**,
读出侧不收窄:它们的形状真相在判题机那边。
query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts` 的 `asFilterValue()` ——
那是纯类型交接,**不加校验**:在那儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」。
唯一还留着 `parse` 的地方是 `judge/events.ts` 的 `parseSubmissionEvent` —— 那是从 Redis
收回来的报文,真边界,且失败返回 `null` 而不是 500。