Compare commits

...
10 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 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
xuyueandClaude Opus 5 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
xuyueandClaude Opus 5 2c6f8d11f3 fix(前端): 接回阶段 2 临时关掉、之后忘了打开的配置推送和 MaxKB
扫前端死代码时发现 apps/web 比 ojnext 多两个"没人调用的导出":
useConfigUpdate 和 useMaxKB。查下去不是死代码,是**搬运时丢的接线** ——
ojnext 的 App.vue 调了这两个,apps/web 的没调,那里留着一句:

    // 配置推送和 MaxKB 仍属于 Phase 3;在它们迁入前不连接旧 WebSocket。

阶段 2 因为后端还没有 /ws/config 而暂时关掉,说好阶段 3 迁完再开,然后就忘了。
阶段 3 早已完成。

表现是两个**静默**的功能缺失:管理员改站点配置后学生要刷新才生效;
知识库挂件根本不出现。都不报错,所以此前所有验证都没发现 ——
包括我昨天那轮浏览器走查,因为我不知道该期待它们出现。

## 验证

后端侧本来就是齐的(POST /admin/website 里已经调 publishConfigUpdate)。
接回来之后实测:

- 浏览器控制台出现 `[WebSocket] 连接成功: ws://localhost:8080/ws/config`
  (之前根本不会连)
- 改站点名 → 收到 8 条 config_update 推送
- **页面全程不刷新,站点名从"判题狗-推送测试"自己变成"判题狗-实时生效了"**

中途我一度断言"configUpdateChannel 没有任何发布者",那是错的 ——
路由调的是封装函数 publishConfigUpdate,我 grep 的是常量名。

## 顺带:两边前端的死代码规模已对齐

各 7 个没人调用的导出函数 + 2 个没人引用的 .vue。检测器已确认
unplugin-vue-components 只解析 Naive UI(无 src/components 目录),
本地组件必须显式 import,所以"没人 import"的判定是可靠的;
并用 3 个明确在用的导出反测过检测器本身。这批还没删。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 07:29:41 -06:00
xuyueandClaude Opus 5 9a63883f30 docs(阶段4评审): 更正一处对旧后端的错误判断
对照表原写 DashboardInfoAPI / RandomUsernameAPI「旧后端任何人可读,含班级用户名
枚举」——不成立。两条都在 /api/admin/ 下,AdminRoleRequiredMiddleware
(account/middleware.py:36)在中间件层就要求登录且 is_admin_role()。
「无装饰器」是真的,「任何人可读」不是。

真实缺口只是「任何管理员可读,而非仅超管」,严重度差很多。
修旧后端时按这条去查,会白改一处不存在的漏洞。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 06:50:21 -06:00
xuyueandClaude Opus 5 95625d6711 refactor: 另外两处守卫也收到注册行上
走查题单进度时撞到 `/problem-sets/:id/user-progress` 对学生 403 —— 那是**正确**的
(它是给教师看全班进度的),但守卫又写在 handler 体内。评审的 M3 只列了 3 处,
按同一模式扫全仓,还有 2 处:

  content.ts     POST /messages                       isSuperAdmin
  problemset.ts  GET  /problem-sets/:id/user-progress  isTeacherOrAbove

改用 requireSuperAdmin / requireTeacher,理由同 M3:守卫要能从注册行上看出来,
不然下一个人加同类端点容易漏掉那个 if。

实测档位没变:学生两条都 403,教师看全班进度 200。
路由遮蔽检查仍然干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 06:28:44 -06:00
xuyueandClaude Opus 5 2edb8cfb0d fix(前端): 我改 /api2 前缀时漏了 5 处;修 4 个字段名不匹配;收紧 .gitignore
浏览器走查 + 真实 AI 调用暴露出来的,都是「接口好的、界面坏的」,
只靠脚本打 API 永远发现不了。

## 一、AI 功能和测试用例下载全都会 404(我今天造成的)

把 api2.ts 的 baseURL 从 /api2 改成 /api 时,只改了走 axios 实例的调用,
漏掉 5 处不走它的:

  oj/store/ai.ts                        AI 题解分析     原生 fetch("/api2/...")
  oj/problem/.../SubmissionResult.vue   AI 提示         原生 fetch
  oj/rank/list.vue                      AI 班级分析     原生 fetch
  oj/class/pk.vue                       AI 班级 PK      原生 fetch
  utils/download.ts                     测试用例下载    独立 axios,baseURL "/api2/admin"

生产的 Caddy 只认 /api,这 5 条全是 404。阶段 0 的端点清单里就写着
「盲点 2:4 处用原生 fetch」「盲点 3:download.ts 是独立 axios 实例」——
我今天读过那份文档,还是漏了。

修完实测四条 AI 链路全通(真实 DeepSeek 调用):
  /api/ai/analysis          200  SSE 176 分片 5.2s  start→delta→done→end 完整
  /api/ai/hint              200  2.4s  且未泄露参考答案
  /api/ai/class-analysis    200  12.6s
  /api/ai/class-pk-analysis 200  12.6s
落库确认(ai_analysis 新增,model=deepseek-v4-flash 与旧后端一致)。
hint 对别人的提交回 404 是**正确**的,它用 userId 限定只能看自己的。

## 二、4 个字段名永久对不上(重写引入的回归)

适配器只在**大写字母**前插下划线:`top10Avg` → `top10_avg`;
而旧 Django 给的是 `top_10_avg`(数字前也有)。前端还按旧名读。

影响比"三列空白"大:`row.top_10_avg.toFixed(2)` 在 undefined 上抛异常,
Vue 放弃整个子树 —— 班级PK 和班级排名页的**整个「分层统计」面板都不显示**
(Q1/Q3/四分位距/标准差/前10%/中间80%/后10%/人数 全没了),控制台还不报错。

修完实测:前10%均值 194.80、中间80%均值 93.79、后10%均值 49.40 全部出现。
写脚本按这个规律全仓扫过,确认只有这 4 个,改完复查归零。

## 三、.gitignore 差一点把生产库提交上去

原来只忽略 data/test_case/ 和 data/judge_server/,而 compose 会在
data/postgres/ 生成**整个数据库**(含 raw_password 明文列)、data/redis/、
data/backend/(学生上传的文件)。这次 `git add -A` 报权限错误才发现 ——
只因为 postgres 容器用别的 uid、目录读不了才没提交成功。改成忽略整个 data/。
已确认历史里从没提交过这些。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 05:37:24 -06:00
xuyueandClaude Opus 5 34ae9b032c fix(后台): SQL 测试点脚本读不出来时不能静默留一片空白编辑器
用浏览器把前后台走了一遍(这是第一次真的在界面上验证,之前全是脚本打 API)。
唯一找到的真问题在 SQLTestcaseEditor:

    onMounted(async () => {
      try { ... } catch {}     // ← 静默吞掉
    })

已有脚本读取失败时被 catch{} 吞掉,编辑器保持初始的 3 个空白项 ——
和「这题本来就没测试点」长得一模一样,教师完全看不出发生了什么。

改成:新题和旧格式测试点(404 problem-not-found / 409 not-sql-test-case)仍然
静默留空,那本来就是对的;其它失败挂一条 alert,并且把可操作的部分说全 ——
后端那句"测试点信息读取失败"只讲了现象,教师需要知道的是「下面是空模板、
直接存不会生效」。

保存本身是安全的,已实测:拿一道真实 SQL 题,在编辑器空白的状态下点提交,
test_case_id、测试点数(3)、sql_config 全部未变 —— 后端读不到测试点信息会
拒绝整个保存(400),不会把测试点清空。所以这条只是提示缺失,不是数据风险。

## 走查过程中的一次自我更正

