diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 170754c..9994d96 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -81,6 +81,15 @@ jobs: - name: 编前端 run: cd apps/web && bun run build + # 格式、lint、前后端类型检查、路由遮蔽、AST 节点,有一项不过就不部署(同步到服务器之前)。 + # 不加它的话,每次 push 都是直接上线、中间没有任何检查 —— vite build 不做类型检查。 + # + # 必须排在「编前端」之后:前端类型检查要 unplugin 生成的 auto-imports.d.ts / + # components.d.ts,它们不进版本库,vite build 时才生成;排在前面会报几百条 + # Cannot find name 'computed'(实测过)。 + - name: 检查 + run: bunx vp run verify + - name: 配置 SSH run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 0c987f0..31b3963 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -3,7 +3,7 @@ // 2026-08-26:旧 Django 后端下线,7 张框架表(auth_group、auth_group_permissions、 // auth_permission、django_content_type、django_dramatiq_task、django_migrations、 // django_session)已由 0002_drop_django_leftovers 删除,drizzle.config.ts 的 -// tablesFilter 随之移除。库里现在就是这 27 张业务表。 +// tablesFilter 随之移除。当时库里剩 27 张业务表(之后陆续加了新表,以本文件为准)。 // // 2026-09-02:又清了一批只有 Django 时代写过、OJ2 一次都没读过的列 —— // 0008 删比赛 IP 白名单与 submission.ip(判题机的 judge_server.ip 保留,那是运维数据), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 31b3f51..a7a5f47 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -44,6 +44,10 @@ app.route('/api', submissionRoutes) app.route('/api', trialRunRoutes) app.route('/api/admin', adminRoutes) +// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出 +// error.code / message,提示就成了笼统的「请求失败」。和其它错误同一个形状 +app.notFound((c) => c.json({ error: { code: 'not-found', message: 'Not found' } }, 404)) + app.onError((error, c) => { console.error(error) return c.json({ error: { code: 'internal-error', message: 'Internal server error' } }, 500) @@ -63,7 +67,13 @@ const DEFAULT_AVATAR_SVG = * 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。 */ async function serveUpload(pathname: string, prefix: string, directory: string) { - const decoded = decodeURIComponent(pathname) + // 畸形的百分号编码(`/public/upload/%`)decodeURIComponent 会抛 URIError,不接住就是 500 + let decoded: string + try { + decoded = decodeURIComponent(pathname) + } catch { + return new Response('Not found', { status: 404 }) + } const filename = basename(decoded) if (!filename || filename !== decoded.slice(prefix.length + 1)) { return new Response('Not found', { status: 404 }) diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index ca5ea6f..0eadb1e 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -16,7 +16,7 @@ import { parseProblemTemplate, shiftCompileLines } from './template' import { withJudgeSlot } from './slots' import { runSqlCase } from './sql' import { nativeRuntimeError, parsePythonTraceback, type RuntimeErrorInfo } from './runtime-diagnosis' -import { compileError, judge, type JudgeCase, type JudgeResponse } from './sandbox' +import { compileError, judge, JudgeUnreachableError, type JudgeCase, type JudgeResponse } from './sandbox' import { readInfo } from '../services/test-case' import { readFile } from 'node:fs/promises' import { resolve as resolvePath } from 'node:path' @@ -79,6 +79,13 @@ function judgeTimeoutMs(timeLimit: number) { return Math.max(120_000, timeLimit * 60) } +/** + * 连不上判题机时隔多久再试,一共再试这么多次。判题机容器重启(部署时重建、OOM 被拉起来) + * 要几秒,合计十几秒盖得住;原来是当场记成系统错误,那几秒里交的全班都得自己重交。 + * 只重试「连不上」:超时(已经等了两分钟)、判题机回了错都不重试。等的时候不占名额 + */ +const UNREACHABLE_RETRY_DELAYS_MS = [2_000, 5_000, 10_000] + async function requestJudge( language: string, code: string, @@ -88,17 +95,22 @@ async function requestJudge( output = false, ) { const timeoutMs = judgeTimeoutMs(timeLimit) - try { - // 超时从拿到名额才开始算:排队等名额不算判题机没回应 - return await withJudgeSlot('judge', { holdMs: timeoutMs + 30_000 }, () => - judge(language, code, timeLimit, memoryLimit, testCase, { output, signal: AbortSignal.timeout(timeoutMs) }), - ) - } catch (error) { - // 这句会写进 err_info,学生能看到,别把 "The operation timed out." 原样给他 - if (error instanceof DOMException && error.name === 'TimeoutError') { - throw new Error(`判题机 ${timeoutMs / 1000} 秒没有回应`) + for (let attempt = 0; ; attempt++) { + try { + // 超时从拿到名额才开始算:排队等名额不算判题机没回应 + return await withJudgeSlot('judge', { holdMs: timeoutMs + 30_000 }, () => + judge(language, code, timeLimit, memoryLimit, testCase, { output, signal: AbortSignal.timeout(timeoutMs) }), + ) + } catch (error) { + // 这句会写进 err_info,学生能看到,别把 "The operation timed out." 原样给他 + if (error instanceof DOMException && error.name === 'TimeoutError') { + throw new Error(`判题机 ${timeoutMs / 1000} 秒没有回应`) + } + const delay = UNREACHABLE_RETRY_DELAYS_MS[attempt] + if (!(error instanceof JudgeUnreachableError) || delay === undefined) throw error + console.warn(`[judge] ${error.message},${delay / 1000} 秒后重试(第 ${attempt + 1} 次)`) + await Bun.sleep(delay) } - throw error } } diff --git a/apps/api/src/judge/sandbox.ts b/apps/api/src/judge/sandbox.ts index 41b95d5..8f3a06e 100644 --- a/apps/api/src/judge/sandbox.ts +++ b/apps/api/src/judge/sandbox.ts @@ -106,8 +106,30 @@ function headers() { return { authorization: `Bearer ${config.judgeServerToken}` } } +/** + * 判题机根本连不上:连接被拒、主机名解析不到、连接被重置 —— 判题机容器正在重启、OOM 被杀 + * 的那几秒就是这样。和「判题机回了错」「超时没回」分开,正式判题碰到它会等几秒再试 + * (judge/run.ts 的 requestJudge),而不是当场把学生的提交记成系统错误 + */ +export class JudgeUnreachableError extends Error { + constructor(cause: unknown) { + super(`连不上判题机(${cause instanceof Error ? cause.message : String(cause)})`) + this.name = 'JudgeUnreachableError' + } +} + +/** fetch 判题机。网络层失败(Bun 里是带 code 的 TypeError)换成 JudgeUnreachableError;超时、中止原样抛 */ +async function judgeFetch(path: string, init: RequestInit & { signal?: AbortSignal }) { + try { + return await fetch(new URL(path, config.judgeServerUrl), init) + } catch (error) { + if (!init.signal?.aborted && error instanceof TypeError) throw new JudgeUnreachableError(error) + throw error + } +} + async function runCommand(command: Record, signal?: AbortSignal) { - const response = await fetch(new URL('/run', config.judgeServerUrl), { + const response = await judgeFetch('/run', { method: 'POST', signal, headers: { ...headers(), 'content-type': 'application/json' }, @@ -120,7 +142,7 @@ async function runCommand(command: Record, signal?: AbortSignal } async function readFile(fileId: string, signal?: AbortSignal) { - const response = await fetch(new URL(`/file/${fileId}`, config.judgeServerUrl), { signal, headers: headers() }) + const response = await judgeFetch(`/file/${fileId}`, { signal, headers: headers() }) if (!response.ok) throw new Error(`判题机取文件返回 HTTP ${response.status}`) return new Uint8Array(await response.arrayBuffer()) } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1580d25..b6bf15f 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -9,7 +9,7 @@ * oj2-api # 等同 serve * oj2-api serve # HTTP + WebSocket * oj2-api worker # BullMQ 判题消费者 - * oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用 + * oj2-api healthcheck # 探活,给 compose 的 healthcheck 用;--worker 探判题 worker * oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调 * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 * oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演 @@ -54,7 +54,18 @@ switch (command) { // 运行镜像是 debian-slim,没有 curl/wget,探活让二进制自己做。 // 只打 /health,不碰库 —— 库挂了该由库自己的 healthcheck 报, // 不该让 api 容器跟着被判成不健康、进而被重启。 + // + // `--worker`:worker 没有 HTTP,看它每 10 秒刷一次的时间戳(worker.ts),60 秒没刷就算挂了 case 'healthcheck': { + if (process.argv.includes('--worker')) { + const { readFileSync } = await import('node:fs') + try { + const at = Number(readFileSync('/tmp/oj2-worker-alive', 'utf8')) + process.exit(Date.now() - at < 60_000 ? 0 : 1) + } catch { + process.exit(1) + } + } const { config } = await import('./config') try { const response = await fetch(`http://127.0.0.1:${config.port}/health`, { diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 3196a3b..b244ea6 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -1,5 +1,4 @@ import { - updateJudgeServerRequestSchema, updateWebsiteConfigRequestSchema, type DashboardInfo, type JudgeServer, @@ -28,7 +27,7 @@ import { TEACHER_ROLES } from '../helpers' import { onlineUserIds } from '../../auth/presence' import { JudgeStatus } from '../../judge/status' import { suggestActiveClass } from '../../services/class-activity' -import { classPrefixCondition, queryInteger } from '../helpers' +import { classPrefixCondition } from '../helpers' export const adminConfRoutes = new Hono() @@ -98,8 +97,10 @@ adminConfRoutes.get('/judge-servers', requireSuperAdmin, async (c) => { return success(c, { // 后台要显示 token 才能拿去配判题机。这个接口是超管专属的 token: config.judgeServerToken, + // task_number、is_disabled 两列是旧 JudgeServer 时代的:判题不经调度、也没有「停用」这回事, + // 列留在库里(删列是破坏性迁移,不值得),不往外给 servers: rows.map( - (row) => + ({ taskNumber: _taskNumber, isDisabled: _isDisabled, ...row }) => ({ ...row, status: isAlive(row.lastHeartbeat) ? 'normal' : 'abnormal', @@ -108,21 +109,6 @@ adminConfRoutes.get('/judge-servers', requireSuperAdmin, async (c) => { } satisfies JudgeServerList) }) -adminConfRoutes.put('/judge-servers/:id', requireSuperAdmin, async (c) => { - const parsed = await parseBody(c, updateJudgeServerRequestSchema, 'isDisabled is required') - if (!parsed.success) return parsed.response - const updated = await db - .update(schema.judgeServer) - .set({ isDisabled: parsed.data.isDisabled }) - .where(eq(schema.judgeServer.id, queryInteger(c.req.param('id'), 0, { min: 1 }))) - .returning({ id: schema.judgeServer.id }) - if (updated.length === 0) return failure(c, 404, 'judge-server-not-found', 'Judge server does not exist') - // 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。 - // 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交 - // 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。 - return success(c, null) -}) - adminConfRoutes.delete('/judge-servers/:hostname', requireSuperAdmin, async (c) => { const deleted = await db .delete(schema.judgeServer) diff --git a/apps/api/src/scripts/check-route-shadowing.ts b/apps/api/src/scripts/check-route-shadowing.ts index 407959b..a242949 100644 --- a/apps/api/src/scripts/check-route-shadowing.ts +++ b/apps/api/src/scripts/check-route-shadowing.ts @@ -14,7 +14,7 @@ * 不是合法 id 而返回 404 或者一堆看不懂的结果。阶段 4 真实发生过一次: * 两个教师用的分析端点被 `/problems/:id` 吃掉,评审时才发现。 * - * 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。 + * 加路由时顺手跑一下,比事后靠人眼在两百来条路由里看出顺序问题可靠。 * * 局限:靠正则读源码,只认 `xxxRoutes.get('字面量', …)` 这种写法(单双引号都认), * 以及 `xxxRoutes.route('字面量', 子路由)` 的嵌套挂载(按挂载位置展开)。 diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 0f5e53b..3f5c7f4 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -1,4 +1,5 @@ import { Worker } from 'bullmq' +import { writeFileSync } from 'node:fs' import { config } from './config' import { startJudgeHeartbeat } from './judge/heartbeat' @@ -8,8 +9,11 @@ import { flowchartQueueName, type FlowchartJobData } from './flowchart/job' import { evaluateFlowchart } from './flowchart/run' import { createBlockingRedis } from './redis' +const judgeConnection = createBlockingRedis() +const flowchartConnection = createBlockingRedis() + const worker = new Worker(judgeQueueName, async (job) => judgeSubmission(job.data), { - connection: createBlockingRedis(), + connection: judgeConnection, concurrency: config.judgeConcurrency, }) @@ -21,7 +25,7 @@ const flowchartWorker = new Worker( evaluateFlowchart(job.data, { isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1), }), - { connection: createBlockingRedis(), concurrency: 2 }, + { connection: flowchartConnection, concurrency: 2 }, ) worker.on('ready', () => { @@ -48,7 +52,28 @@ flowchartWorker.on('error', (error) => console.error('Flowchart worker error', e const stopHeartbeat = startJudgeHeartbeat() +/** + * 给 `oj2-api healthcheck --worker` 看的「还活着」:两个消费者都在跑、连 Redis 的连接都正常, + * 才每 10 秒刷一次时间戳。事件循环卡死、和 Redis 断了、消费者停了,时间戳都会停下来, + * 容器就被判成 unhealthy —— 原来 worker 没有健康检查,挂死了 docker 照样显示 running, + * 判题队列静默积压 + */ +// main.ts 的 healthcheck 写死了同一个路径:那边不能 import 这个文件(一 import 就把 worker 起来了) +const WORKER_ALIVE_FILE = '/tmp/oj2-worker-alive' +const markAlive = () => { + if (!worker.isRunning() || !flowchartWorker.isRunning()) return + if (judgeConnection.status !== 'ready' || flowchartConnection.status !== 'ready') return + try { + writeFileSync(WORKER_ALIVE_FILE, String(Date.now())) + } catch (error) { + console.error('Failed to write worker alive file', error) + } +} +const aliveTimer = setInterval(markAlive, 10_000) +worker.on('ready', markAlive) + async function shutdown() { + clearInterval(aliveTimer) stopHeartbeat() await worker.close() await flowchartWorker.close() diff --git a/apps/web/tests/submitButtonState.test.ts b/apps/web/tests/submitButtonState.test.ts deleted file mode 100644 index 38c3d0b..0000000 --- a/apps/web/tests/submitButtonState.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { getSubmitButtonState } from '../src/oj/problem/components/submitButtonState.ts' - -const idleInput = { - isAuthed: true, - hasCode: true, - isFormatting: false, - isSubmitting: false, - isJudging: false, - isCooldown: false, -} - -test('shows a disabled loading state while formatting', () => { - assert.deepEqual(getSubmitButtonState({ ...idleInput, isFormatting: true }), { - disabled: true, - label: '格式化中', - icon: 'eos-icons:loading', - }) -}) - -test('shows submitting immediately after formatting', () => { - assert.deepEqual(getSubmitButtonState({ ...idleInput, isSubmitting: true }), { - disabled: true, - label: '正在提交', - icon: 'eos-icons:loading', - }) -}) - -test('preserves existing login, judging, cooldown, and idle states', () => { - assert.deepEqual(getSubmitButtonState({ ...idleInput, isAuthed: false }), { - disabled: true, - label: '请先登录', - icon: 'ph:play-fill', - }) - assert.deepEqual(getSubmitButtonState({ ...idleInput, isJudging: true }), { - disabled: true, - label: '正在评分', - icon: 'eos-icons:loading', - }) - assert.deepEqual(getSubmitButtonState({ ...idleInput, isCooldown: true }), { - disabled: true, - label: '正在冷却', - icon: 'ph:lightbulb-fill', - }) - assert.deepEqual(getSubmitButtonState(idleInput), { - disabled: false, - label: '提交代码', - icon: 'ph:play-fill', - }) -}) diff --git a/bun.lock b/bun.lock index 57dec79..82ef564 100644 --- a/bun.lock +++ b/bun.lock @@ -99,7 +99,7 @@ "name": "@oj2/contract", "version": "0.0.0", "dependencies": { - "zod": "^4.4.3", + "zod": "^4.6.6", }, }, }, diff --git a/docker/compose.debian.yml b/docker/compose.debian.yml index 08be0ae..910ad45 100644 --- a/docker/compose.debian.yml +++ b/docker/compose.debian.yml @@ -11,8 +11,17 @@ # 不设它的话 postgres 会在空目录上初始化一个全新的空库,测试点和题面图片也全都不在, # 而且**不报错**。`deploy.sh` 为此专门设了自检。 +# 每个容器的日志最多 3 个 10MB 的文件。docker 默认的 json-file 不设上限,判题、worker +# 天天打日志,2 核小服务器的磁盘迟早被写满 —— 磁盘一满,postgres 写不进、判题跟着失败。 +x-logging: &logging + driver: json-file + options: + max-size: 10m + max-file: '3' + services: oj-postgres: + logging: *logging image: postgres:18-alpine container_name: oj-postgres restart: always @@ -67,6 +76,7 @@ services: retries: 10 oj-redis: + logging: *logging image: redis:8-alpine container_name: oj-redis restart: always @@ -87,6 +97,7 @@ services: retries: 10 oj-judge: + logging: *logging # 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。 # ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建, # 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。 @@ -102,12 +113,13 @@ services: - ${DATA_DIR:-../data}/backend/test_case:/test_case:ro environment: ES_AUTH_TOKEN: ${OJ2_JUDGE_TOKEN:?请在 docker/.env 里设置 OJ2_JUDGE_TOKEN} - # 同一时刻最多六份在编译(正式判题 2、试运行 2、提交前编译检查 2),g++ 编一份 iostream + # 同一时刻最多四份在编译(judge/slots.ts 两个名额池:正式判题 2、学生页面上的运行和编译检查 2),g++ 编一份 iostream # 峰值约 70MB、bits/stdc++.h 约 195MB,再加学生程序本身。512m 不够撑满,超了是容器里 # 随机杀进程、学生看到系统错误 mem_limit: 1g oj-api: + logging: *logging build: &build context: .. dockerfile: docker/Dockerfile @@ -149,6 +161,7 @@ services: cpu_shares: 2048 oj-worker: + logging: *logging build: *build image: oj2-api:latest container_name: oj-worker @@ -160,10 +173,19 @@ services: environment: *api-env # 同一个镜像,换个子命令就是判题消费者 command: ["oj2-api", "worker"] + # worker 每 10 秒刷一次「还活着」,60 秒没刷就是挂了(oj2-api healthcheck --worker)。 + # unhealthy 只是标出来、deploy.sh 起栈时会等它,docker 不会因此自动重启容器 + healthcheck: + test: ["CMD", "oj2-api", "healthcheck", "--worker"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 20s # SQL 判题的子进程各允许 512MB 数据段(judge/sql 的 ulimit -d),两个并发就能顶满 512m mem_limit: 1g oj-web: + logging: *logging build: context: .. dockerfile: docker/Dockerfile diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index e39b081..871409a 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -25,8 +25,17 @@ # 是后端认的名字。忘了设 OJ2_JUDGE_TOKEN 的话 docker compose 会直接报错退出;忘了设 # JUDGE_SERVER_TOKEN 的话后端会随机生成一个并在启动日志里告警,判题请求会被 401 挡掉。 +# 每个容器的日志最多 3 个 10MB 的文件。docker 默认的 json-file 不设上限,判题、worker +# 天天打日志,2 核小服务器的磁盘迟早被写满 —— 磁盘一满,postgres 写不进、判题跟着失败。 +x-logging: &logging + driver: json-file + options: + max-size: 10m + max-file: '3' + services: postgres: + logging: *logging image: postgres:18-alpine container_name: oj2-postgres restart: unless-stopped @@ -48,6 +57,7 @@ services: retries: 10 redis: + logging: *logging image: redis:8-alpine container_name: oj2-redis restart: unless-stopped @@ -60,6 +70,7 @@ services: retries: 10 judge: + logging: *logging # 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。 # ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建, # 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。 diff --git a/docker/compose.school.yml b/docker/compose.school.yml index b6b3a44..36cdea3 100644 --- a/docker/compose.school.yml +++ b/docker/compose.school.yml @@ -15,8 +15,17 @@ # 测试点(`data/backend/test_case`)和题面图片(`data/backend/public/upload`)都在旧目录里, # 不设 `DATA_DIR` 就会挂一堆空目录上去:判题全错、图片 404。**沿用旧数据必须设它。** +# 每个容器的日志最多 3 个 10MB 的文件。docker 默认的 json-file 不设上限,判题、worker +# 天天打日志,2 核小服务器的磁盘迟早被写满 —— 磁盘一满,postgres 写不进、判题跟着失败。 +x-logging: &logging + driver: json-file + options: + max-size: 10m + max-file: '3' + services: oj-redis: + logging: *logging image: redis:8-alpine container_name: oj-redis restart: always @@ -29,6 +38,7 @@ services: retries: 10 oj-judge: + logging: *logging # 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。 # ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建, # 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。 @@ -48,6 +58,7 @@ services: mem_limit: 2g oj-api: + logging: *logging build: &build context: .. dockerfile: docker/Dockerfile @@ -85,6 +96,7 @@ services: mem_limit: 4g oj-worker: + logging: *logging build: *build image: oj2-api:latest container_name: oj-worker @@ -95,9 +107,18 @@ services: - ${DATA_DIR:-../data}/backend:/data environment: *api-env command: ["oj2-api", "worker"] + # worker 每 10 秒刷一次「还活着」,60 秒没刷就是挂了(oj2-api healthcheck --worker)。 + # unhealthy 只是标出来、deploy.sh 起栈时会等它,docker 不会因此自动重启容器 + healthcheck: + test: ["CMD", "oj2-api", "healthcheck", "--worker"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 20s mem_limit: 2g oj-web: + logging: *logging build: context: .. dockerfile: docker/Dockerfile diff --git a/docker/deploy.sh b/docker/deploy.sh index d135421..6658dee 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -4,7 +4,7 @@ # 全部由 docker/compose.debian.yml 管。 # # cd /root/OJDeploy/OJ2 -# docker/deploy.sh # 自检 → 构建 → 起栈 → 冒烟 +# docker/deploy.sh # 自检 → 构建 → 迁移 → 起栈 → 冒烟 # docker/deploy.sh --check # 只自检,不动任何容器 # docker/deploy.sh --no-build # 跳过构建(只改了 env / compose 时用) # docker/deploy.sh --prebuilt # 用现成产物,不在这台机器上编(CI 走这条) @@ -171,6 +171,17 @@ done [ "$status" = healthy ] || die "oj-api 没起来(状态 $status) 看日志:docker logs oj-api --tail 50" +# worker 的健康检查每 30 秒一次,第一次也要等满 30 秒,所以这里多给一些时间。 +# 它不起来的话站点照样能开,但交上去的题没人判 —— 学生那边一直「等待评分」 +wstatus=starting +for _ in $(seq 45); do + wstatus=$(docker inspect -f '{{.State.Health.Status}}' oj-worker 2>/dev/null || echo starting) + [ "$wstatus" = healthy ] && break + sleep 2 +done +[ "$wstatus" = healthy ] || die "oj-worker 没起来(状态 $wstatus)—— 站点能开,但提交没人判 +看日志:docker logs oj-worker --tail 50" + # ---------------------------------------------------------------- 冒烟 say "冒烟(端口 $port)" code() { curl -s -o /dev/null -w '%{http_code}' "http://localhost:$port$1"; } diff --git a/docs/contract.md b/docs/contract.md index 1d0abdd..8784ab3 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -28,7 +28,7 @@ ## 闸门的三处形态 -1. **入参 `safeParse`**(58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 400。 +1. **入参 `safeParse`**(请求体一律走 `http.ts` 的 `parseBody`,全部保留)—— 请求体进来的那一刻校验,对不上回 400。 2. **`db/schema.ts` 的 `.$type<>()`** —— 枚举型的列(`submission.result` / `.language`、 `problem.difficulty` / `.languages`、`achievement.rarity`、`exercise.type`…)和几个形状 确定的 JSONB(`problem.template` / `.astRules` / `.sqlConfig` / `.sqlDisplay`、 diff --git a/docs/deploy.md b/docs/deploy.md index d7cd777..a19b3d0 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -57,8 +57,9 @@ docker exec -it oj-postgres psql -U onlinejudge -d postgres -c '\password online ### 服务器:push 就部署 -`.github/workflows/deploy.yml` —— 在 runner 上编好产物、rsync 到服务器、在服务器上 -跑 `docker/deploy.sh --prebuilt`。触发的是 push 到 **github** 这个 remote +`.github/workflows/deploy.yml` —— 在 runner 上编好产物、跑一遍 `bunx vp run verify` +(格式、lint、前后端类型检查、路由遮蔽、AST 节点,**不过就不部署**)、rsync 到服务器、 +在服务器上跑 `docker/deploy.sh --prebuilt`。触发的是 push 到 **github** 这个 remote (`origin` 是 `git.xuyue.cc`,它会镜像到 github,所以**平时 push `origin` 通常几秒后 也就部署了**,偶尔不跟、要 `git ls-remote github` 核实。Gitea 自己不跑这份工作流 —— 没有 `.gitea/workflows` 时它的 Actions 会退回来读这份工作流,所以 job 上卡了 diff --git a/packages/contract/package.json b/packages/contract/package.json index 7c7acb5..5b3a37a 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -9,7 +9,7 @@ ".": "./src/index.ts" }, "dependencies": { - "zod": "^4.4.3" + "zod": "^4.6.6" }, "sideEffects": false } diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 6348d5d..40da809 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -334,9 +334,7 @@ export const judgeServerSchema = z.object({ cpuUsage: z.number(), lastHeartbeat: z.string(), createTime: z.string(), - taskNumber: z.number().int(), serviceUrl: z.string().nullable(), - isDisabled: z.boolean(), /** 心跳在 6 秒内才算在线,与 dashboard 的判活口径一致 */ status: z.enum(['normal', 'abnormal']), }) @@ -346,10 +344,6 @@ export const judgeServerListSchema = z.object({ servers: z.array(judgeServerSchema), }) -export const updateJudgeServerRequestSchema = z.object({ - isDisabled: z.boolean(), -}) - export const orphanTestCaseSchema = z.object({ id: z.string(), createTime: z.number(), @@ -1247,7 +1241,6 @@ export type ImportUsersRequest = z.input export type DeleteUsersRequest = z.input export type ResetPasswordResponse = z.infer export type UpdateWebsiteConfigRequest = z.input -export type UpdateJudgeServerRequest = z.input export type UploadImageResponse = z.infer export type CreateContestRequest = z.input export type UpdateContestRequest = z.input diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index 5eccd02..33beec6 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -285,8 +285,8 @@ export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record