Files
xuyueandClaude Opus 5.5 1b703d13ae
Deploy / deploy (push) Canceled after 0s
style(专题): 「题单」改名「专题」,顶栏、列表、详情、后台、成就说法统一
只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。
设计稿页名「题单重设计」和已执行的迁移文件保留原样。
线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:12:18 +08:00

5.0 KiB
Raw Permalink Blame History

CLAUDE.md

OJ2 的后端(apps/api)。这里只放后端专属的坑;全仓通用的约束(旧仓库冻结、判题状态码、 时间锚点、判题镜像、部署)在 ../../CLAUDE.md。

单二进制是有代价的

apps/api 编译成 bun build --compile 的单二进制,所以运行时不能依赖 node_modules。 任何 require.resolve / Bun.resolveSync / __dirname 去找文件的写法,本地都正常、编译后 都会炸,而且只在离开仓库目录后才炸(在仓库里跑时它顺着 cwd 摸到了 node_modules, 假装没事)。

资源要用 with { type: "file" } 内嵌。.node 原生模块还要额外注意:这个写法只有打包器认、 bun run 不认,所以必须按形态分叉 —— 见 src/vendor/jieba.ts 的注释,那里把坑写全了。

改完这类代码,dev 和编译两种形态都要跑一遍。 我吃过亏:只验了编译产物,dev 直接起不来。

build 带 --bytecode --format=esm:每道 SQL 题都要起一次 sql-child,预编译字节码让它 从约 120ms 降到约 72ms(单核实测),serve 起服务从约 180ms 降到约 100ms,内存不变, 代价是二进制大 8MB。去掉 --format=esm 会退回 CJS 输出,顶层 await 编不过。

路径解析看 runtime.ts

编译后 import.meta.dir 恒为 /$bunfs/root,往上三级就是文件系统根。 相对路径一律走 runtime.ts 的 pathBase,别自己拼。

SQL 判题会 spawn「自己」

judge/sql/index.ts 起的子进程是二进制自身 + sql-child 子命令(因为编译后磁盘上没有 child.ts 可以 spawn)。所以入口必须有 argv 分发,否则「起自己」变成「把整个程序再跑 一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。OJ2_SQL_CHILD 那道递归闸不要删。

加路由要防遮蔽

Hono 按注册顺序匹配,不是静态优先(实测确认过,别凭直觉)。/problems/:id 注册在 /problems/random 前面的话,后者永远进不去 —— 而且不报错、不警告,只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,一直到评审才发现。

加完路由跑 bun run --filter '@oj2/api' check:routes。

出参不 parse,用 satisfies

后端的响应一律 satisfies XxxType,不要写 xxxSchema.parse({...})。 出参是后端自己刚 拼出来的字面量,TS 已经在编译期校验过;再 parse 一遍拿不到任何新信息,唯一可能失败的输入是 库里的历史数据,而失败的代价是 500 —— 这条规矩是被四次这样的线上故障换来的。

闸设在写入侧:入参 safeParse、db/schema.ts 的 .$type<>() 列收窄、 语义校验函数(astRulesError() / exerciseDataError)。JSONB 原文 (submission.info / statistic_info / exercise.data)一律放行,它们的形状真相在判题机 那边。query 的筛选值走 routes/helpers.ts 的 asFilterValue(),那是纯类型交接、不加校验。

四次故障的细节、.$type<>() 断言该怎么核,见 ../../docs/contract.md; 前端为什么只在三处挂运行时闸门,见 ../web/CLAUDE.md。

数据库

Drizzle schema 最初是 drizzle-kit pull 从生产库拉出来的,所以它长得像 Django 建的表 (表名、bigint/int4 混用),schema.ts 顶部记了哪些地方是手工修的。 schema 现在归 OJ2 独占,结构变更走 migration 正常演进。

外键的删除动作从 0010 起是显式的,不再是 Django 留下的一律 NO ACTION:

  • CASCADE:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表 (problem_tags)、专题/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存 (user_stat)。
  • NO ACTION(即拦住):需要人看见的删除 —— submission.problem_id、以及 user 的绝大 多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。

加新子表时必须回来想一遍该走哪一档,别默认新外键会自己连坐 —— drizzle 不写 .onDelete() 就是 NO ACTION,而 0010 只改了当时存在的那批。

改 schema 走 drizzle migration

bun run db:generate(造迁移文件)→ bun run db:migrate(按 drizzle.__drizzle_migrations 增量执行),就是 Django makemigrations / migrate 的 等价物。索引/结构变更走这条,不要再手写 SQL 往 docs/ 里塞。

  • 部署时自动执行:docker/deploy.sh 在构建镜像之后、起栈之前跑,失败就中止部署。
  • 破坏性迁移默认拦截(DROP TABLE / DROP COLUMN / ALTER COLUMN ... TYPE / TRUNCATE),退出 4,要显式放行:OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh。

CREATE INDEX CONCURRENTLY 怎么写、给已有库打基线的 SQL、.op() 会吞掉索引方向这类 drizzle-kit 的坑,全在 ../../docs/database.md。