Files
OJ2/docs/specs/phase3-coverage.md
yuetsh fb22b7d49f docs(阶段5): 用生产快照跑完整切换演练
演练用 compose.debian.yml **本身**在本机 Docker 里跑,不是简化版。
数据是 2026-08-07 的 pg_dumpall 快照:1710 用户 / 956 题 / 123140 提交。

## 出口标准达成

停旧栈 11s,起新栈 34s(镜像预先构建好),全链路验证约 2 分钟 ——
**停机不到 1 分钟**,远在 30 分钟内。真正的时间风险在构建镜像(首次约 5 分钟),
所以手册里第一条就是「镜像必须在停机窗口之前构建好」。

验证到位的:首页、站点配置、题目列表、标签、公告、登录(argon2 新哈希和
Django pbkdf2 旧哈希都支持)、个人页、排行榜、后台四个接口、判题机自动注册,
以及**完整判题**(提交 Python A+B → AC,1.2 秒,两个测试点全过)。

## 两件原以为要做、实测不用做的事

- **不需要任何 DDL**:生产 dump 和新后端在用的库逐列对比,两边都是 278 列,
  零差异。新后端直接跑在现有结构上。
- **不需要重置序列**:我在 phase3-coverage.md 里记的那条「切换必做:重置序列」
  **是错的**,来自我手工按显式 id 导入、又没补 setval 的本地库。真实的
  pg_dumpall 带 30 条 setval,且把快照里所有序列和 max(id) 逐个对过,错位 0 个。
  已在原文档上标注更正,没有删掉原文 —— 错误结论本身也是信息。

## 回滚保证已实测

新栈跑完登录、提交、判题之后,再和生产 dump 比一次结构:逐列一致,零差异。
加上数据目录布局照抄旧后端,回滚 = 停新栈 + 起旧栈,约 20 秒,不动任何数据。
(未实测的部分也写明了:本机没构建旧 Django 镜像,「起旧栈」这一步没跑过。)

## 演练抓到的真问题

**pg_dumpall 备份会覆盖数据库口令。** 恢复完快照,新后端立刻报
`password authentication failed` —— 因为 dump 里带
`ALTER ROLE onlinejudge ... PASSWORD 'md5…'`,把角色口令覆盖成了备份时生产的那个。
正常切换不受影响(根本不恢复备份),但灾难恢复时这一条不写下来,
现场会被一个看起来毫不相干的报错卡住。

**恢复备份前必须先停应用**,否则 dump 里的 DROP DATABASE 失败。演练时因为
目标库是空的,数据照样进去了 —— 那是运气,目标库有数据就是满屏主键冲突。

## 镜像体积没达标,写明了原因

api 镜像 487MB,设计文档写的是「数十 MB」。一半以上(269MB)是 clang-format
拖进来的 LLVM,光 libLLVM.so 就 124MB。旧 Python 镜像同样装了 clang-format,
所以新镜像仍明显更小,但当初估「数十 MB」时没把它算进去。
瘦身路径也记了(换静态 clang-format 可砍 265MB),暂不做。

## 清理

演练在 data/postgres 留下了一份完整的生产数据副本,含 1710 名学生的
raw_password 明文列,已删除。手册里留了提醒 —— 那不是测试数据。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:13:59 -06:00

