Files
OJ2/docs/specs/2026-08-06-bun-backend-rewrite-design.md
yuetsh a8408c0bb5 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>
2026-09-16 08:03:26 -06:00

307 lines
18 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.
# OJ2 设计文档:后端重写为 Bun + TypeScript
日期2026-08-06
状态:**已实施、已上线**2026-08 切换完成,旧栈已不可逆下线)。本文保留为「当初为什么这么定」
的记录,**不是现状的说明** —— 现状看 `CLAUDE.md``docs/` 下的专题文档。
文中引用的 `docs/spikes/`(三个 spike 脚本)与 `docs/specs/schema.sql`(生产库结构 dump
已在 2026-09-16 删除结论都已落进代码脚本跑完就没用了schema dump 的内容进了迁移
`0000_crazy_gateway.sql`。要看它们去 git 历史。
## 1. 背景与动机
现有平台由两个独立仓库组成:
| 仓库 | 角色 | 栈 | 规模 |
|---|---|---|---|
| `OnlineJudge/` | 后端 REST API + WebSocket | Django 6 + DRF + PostgreSQL + Redis + Dramatiq | 17k 行 Python26 model118 migration127 端点 |
| `ojnext/` | 前端 SPA | Vue 3 + TypeScript + Vite + Naive UI + Pinia | 37k 行217 文件 |
重写的驱动力有三条,均为交付性诉求,非兴趣驱动:
1. **前后端统一 TS 技术栈** —— 消除 Python/TS 上下文切换,让类型从数据库一路贯通到 Vue 组件。
2. **Django 太重** —— 运行时体积、冷启动、DRF serializer/view 样板代码。
3. **现有代码难维护** —— 20+ app 边界模糊,`utils/` 混杂,手写类型与后端 serializer 靠人脑同步。
## 2. 目标与非目标
### 目标
- 用 Bun + TypeScript 重建后端,承接现有 PostgreSQL 全部数据。
- 重新设计 API 契约,前端同步适配。
- 前后端与部署编排收进单一 monorepo。
- 借重写清理存量死代码。
### 非目标
- **不重写前端业务代码。** 37k 行 Vue 中只有约 1.7k 行5%)触及网络层,其余页面、组件、路由、状态原样搬运。
- **不重写判题沙箱。** JudgeServer 是独立 Docker 服务,通过 HTTP 调用,镜像原样复用。
- **不做数据迁移。** 新后端直接接管现有库表结构。
- **不做新旧双跑。** 假期一次性停机切换。
## 3. 约束
| 约束 | 内容 |
|---|---|
| 数据 | 现有用户、题目、提交记录、成就必须完整延续 |
| 停机 | 可在假期安排一次停机切换,无需双写或灰度 |
| 契约 | API 重新设计,前端跟随修改 |
| 人力 | 单人开发,业余时间推进 |
| 目标环境 | 学校机房(低配)+ Debian 服务器 |
| 本机开发环境 | Docker 29.7.2 可用16 核PostgreSQL / Redis / 判题沙箱均可本地运行 |
> **更正2026-08-06**:根 `CLAUDE.md` 长期写着"No Docker locallyPostgreSQL、Redis 和判题沙箱无法在本机运行,后端只能靠代码审查和静态检查验证"。实测这条不成立——Docker 与 docker-compose 早已安装(`1:29.7.2-1` / `5.4.0-1`),只是 systemd 服务从未启用、用户不在 `docker` 组。启用后一切正常。
>
> 此外判题沙箱 **不需要特权模式**`docker-compose.school.yml` 里 `oj-judge` 用的是 `read_only: true` + `cap_drop`(只减能力不加),普通 Docker 主机即可运行。
>
> 影响:阶段 2判题竖线——全方案唯一有真实技术风险的一段——**可以在本机完整验证并反复试错**,不必每次推到服务器。这比阶段 1 省下的那点便利重要得多。根 `CLAUDE.md` 的对应段落需同步更正。
## 4. 存量盘点
重写前必须先做减法。已测得的数据:
| 项 | 数值 |
|---|---|
| 后端端点合计 | 127`oj` 77 + `admin` 50 |
| 其中已由人工标注 `# DEPRECATED: 前端未调用` | 17 |
| 前端实际调用的路径 | 148 条 `method + path`104 条不同路径 |
| **疑似无人调用** | **23 个18%** |
| 空 app | `course/``comment/`(均 0 行) |
| 全站不用的分支 | OI 赛制(所有比赛均为 ACM |
| Python 生态锁定 | 仅 2 处:`jieba``flowchart/views/admin.py` 单文件)、`tree-sitter``ast_checker/`177 行) |
> 更正2026-08-06 阶段 0 重跑后):本表原写「端点合计 122oj 74 / admin 48、DEPRECATED 16、前端调用 78、疑似无人调用约 35%」,四项全错。前三项来自一版漏抓了 `tutorial/urls/tutorial.py` 与 `utils/urls.py` 的提取脚本(共漏 5 个端点,其中 4 个前端在用)与一版只数字面量、不含模板串的前端统计;「约 35%」是从 `(12278)/122` 推出来的,两个输入都错。现表为当时三个提取脚本重跑的实测值,独立核验:`cd OnlineJudge && cat */urls/*.py utils/urls.py | grep -c "path("` → 127。
> **减法空间只有 18%,不是三分之一。** 后续阶段按 18% 排期。
前端网络层集中度高,改动面小:
| 文件 | 规模 | 处置 |
|---|---|---|
| `src/oj/api.ts``src/admin/api.ts``src/shared/api.ts``src/oj/achievement/api.ts` | 1096 行 | 重写 |
| `src/utils/http.ts` | ~80 行 | 重写 |
| `src/utils/judge.ts` | ~40 行 | 重写 |
| `src/utils/types.ts` | 15.5K | **删除**,由 `packages/contract` 取代 |
| 3 个 `.vue` 内的直接调用 | 少量 | 收拢进 api 层 |
## 5. 仓库结构
新建 `OJ2/`,与现有两个仓库平级,为独立 git 仓库(主干 `main`)。
```
Projects/OJ/
├── OnlineJudge/ # 旧后端,冻结不动,切换稳定后归档
├── ojnext/ # 旧前端,冻结不动
└── OJ2/ # 新 monorepo
├── apps/
│ ├── api/ # Bun + Hono + Drizzle
│ └── web/ # 由 ojnext 拷贝而来(不带 git 历史),仅替换 API 层
├── packages/
│ └── contract/ # Zod schema前后端唯一真相源
├── docker/
│ ├── Dockerfile.api
│ ├── Dockerfile.web
│ ├── compose.dev.yml
│ ├── compose.debian.yml
│ ├── compose.school.yml
│ └── caddy/
├── docs/
│ ├── specs/
│ └── spikes/
├── package.json # Bun workspaces
└── bunfig.toml
```
设计要点:
**`packages/contract` 是 monorepo 存在的唯一理由。** Zod schema 单点定义:后端 `.parse()` 做运行时校验,前端 `z.infer` 取类型。当前 `utils/types.ts` 的 15.5K 手写类型与后端 serializer 之间没有任何机器保障,是"难维护"的主要来源。没有这个包monorepo 只是两个文件夹放在一起,不值得搞。
**只用 Bun workspaces。** 不引入 turbo / nx / pnpm。单人两 app 的规模下,额外编排层的维护成本高于收益,`bun run --filter` 足够。
**旧仓库全程不动。** 回滚成本等于改一行 Caddy 上游配置。切换后稳定运行一学期再归档。
## 6. 技术选型
| 层 | 选型 | 替代掉 |
|---|---|---|
| HTTP 框架 | **Hono** | Django + DRF |
| ORM | **Drizzle** | Django ORM |
| 契约 / 校验 | **Zod**`packages/contract` | DRF serializers + `utils/types.ts` |
| 任务队列 | **BullMQ** | Dramatiq |
| WebSocket | **Bun.serve 原生** + Redis pub/sub | Channels + channels-redis |
| 会话 | **Redis opaque token** | Django session |
| 密码 | pbkdf2 兼容验证 → argon2id 透明升级 | Django hashers |
| 中文分词 | `@node-rs/jieba` | jieba |
| AST 解析 | `web-tree-sitter`WASM | tree-sitter Python 绑定 |
| Excel 导出 | `exceljs` | xlsxwriter |
| 站点配置 | KV 表 + 内存缓存 | `SysOptions` metaclass |
| 错误上报 | `@sentry/bun` | sentry-sdk[django] |
### 两个非显然决策
**选 Hono 而非 Elysia。** Elysia 类型推导更强、benchmark 更快,但生态小且绑定 Bun。本项目负载为机房数十名学生框架性能不构成瓶颈Hono 的生态成熟度、`@hono/zod-validator`、以及可将类型直接传递给前端的 `hc` RPC 客户端更有价值。
**会话用 Redis opaque token 而非 JWT。** 理由是账号封禁需即时生效:`User.is_disabled` 一旦置位必须立刻踢人下线JWT 做不到,得额外维护黑名单——那等于把 Redis 又加回来白绕一圈。Redis 本就在架构内,直接用 opaque token 最省。
> 更正2026-08-06 盘点时发现):本条最初的理由写的是"现有 `account/views/oj.py:214` 实现了查看并踢出其他登录会话"。核实后该功能实际是死的——`/api/sessions` 前端从不调用,`User.session_keys` 字段只写不读(`account/middleware.py:30` 每请求追加,无人读取)。结论不变,理由已换成上面成立的那条。新后端不要复刻 `session_keys`。
## 7. 已验证的技术假设
三处高风险假设已在 Bun 1.3.11 上实测通过。当时的 spike 代码在 `docs/spikes/`,已删除(见顶部说明),三条结论都已落进 `apps/api` 的实现。
### 7.1 Django 密码哈希兼容
用 Django 生成 `pbkdf2_sha256$1200000$...` 格式哈希Bun 侧用 `node:crypto``pbkdf2` 验证:
```
正确密码 : true
错误密码 : false
单次耗时 : 95 ms (1200000 轮迭代)
argon2id : true 耗时 88 ms
```
**结论**:存量密码可直接沿用,学生无需重置。
**衍生约束**95ms 的同步 pbkdf2 会阻塞事件循环。上课铃响时 40 人并发登录 = 3.8 秒 CPU 占满。因此:
1. 登录必须使用 `node:crypto` 的**异步** `pbkdf2`(走 libuv 线程池),不得使用 `pbkdf2Sync`
2. 验证成功后立即将该用户哈希升级为 argon2id`Bun.password`),一学期后存量自然清空。
#### 7.1.1 `raw_password` 明文列保留(已决策)
生产库 `user` 表有一列 `raw_password character varying(20)`),存学生明文密码。
**决策:保留。** 这是有意的运维需求——学生忘记密码是高频事件,教师需要能直接查到并告知,走"重置密码"流程在机房环境里成本过高。新后端照样维护这一列。
**对本节结论的影响,须如实记录:** 既然明文与哈希同表共存,第 2 条的 argon2id 升级**不构成对"数据库泄露"这一威胁的防护**——拿到库的人不需要破哈希。该升级实际只解决两件事:
- 摆脱 pbkdf2 每次登录 95ms 的 CPU 开销argon2id 88ms 但只在升级那一次,之后走更快路径)
- 不再依赖 Django 特定的哈希格式,新后端自成一体
不要在任何地方把它描述成"提升了密码安全性"。真实的安全边界由 `raw_password` 决定,不由哈希算法决定。
**若日后想在不改变教师查密码这一工作流的前提下收紧**(本次未采纳,仅备查):把 `raw_password` 改为用一把存在环境变量/密钥文件里、**不在数据库内**的密钥做可逆加密。教师查询走应用层解密,体验不变;而一份裸的数据库备份泄露时不再直接暴露明文。改动量约为一个加解密工具函数 + 一次存量数据迁移。
### 7.2 tree-sitter 迁移
复刻 `ast_checker/mappings/c.py` 的映射表,在 Bun 中用 `web-tree-sitter` 解析 C 代码:
```
解析耗时: 1.10 ms
规则 for_loop -> for_statement 命中 1
规则 while_loop -> while_statement 命中 0
规则 function_definition -> function_definition 命中 1
规则 include -> preproc_include 命中 1
Python 根节点: module | 首个子节点: for_statement
```
C 与 Python 两套 grammar 均正常。`.wasm` 文件随 npm 包分发(`tree-sitter-c` 611K / `tree-sitter-python` 447K无需现场编译。
**澄清**WASM 是编译产物格式不是运行位置。AST 检查仍在服务端执行——现状是 `judge/dispatcher.py:189` 在判题流程中调用,新架构中由 BullMQ worker 在判题前调用,位置等价,学生代码不离开服务器。
**相对现状的改进**
| | 现状Python 绑定) | WASM |
|---|---|---|
| 安装 | 按平台编译 C 扩展 | `.wasm` 直接随包分发 |
| 跨架构 | x86 / arm64 各编一次 | 同一份文件通用 |
| Docker | 镜像需带编译链 | 拷贝即用 |
未选原生 NAPI 绑定:性能更高但需 node-gyp 现场编译Bun 支持稳定性较差。1.1ms 解析耗时在判题流程中可忽略(沙箱启动本身即数十毫秒),选 WASM 图部署简单。
### 7.3 `@node-rs/jieba` 替代 Python jieba
对照 `flowchart/views/admin.py:65,191` 的两处用法——`jieba.add_word(w, freq=9999)` 加自定义词、`jieba.cut(text)` 切词——在 Bun 1.3.11 下验证 `@node-rs/jieba@2.0.1`NAPI 绑定):
```
默认切词: 输入 / 两个 / 整数 / 并 / 输出 / 它们 / 的 / 和
加词后 : 输入 / 两个整数 / 并 / 输出 / 它们 / 的 / 和
1000 次切词耗时: 2 ms
```
**结论**通过。NAPI 二进制在 Bun 下正常加载,`cut` 结果符合预期1000 次切词仅 2ms性能远超需求该功能是后台统计页的低频查询
**与 brief 预期的一个出入**`@node-rs/jieba@2.0.1` 的公开 API 里没有 `insertWord`/`addWord` 方法,`.d.ts` 未导出对应接口。改用 `Jieba.loadDict(buffer)` 加载一份自定义词条缓冲区达到同等效果,格式与 Python jieba 用户词典一致(`"词 词频"` 按行排列):
```typescript
jieba.loadDict(Buffer.from("两个整数 9999\n"))
```
**采用方案**:新后端用 `@node-rs/jieba`
**衍生约束**`loadDict` 语义已实测确认为**累加**(连续两次 `loadDict` 后先后加入的词都仍然成词),不是整份替换;但它每次调用都要重新解析并合并一遍词典,单条调用的固定开销远大于词条本身。实测 200 个自定义词:
```
逐条 loadDict : 39.4 ms
一次性 loadDict: 0.98 ms 40 倍)
```
因此给后续阶段的实现者:
1. `CUSTOM_WORDS` 必须在**启动时**拼成一份完整的词典缓冲区,**一次性** `loadDict`,不得把 Python 那边的 `for w in CUSTOM_WORDS: jieba.add_word(w)` 逐词循环直译成逐条 `loadDict`
2. 缓冲区格式与 Python jieba 用户词典一致:每行 `"词 词频"`,词频沿用现有的 `9999`
3. 不要在请求路径上调 `loadDict`。词表变更走重建缓冲区 + 重启(或重建整个 `Jieba` 实例)。
## 8. 数据层策略
-`drizzle-kit pull` 对现有 PostgreSQL 做 introspect26 张表生成 TypeScript schema。
- **118 个 Django migration 的历史全部丢弃**,从当前 schema 快照重新开始迁移序列。
- 剪除 Django 框架自带表:`django_migrations``django_content_type``django_session``auth_permission` 等。
- 业务表结构保持不变,实现零数据迁移。
### 8.1 不要复刻的现有行为
盘点中发现的、明确不应带进新后端的实现:
- **`SessionRecordMiddleware``account/middleware.py:22-33`**:每个已登录请求都写一遍 sessionuser_agent / ip / last_activity遇到新 session key 还额外触发一次 `request.user.save()` —— 即每请求一次数据库写。这是"Django 太慢"的实际来源之一。新后端的会话信息留在 Redis不落库。
- **`User.session_keys`**:只写不读的死字段,随 `/api/sessions` 端点一并砍掉。
反过来,**必须复刻**的一项:`user.raw_password` 保留,见 7.1.1。
## 9. 判题链路
现有链路:`submission` 写入 → Dramatiq 入队 → `judge/dispatcher.py` 调 JudgeServer → 结果写回 → Channels 推送前端。
新链路:`submission` 写入 → BullMQ 入队 → worker 调 JudgeServerHTTP沙箱不变→ AST 检查web-tree-sitter→ 结果写回 → Bun 原生 WebSocket + Redis pub/sub 推送前端。
判题并发受沙箱数量限制,由 BullMQ 的 `concurrency` 配置控制。
## 10. 部署
- 判题沙箱镜像原样复用(`registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1`)。
- 丢弃 `OnlineJudge/deploy/requirements.txt`48KB及整套 Python 运行时。
- api 用 `bun build --compile` 产出单二进制,镜像体积从数百 MB 降至数十 MB —— 对机房低配机器收益明显。
- 三套 compose`dev`(本地)、`debian`(服务器)、`school`(机房)。
- Caddy 作为反向代理,切换即改上游地址。
## 11. 分阶段路线
| 阶段 | 内容 | 出口标准 |
|---|---|---|
| **0 减法与探路** | 对照 104 条前端实际调用路径筛查 127 个后端端点,砍掉无人调用者;删除 `course`/`comment` 空壳与 OI 分支;验证 `@node-rs/jieba` | 产出端点清单REVIEW 归零、每个端点有 KEEP/CUT 裁决(机器初判:可砍 17 个,疑似无人调用 23 个 = 18% |
| **1 骨架** | 建仓、Bun workspaces、`drizzle-kit pull` 拿 26 张表并剪除 `django_*`、拷贝 ojnext 进 `apps/web` | `bun dev` 可启动,能从真实库读出一道题 |
| **2 判题竖线**(关键) | 最小 auth + 读题 + 提交 → BullMQ → JudgeServer → Bun WS 推回前端;前端仅改对应几个 api 函数 | 一名学生能登录、看题、提交、看到实时判题结果 |
| **3 铺开** | 77 个 `oj` 端点逐个搬运,搬一个换一个前端 api 函数 | 用户侧全部功能运行在新后端上 |
| **4 后台** | 50 个 `admin` 端点,约占全程 40% 工作量 | 后台可用 |
| **5 切换演练** | `docker/` 三套 compose单二进制镜像用生产库快照完整演练 | 演练 30 分钟内完成,回滚路径已验证 |
**阶段顺序的理由**
- 阶段 0 置于最前,因为砍掉的每一个端点都是不必翻译的代码,且零技术风险。现在不砍,就会被原样翻译一遍。
- 阶段 1 的 schema 先于一切,因为数据库是唯一不能重写的部分,它是全部下游设计的硬约束。
- **阶段 2 纵切而非横切。** 全部技术不确定性Dramatiq→BullMQ、Channels→Bun WS、判题分发集中在这一条链路上。打通之后剩余 100+ 端点均为无惊喜的 CRUD可断续推进。若按 app 横向切分,风险会分散到项目后期暴露。
- 契约(`packages/contract`)在阶段 1 建包、阶段 2 试用、阶段 3 铺开。不在最前定契约,因为契约须建立在存量盘点与真实 schema 之上,凭空设计必然返工。
## 12. 风险与回滚
| 风险 | 缓解 |
|---|---|
| 阶段 2 技术链路打不通 | 唯一真风险,故置于最前,失败可及早止损 |
| 假期切换当天出问题 | 旧仓库与旧镜像全程保留,回滚 = 改一行 Caddy 上游 |
| admin 工作量拖长战线 | admin 不影响学生使用,可在切换后继续补,必要时切换初期临时保留旧后台 |
| 前后端须同时完工 | 由 `packages/contract` 解耦:契约先定,前端可对 mock 开发,两边不互相等待 |
## 13. 开放问题
- 具体哪些端点进入砍除清单,需在阶段 0 逐个核对后确定。
- `apps/web` 的 Naive UI / Pinia / Vue Router 版本是否随迁升级,待阶段 1 评估memory 记录:机房 Chrome < 94`mermaid-legacy` 等 fallback 依赖不可删)。