chore(部署): push 后先过检查再部署;判题机重启那几秒交的题不再变成系统错误;worker 挂了看得出来
Deploy / deploy (push) Canceled after 0s

按外部分析报告(reports/analysis)挑出来做的几条:
- CI 在编完前端之后跑 `bunx vp run verify`,有一项不过就不部署(必须排在编前端之后:
  前端类型检查要的 auto-imports.d.ts / components.d.ts 是 vite build 时才生成的)
- 连不上判题机(连接被拒、解析不到、被重置)时,正式判题等 2/5/10 秒重试,不再当场记成
  系统错误;超时、判题机回错、页面上的运行和自测不重试
- worker 每 10 秒刷一次「还活着」,compose 加 `oj2-api healthcheck --worker`,deploy.sh 起栈后
  也等 worker 健康(docker 不会因 unhealthy 自动重启,只是看得见、部署时拦得住)
- 三套 compose 的容器日志限 10MB × 3
- `/public/%` 这类畸形编码回 404 而不是 500;找不到的接口回统一的 JSON 错误
- 删掉没人用的「停用判题机」接口,契约不再给 task_number / is_disabled(列留在库里)
- 删掉腐化的 apps/web/tests/submitButtonState.test.ts;契约包 zod 范围对齐到 ^4.6.6
- 修几处和实际对不上的注释与文档(表数、编译并发、AST 语言数、deploy.sh 流程、校验处数)

⚠️ 改了 compose 的日志配置,这次部署会把 postgres / redis / 判题机也重建一遍,各停几秒

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 20:59:19 +08:00
1 parent b344bd1dd6
commit b046af4ba0
20 files changed
+187 -104

No files matched your search

+9
View File
@@ -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
+1 -1
View File
@@ -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 保留,那是运维数据),
+11 -1
View File
@@ -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 })
+23 -11
View File
@@ -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
}
}
+24 -2
View File
@@ -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<string, unknown>, 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<string, unknown>, 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())
}
+12 -1
View File
@@ -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`, {
+4 -18
View File
@@ -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<AppEnv>()
@@ -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)
@@ -14,7 +14,7 @@
* 不是合法 id 而返回 404 或者一堆看不懂的结果。阶段 4 真实发生过一次:
* 两个教师用的分析端点被 `/problems/:id` 吃掉,评审时才发现。
*
* 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。
* 加路由时顺手跑一下,比事后靠人眼在两百来条路由里看出顺序问题可靠。
*
* 局限:靠正则读源码,只认 `xxxRoutes.get('字面量', …)` 这种写法(单双引号都认),
* 以及 `xxxRoutes.route('字面量', 子路由)` 的嵌套挂载(按挂载位置展开)。
+27 -2
View File
@@ -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<JudgeJobData>(judgeQueueName, async (job) => judgeSubmission(job.data), {
connection: createBlockingRedis(),
connection: judgeConnection,
concurrency: config.judgeConcurrency,
})
@@ -21,7 +25,7 @@ const flowchartWorker = new Worker<FlowchartJobData>(
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()
-51
View File
@@ -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',
})
})
+1 -1
View File
@@ -99,7 +99,7 @@
"name": "@oj2/contract",
"version": "0.0.0",
"dependencies": {
"zod": "^4.4.3",
"zod": "^4.6.6",
},
},
},
+23 -1
View File
@@ -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
+11
View File
@@ -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 然后报找不到。
+21
View File
@@ -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
+12 -1
View File
@@ -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"; }
+1 -1
View File
@@ -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`、
+3 -2
View File
@@ -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 上卡了
+1 -1
View File
@@ -9,7 +9,7 @@
".": "./src/index.ts"
},
"dependencies": {
"zod": "^4.4.3"
"zod": "^4.6.6"
},
"sideEffects": false
}
-7
View File
@@ -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<typeof importUsersRequestSchema>
export type DeleteUsersRequest = z.input<typeof deleteUsersRequestSchema>
export type ResetPasswordResponse = z.infer<typeof resetPasswordResponseSchema>
export type UpdateWebsiteConfigRequest = z.input<typeof updateWebsiteConfigRequestSchema>
export type UpdateJudgeServerRequest = z.input<typeof updateJudgeServerRequestSchema>
export type UploadImageResponse = z.infer<typeof uploadImageResponseSchema>
export type CreateContestRequest = z.input<typeof createContestRequestSchema>
export type UpdateContestRequest = z.input<typeof updateContestRequestSchema>
+2 -2
View File
@@ -285,8 +285,8 @@ export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<string, Record<string, str
}
/**
* 判题机真正能做 AST 检查的语言。设计文档写的是"支持全部 6 种语言",落地的只有
* 这两种 —— 其余语言 judge/ast.ts 的 loadLanguage 返回 null,规则一条都不会跑。
* 判题机真正能做 AST 检查的语言(C / C++ / Python 三种,就是上面这张表的键)。设计文档写的是
* "支持全部 6 种语言",其余语言 judge/ast.ts 的 loadLanguage 返回 null,规则一条都不会跑。
* 所以后台不给别的语言开 tab,题目页也不把它们的规则展示成「要求」。
*/
export const AST_SUPPORTED_LANGUAGES = Object.keys(AST_NODE_TARGETS_BY_LANGUAGE)