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>
This commit is contained in:
2026-09-16 08:03:26 -06:00
parent 3559ae4d6f
commit a8408c0bb5
34 changed files with 593 additions and 7570 deletions

479
CLAUDE.md
View File

@@ -1,25 +1,27 @@
# CLAUDE.md
OJ2 是判题狗Online Judge的后端重写Django 6 → Bun + TypeScript前后端同仓。
上一代在 `../OnlineJudge/`Django`../ojnext/`Vue SPA**仍然完全冻结、
一行都不改**。
上一代在 `../OnlineJudge/`Django`../ojnext/`Vue SPA
> **旧栈已不可逆地下线** `0002_drop_django_leftovers` 删掉了 Django 的框架表
> `django_session` 等),且已在生产库执行完毕。所以「停新栈起旧栈」「把 NPM 上游
> 改回 8080」都已失效**唯一退路是从数据库备份恢复** —— 切换手册里的「回滚保证」
> 那节只剩历史价值。
> **旧栈已不可逆地下线**`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在生产库
> 执行完毕,漏网的一张空 `django_migrations` 由 `0014` 补删)。所以「停新栈起旧栈」已经
> 不是退路**唯一退路是从数据库备份恢复**
>
> 生产库上 0002 有一张没删干净0 行的 `django_migrations`,来源已无法复原),
> 由 `0014_drop_django_migrations` 补删,前因后果写在那个迁移文件的注释里。
>
> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修
> 所有后续工作,包括在旧仓库里发现的 bug都只落在 OJ2先确认 OJ2 是否有对应逻辑、
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
> 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,
> 不再是回滚保证。
> **旧仓库仍然零改动**,没有例外 —— 包括修 bug、包括不影响外部接口的内部小修。
> 所有后续工作,包括在旧仓库里发现的 bug都只落在 OJ2先确认 OJ2 是否有对应逻辑、是否
> 重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户「未处理,按当前政策不动旧仓库」,
> 不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,不再是回滚保证
设计文档`docs/specs/2026-08-06-bun-backend-rewrite-design.md`
切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走
细节文档`CLAUDE.md` 只留日常要记住的,展开都在这几份里):
| 文档 | 什么时候读 |
|---|---|
| `docs/deploy.md` | 部署、上线、备份恢复 |
| `docs/database.md` | 写迁移、给新库打基线、drizzle-kit 抽风 |
| `docs/timezone.md` | 动日历口径、动时间出参格式 |
| `docs/contract.md` | 动 zod 契约、想给某个字段加校验 |
| `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter |
| `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 |
## 仓库结构
@@ -28,18 +30,18 @@ OJ2 是判题狗Online Judge的后端重写Django 6 → Bun + TypeScrip
| `apps/api/` | 后端。Hono + Drizzle + BullMQ编译成单二进制 |
| `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA |
| `packages/contract/` | 前后端共用的 Zod 契约 |
| `docker/` | Dockerfile + 三套 composedev / debian / school |
| `docs/specs/` | 设计、端点清单、各阶段评审报告与演练报告 |
| `docker/` | Dockerfile + 三套 composedev / debian / school+ 部署与运维脚本 |
| `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) 一起起
```
@@ -51,15 +53,15 @@ bun run dev # api(3000) + worker + web(5173) 一起起
```bash
bun run --filter '@oj2/api' typecheck # 后端类型检查
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下
bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑
bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑
cd apps/web && bun run type-check # 前端类型检查
cd apps/web && bun run build # 前端构建
```
⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子
都会**静默通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个
tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.json`
`vite build` 根本不做类型检查。改完 .vue / .ts 别拿构建当验证。
⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子都会**静默
通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` +
references 的壳,真正的配置在 `tsconfig.app.json``vite build` 根本不做类型检查。
改完 .vue / .ts 别拿构建当验证。
**不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
本机 Docker 全套都能起,实跑的成本比想象中低。
@@ -68,17 +70,16 @@ tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.
### 单二进制是有代价的
`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖
node_modules**。任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的
写法,本地都正常、编译后都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着
cwd 摸到了 node_modules假装没事)。
`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖 node_modules**。
任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的写法,本地都正常、编译后
都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着 cwd 摸到了 node_modules
假装没事)。
资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法
只有打包器认、`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts`
的注释,那里把坑写全了。
资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法只有打包器认、
`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts` 的注释,
那里把坑写全了。
**改完这类代码dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,
dev 直接起不来。
**改完这类代码dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,dev 直接起不来。
### 路径解析看 `runtime.ts`
@@ -87,17 +88,15 @@ dev 直接起不来。
### SQL 判题会 spawn「自己」
`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上
没有 child.ts 可以 spawn。所以**入口必须有 argv 分发**,否则「起自己」变成
「把整个程序再跑一遍」→ 指数级 fork。这不是假想开发时炸过一次开发机。
`OJ2_SQL_CHILD` 那道递归闸不要删。
`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上没有
child.ts 可以 spawn。所以**入口必须有 argv 分发**,否则「起自己」变成「把整个程序再跑
一遍」→ 指数级 fork。这不是假想开发时炸过一次开发机。`OJ2_SQL_CHILD` 那道递归闸不要删。
### 加路由要防遮蔽
**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id`
注册在 `/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,
只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,
一直到评审才发现。
**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` 注册在
`/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,只是静默走进前一条的
handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,一直到评审才发现。
加完路由跑 `bun run --filter '@oj2/api' check:routes`
@@ -109,381 +108,121 @@ dev 直接起不来。
### 出参不 `parse`,用 `satisfies`
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。**
出参是后端自己刚拼出来的字面量TS 已经在编译期校验过;再 `parse` 一遍拿不到任何新
信息,唯一可能失败的输入是**库里的历史数据**,而失败的代价是 500。这一层原来有 136 处,
已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚
拼出来的字面量TS 已经在编译期校验过;再 parse 一遍拿不到任何新信息,唯一可能失败的输入是
**库里的历史数据**,而失败的代价是 500 —— 这条规矩是被四次这样的线上故障换来的。
- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time`
是全库唯一可空的列961 道题里 470 道是 NULL——**后台打开任何一道没编辑过的老题都是 500**
- `embeddedSubmissionSchema` `submissionDetailSchema` 继承了 `problemDisplayId` 却没
omit而路由只填了同义的 `problem`——**凡是收到过站内信的人,消息页都打不开**(列表为空
时才碰巧不炸,所以一直没人报)。
**闸设在写入侧**:入参 `safeParse`58 处)、`db/schema.ts``.$type<>()` 列收窄、
语义校验函数(`astRulesError()` / `exerciseDataError`。JSONB 原文
`submission.info` / `statistic_info` / `exercise.data`)一律放行,它们的形状真相在判题机
那边。query 的筛选值走 `routes/helpers.ts``asFilterValue()`,那是纯类型交接、不加校验。
两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类:
`exerciseSchema` 按题型收紧后一行脏数据让整条练习列表 500`info` 写成
`union([完整形状, z.object({})])` 后对不上的一律落进空对象那支且 parse **成功**
管理员详情页的测试点表格静默消失(全量核出 9163/124192 条中招RE 8480/8480 全中——
沙箱在非正常退出的测试点上写 `output_md5: null`,而契约写的是 `z.string()`)。
**闸设在写入侧,一共三处形态:**
1. **入参 `safeParse`**58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 400。
2. **`db/schema.ts``.$type<>()`** —— 枚举型的列(`submission.result` / `.language`
`problem.difficulty` / `.languages``achievement.rarity``exercise.type`…)和几个
形状确定的 JSONB`problem.template` / `.astRules` / `.sqlConfig` / `.sqlDisplay`
`acm_contest_rank.submission_info`)直接在列上收窄,只影响 TS、不产生任何 SQL。
这些断言**逐列拿根目录那份生产备份核过**12.4 万条提交的 `result` 全在 `-2..6,10`
961 道题的 `languages` 全是合法数组、10050 条榜单条目形状全对)。
加这类断言前先照样核一遍,别凭直觉。
3. **语义校验函数** —— `astRulesError()``services/exercise.ts``exerciseDataError`
**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)仍然一律放行**
读出侧不收窄:它们的形状真相在判题机那边。
query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts``asFilterValue()` ——
那是纯类型交接,**不加校验**:在那儿拦一道会把「筛出空列表」变成「筛条件被忽略、
返回全部」。前端那侧(`utils/contract.ts` 为什么只挂三处)见 `apps/web/CLAUDE.md`
唯一还留着 `parse` 的地方是 `judge/events.ts``parseSubmissionEvent` ——
那是从 Redis 收回来的报文,真边界,且失败返回 `null` 而不是 500。
四次故障的细节、`.$type<>()` 断言该怎么核,见 `docs/contract.md`
前端为什么只在三处挂运行时闸门,见 `apps/web/CLAUDE.md`
### AST 代码规则:一张表,外加一个机器检查
契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表,一个 target 一条
`{ label, node }``label` 给后台下拉和题目页,`node` 给判题机比 tree-sitter 节点类型。
运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 一份两用(它的值既是文案又是要比的 token
判题机侧没有第二张表,解析统一走契约的 `astTargetNodeType()`
> 这里原来是两张表:契约那张 target → 中文名,`judge/ast.ts` 的 `mappings` 是
> target → 节点类型,靠一句「两边必须同增同减」的注释维持。**加 target 而漏配节点类型
> 现在在结构上不可能了**,那条注释也就不必再守。
但**配错**仍然可能,而且完全静默:节点类型对不上就是一个都收不到,于是「必须使用 X」
永远失败、「不能使用 X」永远通过两头不报错只有学生受着。所以有
契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表`label` 给界面、`node` 给判题机),
判题机侧没有第二张表,所以加 target 漏配节点类型在结构上不可能。但**配错**仍然可能,
而且完全静默 —— 节点类型对不上就是「必须使用 X」永远失败、「不能使用 X」永远通过
```bash
bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里是否真实存在
bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑
```
**升级 `tree-sitter-*` 依赖之后一定要跑一次** —— 语法改节点名是常事,后果全静默。
加这个检查那天56 个 target 里就抓出一个:`f_string` 一直配的是 `format_string`
而这个版本的 tree-sitter-python 根本没有这种节点f-string 是 `string` 里带
`interpolation`),所以「不能使用 f-string」从上线起就没生效过。
它只验节点类型**存在**,不验语义对不对(把 `while_loop` 配成 `for_statement`
这种两个都存在,机器看不出来),语义那层还是得实跑。
判题机只认 `AST_SUPPORTED_LANGUAGES` 里的语言C / C++ / Python3。别的语言配了规则
一条都不会跑,所以后台不给它们开 tab题目页也不把它们的规则展示成「要求」——
**看得见却不检查**比没有更糟。
C++ 的语法表是「C 的全集 + C++ 独有的几条」,因为 tree-sitter-cpp 继承 tree-sitter-c
C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者不同**,加语言时必须一起看:
`a.push_back()``p->push_back()` 在 C++ 都是 `call_expression` + `field_expression`
不是 Python 的 `attribute``std::sort(...)` 的 function 是 `qualified_identifier`
而不是 `identifier`,所以 `functionCalls` 对 C++ 额外比一次 `::` 末段——否则学生写了
`using namespace std` 与否会得到不同的判定结果。
规则的语义校验在 `astRulesError()`,不在 zod 的 refine 上:`astRulesSchema` 同时用于
**读**后台题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开。同理,保存前
`pickAstRules()` 剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师锁死
——tab 里看不到那组规则,保存却被拦下。
判题机只认 C / C++ / Python3`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑,
所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、
规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`
### 比赛只有 ACM 模式
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要顺手补回来
### 前端基线是 Chrome 1052026-09-16 从 < 94 上调)
机房**部分**电脑是 Chrome 105其余更新 —— 按最低那档定基线。
- **`@vitejs/plugin-legacy` 留着,别删**vite 8 的默认构建 target 是 `chrome111`
比 105 高。这个插件同时把 `build.target` 压到 `es2020/chrome105`、给现代产物补
core-js polyfill`toSorted` / `Set` 运算 / 迭代器辅助那批是 Chrome 110+ 才有的)。
`modernTargets` 不写,用插件自带的基线(`chrome>=105`),正好是这一档。
polyfill 清单写死在 `vite.config.ts`**升级前端依赖后重新审计**
`DEBUG=vite:legacy bun run build` 会打印探测到的全集。
- **Chrome < 94 那套删掉了**`mermaid-legacy`mermaid@9、cytoscape 的 UMD→ESM
别名、`useMermaid.ts` 里按 UA 分叉的 v9 回调式 render —— 105 用得上 mermaid 11。
- **`@vitejs/plugin-legacy` 留着,别删**vite 8 的默认构建 target 是 `chrome111`比 105 高。
这个插件同时把 `build.target` 压到 `es2020/chrome105`、给现代产物补 core-js polyfill
`toSorted` / `Set` 运算 / 迭代器辅助那批是 Chrome 110+ 才有的)。`modernTargets` 不写,
用插件自带的基线(`chrome>=105`),正好是这一档。polyfill 清单写死在 `vite.config.ts`
**升级前端依赖后重新审计**`DEBUG=vite:legacy bun run build` 会打印探测到的全集。
- **Chrome < 94 那套删掉了**`mermaid-legacy`mermaid@9、cytoscape 的 UMD→ESM 别名、
`useMermaid.ts` 里按 UA 分叉的 v9 回调式 render —— 105 用得上 mermaid 11。
- **View Transitions 要 111105 没有**`darkTransition.ts` 的降级分支是真在用的。
### 时间只有一个锚点:`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'`别依赖数据库会话时区。
旧栈 Django 是 `TIME_ZONE = "Asia/Shanghai"` + `USE_TZ = True`:库里存 UTC、
应用层按北京时间算日历。重写时这个锚点丢了,直到 2026-09 才收回来 —— 期间
「今日提交」在北京时间 0:008:00 是空的两个成就「凌晨提交次数」0:005:00、
「早起提交次数」5:007:00整体偏 8 小时。别再把口径散出去。
时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES``packages/contract/src/time.ts`
前后端共用一份。实现按**固定偏移**算(大陆 1991 年起没有夏令时),不查 tzdata、不用
`Intl`,所以 dev / 编译产物 / 任何镜像基底 / 任何浏览器都算得一样。
**刻意不设** Dockerfile 的 `TZ`、也不设数据库连接的 `TimeZone`:它们不改变正确代码的
行为,只会在线上把漏写的地方掩盖掉(`/problems/:displayId/yearly-ac` 就这样漏过一次),
而 dev 上又是另一个答案。
**凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** 不要写
`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 → 前端按东八区渲染。**
- **存**35 个时间列全是 `timestamptz``without time zone` 0 个、`date` 0 个),
写侧一律 `new Date().toISOString()`。库里永远是绝对时刻,换时区不用动数据
- **判定**日历语义(哪一天/几点/哪一年)走 `time.ts`SQL 用 `localTime()`
- **出参**`db/index.ts` 给 OID 1184 挂了 parser**所有读出来的时刻统一成
ISO 8601 UTC**`2026-09-14T12:00:00.000Z`,库里带微秒的保留成 `…00.123456Z`)。
别在这里退回去 —— 原来 drizzle 把 1184 的 parser
换成了恒等函数,读出来是 PG 文本(`2026-09-14 20:00:00+08`),于是同一个字段在
接口上有两种形状实测同一批端点PG 文本 77 处 + ISO 14 处),对接外部系统时对方
得解析两套,而偏移还取决于服务器会话时区、不该进契约。
⚠️ **微秒不能丢**,别改回 `new Date(v).toISOString()`:读出的时刻常被原样塞回查询条件
(提交列表翻页的分界行、班级 AC 排名的 `<= min(create_time)`),截成毫秒后分界行自己
被排除 —— 翻页每页丢一条、排名少 1。生产库 12.3 万条 Django 时代的提交几乎全带微秒。
⚠️ **`::text` 的 OID 是 25、绕过那个 parser**,所以「为了拿回和列一样形状」而写的
`max(join_time)::text` 之类现在会变成异类,见到就撤掉。**只换 1184别碰 1082(date)**
—— `date(... at time zone ...)` 要的是 `2026-09-14`,套上 `toISOString()` 就错了。
- **渲染**:前端 `parseTime()` / `zonedParts()` 按同一个固定偏移取东八区部件(见
`apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse
`normalizeDate`)实测都能吃 ISO改动出参格式不需要动前端。
- **存**35 个时间列全是 `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`)。
### 存量成就的口径是东八区,别再退回 UTC
2026-09-14 用生产备份 `db_backup_2026_09_08_19_22_11.sql` 实测过,结论和直觉相反
记在这里省得下次重新推。下面「那两周留下的实际后果」和脚本的跑数,又用
`db_backup_2026_09_14_18_17_37.sql`(最新提交到北京时间 9-14 17:06逐项复核过一致。
**历史指标本来就是北京时间。** 旧栈 `OnlineJudge/achievement/metrics.py` 全程用
`timezone.localtime(...)`,而 `settings.TIME_ZONE = "Asia/Shanghai"`,所以
2022-04 到 OJ2 上线之间那 10 万多条提交累积出的 `user_stat.metrics` 是**东八区口径**。
判别性核对(只在新旧口径算出不同值的用户里看存量更像哪边):
| 指标 | 两口径不同 | 存量==UTC | 存量==东八区 |
|---|---|---|---|
| `midnight_submissions` | 1259 | 132 | 1123 |
| `early_bird_submissions` | 909 | 1 | 905 |
| `active_days` | 90 | 0 | 90 |
| `max_ac_in_one_day` | 17 | 0 | 17 |
| `max_ac_streak_days` | 40 | 0 | 40 |
**所以修 OJ2 的时区不是「换口径」,是「把 OJ2 弄丢的口径补回来」。** 别以为改动会让
存量数据失配 —— 失配的是 OJ2 上线后那两周,修完反而对齐了。
**那两周留下的实际后果**(截至 2026-09-14 备份1508 条 OJ2 期提交、约 1500 个已结算用户):
- **日期键没被污染**`_active_dates` / `_ac_per_day` 一个都没偏 —— 上课时间的提交
在 UTC 下日期和北京是同一天。所以 5 个日期口径的成就(活跃天数、单日最多 AC、
连续天数)一条都没错。
- **只有小时键被污染**145 人 `midnight_submissions` 虚高、45 人 `early_bird_submissions`
虚高。因为 UTC 的「凌晨 05 点」正好是北京的上午 913 点,而学生恰恰在上课时间提交。
- **结果是 60 条误发**47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2
时期的新账号(`backfilled = false`)。
- **0 条漏发**而且是结构性的OJ2 窗口那 1508 条提交里,真正落在北京 05 点和
57 点的**都是 0 条** —— 真熬夜、真早起的人都在 Django 时代活跃过了,他们的成就
是当时按东八区正确发的。这个 bug 只会多给,不会少给。
**改数据时最大的坑:不能只删 `user_achievement`。**
`unlockAchievements()` 的判定是**纯阈值比较**`metrics[metric] >= threshold`),不是
「这次有没有跨过阈值」。只删行、不修 `user_stat.metrics` 的话,学生**下一次提交就把
同一个成就原样再发一次**。必须「按东八区重算小时指标」和「对账发放」一起做。
现成的工具是一次性对账脚本 `apps/api/src/scripts/fix-achievement-hours.ts`
`bun run --filter '@oj2/api' fix:achievement-hours`,默认 dry-run`--apply` 才写):
重算两个小时指标 → 撤回不达标的 → 补发达标却没发的 → 同步 `achievement.unlock_count`
→ 校正 `achievement_unlocked_count` 与「奖杯收藏家」连锁。实测幂等2026-09-14 那批
跑出来是「修正 148 行 · 撤回 60 条 · 补发 0 条 · 连锁 0 条」。
⚠️ **顺序:先部署时区修复,再跑这个脚本。** 反过来的话,旧代码还在按 UTC 累加,
跑完马上又被写脏、成就又发回来。
**下次再动日历口径,照这套方法核实**:从生产备份里捞出 `submission` / `user_stat` /
`user_achievement` / `achievement` 四张表回放一遍,先用与时间无关的指标
`submission_count` / `accepted_count`)校准重放器(实测逐人 0 差异 / 4 人差异),
再比受影响的指标。别靠推理 —— 这次推理就得出过相反的结论。
时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES``packages/contract/src/time.ts`
前后端共用一份,按**固定偏移**算(大陆 1991 年起没有夏令时)。旧栈的口径本来就是东八区,
重写时丢过一次、2026-09 才收回来 —— 期间「今日提交」在北京时间 0:008:00 是空的
两个小时口径的成就整体偏 8 小时,事后已用一次性脚本对账订正(账平了,脚本已删)。
**再动日历口径之前先读 `docs/timezone.md`**,那里有实测数据和核实方法;
Dockerfile 的 `TZ` 和数据库连接的 `TimeZone` 是**刻意不设**的,别「顺手补上」。
## 数据库
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 翻译成「请改为禁用账号」,这是有意的。
problem_tags、题单/教程/成就的组成部分、一对一附属user_profile与可重算的缓存
user_stat
- **NO ACTION即拦住**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
**加新子表时必须回来想一遍该走哪一档**,别默认新外键会自己连坐 —— drizzle 不写
`.onDelete()` 就是 NO ACTION而 0010 只改了当时存在的那批。
**schema 现在归 OJ2 独占。** 旧后端已下线,「改 schema 要考虑回滚」这条约束不再存在,
结构变更走下面的 migration 正常演进即可。
### 改 schema 走 drizzle migration
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 `drizzle.__drizzle_migrations`
增量执行),就是 Django `makemigrations` / `migrate`等价物。索引/结构变更走这条,
不要再手写 SQL 往 `docs/specs/` 里塞。
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按
`drizzle.__drizzle_migrations` 增量执行),就是 Django `makemigrations` / `migrate`
等价物。索引/结构变更走这条,不要再手写 SQL 往 `docs/` 里塞。
**部署时自动执行。** `docker/deploy.sh` 在「构建镜像」之后、「起栈」之前会跑
`oj2-api migrate`失败就中止部署旧容器原样还在跑。CI 走的也是 deploy.sh
所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放
- **执行器是自己的**`db/migrate.ts`,一条迁移一个事务),不是 drizzle 那个,
`db:migrate` 和线上 `oj2-api migrate` 是同一条代码路径。
- **部署时自动执行**`docker/deploy.sh` 在构建镜像之后、起栈之前跑,失败就中止部署
- 迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
(见 `runtime.ts``migrationsDir`),所以新增迁移不用改任何代码。
- **破坏性迁移默认拦截**`DROP TABLE` / `DROP COLUMN` / `ALTER COLUMN ... TYPE` /
`TRUNCATE`),退出 4要显式放行`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`
- **空库能自举**,直接从 `0000` 建起,新环境不需要先灌 schema dump。
迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
(见 `runtime.ts``migrationsDir`、Dockerfile 里那两条 COPY。这样 drizzle 的
`migrate()` 能原样用——它靠 `meta/_journal.json` 自动发现迁移,**新增迁移不用改任何
代码**。内嵌就得为每条迁移手写一行 import那是迟早会漏的账。
**破坏性迁移默认拦截。**`DROP TABLE` / `DROP COLUMN` / `DROP SCHEMA` /
`ALTER COLUMN ... TYPE` / `TRUNCATE` 的迁移会让部署停在迁移这步并退出 4
需要确认备份后显式放行:
```bash
OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
```
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
**空库自举时这道闸不生效**没有数据可丢0002 那串 `DROP ... IF EXISTS` 全是空转,
拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。
**放行的三条路,别记错:**
1. 服务器上手工部署:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`
2. CI`.github/workflows/deploy.yml`**必须先手工触发**并在
`workflow_dispatch` 上勾 `allow_destructive`。push 触发拿不到这个 input值恒为空
—— 也就是说**自动部署永远不会执行破坏性迁移**,只会停在闸门上把工作流判红。
这是有意的:那种改动得有人先确认备份。
3. 先单跑迁移把结构推到位,再 push 代码:迁移一旦记进
`drizzle.__drizzle_migrations` 就不会再跑,后续自动部署里它已不是 pending
自然不触发闸门。多环境共库时(机房 + 服务器)推荐这条。
**空库能自举了。** `oj2-api migrate` 指向一个空库时直接从 `0000` 建起:
```bash
DATABASE_URL=postgres://... oj2-api migrate
# 空库,从 0000 开始自举。
# 待执行 15 条迁移,开始。
# ✓ 0000_crazy_gateway
# ✓ 0001_add_submission_public_create_time_idx
# ✓ 0002_drop_django_leftovers
# …
# ✓ 0014_drop_django_migrations
```
`0000_crazy_gateway.sql` 原本是 `drizzle-kit pull` 的产物、整份被 `/* */` 包着、可执行
语句 0 条,所以以前新库只能先手工 `psql -f docs/specs/schema.sql`。现在它的内容由那份
生产 dump 机械转换而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键,
其余原样保留)。**实测**:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」
这条老路子跑出来的结构,`pg_dump --schema-only` 逐字节一致734 行,零差异)。
改 0000 对生产库没有影响 —— migrator 只比 `created_at`、**从不校验 hash**
`pg-core/dialect.js` 里就一句 `Number(lastDbMigration.created_at) < migration.folderMillis`
而生产库那行 `baseline-0000-faked` 早把它挡在门外了。
⚠️ **0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。**
`readMigrationFiles` 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开,
后半截当成 SQL 发出去,报的是 `syntax error at or near "。"` 这种和真实原因毫不相干的错。
**给一个已经存在的库做基线**drizzle 没有 `--fake-initial``migrate` 见到空的
`__drizzle_migrations`、库里却已经有表,会拒绝执行并 exit 3裸跑 `drizzle-kit migrate`
的话则是从 `0000` 撞上已存在的表、整个事务回滚,**而且 exit 1 却一个错误都不打印**)。
对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行:
```sql
CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when
```
migrator 只比 `created_at`,不校验 hash所以 hash 随便填。
**已知的三个坑**`meta/0000_snapshot.json``pull` 出来的,没法无损还原 Django 建的
schema下面三处已经修过了别让它们回潮
- ~~**快照里的 Django 序列**~~:已随 `0002_drop_django_leftovers` 删表一并解决,
`tablesFilter` 也移除了。(历史原因:`tablesFilter` 只过滤表、不过滤它们的序列,
于是 `generate` 会吐出 5 条 `DROP SEQUENCE`。)
- **bigint 上限精度**`pull` 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量,
round-trip 成 `...776000`,每次 generate 都会多出 10 条 `ALTER COLUMN ... SET MAXVALUE`
已改成字符串。
- **表达式索引的 opclass**`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
**还有一个写代码时要绕开的**
- **`.op()` 会吞掉索引方向**:真正的根因不是 `.desc()`,是 opclass。drizzle-kit 的
`CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支:
`${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`。而 `drizzle-kit pull`
给**每一列**都挂了 `.op(...)`,所以本仓库里"写了 `.desc()` 却生成不出 DESC"每次都会重演。
**要方向就别写 `.op()`。** 不写没有任何代价——`int4_ops` / `timestamptz_ops` 本来就是
这些类型的默认 opclass写了等于没写。实测drizzle-kit 0.31.10,探针索引跑过 generate
| schema.ts | 生成的 SQL |
|---|---|
| `.desc().nullsFirst().op("timestamptz_ops")` | `"create_time" timestamptz_ops` ← 方向丢了 |
| `.desc().nullsFirst()` | `"create_time" DESC NULLS FIRST` ✅ |
| `.desc()` | `"create_time" DESC NULLS LAST` ✅ |
所以**多列混合方向的索引可以正常 generate**,不必手写。
假 diff 的机制也要理解对:带 `.op()` 时快照记的是 `asc: false`SQL 建出来却是 ASC
**分歧在快照和真实库之间**,不在快照和 schema.ts 之间——所以再跑 generate 是干净的,
要等到下次 pull 才炸出来。这是当初难定位的原因。
### 迁移执行器是自己的,不是 drizzle 那个
`db/migrate.ts` 不调用 drizzle 的 `migrate()`,自己按 journal 逐条执行。换掉它是因为
`pg-core/dialect.js` 里那个实现有两条硬伤:
1. **所有待执行的迁移共用一个事务**,第 3 条失败会把第 1、2 条一起回滚。现在是**一条一个
事务**,语义和 Django `migrate` 一致,失败时也说得清库停在哪儿。
2. 正因为全在事务里,`CREATE INDEX CONCURRENTLY` 一律跑不了,没有开关。
记账行的写法和 drizzle 完全一致(`hash` = 整个文件的 sha256`created_at` = journal 的
`when`),而 migrator 只比 `created_at`、不校验 hash所以两套执行器可以互换不会看不懂
对方写的记录。
**`CREATE INDEX CONCURRENTLY` 现在能跑了。** 在迁移文件**第一行**写上标记:
```sql
-- oj2:no-transaction
CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language");
```
这条迁移就走裸执行(简单查询协议,不包事务)。代价是**没有回滚**:中途失败时前面的语句
已经生效,而且 CONCURRENTLY 失败会在库里留下一个 INVALID 索引,要先
`DROP INDEX` 再重来(`select indexrelid::regclass from pg_index where not indisvalid`
能找出来)。所以**这种迁移一个文件只放一条语句**。
要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 `CREATE INDEX` 只锁 74ms
一般不用纠结CONCURRENTLY 留给真扛不住锁写窗口的场合。
退出码2 = 配置/文件问题3 = 基线不对4 = 撞上破坏性迁移5 = 某条迁移执行失败。
`CREATE INDEX CONCURRENTLY` 怎么写、给已有库打基线的 SQL、`.op()` 会吞掉索引方向这类
drizzle-kit 的坑,全在 `docs/database.md`
## 部署
三套 compose 在 `docker/``dev`(本机)、`debian`(服务器)、`school`(机房)。
**机房那套没有 postgres连的是服务器的库。** 两个站点共用一个数据库,
但各有各的 Redis 和判题沙箱 —— 所以上线那天**两边必须一起切**
**机房那套没有 postgres连的是服务器的库。** 两个站点共用一个数据库,但各有各的 Redis
和判题沙箱 —— 所以涉及两边的变更要一起做
`compose.debian.yml` 有两种形态,靠 env 切换:
`compose.debian.yml` 靠 env 切形态:设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST` 就是接现有的库
(线上就是这个),留空并加 `--profile local-data` 就是自带 postgres / redis。
- **只换前后端**(上线用这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST`
沿用旧栈已经在跑的 postgres 和 redis只起 api / worker / web / judge。
- **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data`
- **并行试跑**(上线前先挂 `oj2.xuyue.cc` 跑几天):在「只换前后端」基础上再加
`WEB_PORT`8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。
这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。
⚠️ **`DATA_DIR` 默认值 `../data` `OJ2/data`,不是部署目录的 `data/`。** 沿用旧数据却忘了
设它,会静默起一套空数据(空库、没测试点、图片 404而且**不报错** —— 这是整个部署里
唯一会静默走歪的地方,`deploy.sh` 为它专门设了一道自检
⚠️ `DATA_DIR` 默认值 `../data`**`OJ2/data`**,不是部署目录的 `data/`
沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404
而且**不报错** —— 这是切换当天唯一会静默走歪的地方。
细节和演练结果都在 `docs/specs/phase5-cutover-runbook.md`
上线两条路push 触发 CI / 手工 `docker/deploy.sh`、部署后的验证清单、NPM 反代那两个
不能关的开关、备份恢复的两个坑,都在 `docs/deploy.md`