Commit Graph

17 Commits

Author SHA1 Message Date
586c88f629 build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
Some checks failed
Deploy / deploy (push) Has been cancelled
一条线上的三件事:让 drizzle 的迁移机制真正可用 → 用它加索引 → 用它清掉
不再需要的 Django 表,最后接进部署和 CI。

## 1. 让 drizzle-kit generate 可用

原本以为不能用:只加一个索引,generate 却吐出一堆噪音,其中 5 条
`DROP SEQUENCE auth_*/django_*` 打到生产库上会直接搞坏旧后端。
逐个查下来全是 `pull` 出的基线自己不能 round-trip,都是可修的:

- **快照里的 Django 序列**:tablesFilter 只过滤表、不过滤它们的序列。
  已从 0000_snapshot.json 清掉。
- **bigint 上限精度**:pull 生成的 `maxValue: 9223372036854775807` 是 JS
  number 字面量,round-trip 成 ...776000,每次 generate 都多出 10 条
  ALTER COLUMN。改成字符串。
- **表达式索引的 opclass**:problem_tag_name_ci_unique 在快照里带
  opclass,drizzle 自己序列化不出来,导致每次 drop + recreate。已去掉。

改完 `generate` 是干净的 no-op。

两个改不掉、只能绕的写进了 CLAUDE.md:索引 `.desc()` 生成 SQL 时会被丢
(单列索引不写方向即可,Postgres 用 Index Scan Backward 服务 ORDER BY
DESC,实测同样 0.08ms);migrator 把所有语句包一个事务,
CREATE INDEX CONCURRENTLY 跑不了。

最容易吃亏的是 drizzle 没有 --fake-initial:对已有数据的库直接 migrate
会从 0000 跑起、撞表回滚,**而且 exit 1 但一个错误都不打印**。

## 2. 0001 提交列表索引

`WHERE contest_id IS NULL ORDER BY create_time DESC LIMIT n` 用不上现有的
contest_create_time_idx (contest_id, create_time DESC) —— Postgres 不把
`IS NULL` 当成能吃掉首列、从而继承第二列有序性的等值条件。把
enable_seqscan / enable_bitmapscan 全关掉逼它用也不肯,宁可走单列
contest_id 索引再全量排序。于是每翻一页都 Parallel Seq Scan 扫完整张表。

换成部分索引后谓词由索引自己保证,索引序就是查询要的排序序。生产快照
(12.3 万条提交)实测首页取 10 行:61.8ms / 读 18936 blocks →
0.22ms / 读 34 blocks。端到端 94ms → 6ms。

真正要命的不是单次 61ms,是每个请求都要把 169MB 的表刷一遍
shared_buffers —— 一节课几十个学生同时开提交列表,磁盘和缓存直接被打穿。

## 3. 0002 删掉 Django 残留

确认旧 Django 后端不再使用、也不再作为回滚路径。删前核实过:没有任何
OJ2 保留的表引用这 7 张,3 条外键全在它们内部(所以不用 CASCADE,真有
漏网的会报错而不是被悄悄级联掉);5 个序列都由各自的表 owned,随
DROP TABLE 一并消失;数据全是 Django 自身元数据。tablesFilter 随之移除。

**回滚路径就此作废** —— CLAUDE.md 开头和 runbook 的「回滚保证」「七、回滚」
都改了。这条迁移已在本机 dev 库和生产快照副本上跑通,**生产库尚未执行**。

## 4. migrate 接进部署与 CI

deploy.sh 在构建之后、起栈之前跑 `oj2-api migrate`,失败就中止部署(旧
容器原样还在跑)。CI 走的也是 deploy.sh,所以不用给 GitHub 配数据库凭据,
也不用把生产库对外开放。

迁移文件**不内嵌进二进制**,随镜像装在 /usr/local/share/oj2/migrations。
这样 drizzle 的 migrate() 能原样用 —— 靠 _journal.json 自动发现,新增迁移
不用改任何代码,和 Django 扫 migrations/ 是一回事。内嵌就得为每条迁移
手写一行 import,那是迟早会漏的账。(CLAUDE.md 里「单二进制不能读文件」
那条讲的是 node_modules 和 import.meta.dir 推路径,按显式绝对路径读一个
数据目录不在此列。)

三道闸门,都是写完测出来才补上的:

