Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a059d0b88 | ||
|
|
cbff292551 | ||
|
|
2c6f8d11f3 | ||
|
|
9a63883f30 | ||
|
|
95625d6711 | ||
|
|
2edb8cfb0d | ||
|
|
34ae9b032c | ||
|
|
516929461f | ||
|
|
967e9ef7f7 | ||
|
|
f6bc42f534 |
+8
-3
@@ -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
|
||||
|
||||
@@ -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 + 三套 compose(dev / 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`。
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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">
|
||||
清空
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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="人数">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -7,14 +7,39 @@
|
||||
# 和旧的 docker-compose.debian.yml 的对应关系:
|
||||
# oj-backend(supervisord 跑 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
否则判题机会静默离线。
|
||||
|
||||
@@ -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` | 一致 |
|
||||
|
||||
@@ -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` 明文列。演练结束后已删除该目录。
|
||||
|
||||
Reference in New Issue
Block a user