中途我一度报告"保存返回 400 而界面没有任何错误提示",那是错的:提示是正常
显示的,我第一次等了 4 秒才检查,而 Naive UI 的提示 3 秒就自动消失了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 05:19:23 -06:00
xuyueandClaude Opus 5 516929461f docs: 拿阶段 0 的权威清单核对交付,110/110 零缺口
之前那个正则脚本只能说"没发现缺口",证明不了"没有缺口"。这次拿阶段 0 人工裁决过的
端点清单来对 —— 那 110 条 KEEP 是「必须搬」的权威名单,逐条对到新后端。

**结果 110/110 全部有对应实现,零缺口。**

87 条词元化后自动匹配上;剩下 23 条是 API 重新设计导致路径本来就对不上的,
逐条人工落实,没有一条是真的漏了。一度以为 /api/register 没实现,
查下去是改叫 POST /api/users 了。

把其中**猜不到的那 10 条改名**记进了清单(problemset → problem-sets 这类
一眼能猜的没列)。日后排查「这个功能以前的接口现在在哪」,看这张表就够了:

  /api/register              → POST   /api/users
  /api/logout                → DELETE /api/auth/session
  /api/hitokoto              → GET    /api/quotes/random
  /api/pickone               → GET    /api/problems/random
  /api/user_activity_rank    → GET    /api/rankings/activity
  /api/profile/fresh_display_id → POST /api/me/problem-display-ids/refresh
  ……

最后一条 /api/judge_server_heartbeat/ → /api/judge-server/heartbeat 单独标了:
判题沙箱镜像是原样复用的,靠 compose 的 BACKEND_URL 找后端,改这条要同步改
compose,否则判题机静默离线。已核对三套 compose 都是新路径。

也写明了方法的局限:词元匹配只能提示「这两条像是同一个」,证明不了行为一致 ——
行为一致靠的是两轮独立评审和阶段 5 的实跑演练,不是这张表。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 04:11:39 -06:00
xuyueandClaude Opus 5 967e9ef7f7 feat: 路由遮蔽检查脚本;切换前把前后端接口逐条对了一遍
## 切换前核对:前端调的接口后端有没有漏

写脚本把前端 224 个调用点和后端注册的路由逐条比对。**零缺口** —— 唯一报出来的
一条是我的正则被嵌套括号截断了(`users/${encodeURIComponent(...)}/badges`),
后端那条路由是有的。「后端有、前端没调」那 25 条也逐个看过,全是脚本的假阳性
(把 `.get("user")` 这类非路由调用当成了路由)和假阴性(前端用三元表达式拼路径,
正则看不见,比如 `GET /me` 其实在 shared/api.ts 里被调)。

结论是没发现缺口,但这个脚本不够可靠、不足以证明"一定没有",所以没留进仓库。

## 路由遮蔽检查(留成常驻脚本)

比"有没有漏"更值得防的是遮蔽:**Hono 按注册顺序匹配,不是静态优先**。
`/problems/:id` 注册在 `/problems/random` 前面的话,后者永远进不去 ——
不报错、不警告,只是静默走进前一条的 handler。阶段 4 真实发生过一次,
两个教师用的分析端点被吃掉,一直到评审才发现。

全仓 167 条路由按真实注册顺序扫:**零遮蔽**。

这个结论敢下,是因为检测器本身也验了:
- 自检用例里放了阶段 4 那个历史真实案例,能抓到;边界(两边都是参数、
  段数不同、不同前缀)不误报
- 核对了 24 个 router 全在扫描范围内,没有漏扫
- 反向验证:往 problem.ts 末尾加一条注册在 `:displayId` 之后的字面量路由,
  脚本立刻报出来并 exit 1

未经验证的检测器报"没问题"是没有意义的 —— 这个教训今天已经吃过两次
(tree-sitter 那次、SQL 内存那次)。