- **破坏性迁移拦截**:DROP TABLE / DROP COLUMN / DROP SCHEMA /
  ALTER COLUMN ... TYPE / TRUNCATE 命中就退出 4,需要
  `OJ2_ALLOW_DESTRUCTIVE=1` 显式放行。DROP INDEX / DROP CONSTRAINT 不算,
  拦了只会让人习惯性带上放行开关。扫描前先剥注释,避免误报。
- **基线缺失**:退出 3 并直接打印该敲的 SQL。注意判的是
  `max(created_at) < 0` 而不是「表不存在」—— 表存在而为空(上次迁移失败
  留下的)同样是没基线。
- **迁移目录读不到**:这是最可能犯的错(Dockerfile 漏拷),原本是 drizzle
  的堆栈,现在直接说该检查哪一行。

另外发现 0000_crazy_gateway.sql 是 pull 的产物,**整份被 /* */ 包着,
可执行语句 0 条**,所以这个库根本不能靠迁移自举建表。原先写的「空库就从
0000 建」跑起来会炸在一个和真实原因毫不相干的 unterminated /* comment 上。
现在如实说明:结构只能来自 docs/specs/schema.sql 或生产 dump。

验证:镜像内编译(ARTIFACTS=build)出的真实镜像跑完五种场景 —— 拦截、
放行、幂等、无基线、漏拷目录,全部符合预期;dev 形态同样五种场景全过。
tsc / 路由遮蔽 / deploy.sh 语法 / generate no-op 都通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:56:30 -06:00
dd6a6a04eb refactor(密码): 删掉 PASSWORD_HASH_UPGRADE,无条件写 argon2
Some checks failed
Deploy / deploy (push) Has been cancelled
上一个 commit 把五个写入点收进 hashPassword,顺手把开关默认值翻成了 true 并
留下 false 分支当退路。这个退路是假的,删掉。

**开关只能拦住将来,修不了已经发生的。** 等真到了要把旧站拉回来的那天,活跃
账号早就都被登录/重置密码升成 argon2 了,这时候设 `=false` 一个也救不回来。
正经退路是切换手册「万一已经改坏了」那节的脚本 —— 拿 `raw_password` 重算
Django 的 `make_password`,能修已经变成 argon2 的账号。留着一个永远轮不到它
上场的开关,只是给后来人多一件要读懂的东西。

删掉的:`config.passwordHashUpgrade`、`hashDjangoPbkdf2` 和它那套 Django 参数
(1200000 迭代 / 22 位 salt / RANDOM_STRING_CHARS)、两个 compose 里的
`PASSWORD_HASH_UPGRADE` env。auth.ts 的登录升级变成无条件。

保留的:

- `hashPassword()` —— 虽然现在只是一行 argon2,但「只有一个地方写密码」正是上
  一个 commit 的重点。五处各写各的才是当初那个 bug 的根。
- `verifyPassword` 的 **pbkdf2 分支** —— 生产库 1710 个账号全是 Django 写的
  pbkdf2(迭代次数 120000~1200000),只会在各自下次登录时才迁移。删了就是
  全站登不上。代码注释里写死了这句话。
- 切换手册里的 `raw_password` 恢复脚本,以及回滚流程里新加的「密码要额外处理」
  那一步。

## 验证

改完重跑了一遍关键路径(临时造一个 Django 生成的 120000 迭代老哈希):

- 存量账号登录 200 → 哈希变成 argon2 → 再登 200 → 错密码 401
- 后台重置密码 → argon2 → 新密码能登录
- 注册 → argon2 → 能登录

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过、
两个 compose `config -q` 通过。测试用户已删干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:50:55 -06:00
ed9d9f65bf chore(部署): deploy.sh 结尾别再把 NPM 当待办事项
Some checks failed
Deploy / deploy (push) Has been cancelled
反代早就配好了,每次部署完还提示「还差 NPM 那一步」是噪音。改成只报站点
地址,把 Websockets Support / client_max_body_size 200M 降级成「别关」的
提醒 —— 只有改 WEB_PORT 时才需要回 NPM 动一下。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:36:08 -06:00
6356643cc9 ci(部署): 部署走 GitHub Actions,产物挪到 runner 上编
Some checks failed
Deploy / deploy (push) Has been cancelled
服务器性能差,镜像 builder 阶段(1238 个包的 bun install + bun compile +
vite build)首次约 5 分钟,光前端就 160s。改成在 runner 上编好 dist/oj2-api
和 apps/web/dist,rsync 过去,服务器那边只剩几条 COPY。

服务器自己编的能力没有砍掉。Dockerfile 顶部加了全局 ARG ARTIFACTS:

  build(默认)    builder 阶段自己编,只要有 docker 就能手动部署
  prebuilt         用构建上下文里现成的产物,builder 不进构建图

实现是两个 scratch 阶段归一成 /artifacts/ 布局,再用变量阶段名
FROM artifacts-${ARTIFACTS} 选一个,运行时阶段不关心产物是谁编的。

deploy.sh 加 --prebuilt:产物缺失在自检就 die;顺带检测 buildx —— 没装的话
compose 会退回 classic builder,那个不看依赖图、所有阶段挨个跑,产物白编
(结果正确但不省时间),黄字提醒不阻断。

.dockerignore 末尾放行两个产物路径,必须在 dist/ 那些规则之后,
dockerignore 是最后一条匹配说了算。

顺手删掉 apps/web/.github/ —— 从 ojnext 抄来的残留,路径不对(GitHub 只认
仓库根的 .github/workflows/)从来没跑过,内容也是 ojnext 的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:30:22 -06:00
604857b25e fix(一言): 接回真数据集,之前线上返回的是三条硬编码
/quotes/random 在阶段 3 基线里只铺了个桩(三条写死的句子),读盘逻辑没移植,
所以 oj2.xuyue.cc 上一直是假数据——和 DATA_DIR 无关,哪儿部署都一样。

数据本来就在服务器上、位置也已经对上:旧栈挂 ./data/backend:/data,Django 的
HITOKOTO_DIR 就是它下面的 hitokoto/;OJ2 的 api 挂的是同一个目录。所以
Dockerfile 里跟着 TEST_CASE_DIRECTORY 写死 /data/hitokoto 即可,不用搬文件。

按分类懒加载并常驻,但只留前端用得上的 hitokoto/from 两个字段:原样缓存
7160 条要 2.5MB,裁完 1.4MB,而 api 容器只有 512m。读不到数据集时回落到
内置三条,不影响启动(本机 dev 默认就走这条)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:20:00 -06:00
face92a5dc fix(登录): 默认不再把 Django 的 pbkdf2 升级成 argon2 —— 这是道单向门
试跑第一天就出事了:在 oj2 上登录过的账号,回旧站 oj.xuyue.cc 登不上,WS 也连不上
(旧站的 Channels 认 session,没登录自然连不上,两个症状同一个根因)。

`routes/auth.ts` 原来在登录成功后把 pbkdf2 哈希重算成 argon2id 写回库。问题是:

- Bun 写出来的是 `$argon2id$v=19$…`,Django 存 argon2 是 `argon2$argon2id$v=19$…`
  (多一个算法标签、没有开头的 `$`)。Django 按 `$` 切第一段当算法名,拿到空串,认不出。
- 更彻底的一点:旧后端的 pyproject.toml 里**根本没有 argon2-cffi**,
  格式对了也验不了。

所以这不只是双跑的问题 —— 一次性切换后的回滚窗口内同样成立:学生登录过新站,
回滚之后就登不上了。演练时比对的是表结构「逐列一致」,比不出这种语义上的单向门。

改成 `config.passwordHashUpgrade` 控制,**默认关闭**,并在两份 compose 的
environment 里显式声明 `PASSWORD_HASH_UPGRADE`(原来根本没透传,想开也开不了)。
等确定不会再回滚了再打开。

## 验证(真库真容器,不是看代码)

schema.sql 起库 + 造一个 Django 格式的 pbkdf2 用户(密码已知),跑新栈实测:

- 默认形态:登录 200,库里的哈希**仍是** `pbkdf2_sha256$260000$…`,容器里
  PASSWORD_HASH_UPGRADE 为空
- 对照组 PASSWORD_HASH_UPGRADE=true:登录 200,哈希变成 `$argon2id$v=19$m=655…`
  —— 正是生产库现在的样子,锁死可复现

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:57:43 -06:00
e060327de4 fix(部署脚本): sh docker/deploy.sh 会挂在 pipefail,自己换 bash 重跑
Debian 的 /bin/sh 是 dash,没有 pipefail,一上来就 `Illegal option -o pipefail`。
在 set 之前加一行守卫,非 bash 就 exec 换 bash 重新执行(只用 dash 也认的语法)。

实测:本机 /bin/sh 指向 bash,所以这条得在容器里的真 dash 上验 ——
带守卫的版本正常往下跑(停在 docker/.env 不存在,因为只挂了脚本没挂仓库);
去掉守卫的对照版本在 dash 下直接 Bad substitution + 语法错误。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:37:06 -06:00
eadfa0b04a fix(部署脚本): 改成在服务器上直接跑,加 --check
上一版是从本机 rsync + ssh 过去的,不是想要的形态。改成服务器上跑:
代码怎么上去(rsync / 以后的 git pull)不归它管,脚本只管起栈。

五道自检,前两道是这次在服务器上真撞到的失败:

  compose 版本 ≥ 2.20   depends_on.required 是那版才有的字段
  DATA_DIR              卷指向 OJ2/data → 中止(空数据,且不报错)
  DB_HOST               DATABASE_URL 还指着 oj-postgres → 中止
  JUDGE_STATE_DIR       没设的话新旧两个判题机共用运行目录
  旧栈还活着            oj-postgres / oj-redis 是新栈的上游

--check 只做只读校验,不建目录不动容器(mkdir 挪到起栈那一段了)。

## 跑出来的两个问题

1. 版本比较写反了。原来用 `sort -V -C` 判「这两行本来就有序」,结果本机
   compose 5.4.0 被判成「太老」。改成取 min(2.20, ver),在 1.29.2 / 2.19.9 /
   2.20.0 / 2.24.5 / 5.4.0 / 0 六个值上逐个验过分界。
2. --check 里的 mkdir 会真的建目录,本机跑直接撞权限。挪走了。

自检全路径实跑过:旧栈没起时正确拦下;造两个同名容器冒充旧栈后全绿通过;
抹掉 DATA_DIR / DB_HOST 两条守卫都按预期中止并打出可操作的提示。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:34:39 -06:00
eb849ca5ab fix(镜像): apt 换清华源、去掉用不上的 ca-certificates;补部署脚本
## apt-get update 在服务器上卡死

不是慢,是挂住不返回 —— 本机构建从来没事,所以演练没暴露。换清华源。两个细节:

- trixie 的源是 deb822 格式,在 `/etc/apt/sources.list.d/debian.sources`,
  老的 `sources.list` 在这个基底里是**空文件**,改它没有任何效果。
- **只能换主机名、必须保持 http。** https 源会在这一步失败,因为镜像里还没有根证书。

`ARG APT_MIRROR` 可覆盖。

## ca-certificates 是多余的

原注释写「给 AI 接口的 https 出站用」,不成立:Bun 和 Node 一样内嵌了一份根证书,
走自己那份,不读系统的 /etc/ssl/certs。

实测:把探针二进制丢进裸 debian:trixie-slim(没有 ca-certificates)请求
api.deepseek.com,握手正常、返回 401(没带 key),和装了的镜像行为一致。
去掉之后重新构建,clang-format / ruff 都在,https 出站照旧,487MB → 483MB。

哪天镜像里加了用 OpenSSL 做 TLS 的东西(curl、wget 之类),这条要重新考虑,
注释里写了。

## docker/deploy.sh

没有 git remote 时的部署路径:本机 rsync 推代码 → 服务器上构建 → 起栈 → 冒烟。
默认不推 docker/.env(两边不是一回事,覆盖掉是静默故障),要同步显式 --env。

起栈前两道守卫,就是今天在服务器上真撞到的那两种失败:DATA_DIR 没生效(卷指向
OJ2/data)、DB_HOST 没生效(DATABASE_URL 还指着试跑形态下并不存在的 oj-postgres)。
两道守卫都用当天那份坏 env 正反跑过:齐全时放行,抹掉这两个变量时都触发。
两半的语法也都 `bash -n` 过(远端那半是 heredoc,单独渲染后再查的)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:30:50 -06:00
1a059d0b88 feat(切换): 支持并行试跑,新站先挂 oj2.xuyue.cc 跑几天
设计文档写的是「不双跑不灰度」,这次改主意了。理由:「只换前后端」形态下新栈
本来就不碰数据库进程,双跑的增量风险只剩「两个后端同时写同一个库」这一条;
换来的是**正式切换退化成改一行 NPM 上游**,比停机切换更稳,回滚也不用停任何容器。

## 两个新变量

    WEB_PORT          对外端口。8080 还被旧 backend 占着(机房是 81)
    JUDGE_STATE_DIR   判题机运行状态目录

JUDGE_STATE_DIR 是必须的:不设的话新旧两个 judger 会同时往
`data/judge_server/{run,log}` 里写。默认值用嵌套写法跟着 DATA_DIR 走
(`${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}`,compose 支持嵌套默认值,
试过),所以一次性切换那条路径完全不受影响。

test_case 和 public/upload 仍然共享 —— 那是故意的,测试点和题面图片两边必须
看到同一份。

## 手册

新增第四节「并行试跑」,后面章节顺移(原四~九 → 五~十),两处交叉引用一并改了。
第五节拆成两条路径:试跑过的只需改 NPM 上游 + 事后停旧栈;没试跑的走原来那套。
第七节回滚同理。

试跑那节写明了三件容易踩的:NPM 的 Websockets Support 必须打开(漏了的话页面
一切正常,唯独「判题中…」永远不动,而刷新一下结果就出来,自测很难发现)、
两边登录态不互通、以及双写的是真实数据不是沙盒(别在 oj2 上办正式比赛)。

## 验证

试跑形态 `config` 解析:判题机目录落到 judge_server_oj2、端口 8090、test_case
仍指向共享的那份;不设新变量时默认值一个没变(judge_server / 8080)。
四份 compose `config -q` 全通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:04:16 -06:00
cbff292551 feat(切换): compose 支持「只换前后端」,接着用旧栈的 postgres/redis
演练把一件事盖住了:当时是把生产 dump 恢复进 `OJ2/data/postgres` 的,
所以没人发现新 compose 在 `OJ2/docker/` 下,`../data` 解析出来是 `OJ2/data`,
而旧栈用的是 `<部署目录>/data`(服务器上是 `/root/OJDeploy/data`)。

照原手册切过去,postgres 会在一个空目录上初始化一个全新的空库 —— 站点起得来,
但没有用户没有题、判题全挂、题面图片 404。旧数据完好,回滚正常,但当天会白吓一场。

## 改法

三组 env 旋钮,默认值保持原样,不影响本机和演练那条路:

    DATA_DIR    所有数据卷的根,默认 ../data
    DB_HOST/PORT      默认 oj-postgres:5432
    REDIS_HOST/PORT   默认 oj-redis:6379

postgres 和 redis 挪进 `profiles: ["local-data"]`,默认不起 —— 否则会跟旧栈
那两个抢 5445 / 5446。配套给 depends_on 加 `required: false`:实测严格的
depends_on 碰上未启用的 profile 会让整个 project 直接 invalid,不是可选项。
代价写进注释了:自带数据形态下 postgres 起不来时 compose 只警告不中止。

给 api / worker 加 host-gateway 映射,DB_HOST 填 host.docker.internal 就行,
不用去猜 docker0 的网段。数据库流量不出本机。

school 那套的 7 个挂载点同样换成 DATA_DIR —— 机房那台也有自己的旧数据目录,
测试点和题面图片都在里面,同一个坑。

## 验证

用 docs/specs/schema.sql 起了个发布在宿主机 5445 的 postgres 冒充旧栈:
正好 4 个容器(没有 postgres/redis)、oj-api healthy、首页与 /api/site
/api/problems 200、未登录进后台 401。读写两个方向都验了 —— 那个库的
pg_stat_activity 里有来自 172.17.0.1 的 postgres.js 连接,judge_server 表里
也出现了新判题机写进去的心跳行。

四份 compose 的 `config -q` 全通过。

手册第三、四、六节按这个形态重写:停旧栈改成只 stop oj-backend / oj-judge
(旧判题机会争 data/judge_server/run,旧 backend 占着 8080),回滚变成把这两个
再 start 起来,数据库进程全程不停。

**DATA_DIR 漏填不会报错**(它有默认值),是切换当天唯一会静默走歪的地方,
手册里给了 `config | grep source:` 的自查和两种症状的区分。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:57:25 -06:00
f6bc42f534 docs: 给 OJ2 补一份自己的 CLAUDE.md;Dockerfile 拷上 bunfig.toml
## bunfig.toml 没拷进容器,才是那批「本地能过、容器过不了」的根因

bunfig.toml 里设了 `linker = "hoisted"`,但 Dockerfile 只拷了 package.json 和
bun.lock,于是容器里用的是默认的 isolated 布局(包都在 node_modules/.bun/ 下),
本地靠"提升"才解析得到的包在容器里一律 Could not resolve —— 而本地构建始终是好的,
只有镜像构建才炸。之前是一个一个补直接依赖补过去的(那是对的、该保留),
这里让两边布局一致,是第二道保险。

带上之后装 622 个包(isolated 是 1238),镜像重建通过,二进制在容器里
serve + healthcheck 正常。

顺手补了 main.ts 帮助文本里漏掉的 healthcheck 子命令(compose 里把 command
写错时,看到的就是这行)。

## OJ2/CLAUDE.md

OJ2 是独立仓库,之前没有自己的项目指引。写了一份,重点是几条「不知道就会踩」的:

- **本机 Docker 可用**,整套依赖和上线演练都能在本机跑 —— 别沿用上一代
  "本机跑不起来后端"的旧假设
- 单二进制不能依赖 node_modules,`.node` 资源导入 dev 和编译两种形态行为不同,
  **改完两种形态都要跑**
- SQL 判题 spawn 的是二进制自己,入口必须有 argv 分发,那道递归闸不能删
- 判题状态码三处同步、raw_password 要保留、比赛只有 ACM、前端要兼容老 Chrome
- 不写迁移:新旧后端跑同一套表结构,这是回滚能成立的前提

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 03:37:45 -06:00
fb22b7d49f docs(阶段5): 用生产快照跑完整切换演练
演练用 compose.debian.yml **本身**在本机 Docker 里跑,不是简化版。
数据是 2026-08-07 的 pg_dumpall 快照:1710 用户 / 956 题 / 123140 提交。

## 出口标准达成

停旧栈 11s,起新栈 34s(镜像预先构建好),全链路验证约 2 分钟 ——
**停机不到 1 分钟**,远在 30 分钟内。真正的时间风险在构建镜像(首次约 5 分钟),
所以手册里第一条就是「镜像必须在停机窗口之前构建好」。

验证到位的:首页、站点配置、题目列表、标签、公告、登录(argon2 新哈希和
Django pbkdf2 旧哈希都支持)、个人页、排行榜、后台四个接口、判题机自动注册,
以及**完整判题**(提交 Python A+B → AC,1.2 秒,两个测试点全过)。

## 两件原以为要做、实测不用做的事

- **不需要任何 DDL**:生产 dump 和新后端在用的库逐列对比,两边都是 278 列,
  零差异。新后端直接跑在现有结构上。
- **不需要重置序列**:我在 phase3-coverage.md 里记的那条「切换必做:重置序列」
  **是错的**,来自我手工按显式 id 导入、又没补 setval 的本地库。真实的
  pg_dumpall 带 30 条 setval,且把快照里所有序列和 max(id) 逐个对过,错位 0 个。
  已在原文档上标注更正,没有删掉原文 —— 错误结论本身也是信息。

## 回滚保证已实测

新栈跑完登录、提交、判题之后,再和生产 dump 比一次结构:逐列一致,零差异。
加上数据目录布局照抄旧后端,回滚 = 停新栈 + 起旧栈,约 20 秒,不动任何数据。
(未实测的部分也写明了:本机没构建旧 Django 镜像,「起旧栈」这一步没跑过。)

## 演练抓到的真问题

**pg_dumpall 备份会覆盖数据库口令。** 恢复完快照,新后端立刻报
`password authentication failed` —— 因为 dump 里带
`ALTER ROLE onlinejudge ... PASSWORD 'md5…'`,把角色口令覆盖成了备份时生产的那个。
正常切换不受影响(根本不恢复备份),但灾难恢复时这一条不写下来,
现场会被一个看起来毫不相干的报错卡住。

**恢复备份前必须先停应用**,否则 dump 里的 DROP DATABASE 失败。演练时因为
目标库是空的,数据照样进去了 —— 那是运气,目标库有数据就是满屏主键冲突。

## 镜像体积没达标,写明了原因

api 镜像 487MB,设计文档写的是「数十 MB」。一半以上(269MB)是 clang-format
拖进来的 LLVM,光 libLLVM.so 就 124MB。旧 Python 镜像同样装了 clang-format,
所以新镜像仍明显更小,但当初估「数十 MB」时没把它算进去。
瘦身路径也记了(换静态 clang-format 可砍 265MB),暂不做。

## 清理

演练在 data/postgres 留下了一份完整的生产数据副本,含 1710 名学生的
raw_password 明文列,已删除。手册里留了提醒 —— 那不是测试数据。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:13:59 -06:00
ea521e7b0a feat(阶段5): 镜像、三套 compose,前端切回 /api 与 /ws
## 镜像

一个 Dockerfile 两个 target:api(单二进制)和 web(Caddy + 前端产物)。
旧后端是一个容器里用 supervisord 跑 caddy+gunicorn+dramatiq,这里拆成
oj-web / oj-api / oj-worker 三个容器 —— Docker 本身就是进程管理器,
拆开之后 worker 挂了能单独重启、日志也分得开,少一层 supervisord 要维护。

同一个镜像换个子命令就是 worker,镜像里只有一份运行时。
新增 healthcheck 子命令:运行镜像是 debian-slim,没有 curl/wget,
让二进制自己打 /health(只打 /health 不碰库 —— 库挂了该由库的 healthcheck 报,
不该让 api 跟着被判不健康然后被重启)。

**数据目录照抄旧后端**(test_case、public/upload、public/avatar)。
不是审美问题:切换那天不用搬动任何文件,回滚时旧后端立刻能找到自己的数据。
少一次几十 GB 的 mv,就少一个在停机窗口里出错的机会。

构建路上踩到三个坑,都是「本地能过、容器里过不了」那一类:

- 构建上下文吸进了 data/,judge_server/run 是判题沙箱用别的 uid 建的,
  docker 连 stat 都做不了,构建直接失败 → 补 .dockerignore
- mermaid@9.4.3(机房老 Chrome 的 legacy 依赖,不能砍)从容器里连
  registry.npmjs.com 稳定失败,主机上没问题 → 换 npmmirror,并重试两次
- 容器里 bun 用 isolated 布局,本地是扁平的。靠「提升」才能解析到的包
  在容器里一律解析不到:@node-rs/jieba-linux-x64-gnu(编译要 import 它的 .node)、
  以及前端的 @codemirror/{language,state,view} 和 @lezer/highlight。
  这些本来就是代码直接 import 的,补成直接依赖。顺手写了个脚本扫全仓,
  确认只有这 4 个。

## 前端切回 /api、/ws

迁移期用 /api2、/ws2 指新后端,/api、/ws 还指着 Django。端点已全部搬完,
临时前缀去掉。改动只有三处(api2.ts 的 baseURL、websocket.ts 的两个 URL),
`api2` 那些 import 是模块名不是路径,不动。vite 代理同步收敛成三条。

## compose

- debian:全套(含 postgres,对外开 5445 给机房连)
- school:**没有 postgres**,连服务器的库;本地 Redis + 本地判题沙箱
- 两边共用一个库但各有各的队列和 WS 推送,和旧后端的 Dramatiq/Channels 拓扑一致

密钥一律走 env 且带 `:?`,没设置就直接报错退出,不静默用弱默认值。
COOKIE_SECURE 在机房必须是 false(http 直连 IP,带 Secure 的 Cookie 发不回来,
表现是「登录成功但立刻又变未登录」)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:41:20 -06:00
b7adf2993d fix(阶段3): 去掉判题机 token 的弱默认值
F5:token 校验实现本身是对的(用了 timingSafeEqual),问题是缺省值
"oj2-dev-token" 写死在仓库里 —— 写死在仓库里的 token 等于没有 token。

后端改为对齐旧后端 options/options.py:93 的 fail-safe:
env 缺失时生成随机值并在启动日志里告警,判题机连不上,但不会静默用弱默认值。
compose 改为 ${OJ2_JUDGE_TOKEN:?...},未设置直接报错退出。

本地开发怎么设写在 docker/compose.dev.yml 顶部与 .env.example 里:
两个变量名不同(判题机镜像认 TOKEN,后端认 JUDGE_SERVER_TOKEN)但值必须相同。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:59:27 -06:00
ec274419c3 Build Phase 2 judge vertical slice 2026-08-06 22:42:39 -06:00
901906328d chore: 本机开发依赖 compose(postgres 16 + redis)
PostgreSQL 固定 16-alpine 与生产 16.10 对齐,首次启动自动灌入
docs/specs/schema.sql(仅结构)。端口错开为 5433 / 6380。

实测:34 张表与 schema.sql 逐一对应,差集 0,两个服务健康。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:32:48 -06:00