docs: 文档整理,CLAUDE.md 瘦身一半,删掉重写期已完成的 22 份阶段产物

CLAUDE.md 从 490 行降到 228 行:只留日常要当场记住的约束,展开拆成五份专题
文档 —— docs/deploy.md(部署与备份恢复)、database.md(迁移执行器、基线、
drizzle-kit 的坑)、timezone.md(时区口径与那次成就订正)、contract.md
(出参不 parse 的四次故障)、ast-rules.md(AST 规则与 C++ 的调用形态)。

删掉的是阶段 0–5 那批一次性产物:4 份实施计划、10 份评审/核验/修复报告、
endpoint-inventory.md(110 端点是 2026-08 的快照,现在 363 条路由)、
docs/spikes/ 的 spike 与提取脚本(结论早已落进代码)。phase5 切换手册删之前
先把仍然有效的部分提炼进 docs/deploy.md:拓扑、deploy.sh、部署后验证清单、
NPM 那两个不能关的开关、pg_dumpall 恢复的两个坑、镜像体积;演练报告与回滚
两节随旧栈下线一并作废。

两份设计文档保留,补上状态行说明它们是「当初为什么这么定」而不是现状。

apps/web/CLAUDE.md 顺手订正过期内容:PUBLIC_OJ_URL / PUBLIC_WS_URL 两个变量
早已不存在(baseURL 写死 /api,dev 走 vite proxy、线上由 Caddy 同源伺服),
store 与 composable 清单补齐到与目录一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 08:03:26 -06:00
parent 3559ae4d6f
commit a8408c0bb5
34 changed files with 593 additions and 7570 deletions

47
docs/contract.md Normal file
View File

@@ -0,0 +1,47 @@
# 契约:闸门设在写入侧的来龙去脉
规矩在 `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`**58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 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。