脚本落在 apps/api/src/scripts/check-route-shadowing.ts,
`bun run --filter '@oj2/api' check:routes`,加完路由跑一下。
CLAUDE.md 里也写了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 03:42:37 -06:00
xuyueandClaude Opus 5 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
21 changed files with 765 additions and 99 deletions
+8 -3
View File
@@ -20,9 +20,14 @@ build/
docs/spikes/node_modules/
docs/spikes/endpoints-*.json
# 本地判题验收数据来自生产测试点,只读挂载,不进版本库
data/test_case/
data/judge_server/
# data/ 整个目录都是运行时数据,一律不进版本库
#
# 必须写成整目录:原来只忽略了 test_case/ 和 judge_server/,而
# docker/compose.*.yml 会在这里生成 data/postgres/**整个数据库**,含
# raw_password 明文列)、data/redis/、data/backend/(学生上传的文件)。
# 阶段 5 演练时 `git add -A` 差一点就把生产库快照提交上去,只因为 postgres
# 容器用的是别的 uid、目录读不了才失败。别再收窄这条。
data/
# 生产题目样本只用于本地验收,不进入 git
*.csv
+129
View File
@@ -0,0 +1,129 @@
# CLAUDE.md
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
上一代在 `../OnlineJudge/`Django)和 `../ojnext/`(Vue SPA),**两者都已冻结,
是回滚路径,任何情况下都不要改。**
设计文档:`docs/specs/2026-08-06-bun-backend-rewrite-design.md`
切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走
## 仓库结构
| 目录 | 作用 |
|---|---|
| `apps/api/` | 后端。Hono + Drizzle + BullMQ,编译成单二进制 |
| `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA |
| `packages/contract/` | 前后端共用的 Zod 契约 |
| `docker/` | Dockerfile + 三套 composedev / debian / school |
| `docs/specs/` | 设计、端点清单、各阶段评审报告与演练报告 |
## 本机环境
**Docker 可用,全套依赖都能在本机跑起来**PostgreSQL、Redis、判题沙箱),
镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端"
的旧假设。
```bash
bun install
bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081)
bun run dev # api(3000) + worker + web(5173) 一起起
```
首次要先建 `.env`(照 `.env.example`)。判题机 token 两边必须一致:
`.env``JUDGE_SERVER_TOKEN``docker/.env``OJ2_JUDGE_TOKEN`
常用检查:
```bash
bunx tsc --noEmit -p apps/api # 后端类型检查
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下
cd apps/web && bun run build # 前端构建(vite 不做类型检查,构建即验证)
```
**不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
## 几件必须知道的事
### 单二进制是有代价的
`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`
的注释,那里把坑写全了。
**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,
dev 直接起不来。
### 路径解析看 `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`
### 判题状态码要三处同步
`apps/api/src/judge/status.ts``apps/web/src/utils/constants.ts`
以及上一代的 `../OnlineJudge/submission/models.py`(回滚时要对得上)。
题目表情 reaction 的语义 key 同理。
### 比赛只有 ACM 模式
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。
### 前端要兼容老 Chrome
机房电脑 Chrome < 94。`mermaid-legacy` 等 fallback 依赖和 vite 的构建 target
不能动,`vite.config.ts` 里有注释说明。
## 数据库
Drizzle schema 是从生产库 `drizzle-kit pull` 出来的,**不写迁移**。
新旧后端跑在同一套表结构上(阶段 5 演练逐列比对过,零差异),这是回滚能成立的前提 ——
所以改 schema 前先想清楚回滚怎么办。
生产库的几个约定:
- `raw_password` 明文列**要保留**,老师用它找学生密码。不要"顺手清理"。
- `judge_server_heartbeat` 保留。
- `problem.prompt` 是给未来 AI 预留的,当前没接线,不要删。
## 部署
三套 compose 在 `docker/``dev`(本机)、`debian`(服务器)、`school`(机房)。
**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,
但各有各的 Redis 和判题沙箱 —— 所以上线那天**两边必须一起切**。
`compose.debian.yml` 有两种形态,靠 env 切换:
- **只换前后端**(上线用这个):设 `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),
而且**不报错** —— 这是切换当天唯一会静默走歪的地方。
细节和演练结果都在 `docs/specs/phase5-cutover-runbook.md`
+1
View File
@@ -12,6 +12,7 @@
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
"seed:dev": "bun src/scripts/seed-dev.ts",
"typecheck": "tsc --noEmit",
"check:routes": "bun src/scripts/check-route-shadowing.ts",
"db:pull": "drizzle-kit pull"
},
"dependencies": {
+1 -1
View File
@@ -47,6 +47,6 @@ switch (command) {
}
}
default:
console.error(`未知子命令:${command}\n可用:serve | worker | sql-child`)
console.error(`未知子命令:${command}\n可用:serve | worker | healthcheck | sql-child`)
process.exit(2)
}
+3 -4
View File
@@ -15,11 +15,11 @@ import {
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { isSuperAdmin, objectValue, queryInteger, sampleUser } from "./helpers"
import { objectValue, queryInteger, sampleUser } from "./helpers"
export const contentRoutes = new Hono<AppEnv>()
@@ -108,9 +108,8 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
}))
})
contentRoutes.post("/messages", requireAuth, async (c) => {
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
const user = c.get("user")!
if (!isSuperAdmin(user)) return failure(c, 403, "permission-denied", "Permission denied")
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload")
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
+3 -5
View File
@@ -27,13 +27,13 @@ import {
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { updateAchievementsForProblemSet } from "../services/achievements"
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers"
import { objectValue, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>()
@@ -362,9 +362,7 @@ problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
return success(c, badges.map((badge) => badgeData(badge)))
})
problemsetRoutes.get("/problem-sets/:id/user-progress", requireAuth, async (c) => {
const user = c.get("user")!
if (!isTeacherOrAbove(user)) return failure(c, 403, "permission-denied", "Permission denied")
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
@@ -0,0 +1,119 @@
/**
* 检查有没有路由被先注册的同形路由「吃掉」。
*
* bun run --filter '@oj2/api' check:routes
*
* ## 为什么需要这个
*
* **Hono 按注册顺序匹配,不是静态优先**(已实测确认,别凭直觉假设)。所以
*
* problemRoutes.get("/problems/:id", …) // 先注册
* problemRoutes.get("/problems/random", …) // 永远进不去
*
* 第二条不会报错、不会警告,只是静默走进第一条的 handler,然后因为 "random"
* 不是合法 id 而返回 404 或者一堆看不懂的结果。阶段 4 真实发生过一次:
* 两个教师用的分析端点被 `/problems/:id` 吃掉,评审时才发现。
*
* 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。
*
* 局限:靠正则读源码,只认 `xxxRoutes.get("字面量", …)` 这种写法。
* 动态拼出来的路径看不见 —— 但本仓库没有那种写法,加的时候请保持。
*/
import { readFileSync, readdirSync, statSync } from "node:fs"
import { join, resolve } from "node:path"
const SRC = resolve(import.meta.dir, "..")
interface Route {
method: string
path: string
file: string
}
function walk(dir: string, out: string[] = []) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry)
if (statSync(path).isDirectory()) walk(path, out)
else if (entry.endsWith(".ts")) out.push(path)
}
return out
}
/** 先注册的 pattern 会不会把后注册的 target 吃掉 */
export function shadows(pattern: string, target: string) {
const a = pattern.split("/").filter(Boolean)
const b = target.split("/").filter(Boolean)
if (a.length !== b.length) return false
let usedParam = false
for (let i = 0; i < a.length; i++) {
const seg = a[i]!
const other = b[i]!
if (seg.startsWith(":")) {
// 参数段吃得掉任何字面量段;两边都是参数说明本来就是同一条,不算遮蔽
if (other.startsWith(":")) continue
usedParam = true
continue
}
if (seg !== other) return false
}
return usedParam
}
function collect(): Route[] {
const routerFile = new Map<string, string>()
for (const file of walk(SRC)) {
for (const m of readFileSync(file, "utf8").matchAll(/export const (\w+) = new Hono/g)) {
routerFile.set(m[1]!, file)
}
}
const routesOf = (router: string, prefix: string): Route[] => {
const file = routerFile.get(router)
if (!file) return []
const text = readFileSync(file, "utf8")
const pattern = new RegExp(`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`, "g")
return [...text.matchAll(pattern)].map((m) => ({
method: m[1]!.toUpperCase(),
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
file: file.replace(SRC + "/", ""),
}))
}
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
const index = readFileSync(join(SRC, "index.ts"), "utf8")
const adminIndex = readFileSync(join(SRC, "routes/admin/index.ts"), "utf8")
const adminMounts = [...adminIndex.matchAll(/\.route\(\s*"([^"]*)"\s*,\s*(\w+)\s*\)/g)]
const all: Route[] = []
for (const m of index.matchAll(/app\.route\(\s*"([^"]+)"\s*,\s*(\w+)\s*\)/g)) {
const [, prefix, router] = m
if (router === "adminRoutes") {
for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!))
} else {
all.push(...routesOf(router!, prefix!))
}
}
return all
}
const routes = collect()
const hits: [Route, Route][] = []
for (let i = 0; i < routes.length; i++) {
for (let j = i + 1; j < routes.length; j++) {
if (routes[i]!.method !== routes[j]!.method) continue
if (shadows(routes[i]!.path, routes[j]!.path)) hits.push([routes[i]!, routes[j]!])
}
}
console.log(`按注册顺序检查了 ${routes.length} 条路由`)
if (hits.length === 0) {
console.log("✓ 没有路由被遮蔽")
process.exit(0)
}
for (const [first, second] of hits) {
console.log(`\n⚠ ${second.method} ${second.path} ${second.file}`)
console.log(` 进不去:被先注册的 ${first.method} ${first.path} 吃掉(${first.file}`)
console.log(` 改法:把它挪到那条之前注册,或换一个不同形的路径`)
}
process.exit(1)
+7 -1
View File
@@ -3,6 +3,8 @@ import { darkTheme, dateZhCN, zhCN } from "naive-ui"
import "normalize.css"
import "./index.css"
import { useConfigStore } from "shared/store/config"
import { useConfigUpdate } from "shared/composables/configUpdate"
import { useMaxKB } from "shared/composables/maxkb"
import { useUserStore } from "shared/store/user"
const isDark = useDark()
@@ -15,7 +17,11 @@ onMounted(() => {
userStore.getMyProfile()
})
// 配置推送和 MaxKB 仍属于 Phase 3;在它们迁入前不连接旧 WebSocket。
// 配置实时推送 + MaxKB 挂件。阶段 2 时因为后端还没有 /ws/config 而暂时关掉,
// 阶段 3 迁完之后一直没接回来 —— 表现是管理员改站点配置后学生要刷新才生效、
// 知识库挂件根本不出现,且没有任何报错。两者现在都走新后端的 /ws/config。
useConfigUpdate()
useMaxKB()
// 延迟加载 highlight.js,避免阻塞首屏
const hljsInstance = ref<any>(null)
@@ -44,6 +44,8 @@ const refSQL = computed(
"",
)
/** 已有脚本读取失败时的提示。空白编辑器和「本来就没测试点」长得一样,必须区分开 */
const loadError = ref("")
const isPreviewing = ref(false)
const isUploading = ref(false)
const isGenerating = ref(false)
@@ -79,7 +81,20 @@ onMounted(async () => {
if (res.data.length) {
scripts.value = res.data.map((f) => ({ ...blankEntry(), sql: f.content }))
}
} catch {}
} catch (err: any) {
// 新题、以及旧格式(非 SQL)测试点,后端回 404/409,保持空白就是对的,不该报错。
// 但**其它**失败必须说出来:读不出来时编辑器长得和「这题本来就没测试点」一模一样,
// 教师看不出区别,会以为要自己重新填。保存本身是安全的(后端读不到测试点信息会拒绝
// 整个保存),所以这里只提示、不阻塞。
const code = err?.error
if (code && code !== "problem-not-found" && code !== "not-sql-test-case") {
// 后端那句话(如"测试点信息读取失败")只说了现象,教师需要的是「下面是空的、别存」
const detail = err?.data ? `${err.data}` : ""
loadError.value =
`已有测试点脚本没能读出来${detail}。下方是空白模板,不是本题真实的测试点 —— ` +
`直接保存不会丢数据(后端会拒绝),但也不会生效。请先排查测试点文件,或重新上传。`
}
}
})
function add() {
@@ -195,6 +210,14 @@ async function upload() {
>
还没有填写 SQL 标准答案请先在上方"本题参考答案"中填写再来编写测试点
</n-alert>
<n-alert
v-if="loadError"
type="error"
:show-icon="false"
style="margin-bottom: 8px"
>
{{ loadError }}
</n-alert>
<n-flex align="center" wrap>
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
清空
+18 -18
View File
@@ -57,9 +57,9 @@ interface ClassComparison {
q3_ac: number
iqr: number
std_dev: number
top_10_avg: number
middle_80_avg: number
bottom_10_avg: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
@@ -68,7 +68,7 @@ interface ClassComparison {
recent_total_ac?: number
recent_avg_ac?: number
recent_median_ac?: number
recent_top_10_avg?: number
recent_top10_avg?: number
recent_active_count?: number
}
@@ -176,7 +176,7 @@ async function analyzeWithAI() {
if (csrfToken) headers["X-CSRFToken"] = csrfToken
try {
const response = await fetch("/api2/ai/class-pk-analysis", {
const response = await fetch("/api/ai/class-pk-analysis", {
method: "POST",
headers,
body: JSON.stringify({
@@ -382,7 +382,7 @@ const top10AvgChartData = computed(() => {
const datasets = [
{
label: "前10%平均",
data: comparisons.value.map((c) => c.top_10_avg),
data: comparisons.value.map((c) => c.top10_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -400,7 +400,7 @@ const bottom10AvgChartData = computed(() => {
const datasets = [
{
label: "后10%平均",
data: comparisons.value.map((c) => c.bottom_10_avg),
data: comparisons.value.map((c) => c.bottom10_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -418,7 +418,7 @@ const middle80AvgChartData = computed(() => {
const datasets = [
{
label: "中间80%均值",
data: comparisons.value.map((c) => c.middle_80_avg),
data: comparisons.value.map((c) => c.middle80_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -640,35 +640,35 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
},
{
title: "前10%均值",
key: "top_10_avg",
key: "top10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#cf1322", fontWeight: "600" } },
row.top_10_avg.toFixed(2),
row.top10_avg.toFixed(2),
),
},
{
title: "中间80%均值",
key: "middle_80_avg",
key: "middle80_avg",
width: 110,
render: (row) =>
h(
"span",
{ style: { color: "#389e0d", fontWeight: "600" } },
row.middle_80_avg.toFixed(2),
row.middle80_avg.toFixed(2),
),
},
{
title: "后10%均值",
key: "bottom_10_avg",
key: "bottom10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#096dd9", fontWeight: "500" } },
row.bottom_10_avg.toFixed(2),
row.bottom10_avg.toFixed(2),
),
},
{
@@ -956,17 +956,17 @@ const radarChartOptions = {
<!-- 分层统计 -->
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classData.top_10_avg.toFixed(2)
classData.top10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classData.middle_80_avg.toFixed(2)
classData.middle80_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classData.bottom_10_avg.toFixed(2)
classData.bottom10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
@@ -1049,7 +1049,7 @@ const radarChartOptions = {
</n-descriptions-item>
<n-descriptions-item label="时间段前10名平均">
<span style="color: #ff4d4f; font-weight: 600">{{
classData.recent_top_10_avg?.toFixed(2)
classData.recent_top10_avg?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="活跃学生数" :span="2">
@@ -83,7 +83,7 @@ async function fetchHint(submissionId: string) {
headers["X-CSRFToken"] = csrfToken
}
const response = await fetch("/api2/ai/hint", {
const response = await fetch("/api/ai/hint", {
method: "POST",
headers,
body: JSON.stringify({ submissionId }),
+7 -7
View File
@@ -95,7 +95,7 @@ async function analyzeSingleClassWithAI() {
if (csrfToken) headers["X-CSRFToken"] = csrfToken
try {
const response = await fetch("/api2/ai/class-analysis", {
const response = await fetch("/api/ai/class-analysis", {
method: "POST",
headers,
body: JSON.stringify({ comparison: classDetailData.value }),
@@ -158,9 +158,9 @@ interface ClassComparison {
q3_ac: number
iqr: number
std_dev: number
top_10_avg: number
middle_80_avg: number
bottom_10_avg: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
@@ -719,17 +719,17 @@ watch(
</n-descriptions-item>
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classDetailData.top_10_avg.toFixed(2)
classDetailData.top10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classDetailData.middle_80_avg.toFixed(2)
classDetailData.middle80_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classDetailData.bottom_10_avg.toFixed(2)
classDetailData.bottom10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="人数">
+1 -1
View File
@@ -104,7 +104,7 @@ export const useAIStore = defineStore("ai", () => {
}
try {
const response = await fetch("/api2/ai/analysis", {
const response = await fetch("/api/ai/analysis", {
method: "POST",
headers,
body: JSON.stringify({
+2 -2
View File
@@ -1,9 +1,9 @@
import axios from "axios"
// 指向新后端的 /api2/admin。响应是 zip 二进制,不走 { error, data } 信封,
// 指向新后端的 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,
// 所以不能复用 utils/api2 的拦截器(它会把 response.data.data 取出来)。
const http = axios.create({
baseURL: "/api2/admin",
baseURL: "/api/admin",
responseType: "blob",
withCredentials: true,
})
+46 -2
View File
@@ -19,9 +19,53 @@ JUDGE_CONCURRENCY=2
# DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。
AI_KEY=
# --- 只有机房那套需要 ---
# 服务器地址。默认值写在 compose.school.yml 里,换机器时在这里覆盖。
# --- 数据在哪 ---
#
# 这三个变量决定新栈是「自带 postgres/redis」还是「接着用旧栈的」。
#
# DATA_DIR 是所有数据卷的根(库、测试点、上传的图片、判题机日志)。
# **不设的话默认是 `../data`,也就是 `OJ2/data` —— 不是部署目录的 `data/`。**
# 沿用旧数据时必须写成旧目录的绝对路径,例如服务器上:
#
# DATA_DIR=/root/OJDeploy/data
#
# 不设它就等于开一套空数据:空库(站点没有用户没有题)、没有测试点、题面图片 404。
DATA_DIR=
# 库和 redis 的位置。留空 = 用本 compose 自己起的容器(要加 `--profile local-data`)。
# 沿用旧栈那两个容器时这样填(它们已经把端口发布在宿主机上了):
#
# DB_HOST=host.docker.internal
# DB_PORT=5445
# REDIS_HOST=host.docker.internal
# REDIS_PORT=5446
#
# host.docker.internal 由 compose 里的 extra_hosts 映射到 host-gateway,不出本机。
# 机房那套没有本地库,DB_HOST 不填时默认指向服务器(默认值在 compose.school.yml 里)。
DB_HOST=
DB_PORT=
REDIS_HOST=
REDIS_PORT=
# --- 并行试跑(oj2.xuyue.cc)才需要 ---
#
# 新站和旧站同时跑的时候,这两个必须设,否则会跟旧栈抢资源。
#
# 对外端口。旧 backend 占着 8080(机房 81),新栈换一个,NPM 那边把
# oj2.xuyue.cc 指到这个端口。**NPM 的记录要打开 Websockets Support**
# 否则学生那边「判题中…」永远不动。
WEB_PORT=
# 判题机的运行状态目录。不设的话是 `$DATA_DIR/judge_server`,和旧判题机同一份 ——
# 试跑期间两个 judger 都在跑,必须分开,例如:
#
# JUDGE_STATE_DIR=/root/OJDeploy/data/judge_server_oj2
#
# 建完记得 `mkdir -p .../{log,run}`。
# `test_case` 和 `public/upload` 仍然共享,那是**故意的** —— 测试点和题面图片
# 两边必须看到同一份。
JUDGE_STATE_DIR=
# --- 只有机房那套需要 ---
# 机房走 http 直连 IP,没有 TLS,必须 false,否则 Cookie 发不回来。
COOKIE_SECURE=false
+6 -2
View File
@@ -21,8 +21,12 @@ WORKDIR /build
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV BUN_CONFIG_REGISTRY=${NPM_REGISTRY}
# 先只拷 manifest,依赖没变时这一层能命中缓存
COPY package.json bun.lock ./
# 先只拷 manifest,依赖没变时这一层能命中缓存
# bunfig.toml 必须一起拷:它设了 linker = "hoisted",漏掉的话容器里会用默认的
# isolated 布局(包都在 node_modules/.bun/ 下),于是本地靠"提升"才解析得到的包
# 在容器里一律 Could not resolve —— 而本地构建始终是好的,只有镜像构建才炸。
# 真正的修法是把直接 import 的包声明成直接依赖(已做),这里让两边布局一致是第二道保险。
COPY package.json bun.lock bunfig.toml ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/contract/package.json packages/contract/
+56 -11
View File
@@ -7,14 +7,39 @@
# 和旧的 docker-compose.debian.yml 的对应关系:
# oj-backendsupervisord 跑 caddy+gunicorn+dramatiq)→ 拆成 oj-web + oj-api + oj-worker
# 端口、数据目录、判题机配置全部保持不变,这样回滚只是换回旧 compose。
#
# ## 两种形态
#
# **自带数据**(演练用的就是这个):postgres 和 redis 也由本文件起,要显式加 profile。
#
# docker compose -f docker/compose.debian.yml --env-file docker/.env --profile local-data up -d
#
# **只换前后端**(默认,上线用这个):旧栈的 postgres / redis 容器继续跑,
# 这里只起 api / worker / web / judge
# 通过 env 指过去。切换当天数据库进程根本不重启,库和文件都不用挪位置。
#
# docker compose -f docker/compose.debian.yml --env-file docker/.env up -d
#
# 后者要在 env 里设 `DB_HOST` / `REDIS_HOST` / `DATA_DIR`,见 `.env.example` 末尾。
#
# **并行试跑**(新站挂在 oj2.xuyue.cc、旧站原样不动)是「只换前后端」再加两个变量:
# `WEB_PORT`8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。
# 这种形态下旧栈一个容器都不用停。见手册第四节。
#
# ⚠️ `DATA_DIR` 默认值 `../data` 解析出来是 **`OJ2/data`**,不是部署目录的 `data/`。
# 旧栈用的是 `<部署目录>/data/`,两者不是一个地方 —— 直接用默认值切过去,postgres 会在
# 空目录上初始化一个全新的空库,测试点和题面图片也全都不在。**用旧数据就必须设 `DATA_DIR`。**
services:
oj-postgres:
image: postgres:16-alpine
container_name: oj-postgres
restart: always
# 只在「自带数据」形态下启动。用旧栈的库时不启动它 —— 否则 5445 端口会和
# 旧的 postgres 撞,而且两个进程开同一个数据目录本来也起不来。
profiles: ["local-data"]
volumes:
- ../data/postgres:/var/lib/postgresql/data
- ${DATA_DIR:-../data}/postgres:/var/lib/postgresql/data
environment:
POSTGRES_DB: onlinejudge
POSTGRES_USER: onlinejudge
@@ -32,8 +57,12 @@ services:
image: redis:7-alpine
container_name: oj-redis
restart: always
# 同上:旧栈的 redis 也发布了 5446,两个一起跑会撞端口。
# redis 里没有非丢不可的东西(会话、判题队列),用旧的那个也无所谓 ——
# 旧后端已经停了,新后端独占它,剩下的 Django / dramatiq 残留键前缀不同,互不干扰。
profiles: ["local-data"]
volumes:
- ../data/redis:/data
- ${DATA_DIR:-../data}/redis:/data
ports:
- "5446:6379"
healthcheck:
@@ -57,9 +86,12 @@ services:
tmpfs:
- /tmp
volumes:
- ../data/backend/test_case:/test_case:ro
- ../data/judge_server/log:/log
- ../data/judge_server/run:/judger
- ${DATA_DIR:-../data}/backend/test_case:/test_case:ro
# 判题机的运行状态目录。默认跟着 DATA_DIR 走,和旧栈是同一份 ——
# 一次性切换时无所谓(旧判题机已经停了),但**并行试跑时必须单独设 JUDGE_STATE_DIR**
# 否则新旧两个 judger 同时往一个 run/log 目录里写。
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger
environment:
SERVICE_URL: http://oj-judge:8080
# 心跳路径跟着新后端改了:judge_server_heartbeat/ → judge-server/heartbeat
@@ -76,15 +108,25 @@ services:
container_name: oj-api
restart: always
depends_on:
# required: false —— 「只换前后端」时这两个服务不在启动集合里(profile 未启用),
# 严格的 depends_on 会让整个 project 直接判定 invalid(实测过,不是猜的)。
# 代价:自带数据形态下 postgres 起不来时,compose 只警告不中止,oj-api 照样起,
# 然后自己 crash 循环。看 `docker compose ps`oj-api 会是 unhealthy。
oj-postgres:
condition: service_healthy
required: false
oj-redis:
condition: service_healthy
required: false
# 用旧栈的库时,库在宿主机上(旧 postgres 发布了 5445),走 host-gateway 过去,
# 不出本机、不走公网。DB_HOST 填 host.docker.internal 即可。
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../data/backend:/data
- ${DATA_DIR:-../data}/backend:/data
environment: &api-env
DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@oj-postgres:5432/onlinejudge
REDIS_URL: redis://oj-redis:6379
DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@${DB_HOST:-oj-postgres}:${DB_PORT:-5432}/onlinejudge
REDIS_URL: redis://${REDIS_HOST:-oj-redis}:${REDIS_PORT:-6379}
JUDGE_SERVER_URL: http://oj-judge:8080
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2}
@@ -106,8 +148,10 @@ services:
restart: always
depends_on:
- oj-api
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../data/backend:/data
- ${DATA_DIR:-../data}/backend:/data
environment: *api-env
# 同一个镜像,换个子命令就是判题消费者
command: ["oj2-api", "worker"]
@@ -125,7 +169,8 @@ services:
- oj-api
volumes:
# Caddy 只往这里写访问日志,静态资源在镜像里
- ../data/backend/log:/data/log
- ${DATA_DIR:-../data}/backend/log:/data/log
ports:
- "0.0.0.0:8080:8000"
# 并行试跑(oj2.xuyue.cc)时换一个端口,8080 还被旧 backend 占着。
- "0.0.0.0:${WEB_PORT:-8080}:8000"
mem_limit: 256m
+14 -8
View File
@@ -10,6 +10,10 @@
# 旧后端的 Channels 也是这样,不是回归;
# - **切换那天两边都要切。** 只切一边的话,另一边的旧后端仍在读写同一个库,
# 而库结构已经按新后端迁过了。
#
# ⚠️ `DATA_DIR` 默认值 `../data` 解析出来是 **`OJ2/data`**,不是机房部署目录的 `data/`。
# 测试点(`data/backend/test_case`)和题面图片(`data/backend/public/upload`)都在旧目录里,
# 不设 `DATA_DIR` 就会挂一堆空目录上去:判题全错、图片 404。**沿用旧数据必须设它。**
services:
oj-redis:
@@ -17,7 +21,7 @@ services:
container_name: oj-redis
restart: always
volumes:
- ../data/redis:/data
- ${DATA_DIR:-../data}/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@@ -39,9 +43,10 @@ services:
tmpfs:
- /tmp
volumes:
- ../data/backend/test_case:/test_case:ro
- ../data/judge_server/log:/log
- ../data/judge_server/run:/judger
- ${DATA_DIR:-../data}/backend/test_case:/test_case:ro
# 并行试跑时必须单独设 JUDGE_STATE_DIR,否则新旧两个 judger 共用一个运行目录
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger
environment:
SERVICE_URL: http://oj-judge:8080
BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat
@@ -61,7 +66,7 @@ services:
oj-redis:
condition: service_healthy
volumes:
- ../data/backend:/data
- ${DATA_DIR:-../data}/backend:/data
environment: &api-env
# 库在服务器上,走公网。DB_HOST 默认值就是服务器地址,换机器改 env 文件
DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@${DB_HOST:-150.158.29.156}:${DB_PORT:-5445}/onlinejudge
@@ -89,7 +94,7 @@ services:
depends_on:
- oj-api
volumes:
- ../data/backend:/data
- ${DATA_DIR:-../data}/backend:/data
environment: *api-env
command: ["oj2-api", "worker"]
mem_limit: 2g
@@ -105,7 +110,8 @@ services:
depends_on:
- oj-api
volumes:
- ../data/backend/log:/data/log
- ${DATA_DIR:-../data}/backend/log:/data/log
ports:
- "81:8000"
# 并行试跑时换一个端口,81 还被旧 backend 占着
- "${WEB_PORT:-81}:8000"
mem_limit: 256m
+37
View File
@@ -151,3 +151,40 @@
| KEEP | ai | oj | `/api/ai/class_single` | SingleClassAnalysisAPI.as_view | 否 | 否 | 盲点 2:走原生 `fetch``src/oj/rank/list.vue:98`),实际在用 |
| KEEP | conf | oj | `/api/judge_server_heartbeat/` | JudgeServerHeartbeatAPI.as_view | 否 | 否 | 非前端调用:判题机向后端注册心跳。新架构判题沙箱镜像原样复用,此接口必须保留 |
| KEEP | submission | oj | `/api/contest_submissions` | ContestSubmissionListAPI.as_view | 否 | 否 | 盲点 1`getSubmissions``endpoint` 变量的比赛分支(`src/oj/api.ts:73`),实际在用 |
---
## 交付核对(2026-08-08,阶段 5 之后)
把这张表里的 **110 条 KEEP 逐条对到新后端**,确认没有「当初判了要搬、后来忘了」的。
**结果:110/110 全部有对应实现,零缺口。**
核对方法:把旧路径和新后端注册的 167 条路由都做词元化(去掉 `/api``admin`
参数段,snake/kebab 拆开,单复数归一)后求交集。87 条自动匹配上,剩下 23 条
(API 是重新设计过的,路径本来就对不上)逐条人工落实,见下表。
> 方法的局限:词元匹配只能提示「这两条像是同一个」,不能证明**行为**一致。
> 行为一致性靠的是阶段 3/4 的两轮独立评审和阶段 5 的实跑演练,不是这张表。
### 非显然的改名对照
`problemset``problem-sets` 这类一眼能猜到的没列。下面这些是**猜不到、
日后排查时会卡住人**的:
| 旧(Django | 新(Bun |
|---|---|
| `POST /api/register` | `POST /api/users` |
| `GET /api/logout` | `DELETE /api/auth/session` |
| `GET /api/hitokoto` | `GET /api/quotes/random` |
| `GET /api/pickone` | `GET /api/problems/random` |
| `GET /api/user_activity_rank` | `GET /api/rankings/activity` |
| `GET /api/profile/fresh_display_id` | `POST /api/me/problem-display-ids/refresh` |
| `GET /api/flowchart/submission/detail` | `GET /api/flowcharts/:id` |
| `POST /api/reaction` | `POST /api/problems/:id/reaction` |
| `PUT /api/admin/problemset/visible` | `PUT /api/admin/problem-sets/:id/visibility` |
| `GET /api/judge_server_heartbeat/` | `POST /api/judge-server/heartbeat` |
最后一条尤其要注意:**判题沙箱镜像是原样复用的**,它靠 compose 里的
`BACKEND_URL` 找后端,三套 compose 都已改成新路径。改动这条要同步改 compose,
否则判题机会静默离线。
+11 -1
View File
@@ -21,6 +21,16 @@
>
> Minor 四条未修:M1 已按「有意偏离」保留,M2/M3/M4 留待阶段 5。
> **更正(2026-08-08):本报告有一处对旧后端的判断是错的。**
>
> 文末对照表原写「`DashboardInfoAPI` / `RandomUsernameAPI` 旧后端任何人可读,
> 含班级用户名枚举」。**不成立** —— 这两条都挂在 `/api/admin/` 下,而
> `account/middleware.py:36``AdminRoleRequiredMiddleware` 在中间件层就要求
> 登录且 `is_admin_role()`。「无装饰器」是真的,「任何人可读」不是。
>
> 差别很大:真实缺口只是「任何管理员可读,而非仅超管」。查旧后端的权限时,
> 别只看装饰器,那个中间件是所有 `/api/admin/` 的地板。
## 结论速览
| 级别 | 数量 | 条目 |
@@ -394,7 +404,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => {
| `/announcements*`5 条) | requireSuperAdmin | `announcement/views/admin.py:13,23,40,57` | 一致 |
| `/tutorials*` `/exercises*`10 条) | requireSuperAdmin | `tutorial/views/admin.py:17,27,44,62,70,90,106,119,127` | 一致 |
| `/achievements*` `/achievement-metrics`6 条) | requireSuperAdmin | `achievement/views/admin.py:10,21,43,72,82` | 一致 |
| `/website` `/judge-servers*` `/orphan-test-cases*` `/dashboard` `/random-usernames`9 条) | requireSuperAdmin | `conf/views.py:49,66,76,84,148,162``DashboardInfoAPI`(187) 与 `RandomUsernameAPI`(216) 旧后端**无装饰器** | 一致或**收紧**(后两条旧后端任何人可读,含班级用户名枚举 |
| `/website` `/judge-servers*` `/orphan-test-cases*` `/dashboard` `/random-usernames`9 条) | requireSuperAdmin | `conf/views.py:49,66,76,84,148,162``DashboardInfoAPI`(187) 与 `RandomUsernameAPI`(216) 旧后端**无装饰器** | **收紧**(后两条旧后端任何管理员可读,不限超管 |
| `/contests*`5 条) | requireTeacher | `contest/views/admin.py:33,53,78,267` `@teacher_admin_required` | 一致 |
| `/contests/:id/acm-helper`2 条) | requireTeacher | `contest/views/admin.py:167,200` | 一致 |
| `/problem-sets*`15 条) | requireTeacher | `problemset/views/admin.py` 全部 `@teacher_admin_required` | 一致 |
+271 -31
View File
@@ -94,63 +94,303 @@ WS 经 Caddy upgrade: 已连接
---
## 三、切换前检查清单(停机窗口之前做完)
## 三、切换前准备(停机窗口之前做完)
- [ ] **先把镜像构建好**`docker compose -f docker/compose.debian.yml build`
首次约 5 分钟。别在停机窗口里构建。
- [ ] 填好 `docker/.env`(照 `docker/.env.example`)。
`POSTGRES_PASSWORD` 必须**和生产库现有的口令一致** —— 库是原地不动的,
不是新建的,密码改不了。
- [ ] `OJ2_JUDGE_TOKEN` 可以换新的(判题机和后端读同一个变量,一起换即可)。
- [ ] `AI_KEY` 服务器和机房是两个不同的 key,别填串。
- [ ] 机房那份 env 里 `COOKIE_SECURE=false`http 直连 IP,带 Secure 的 Cookie
浏览器不回传,表现是「登录成功但立刻又变未登录」)。
- [ ] 做一次 `pg_dumpall` 备份(不是为了迁移,是为了兜底)。
- [ ] 确认 `data/backend/``test_case``public/upload``public/avatar` 都在。
### ⚠️ 演练没暴露的一个坑:两套 compose 的数据目录不是同一个地方
## 四、切换步骤
演练时是把生产 dump 恢复进 `OJ2/data/postgres` 的(见第十节),所以这件事被盖住了。
真实部署目录 `/root/OJDeploy` 的实际情况:
**两个站点都要切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端
还在读写同一个库。
| | 旧栈(`docker-compose.yml` | 新栈默认值 |
|---|---|---|
| 库 | `/root/OJDeploy/data/postgres` | `/root/OJDeploy/OJ2/data/postgres` |
| 测试点 / 上传 / 头像 | `/root/OJDeploy/data/backend/` | `/root/OJDeploy/OJ2/data/backend/` |
服务器(xuyue.cc):
新 compose 在 `OJ2/docker/` 下,`../data` 解析到的是 `OJ2/data`。**照默认值切过去,
postgres 会在一个空目录上初始化一个全新的空库** —— 站点能起来,但没有用户、没有题、
判题全挂、题面图片 404。旧数据完好无损(回滚正常),但当天会白吓一场。
因此 `compose.debian.yml` / `compose.school.yml` 加了 `DATA_DIR` 等三组变量,
**下面的流程按「只换前后端」形态写**:旧栈的 postgres / redis 容器继续跑,
新栈只起 api / worker / web / judge。数据库进程根本不重启,库和文件一个字节都不用挪。
### 已经做掉的(2026-08-16
- **`docker/.env.school` 已写好**`.gitignore` 排除,不进版本库):判题 token 新生成一条、
`JUDGE_CONCURRENCY=4``DB_HOST=150.158.29.156``DB_PORT=5445``COOKIE_SECURE=false`
`docker compose config` 验过插值正确。**还差两个值**,见下面第 1 步。
- **构建复验通过。** 演练之后又改过两次前端(`/api2` 前缀漏改 5 处、接回配置推送与
MaxKB),在本机重新构建:`oj2-web` 75MB 构建通过、单起容器首页 200;`oj2-api`
完全命中缓存,说明后端源码在演练之后没动过。
这只证明「还能构建出来」—— 镜像不走镜像仓库,是各站点本地构建的,
**服务器和机房当地各自还要 build 一次**
- **「只换前后端」形态本机实跑验过。** 用 `docs/specs/schema.sql` 起了一个发布在宿主机
5445 的 postgres 冒充旧栈,新栈按下面的 env 起来:4 个容器(没有 postgres / redis)、
`oj-api` healthy、首页与 `/api/site` `/api/problems` 200、未登录进后台 401。
读写两个方向都验了 —— 那个库的 `pg_stat_activity` 里有一条来自 172.17.0.1 的
`postgres.js` 连接,`judge_server` 表里也出现了新判题机写进去的心跳行。
### 1. 填两份 env(各站一份,都不进版本库)
服务器 `docker/.env`(照 `docker/.env.example` 拷一份再填):
| 变量 | 填什么 |
|---|---|
| `POSTGRES_PASSWORD` | **生产库现有的口令**。库是原地不动的、不是新建的,这个值改不了(旧 compose 里是 `onlinejudge` |
| `DATA_DIR` | `/root/OJDeploy/data` —— **旧数据目录的绝对路径**。不填就是上面那个空数据坑 |
| `DB_HOST` / `DB_PORT` | `host.docker.internal` / `5445` —— 旧 postgres 已经把 5445 发布在宿主机上,走 host-gateway 过去,不出本机 |
| `REDIS_HOST` / `REDIS_PORT` | `host.docker.internal` / `5446` —— 同理,旧 redis 发布的是 5446 |
| `OJ2_JUDGE_TOKEN` | 可以换新的,`openssl rand -hex 32`;后端和判题机读同一个变量,一起换即可 |
| `JUDGE_CONCURRENCY` | 服务器 `2` |
| `AI_KEY` | 服务器那把 DeepSeek key |
机房 `docker/.env.school`:已写好,只差三个 ——
| 变量 | 填什么 |
|---|---|
| `POSTGRES_PASSWORD` | 同上,**和服务器那份一模一样**(连的是同一个库) |
| `DATA_DIR` | 机房那台的旧数据目录绝对路径。**机房也有测试点和上传文件**,同样不能用默认值 |
| `AI_KEY` | 机房那把,**和服务器不是同一把,别填串** |
漏填 `POSTGRES_PASSWORD` 不会静默起一个坏服务:compose 里写的是 `${POSTGRES_PASSWORD:?}`
没填直接报错退出。**但 `DATA_DIR` 漏填不会报错**,它有默认值 —— 这条只能靠人盯。
> 想让新栈自带 postgres / redis(本机开发、或将来旧栈彻底拆掉):不设 `DB_HOST`
> `REDIS_HOST` `DATA_DIR`,起栈时加 `--profile local-data` 即可。
### 2. 先把镜像构建好(**别在停机窗口里干这件事**)
服务器:
```bash
cd <部署目录>
docker compose -f docker-compose.debian.yml down # 停旧栈,约 11s
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env build
```
机房:
```bash
docker compose -f docker-compose.school.yml down
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school up -d
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school build
```
端口没变(服务器 8080,机房 81),前面的 Nginx Proxy Manager 不用动。
首次约 5 分钟,依赖下载占大头。构建完确认两个镜像都在:
## 五、切换后验证(照着点一遍)
```bash
docker images | grep oj2-
# oj2-api latest 487MB
# oj2-web latest 75MB
```
### 3. 兜底备份
```bash
docker exec oj-postgres pg_dumpall -U onlinejudge > ~/oj-before-cutover-$(date +%F).sql
```
**不是为了迁移**(不需要 DDL、不需要迁数据),是为了出事有退路。真要用它重建,
先读第八节第 1 条 —— 这份备份会把角色口令覆盖回备份当时的值。
### 4. 确认 `DATA_DIR` 指对了
```bash
ls /root/OJDeploy/data/backend/test_case \
/root/OJDeploy/data/backend/public/upload \
/root/OJDeploy/data/backend/public/avatar
```
判题测试点、题面图片、头像都在这三个目录里。**这个路径必须和 env 里的 `DATA_DIR` 一致。**
再用 compose 自己确认一遍解析结果,别靠脑补:
```bash
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env config | grep source:
# 每一条都应该在 /root/OJDeploy/data 下,出现 OJ2/data 就是 DATA_DIR 没生效
```
### 5. 出发前对一遍
- [ ] 两份 env 都填完了,两边的 `POSTGRES_PASSWORD` 一致
- [ ] **两边的 `DATA_DIR` 都指向旧数据目录**`config | grep source:` 核对过
- [ ] 两边镜像都构建完了
- [ ] 备份做了
- [ ] 决定好走哪条路:先并行试跑(第四节,推荐),还是直接切(第五节)
## 四、并行试跑(oj2.xuyue.cc
正式切换之前,先让新栈挂在一个独立域名上跑几天,**旧站原样不动、一个容器都不用停**。
这是双跑 —— 设计文档原本明确否掉过(「不双跑不灰度」)。改主意的理由是:
「只换前后端」形态下新栈本来就不碰数据库进程,双跑的增量风险只剩「两个后端同时写同一个库」
这一条;而换来的是**正式切换退化成改一行 NPM 上游**,反而比停机切换更稳。
代价见下面「试跑期间要知道的」,不是零。
### 1. env 比「只换前后端」多两个
```
WEB_PORT=8090
JUDGE_STATE_DIR=/root/OJDeploy/data/judge_server_oj2
```
`WEB_PORT` 是因为 8080 还被旧 backend 占着。`JUDGE_STATE_DIR` 是因为**两个判题机不能
共用运行目录** —— 不设的话新旧两个 judger 会同时往 `data/judge_server/{run,log}` 里写。
`test_case``public/upload` 仍然共享,**那是故意的**:测试点和题面图片两边必须看到同一份。
### 2. 起
```bash
mkdir -p /root/OJDeploy/data/judge_server_oj2/log /root/OJDeploy/data/judge_server_oj2/run
cd /root/OJDeploy
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8090/ # 期望 200
```
旧站这时候完全没受影响,8080 上照常服务。
### 3. NPM 加一台 proxy host
| 项 | 值 |
|---|---|
| Domain | `oj2.xuyue.cc`(先把 DNS 解析加上) |
| Forward | `<宿主机 IP>` : `8090` |
| **Websockets Support** | **必须打开** |
| SSL | 签一张证书,`COOKIE_SECURE=true` 依赖 https |
| client_max_body_size | `200M`,和 Caddyfile 里的 `200MiB` 对齐(上传测试用例压缩包) |
WebSocket 那个开关是双跑最容易漏的一格:漏了的话页面一切正常,唯独学生盯着的
「判题中…」永远不动 —— 而这恰恰是最不容易在自测里发现的一条,因为刷新一下结果就出来了。
### 4. 试跑期间要知道的
- **两边登录态不互通**。旧站是 Django session,新站是 Redis opaque token。
学生到 oj2 要重新登录一次,这不是 bug。
- **同一个库,双写**。结构完全兼容不会写坏,但提交、统计、成就都是**真实数据**,
不是沙盒。别拿它做破坏性试验。
- 后台判题机列表会出现**两台**(新旧各自心跳),正常。
- 比赛排名等缓存两边各存各的 Redis,可能短暂不一致。
**试跑期间不要在 oj2 上办正式比赛。**
### 5. 试跑要盯的是这些
都是只有真实数据 + 真实浏览器才暴露的:
- 机房那种 **Chrome < 94** 打开正常(`mermaid-legacy` 这条 fallback 只在老浏览器上生效)
- 带图片的题面(`/public/upload/*` 走的是反代,不是 Caddy 直接读盘)
- AI 分析的 **SSE 流式输出**经过 NPM 之后还是不是逐块下发(这一层最容易被缓冲住)
- WebSocket 在 NPM 后面长时间挂着稳不稳(超时断连会不会自动重连)
- 后台**上传测试用例压缩包**这条 200MB 的路
- 老师日常用的后台:出题、建比赛、题单
## 五、正式切换
**两个站点必须同一天切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端
还在读写同一个库。
### 试跑过了 —— 那就只是改一行上游
新栈已经在 8090 上跑着、验过了,切换不需要重启任何容器:
1. NPM 里把 `xuyue.cc` 那台 proxy host 的上游从 `8080` 改成 `8090`**Websockets Support 同样要开**
2. 看一眼首页和一次提交,正常
3. 确认没问题之后再停旧栈:`docker compose -f docker-compose.yml stop oj-backend oj-judge`
**停机时间约等于零**,回滚就是把 NPM 那一行改回 `8080` —— 旧栈这时候都还没停。
这正是设计文档最初写的那个回滚故事:「改一行上游」。
`oj2.xuyue.cc` 可以留着,它和 `xuyue.cc` 指向同一个新栈,没有坏处。
### 没试跑、直接切 —— 走下面这套
服务器(`/root/OJDeploy`):
```bash
cd /root/OJDeploy
# 只停应用,postgres / redis 留着继续跑 —— 新栈接着用它们
docker compose -f docker-compose.yml stop oj-backend oj-judge
docker compose -f docker-compose.yml ps # 确认 oj-postgres / oj-redis 还在 Up
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env ps
# 应该正好 4 个:oj-api / oj-worker / oj-web / oj-judge,等 oj-api 变 healthy
```
旧判题机必须一起停:它和新判题机会争同一个 `data/judge_server/run`
旧 backend 也必须停:8080 端口要交给 `oj-web`
机房:
```bash
cd <机房部署目录>
docker compose -f docker-compose.yml stop oj-backend oj-judge
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school up -d
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school ps
```
(机房本来就没有 postgres;它的 redis 是新栈自带的,和旧 redis 不冲突 ——
旧的那个没往宿主机发布端口。)
起不来先看这两条日志,绝大多数问题在里面直说了:
```bash
docker logs oj-api --tail 50 # 连不上库、token 不对都在这里
docker logs oj-judge --tail 20 # 判题机注册不上看这条
```
## 六、切换后验证
这一节试跑刚起来时也照着跑一遍,只是端口换成试跑用的(`WEB_PORT`,例如 8090)。
先用命令快速过一遍(服务器 8080,机房 81):
```bash
BASE=http://localhost:8080
curl -s -o /dev/null -w '首页 %{http_code}\n' $BASE/
curl -s -o /dev/null -w '站点配置 %{http_code}\n' $BASE/api/site
curl -s -o /dev/null -w '题目列表 %{http_code}\n' $BASE/api/problems
curl -s -o /dev/null -w '未登录进后台 %{http_code}\n' $BASE/api/admin/dashboard # 期望 401
```
前三条 200、第四条 401 才算过。两个失败模式各有各的症状,别搞混:
- **题目列表 `"total":0`** → 连错库了(`DB_HOST` 没设,或误加了 `--profile local-data`
起了个自带的空 postgres)。立刻停下来查,别往下走。
- **库是对的,但判题全错、题面图片 404**`DATA_DIR` 指错了,挂上去一堆空目录。
然后照着点一遍 —— 下面这几步是命令测不到的:
1. 打开首页,能看到题目列表
2. 用一个学生账号登录,看得到自己的提交历史
3. 提交一道题,**看判题结果是否实时刷出来**(这一步同时验证了 WebSocket)
4. 后台 → 判题机列表,确认判题机在线
4. 后台 → 判题机列表,确认判题机在线(心跳走 `/api/judge-server/heartbeat`
5. 后台 → 题目列表能翻页
6. 题面里带图片的题,图片能显示(`/public/upload/*`
## 六、回滚
机房那边额外确认一条:**登录之后刷新页面还是登录态**。如果「登录成功又立刻变未登录」,
就是 `COOKIE_SECURE` 没设成 false。
## 七、回滚
**试跑之后切的**(推荐路径):NPM 里把 `xuyue.cc` 的上游从 `8090` 改回 `8080`
旧栈这时候还跑着,**秒级生效,什么都不用停不用起**。等确认稳定了再决定何时收掉新栈。
**直接切的**
```bash
docker compose -f OJ2/docker/compose.debian.yml down
docker compose -f docker-compose.debian.yml up -d
cd /root/OJDeploy
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env down
docker compose -f docker-compose.yml start oj-backend oj-judge
```
约 20 秒。**不需要恢复数据库,不需要动任何文件。**
机房同理,换成 `compose.school.yml`
约 20 秒,比演练时还快一点 —— **postgres / redis 全程没停过**,回滚只是把旧的
backend 和判题机再 start 起来。**不需要恢复数据库,不需要动任何文件。**
这也是「只换前后端」形态的主要好处:切换和回滚都不碰数据库进程,
库出问题的可能性从流程里被整个拿掉了。
---
## 、演练中踩到的坑(写下来是因为它们只在容器里出现)
## 、演练中踩到的坑(写下来是因为它们只在容器里出现)
### 1. pg_dumpall 备份会覆盖数据库口令 ⚠️
@@ -198,7 +438,7 @@ ERROR: database "onlinejudge" is being accessed by other users
---
## 、镜像体积(没达到设计文档的预期,说明原因)
## 、镜像体积(没达到设计文档的预期,说明原因)
| 镜像 | 体积 |
|---|---|
@@ -224,7 +464,7 @@ clang-format,再加整个 Python 运行时和 Django 依赖,所以新镜像
---
## 、演练产生的临时数据(已清理)
## 、演练产生的临时数据(已清理)
演练在 `OJ2/data/postgres` 下留下了**一份完整的生产数据副本**,其中包含 1710 名
学生的 `raw_password` 明文列。演练结束后已删除该目录。