# 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 行 Python,26 model,118 migration,127 端点 | | `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 locally,PostgreSQL、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 重跑后):本表原写「端点合计 122(oj 74 / admin 48)、DEPRECATED 16、前端调用 78、疑似无人调用约 35%」,四项全错。前三项来自一版漏抓了 `tutorial/urls/tutorial.py` 与 `utils/urls.py` 的提取脚本(共漏 5 个端点,其中 4 个前端在用)与一版只数字面量、不含模板串的前端统计;「约 35%」是从 `(122−78)/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 做 introspect,26 张表生成 TypeScript schema。 - **118 个 Django migration 的历史全部丢弃**,从当前 schema 快照重新开始迁移序列。 - 剪除 Django 框架自带表:`django_migrations`、`django_content_type`、`django_session`、`auth_permission` 等。 - 业务表结构保持不变,实现零数据迁移。 ### 8.1 不要复刻的现有行为 盘点中发现的、明确不应带进新后端的实现: - **`SessionRecordMiddleware`(`account/middleware.py:22-33`)**:每个已登录请求都写一遍 session(user_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 调 JudgeServer(HTTP,沙箱不变)→ 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 依赖不可删)。