commit ec4f649a2e95d8ac9fdedb653e58650942c51390 Author: yuetsh <517252939@qq.com> Date: Thu Aug 6 19:16:57 2026 -0600 docs: OJ2 后端重写设计文档 将 Django 后端重写为 Bun + TypeScript 的设计方案,含前后端 monorepo 结构、技术选型与分阶段路线。 两处高风险技术假设已实测验证,spike 代码见 docs/spikes/: - Django pbkdf2 密码哈希可在 Bun 侧验证,存量密码无需重置 - tree-sitter 可用 WASM 在服务端运行,且比现状更易部署 Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f07db3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +build/ +*.log + +.env +.env.* +!.env.example + +.DS_Store +.vscode/ +.idea/ + +# spike 的依赖不入库,spike 源码入库 +docs/spikes/node_modules/ +docs/spikes/bun.lock diff --git a/docs/specs/2026-08-06-bun-backend-rewrite-design.md b/docs/specs/2026-08-06-bun-backend-rewrite-design.md new file mode 100644 index 0000000..0d04f44 --- /dev/null +++ b/docs/specs/2026-08-06-bun-backend-rewrite-design.md @@ -0,0 +1,236 @@ +# OJ2 设计文档:后端重写为 Bun + TypeScript + +日期:2026-08-06 +状态:已确认,待实施 + +## 1. 背景与动机 + +现有平台由两个独立仓库组成: + +| 仓库 | 角色 | 栈 | 规模 | +|---|---|---|---| +| `OnlineJudge/` | 后端 REST API + WebSocket | Django 6 + DRF + PostgreSQL + Redis + Dramatiq | 17k 行 Python,26 model,118 migration,122 端点 | +| `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 服务器 | + +## 4. 存量盘点 + +重写前必须先做减法。已测得的数据: + +| 项 | 数值 | +|---|---| +| 后端端点合计 | 122(`oj` 74 + `admin` 48) | +| 前端实际调用的路径 | 78 | +| **疑似无人调用** | **约 35%** | +| 空 app | `course/`、`comment/`(均 0 行) | +| 全站不用的分支 | OI 赛制(所有比赛均为 ACM) | +| Python 生态锁定 | 仅 2 处:`jieba`(`flowchart/views/admin.py` 单文件)、`tree-sitter`(`ast_checker/`,177 行) | + +前端网络层集中度高,改动面小: + +| 文件 | 规模 | 处置 | +|---|---|---| +| `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。** 现有 `account/views/oj.py:214` 实现了"查看并踢出其他登录会话",JWT 无法即时失效。Redis 已在架构内,不应为追新引入 JWT 的撤销难题。 + +## 7. 已验证的技术假设 + +两处高风险假设已在 Bun 1.3.11 上实测通过,spike 代码见 `docs/spikes/`。 + +### 7.1 Django 密码哈希兼容(`docs/spikes/pbkdf2-spike.ts`) + +用 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.2 tree-sitter 迁移(`docs/spikes/ast-spike.ts`) + +复刻 `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` 在 Bun 下的可用性。仅影响 `flowchart/views/admin.py` 单个文件;即便不可用亦可用纯 JS 分词兜底。放入阶段 0 验证。 + +## 8. 数据层策略 + +- 用 `drizzle-kit pull` 对现有 PostgreSQL 做 introspect,26 张表生成 TypeScript schema。 +- **118 个 Django migration 的历史全部丢弃**,从当前 schema 快照重新开始迁移序列。 +- 剪除 Django 框架自带表:`django_migrations`、`django_content_type`、`django_session`、`auth_permission` 等。 +- 业务表结构保持不变,实现零数据迁移。 + +## 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 减法与探路** | 对照 78 个前端实际调用筛查 122 个后端端点,砍掉无人调用者;删除 `course`/`comment` 空壳与 OI 分支;验证 `@node-rs/jieba` | 产出端点清单,长度比 122 少约三分之一 | +| **1 骨架** | 建仓、Bun workspaces、`drizzle-kit pull` 拿 26 张表并剪除 `django_*`、拷贝 ojnext 进 `apps/web` | `bun dev` 可启动,能从真实库读出一道题 | +| **2 判题竖线**(关键) | 最小 auth + 读题 + 提交 → BullMQ → JudgeServer → Bun WS 推回前端;前端仅改对应几个 api 函数 | 一名学生能登录、看题、提交、看到实时判题结果 | +| **3 铺开** | 74 个 `oj` 端点逐个搬运,搬一个换一个前端 api 函数 | 用户侧全部功能运行在新后端上 | +| **4 后台** | 48 个 `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 依赖不可删)。 diff --git a/docs/spikes/ast-spike.ts b/docs/spikes/ast-spike.ts new file mode 100644 index 0000000..ad6dcf6 --- /dev/null +++ b/docs/spikes/ast-spike.ts @@ -0,0 +1,48 @@ +import { Language, Parser } from "web-tree-sitter" + +// 复刻 ast_checker 的 C_MAPPING 片段 +const C_MAPPING: Record = { + for_loop: "for_statement", + while_loop: "while_statement", + function_definition: "function_definition", + include: "preproc_include", +} + +await Parser.init() + +const parser = new Parser() +const cLang = await Language.load("./node_modules/tree-sitter-c/tree-sitter-c.wasm") +parser.setLanguage(cLang) + +const code = `#include +int main() { + int sum = 0; + for (int i = 1; i <= 100; i++) { + sum += i; + } + printf("%d\\n", sum); + return 0; +}` + +const t0 = performance.now() +const tree = parser.parse(code)! +const parseMs = performance.now() - t0 + +// 数各类节点出现次数(等价于 ast_checker 的 engine.check 遍历) +const counts: Record = {} +const walk = (n: any) => { + counts[n.type] = (counts[n.type] ?? 0) + 1 + for (let i = 0; i < n.childCount; i++) walk(n.child(i)) +} +walk(tree.rootNode) + +console.log("解析耗时:", parseMs.toFixed(2), "ms") +for (const [label, tsType] of Object.entries(C_MAPPING)) { + console.log(` 规则 ${label.padEnd(20)} -> ${tsType.padEnd(20)} 命中 ${counts[tsType] ?? 0}`) +} + +// Python grammar 也验一下 +const pyParser = new Parser() +pyParser.setLanguage(await Language.load("./node_modules/tree-sitter-python/tree-sitter-python.wasm")) +const pyTree = pyParser.parse("for i in range(10):\n print(i)")! +console.log("\nPython 根节点:", pyTree.rootNode.type, "| 首个子节点:", pyTree.rootNode.child(0)?.type) diff --git a/docs/spikes/pbkdf2-spike.ts b/docs/spikes/pbkdf2-spike.ts new file mode 100644 index 0000000..ca0ac78 --- /dev/null +++ b/docs/spikes/pbkdf2-spike.ts @@ -0,0 +1,26 @@ +import { pbkdf2Sync, timingSafeEqual } from "node:crypto" + +// 验证 Django pbkdf2_sha256$$$ +function verifyDjangoPassword(raw: string, encoded: string): boolean { + const [algo, iterStr, salt, hash] = encoded.split("$") + if (algo !== "pbkdf2_sha256") return false + const expected = Buffer.from(hash, "base64") + const actual = pbkdf2Sync(raw, salt, Number(iterStr), expected.length, "sha256") + return expected.length === actual.length && timingSafeEqual(expected, actual) +} + +const encoded = "pbkdf2_sha256$1200000$JIVbwvl1TpoWNHUitEA0iJ$5pOkPVGvtZbPGHZ1DnYbhpbtywLdsnEKtzm66IBABIU=" + +const t0 = performance.now() +const ok = verifyDjangoPassword("student123", encoded) +const cost = performance.now() - t0 + +console.log("正确密码 :", ok) +console.log("错误密码 :", verifyDjangoPassword("wrongpass", encoded)) +console.log("单次耗时 :", cost.toFixed(0), "ms (1200000 轮迭代)") + +// 透明升级路径:验通后改存 argon2id +const upgraded = await Bun.password.hash("student123", { algorithm: "argon2id" }) +const t1 = performance.now() +const ok2 = await Bun.password.verify("student123", upgraded) +console.log("argon2id :", ok2, "耗时", (performance.now() - t1).toFixed(0), "ms")