演练用 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>
227 lines
12 KiB
Markdown
227 lines
12 KiB
Markdown
# 阶段 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 退出时有概率 panic(SIGILL),结果早已写出但进程异常终止,
|
||
父进程读到空串误判成超时 —— 6 次里坏 2 次,时好时坏。换 `-d`(实际提交内存,
|
||
Linux 4.7 起也覆盖匿名 mmap)后 12/12 稳定。
|
||
2. 子进程写完结果**直接 SIGKILL 自己**,不走 `process.exit()` —— 后者仍有一段清理会撞限额。
|