227 lines
12 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.
# 阶段 3 覆盖率对账
日期2026-08-07首次对账2026-08-07 补记(阶段 3 收口)
基准:`docs/specs/endpoint-inventory.md` 的 110 条 KEEP 端点
对象:`apps/api/src/routes/*.ts` 的路由 handler
> ## 状态:阶段 3 出口标准已达成
>
> 出口标准(设计文档第 11 节)是「用户侧全部功能运行在新后端上」。达成判据:
> `apps/web` 里 `oj/` 与 `shared/` 两个目录**已无任何指向旧 Django 的运行时调用**
> 残留的 `utils/http` 引用全是 `import type { ApiResponse }` 这一个类型。
> 仍走旧后端的只剩 `admin/api.ts`85 处)与 `utils/download.ts`,入口都在后台管理界面,属阶段 4。
>
> 收口时补做的三件事:
> 1. 补齐 3 条被用户侧页面调用的 admin 端点(重判 / 提交统计 / 流程图统计),见下表;
> 2. 删除阶段 1 的临时验证物(`GET dev/problems`、`dev-problems.vue`、路由项、`problemSummarySchema`
> 3. 给 `utils/api2.ts` 补上 `login-required` 弹登录框、`permission-denied` 弹提示 ——
> 这两条 `utils/http.ts` 一直有api2 从建包起就漏了,导致此前已迁移的所有端点
> 在鉴权失败时都是「点了没反应」。新加的两个教师专属端点会放大这个问题,故一并补。
>
> 补记:两份评审里的 7 条 Minor 也已全部处理,逐条结论见 `phase3-fix-list.md` 文末。
## 结论
| | 旧端点 KEEP | 新后端已实现 | 缺口 |
|---|---|---|---|
| **oj 侧** | 65 | 65另有 4 条新增) | **0** |
| **admin 侧** | 45 | **3** | **42** |
| 合计 | 110 | 68 | 42 |
**oj 侧已全部覆盖。** admin 侧原计划整块推到阶段 4但其中 3 条被用户侧页面直接调用,
不做完阶段 3 的出口标准(用户侧全部功能跑在新后端上)就不成立,因此在本阶段一并补上:
| 旧端点 | 新路由 | 调用它的用户侧页面 |
|---|---|---|
| `GET admin/submission/rejudge` | `POST submissions/:id/rejudge` | `oj/submission/list.vue` 的重判按钮 |
| `GET admin/submission/statistics` | `GET submissions/statistics` | `StatisticsPanel.vue`(提交列表页 + 题目页) |
| `GET admin/flowchart/statistics` | `GET flowcharts/statistics` | `FlowchartStatisticsPanel.vue`(提交列表页) |
这三条虽然挂在旧后端的 admin 路由下,权限也确实是 `teacher_admin_required` /
`super_admin_required`,但入口在用户侧页面里 —— **「admin 路由」和「admin 页面」不是一回事**
按 URL 前缀切阶段会漏掉它们。剩下 42 条的入口都在后台管理界面,留给阶段 4。
> 对账方法说明:阶段 0 已定案 API 重新设计,新旧路径不同名(如旧 `/api/problem` → 新 `/api/problems`、
> 旧 `/api/pickone` → 新 `/api/problems/random`**无法按字符串自动匹配**。本表由人工按语义逐条比对,
> 判断依据是路由文件归属 + 路径语义 + HTTP 动词。个别条目的对应关系带主观判断,下表逐条列出以便复核。
## oj 侧逐条对照65 条)
| 旧 app | 旧端点 | 新路由 |
|---|---|---|
| account | `login` | `POST auth/login` |
| account | `logout` | `DELETE auth/session` |
| account | `register` | `POST users` |
| account | `profile` | `GET me` / `GET profiles/:username` / `PUT me/profile` |
| account | `profile/fresh_display_id` | `POST me/problem-display-ids/refresh` |
| account | `metrics` | `GET users/:id/metrics` |
| account | `upload_avatar` | `POST me/avatar` |
| account | `user_rank` | `GET rankings/users` |
| account | `user_activity_rank` | `GET rankings/activity` |
| account | `user_problem_rank` | `GET problems/:displayId/rank` |
| achievement | `achievements` | `GET achievements` |
| achievement | `achievements/summary` | `GET achievements/summary` |
| achievement | `achievements/pending` | `GET achievements/pending` |
| ai | `ai/detail` | `GET ai/detail` |
| ai | `ai/duration` | `GET ai/duration` |
| ai | `ai/heatmap` | `GET ai/heatmap` |
| ai | `ai/login_summary` | `GET ai/login-summary` |
| ai | `ai/pinned` | `GET ai/pinned` |
| ai | `ai/analysis` | `POST ai/analysis` |
| ai | `ai/hint` | `POST ai/hint` |
| ai | `ai/class_pk` | `POST ai/class-pk-analysis` |
| ai | `ai/class_single` | `POST ai/class-analysis` |
| announcement | `announcement` | `GET announcements` / `GET announcements/:id` |
| class_pk | `class_rank` | `GET rankings/classes` |
| class_pk | `user_class_rank` | `GET me/class-rank` |
| class_pk | `class_pk` | `POST classes/comparison` |
| conf | `website` | `GET site` |
| conf | `hitokoto` | `GET quotes/random` |
| conf | `class_usernames` | `GET classes/:className/usernames` |
| conf | `judge_server_heartbeat/` | `POST judge-server/heartbeat` |
| contest | `contests` | `GET contests` |
| contest | `contest` | `GET contests/:id` |
| contest | `contest/password` | `POST contests/:id/access` |
| contest | `contest/access` | `GET contests/:id/access` |
| contest | `contest_rank` | `GET contests/:id/rank` |
| flowchart | `flowchart/submission`POST | `POST flowcharts` |
| flowchart | `flowchart/submissions` | `GET flowcharts` |
| flowchart | `flowchart/submission/retry` | `POST flowcharts/:id/retry` |
| flowchart | `flowchart/submission/detail` | `GET flowcharts/:id` |
| flowchart | `flowchart/submission/current` | `GET problems/:id/flowchart/current` |
| message | `message` | `GET messages` / `POST messages` |
| problem | `problem/tags` | `GET problem-tags` |
| problem | `problem` | `GET problems/:displayId` |
| problem | `problem/beat_count` | `GET problems/:id/beat-count` |
| problem | `problem/similar` | `GET problems/:displayId/similar` |
| problem | `problem/author` | `GET problem-authors` |
| problem | `problem/yearly_ac` | `GET problems/:displayId/yearly-ac` |
| problem | `pickone` | `GET problems/random` |
| problem | `contest/problem` | `GET contests/:id/problems` + `GET contests/:id/problems/:displayId` |
| problemset | `problemset` | `GET problem-sets` |
| problemset | `problemset/<id>` | `GET problem-sets/:id` |
| problemset | `problemset/<id>/problems` | `GET problem-sets/:id/problems` |
| problemset | `problemset/progress` | `POST problem-set-progress` / `PUT problem-set-progress` |
| problemset | `user/badges` | `GET users/:username/badges` |
| problemset | `problemset/<id>/badges` | `GET problem-sets/:id/badges` |
| problemset | `problemset/<id>/users_progress` | `GET problem-sets/:id/user-progress` |
| reaction | `reaction` | `GET problems/:id/reaction` / `POST problems/:id/reaction` |
| submission | `submission` | `GET submissions/:id` |
| submission | `submissions` | `GET submissions` |
| submission | `submissions/today_count` | `GET submissions/today-count` |
| submission | `format_code` | `POST code/format` |
| submission | `contest_submissions` | `GET contests/:contestId/submissions` |
| tutorial | `tutorial` | `GET tutorials/:id` |
| tutorial | `tutorials` | `GET tutorials` |
| tutorial | `exercises` | `GET tutorials/:id/exercises` |
### 新增的 4 条(旧后端没有对应)
| 新路由 | 说明 |
|---|---|
| `POST submissions` | 旧后端提交走 `POST /api/submission`,与 `GET submission` 同路径不同动词,拆开后成独立条目 |
| `PUT submissions/:id` | **提交分享开关**(对齐旧 `SubmissionAPI.put` + `ShareSubmissionSerializer`)。判题结果写回走内部 worker不经 HTTP —— 早先这里写成「判题结果写回」,会让人误以为存在一个需要判题机凭据的写入端点 |
| `POST achievements/pending/read` | 成就已读标记 |
| `GET problems/:id/flowchart/history` | 流程图历史 |
| ~~`GET dev/problems`~~ | 阶段 1 的临时验证端点,**已删除**(连同 `dev-problems.vue`、路由与 `problemSummarySchema` |
## admin 侧缺口42 条,按 app
| app | 条数 |
|---|---|
| problem | 14 |
| problemset | 10 |
| conf | 5 |
| contest | 3 |
| tutorial | 3 |
| account | 2 |
| achievement | 2 |
| ai | 1 |
| announcement | 1 |
| utils | 1 |
`problem``problemset` 两块占了 24 条,超过 admin 缺口的一半 —— 排期时应作为主体。
`submission` 原 2 条、`flowchart` 原 1 条已在本阶段做完,见上方表格。)
## 待处理项
1. 本报告的对照关系带人工判断成分,若某条对应有异议,以实际业务行为为准。
2. `utils/download.ts` 仍指向旧后端的 `/api/admin`blob 下载),只被 admin 侧两个页面用,随阶段 4 一起切。
---
## 阶段 5 切换必做项(阶段 4 施工时发现,记在这里以免忘)
> **2026-08-08 更正:这条不是「切换必做项」,降级为「手工造库时的注意事项」。**
>
> 阶段 5 演练时实测了真实的 pg_dumpall 备份:里面带 30 条 `setval`
> 并且把生产快照里所有序列和 `max(id)` 逐个对过,**错位 0 个**。
> 下面这个现象只出现在我手工按显式 id 导入、又没补 setval 的本地库上,
> 对正常的备份/恢复不成立。切换当天不用管序列。详见
> [phase5-cutover-runbook.md](phase5-cutover-runbook.md)。
**导入数据后必须重置全部序列。** 本地库是按显式 id 从生产导入的,
`problem_tag_id_seq` 停在 6 而表里 max(id)=87于是第一次新建标签就撞
`duplicate key value violates unique constraint "problem_tag_pkey"`500
生产切换若沿用同一个库则不受影响(序列本来就是对的);但只要有任何一步是
「导出 → 导入到新库」,就必须补这一句:
```sql
do $$
declare r record; mx bigint;
begin
for r in
select split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2) as seqname,
t.table_name, c.column_name
from information_schema.tables t
join information_schema.columns c on c.table_name = t.table_name
join pg_sequences s on s.sequencename = split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2)
where t.table_schema = 'public'
loop
execute format('select coalesce(max(%I),0) from %I', r.column_name, r.table_name) into mx;
execute format('select setval(%L, greatest(%s, 1))', r.seqname, mx);
end loop;
end $$;
```
症状很隐蔽:读全部正常,只有**写**才炸,而且是导入后第一次写才炸。
---
## SQL 判题链路(阶段 2 补课2026-08-07
阶段 4 做题目管理时才发现:新后端**完全没有 SQL 判题**。旧后端有
`judge/sql_runner.py`378 行)+ `sql_dispatcher.py`113 行),走的是与沙箱完全
不同的路径(跑 SQLite 比结果集)。阶段 2 纵切时只打通了沙箱那条线,漏了这条。
### 防护为什么换了实现
旧实现靠 Python sqlite3 的三件套。`bun:sqlite` 一个都没有,实测:
| | 结论 |
|---|---|
| `setAuthorizer` / `setProgressHandler` / `setLimit` | 均无 |
| `PRAGMA max_page_count` | 有效 |
| `Worker.terminate()` 能否停掉跑飞的查询 | **不能** —— 递归 CTE 死循环卡死整个 worker只能从外面杀进程 |
| `node:sqlite` | 该 Bun 版本不可用 |
因此改成「WASM 引擎sql.js+ 独立子进程」,逐条替代:
| 旧防护 | 新做法 | 实测 |
|---|---|---|
| authorizer 禁 ATTACH | WASM 无宿主文件系统绑定,**结构上**够不到 | `attach '/etc/passwd'``unable to open database` |
| authorizer 白名单让查询题只读 | `PRAGMA query_only=1` | 查询题里 INSERT → 运行错误并说明 |
| progress_handler 墙钟超时 | 子进程外部 SIGKILL | 递归 CTE 死循环 → CPU 超时 |
| `setlimit(LIMIT_LENGTH)` | 子进程 `ulimit -d` | `hex(zeroblob(2e8))` → 内存超限 |
ATTACH 这条比旧实现**更强**:旧的靠 authorizer 拦,新的是够不到。
### 两个踩过的坑
1. **`ulimit` 必须用 `-d` 不能用 `-v`。** `-v` 限虚拟地址空间,而 JS 引擎预留巨量地址;
实测 `-v` 之下 Bun 退出时有概率 panicSIGILL结果早已写出但进程异常终止
父进程读到空串误判成超时 —— 6 次里坏 2 次,时好时坏。换 `-d`(实际提交内存,
Linux 4.7 起也覆盖匿名 mmap后 12/12 稳定。
2. 子进程写完结果**直接 SIGKILL 自己**,不走 `process.exit()` —— 后者仍有一段清理会撞限额。