# CLAUDE.md OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。 上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA)。 > **旧栈已不可逆地下线**(`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在生产库 > 执行完毕,漏网的一张空 `django_migrations` 由 `0014` 补删)。所以「停新栈起旧栈」已经 > 不是退路,**唯一退路是从数据库备份恢复**。 > > **旧仓库仍然零改动**,没有例外 —— 包括修 bug、包括不影响外部接口的内部小修。 > 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、是否 > 重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户「未处理,按当前政策不动旧仓库」, > 不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,不再是回滚保证。 细节文档(`CLAUDE.md` 只留日常要记住的,展开都在这几份里): | 文档 | 什么时候读 | |---|---| | `docs/deploy.md` | 部署、上线、备份恢复 | | `docs/database.md` | 写迁移、给新库打基线、drizzle-kit 抽风 | | `docs/timezone.md` | 动日历口径、动时间出参格式 | | `docs/contract.md` | 动 zod 契约、想给某个字段加校验 | | `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter | | `docker/judge/README.md` | 换判题沙箱镜像、升语言版本(gcc / Python …) | | `docs/specs/` | 三份设计文档:后端重写、课堂求助与协作编辑、题目详情页重设计(动题目页之前必读) | ## 本机环境 **Docker 可用,全套依赖都能在本机跑起来**(PostgreSQL、Redis、判题沙箱), 镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用「本机跑不起来后端」的旧假设。 ```bash bun install bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081) bun run db:migrate # 空库会从 0000 自举出全部结构 bun run dev # api(3000) + worker + web(5173) 一起起 ``` 首次要先建 `.env`(照 `.env.example`)。判题机 token 两边必须一致: `.env` 的 `JUDGE_SERVER_TOKEN` 和 `docker/.env` 的 `OJ2_JUDGE_TOKEN`。 **Bun 版本是锁死的**:单二进制把运行时一起打进去,CI 装的是几、线上跑的就是几。 升级要改三处 —— 根 `package.json` 的 `packageManager`(CI 的 setup-bun 读它)和 `@types/bun`、`docker/Dockerfile` 的 `oven/bun:x.y.z` —— 然后 `bun install`。 `bunfig.toml` 设了 `minimumReleaseAge = "3d"`,新发布不满 3 天的包装不上,`@types/bun` 除外。 常用检查: ```bash bunx vp run verify # 下面这些一次跑完,带缓存(没改过的直接跳过) bun run check # 格式检查 + lint,不含类型检查 bun run fmt # 格式化(bun fmt),全仓一把 bun run lint # lint(Oxlint),有一条 warning 就算失败 bun run --filter '@oj2/api' typecheck # 后端类型检查 bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下 bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑 cd apps/web && bun run type-check # 前端类型检查 cd apps/web && bun run build # 前端构建(不在 verify 里) ``` **lint 和任务编排走 Vite+**(`vite-plus`,`vp` 命令),**格式化走 Bun 自带的 `bun fmt`**, 配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它; `semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks` (`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在 长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css` 来回改。前端自己的构建 配置在 `apps/web/vite.config.ts`,和根目录那份无关。 - 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了 (文档的中文表格、compose 的逐行注释是手排的),`src/db/meta/` 快照和 unplugin 的两个 `.d.ts` 也排掉了。 - **`run.tasks` 里一律经 `bun run <脚本>` 调**,不直接写 `tsc`:任务定义在根包,PATH 是根目录 的 `node_modules/.bin`,直接写拿到的是根目录的 TS 5.9 而不是 `apps/api` 的 TS 7。 - 任务名不能和根 `package.json` 的脚本重名(Vite+ 直接报错),所以 `verify` 没有对应的 `bun run` 脚本,用 `bunx vp run verify`。 - 缓存在 `node_modules/.vite/task-cache`,按读到的文件内容做指纹;怀疑缓存不对时删掉它。 - **改完代码顺手跑一下 `bun run fmt`**。 ⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子都会**静默 通过**:`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build` 根本不做类型检查。 改完 .vue / .ts 别拿构建当验证。 **不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。 本机 Docker 全套都能起,实跑的成本比想象中低。 ## 提交 **直接在 `main` 上干活,不开特性分支。** 注意 push 到 `github` 这个 remote 的 `main` 会触发 CI 部署;而 `origin` 会镜像到 `github`,**平时 push `origin` 通常几秒后也就部署了** (偶尔不跟,要 `git ls-remote github` 核实)。见 `docs/deploy.md`。 提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如 `fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。 常用 type:`fix` / `feat` / `refactor` / `perf` / `chore` / `style`。 ## 几件必须知道的事 后端专属的坑 —— 单二进制不能依赖 node_modules、路径走 `runtime.ts`、SQL 判题 spawn 自己、 路由遮蔽、出参用 `satisfies` 不用 `parse`、外键删除动作与迁移 —— 在 **`apps/api/CLAUDE.md`**, 动 `apps/api` 的文件时会自动加载。下面是全仓通用的。 ### 判题状态码不能改 `apps/api/src/judge/status.ts` 和 `apps/web/src/utils/constants.ts` 必须一致。 这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。 ### 判题机是 go-judge,判题流程在后端 `compose.*.yml` 里的 `oj2-judge-3` **不在任何 registry 上**:`docker/judge/` 把 [go-judge](https://github.com/criyle/go-judge) 的二进制和 gcc / python 装进一个镜像,本机构建。 go-judge 只管「在沙箱里跑一条命令」,**编译、逐个测试点运行、比对输出、定状态码都在 `apps/api/src/judge/sandbox.ts`**,口径逐条照搬了 2026-10 之前的 QingdaoU/JudgeServer (输出 rstrip 后比 md5、stdout 和 stderr 合在一起比、地址空间限两倍内存、16MB 输出上限 …), 出参也还是它的形状 —— 12 万条历史提交的 `info` 就是这个形状。动那个文件前先读它顶部的注释。 - 构建、分发、改名的步骤见 `docker/judge/README.md`。**服务器和机房各有各的判题沙箱,两边都要装。** - 判题机容器是 `privileged` 的(go-judge 要自己建 cgroup 和 namespace),别「顺手」改回 `read_only` + `cap_drop`。 - 判题机不再主动报心跳,后台「判题机列表」是 worker 每 5 秒去问一次记下的(`judge/heartbeat.ts`)。 - 编译/运行命令在 `apps/api/src/judge/languages.ts`,不在镜像里。gcc-14 把隐式函数 声明等提成了 error(`-w` 压不住),那边的 `cLooseErrors` 几个 `-Wno-error=` 就是 为此加的 —— 删掉它们等于让一批历史题解和 C 教程示例集体 CE。 - **判题沙箱只认 C / C++ / Python。** Java / JavaScript / Golang 连同镜像里的 JDK / Node / Go 在 2026-09 一起砍了(前端本来就没给入口,12 万条提交里它们共 62 条), 契约 `judgeLanguageSchema` 里的键留着是为了渲染那 62 条历史提交。 **`Python3` / `Python2` 这两个旧值已经没有了** —— 0019 迁移把 104530 条提交、937 道题、 1235 个用户的成就指标并成了一个 `Python`,0020 顺手把那三种语言从题目的可选语言里摘掉 (不摘的话 84 道题的语言下拉还能选 Java,提交必 SYSTEM_ERROR)。代码里也不再认旧值(别名表 2026-10 删了), **回滚到 0019 之前要连数据一起回**,只滚代码会让 Python 提交全炸。 - 换完镜像跑 `bun docker/judge/smoke.ts`:三种语言、六种状态码、gcc 宽松度一起核。 ### AST 代码规则:一张表,外加一个机器检查 契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表(`label` 给界面、`node` 给判题机), 判题机侧没有第二张表,所以加 target 漏配节点类型在结构上不可能。但**配错**仍然可能, 而且完全静默 —— 节点类型对不上就是「必须使用 X」永远失败、「不能使用 X」永远通过。 ```bash bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑 ``` 判题机只认 C / C++ / Python(`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑, 所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、 规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`。 ### 比赛只有 ACM 模式 没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要「顺手补回来」。 ### 前端基线是 Chrome 105 机房**部分**电脑是 Chrome 105,其余更新 —— 按最低那档定基线。 **`@vitejs/plugin-legacy` 和写死的 polyfill 清单别删**(vite 8 默认 target 是 `chrome111`)。 升级前端依赖后要重新审计 polyfill 清单,步骤与 View Transitions 的降级见 `apps/web/CLAUDE.md`。 ### 时间只有一个锚点:`apps/api/src/time.ts` **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** 不要写 `new Date(x).getHours()`、`setHours(0,0,0,0)`、`getFullYear()`、`new Date(y, m, d)` 这类跟 **进程时区**走的代码 —— 容器是 UTC、开发机是本机时区,两边答案不同而且不报错。 SQL 里要按日历切,用 `localTime(列)`(生成 `列 at time zone 'Asia/Shanghai'`), 别依赖数据库会话时区。 **分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。** - **存**:时间列全是 `timestamptz`,写侧一律 `new Date().toISOString()`。 - **判定**:日历语义走 `time.ts`,SQL 用 `localTime()`。 - **出参**:`db/index.ts` 给 OID 1184 挂了 parser,读出来的时刻统一成 ISO 8601 UTC, **微秒必须保留**(截成毫秒会让翻页每页丢一条、班级 AC 排名少 1)。 - **渲染**:前端 `parseTime()` / `zonedParts()` 按同一个固定偏移取东八区部件 (见 `apps/web/CLAUDE.md`)。 时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES` 在 `packages/contract/src/time.ts`, 前后端共用一份,按**固定偏移**算(大陆 1991 年起没有夏令时),与旧栈的东八区口径一致。 **再动日历口径之前先读 `docs/timezone.md`**,那里有实测数据和核实方法; Dockerfile 的 `TZ` 和数据库连接的 `TimeZone` 是**刻意不设**的,别「顺手补上」。 ## 部署 三套 compose 在 `docker/`:`dev`(本机)、`debian`(服务器)、`school`(机房)。 **机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,但各有各的 Redis 和判题沙箱 —— 所以涉及两边的变更要一起做。 库是 PostgreSQL 18,本机 dev 用同一个大版本,别单独换。迁移在部署时自动跑,**破坏性迁移 默认拦截**,要 `OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh` 显式放行。 ⚠️ **`DATA_DIR` 默认值 `../data` 是 `OJ2/data`,不是部署目录的 `data/`。** 沿用旧数据却忘了 设它,会静默起一套空数据(空库、没测试点、图片 404),而且**不报错** —— 这是整个部署里 唯一会静默走歪的地方,`deploy.sh` 为它专门设了一道自检。 上线两条路(push 触发 CI / 手工 `docker/deploy.sh`)、部署后的验证清单、NPM 反代那两个 不能关的开关、备份恢复的两个坑,都在 `docs/deploy.md`。