From 84274753370f99f8e969fb0a1b6d838eb849a66d Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 26 Oct 2026 14:15:04 +0800 Subject: [PATCH] =?UTF-8?q?chore(=E6=B8=85=E7=90=86):=20=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E8=AF=95=E8=B7=91=E3=80=81=E6=90=AC=E5=BA=93?= =?UTF-8?q?=E3=80=81Python3=20=E5=88=AB=E5=90=8D=E3=80=81=E6=97=A7?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E8=BF=99=E4=BA=9B=E8=BF=87=E6=B8=A1=E6=9C=9F?= =?UTF-8?q?=E7=9A=84=E5=85=BC=E5=AE=B9=E5=86=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 部署:JUDGE_STATE_DIR 不再强制设;删掉搬库脚本和只为搬库存在的自检、提醒 - 语言:Python3 / Python2 别名表删掉,normalizeLanguage 改名 parseLanguage - 旧链接:后台题单详情的跳转、用户列表 -last_login 的转换 - 旧页面:布置作业必须带 language(null = 没选);不再清旧版出题页草稿 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- apps/api/src/collab/state.ts | 10 +- apps/api/src/db/schema.ts | 3 +- apps/api/src/judge/languages.ts | 12 +- apps/api/src/routes/classroom.ts | 3 +- apps/api/src/routes/problem.ts | 9 +- apps/web/src/admin/api.ts | 4 +- apps/web/src/admin/problem/detail.vue | 2 - apps/web/src/admin/user/list.vue | 2 +- apps/web/src/oj/store/code.ts | 7 +- apps/web/src/routes.ts | 9 -- apps/web/src/utils/constants.ts | 1 - docker/.env.example | 19 +-- docker/Dockerfile | 5 +- docker/backup-db.sh | 4 +- docker/compose.debian.yml | 8 +- docker/compose.school.yml | 3 +- docker/deploy.sh | 16 +- docker/move-db-into-oj2.sh | 223 -------------------------- docs/deploy.md | 57 ++----- packages/contract/src/classroom.ts | 4 +- packages/contract/src/language.ts | 26 +-- packages/contract/src/problem.ts | 2 +- 23 files changed, 47 insertions(+), 386 deletions(-) delete mode 100755 docker/move-db-into-oj2.sh diff --git a/CLAUDE.md b/CLAUDE.md index d15a137..5e76554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,8 +121,8 @@ QingdaoU/JudgeServer 停更在 2024-04(官方镜像的 `latest` 和 `1.6.1` 契约 `judgeLanguageSchema` 里的键留着是为了渲染那 62 条历史提交。 **`Python3` / `Python2` 这两个旧值已经没有了** —— 0019 迁移把 104530 条提交、937 道题、 1235 个用户的成就指标并成了一个 `Python`,0020 顺手把那三种语言从题目的可选语言里摘掉 - (不摘的话 84 道题的语言下拉还能选 Java,提交必 SYSTEM_ERROR)。查判题配置走 - `judgeConfigFor()`,它带旧值别名;**回滚要连数据一起回**,只滚代码会让 Python 提交全炸。 + (不摘的话 84 道题的语言下拉还能选 Java,提交必 SYSTEM_ERROR)。代码里也不再认旧值(别名表 2026-10 删了), + **回滚到 0019 之前要连数据一起回**,只滚代码会让 Python 提交全炸。 - 换完镜像跑 `bun docker/judge/smoke.ts`:三种语言、六种状态码、gcc 宽松度一起核。 ### AST 代码规则:一张表,外加一个机器检查 diff --git a/apps/api/src/collab/state.ts b/apps/api/src/collab/state.ts index ea2a9bf..c4dd58b 100644 --- a/apps/api/src/collab/state.ts +++ b/apps/api/src/collab/state.ts @@ -6,8 +6,6 @@ * 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。 */ -import { normalizeLanguage } from '@oj2/contract' - export type CollabSocket = Bun.ServerWebSocket /** @@ -19,14 +17,10 @@ export const COLLAB_LANGUAGES = ['C', 'C++', 'Python', 'Java', 'JavaScript', 'Go export type CollabLanguage = (typeof COLLAB_LANGUAGES)[number] /** - * 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的。 - * - * 先过契约的别名表 —— 上线那一刻学生页面里还揣着 `Python3`,不翻译的话会**静默** - * 落到 C,求助窗口里的代码高亮和同步编辑都按 C 走,没人会报错。 + * 认不出来的一律当 C。 */ export function normalizeCollabLanguage(value: unknown): CollabLanguage { - const normalized = normalizeLanguage(value) ?? value - return (COLLAB_LANGUAGES as readonly string[]).includes(normalized as string) ? (normalized as CollabLanguage) : 'C' + return (COLLAB_LANGUAGES as readonly string[]).includes(value as string) ? (value as CollabLanguage) : 'C' } /** diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index a7d5dda..feb3bab 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1327,8 +1327,7 @@ export const classLesson = pgTable( createdBy: integer('created_by').notNull(), /** * 这天第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。 - * 加这一列之前布置的用 updated_at 回填(0034)。带默认值是给共库的另一个站点: - * 机房那套还没升级时,它的旧代码插入不带这一列 + * 加这一列之前布置的用 updated_at 回填(0034) */ createdAt: timestamp('created_at', { withTimezone: true, diff --git a/apps/api/src/judge/languages.ts b/apps/api/src/judge/languages.ts index d2bcd4a..775a3c6 100644 --- a/apps/api/src/judge/languages.ts +++ b/apps/api/src/judge/languages.ts @@ -1,5 +1,3 @@ -import { normalizeLanguage } from '@oj2/contract' - /** * 判题沙箱认得的语言,**只有 C / C++ / Python 这三种**。 * @@ -11,8 +9,7 @@ import { normalizeLanguage } from '@oj2/contract' * 想恢复某种语言,得同时改这里和 `docker/judge/Dockerfile` 的工具链,再重建镜像。 * * `Python` 这个键 2026-09 之前叫 `Python3`(库里还有 3 条更老的 `Python2`), - * 0019 迁移把数据并成了一个值。查配置一律走 `judgeConfigFor()`,别直接下标 —— - * 那里带着旧值的别名,迁移之前排进队列的任务、旧客户端传上来的值都还能判。 + * 0019 迁移把数据并成了一个值。 * * SQL 题不走这里,走 `judge/sql/`;流程图题走 AI 评分。 */ @@ -84,10 +81,7 @@ export const languageConfigs: Record> = { }, } -/** - * 按语言取判题配置。**判题侧一律走这个函数**,不要直接 `languageConfigs[x]`: - * 它先过 `normalizeLanguage()`,所以 `Python3` / `Python2` 这类旧值也能命中。 - */ +/** 按语言取判题配置,判题机不认的语言(Java 等历史值、SQL、流程图)返回 null */ export function judgeConfigFor(language: string) { - return languageConfigs[language] ?? languageConfigs[normalizeLanguage(language) ?? ''] ?? null + return languageConfigs[language] ?? null } diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 11da4aa..3379d88 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -733,8 +733,7 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => { const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对') if (!parsed.success) return parsed.response const user = c.get('user')! - const { className, problemDisplayIds } = parsed.data - const language = parsed.data.language ?? null + const { className, problemDisplayIds, language } = parsed.data const day = calendarDay() const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1) diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index d6920ea..9aedece 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -1,7 +1,7 @@ import { AST_SUPPORTED_LANGUAGES, isSqlProblem, - normalizeLanguage, + parseLanguage, problemListSortSchema, problemTypeFilterSchema, type ChallengeList, @@ -142,9 +142,8 @@ problemRoutes.get('/problems', optionalAuth, async (c) => { if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved])) const where = and(...filters) - // 排序值沿用原来的(地址栏里存着的旧链接照样能用),但「最多 / 最少」现在按**人数**排, - // 和列表最后一列「做对 / 做过」对得上:submission_number 是做过的人,accepted_number 是做对的人。 - // 旧的「画流程图 / 语法检查」排序已经拆成类型筛选,传进来落到默认(最新创建) + // 「最多 / 最少」按**人数**排,和列表最后一列「做对 / 做过」对得上: + // submission_number 是做过的人,accepted_number 是做对的人。认不出的排序值落到默认(最新创建) const sort = problemListSortSchema.safeParse(c.req.query('sort') ?? '').data ?? '' const byPeople = sort.endsWith('submission_number') || sort.endsWith('accepted_number') const allCounts = byPeople ? await allProblemUserCounts() : null @@ -231,7 +230,7 @@ problemRoutes.get('/problems/random', optionalAuth, async (c) => { const type = problemTypeFilterSchema.safeParse(c.req.query('type')).data // 上次用的是 SQL(刚做完一道 SQL 题)不算偏好:SQL 题一共 9 道,照它挑就成了「随便来一道 // SQL」。要 SQL 题的人会选「SQL」知识点或类型,那时按上面说的照样抽得到 - const saved = normalizeLanguage(c.req.query('language')) + const saved = parseLanguage(c.req.query('language')) const language = saved && saved !== 'SQL' && saved !== 'Flowchart' ? saved : 'Python' const states = user ? await problemStates(user.id) : null const solved = states ? [...states.solved] : [] diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index c4cd9f0..c01c2ab 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -233,14 +233,12 @@ export function getUserList( className = '', ) { return api.get('admin/users', { - // "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin"; - // "-online" 是新增的,原样透传 params: { offset, limit, keyword, type, - orderBy: orderBy === '-last_login' ? '-lastLogin' : orderBy, + orderBy, className: className || undefined, }, }) diff --git a/apps/web/src/admin/problem/detail.vue b/apps/web/src/admin/problem/detail.vue index 4b760c8..012ceaf 100644 --- a/apps/web/src/admin/problem/detail.vue +++ b/apps/web/src/admin/problem/detail.vue @@ -521,8 +521,6 @@ function clearDraft() { } function loadDraft() { - // 旧版出题页的草稿(另一个 key、另一种形状)不认了 - localStorage.removeItem(STORAGE_KEY.ADMIN_PROBLEM) const saved = draft.value // 草稿是本机存的,形状不对(旧版本、被别的页面写坏)就当没有,别让整页起不来 if (saved && typeof saved.form === 'object' && Array.isArray(saved.rows)) { diff --git a/apps/web/src/admin/user/list.vue b/apps/web/src/admin/user/list.vue index 6caa304..ed4ef2e 100644 --- a/apps/web/src/admin/user/list.vue +++ b/apps/web/src/admin/user/list.vue @@ -67,7 +67,7 @@ const adminOptions = [ const sortOptions = [ { label: '最近建号', value: '' }, { label: '按用户名', value: 'username' }, - { label: '最近登录', value: '-last_login' }, + { label: '最近登录', value: '-lastLogin' }, { label: '在线优先', value: '-online' }, ] const [create, toggleCreate] = useToggle(false) diff --git a/apps/web/src/oj/store/code.ts b/apps/web/src/oj/store/code.ts index f307e98..5991066 100644 --- a/apps/web/src/oj/store/code.ts +++ b/apps/web/src/oj/store/code.ts @@ -1,4 +1,4 @@ -import { normalizeLanguage } from '@oj2/contract' +import { parseLanguage } from '@oj2/contract' import { defineStore } from 'pinia' import { STORAGE_KEY } from 'utils/constants' import storage from 'utils/storage' @@ -9,7 +9,7 @@ import type { Code, LANGUAGE } from 'utils/types' * 管理全局的代码、输入、输出状态 */ function savedCodeLanguage() { - const saved = normalizeLanguage(storage.get(STORAGE_KEY.LANGUAGE)) + const saved = parseLanguage(storage.get(STORAGE_KEY.LANGUAGE)) return saved && saved !== 'Flowchart' ? saved : null } @@ -17,9 +17,6 @@ export const useCodeStore = defineStore('code', () => { // ==================== 状态 ==================== const code = reactive({ value: '', - // 过一道 normalizeLanguage:上线那一刻学生浏览器的 localStorage 里存的还是 - // 旧值 Python3,直接拿来用会被后端的契约挡掉(而且报错看不出是这个原因) - // 以前的版本会把 Flowchart 也记成语言偏好,那种老值在这里一并不认 language: savedCodeLanguage() ?? 'Python', }) diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index 5d9cc6e..e1cd941 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -330,15 +330,6 @@ export const admins: RouteRecordRaw = { props: true, meta: { requiresTeacherAdmin: true }, }, - // 原来的「详情」页(题目 / 奖章 / 进度三个页签)并进了一屏编辑,老链接转过去 - { - path: 'problemset/:problemSetId', - name: 'admin problemset detail', - redirect: (to) => ({ - name: 'admin problemset edit', - params: { problemSetId: to.params.problemSetId }, - }), - }, { path: 'ai/reports', name: 'admin ai reports', diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index f21a682..44f62d6 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -152,7 +152,6 @@ export const STORAGE_KEY = { ACCESS_TOKEN: 'accessToken', REFRESH_TOKEN: 'refreshToken', LANGUAGE: 'problemLanguage', - ADMIN_PROBLEM: 'adminProblem', ADMIN_SQL_PROBLEM: 'adminSqlProblem', ADMIN_CODE_PROBLEM: 'adminCodeProblem', ADMIN_PROBLEM_TAGS: 'adminProblemTags', diff --git a/docker/.env.example b/docker/.env.example index d421480..05630b0 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -47,23 +47,14 @@ AI_HINT_DIAGNOSE= # 不设它就等于开一套空数据:空库(站点没有用户没有题)、没有测试点、题面图片 404。 DATA_DIR= -# --- 并行试跑(oj2.xuyue.cc)才需要 --- +# --- 可选 --- # -# 新站和旧站同时跑的时候,这两个必须设,否则会跟旧栈抢资源。 -# -# 对外端口。旧 backend 占着 8080(机房 81),新栈换一个,NPM 那边把 -# oj2.xuyue.cc 指到这个端口。**NPM 的记录要打开 Websockets Support**, -# 否则学生那边「判题中…」永远不动。 +# 对外端口,不设是 8080(机房 81)。改了的话 NPM 那边的记录要跟着改, +# **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` 仍然共享,那是**故意的** —— 测试点和题面图片 -# 两边必须看到同一份。 +# 判题机的运行状态目录(log/、run/),不设是 `$DATA_DIR/judge_server`。 +# 换目录记得先 `mkdir -p .../{log,run}`。 JUDGE_STATE_DIR= # --- 只有机房那套需要 --- diff --git a/docker/Dockerfile b/docker/Dockerfile index d68fa11..de14242 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -167,9 +167,8 @@ COPY apps/api/src/db/meta/_journal.json /usr/local/share/oj2/migrations/meta/ # 相对路径按 cwd 解析(见 apps/api/src/runtime.ts),所以 workdir 设成 /data; # 但下面的路径全写绝对值,不靠这个巧合。 # -# 目录布局**照抄旧后端**(test_case、public/upload、public/avatar)。 -# 这不是审美问题:切换那天不用搬动任何文件,回滚时旧后端也立刻能找到自己的数据。 -# 少一次几十 GB 的 mv,就少一个在停机窗口里出错的机会。 +# 目录布局**照抄旧后端**(test_case、public/upload、public/avatar):切换那天不用搬动 +# 任何文件。旧后端已经下线,但机房和服务器两边的数据目录都是这个布局,别改。 WORKDIR /data ENV TEST_CASE_DIRECTORY=/data/test_case \ HITOKOTO_DIRECTORY=/data/hitokoto \ diff --git a/docker/backup-db.sh b/docker/backup-db.sh index b7317ab..708db65 100755 --- a/docker/backup-db.sh +++ b/docker/backup-db.sh @@ -6,8 +6,8 @@ # # 按容器名 `docker exec`,跟容器是谁起的无关:以前线上的 postgres 由旧栈那份 # /root/OJDeploy/docker-compose.yml 起,在 OJ2 目录里 `docker compose exec` 会报 -# `service "oj-postgres" is not running`。现在它归 compose.debian.yml 了,但按名字找 -# 照样能用,move-db-into-oj2.sh 搬家时也靠这一点去备份旧容器(CONTAINER=旧容器名)。 +# `service "oj-postgres" is not running`。现在它归 compose.debian.yml 了,按名字找 +# 照样能用,不跟着 compose 走。 # # ## 用法 # diff --git a/docker/compose.debian.yml b/docker/compose.debian.yml index 5c877a1..4233a76 100644 --- a/docker/compose.debian.yml +++ b/docker/compose.debian.yml @@ -5,8 +5,7 @@ # 构建上下文是仓库根,所以 build.context 写 ..(相对本文件)。 # # **服务器上的全部容器都由这一个文件起**,包括 postgres 和 redis。以前库和 redis 是旧栈 -# 那份 /root/OJDeploy/docker-compose.yml 起的、这里通过 DB_HOST 外接过去,升级它们就得去 -# 改那份文件;2026-10 起收了进来(搬家用 docker/move-db-into-oj2.sh,一次性的)。 +# 那份 /root/OJDeploy/docker-compose.yml 起的、这里通过 DB_HOST 外接过去,2026-10 起收了进来。 # # ⚠️ `DATA_DIR` 默认值 `../data` 解析出来是 **`OJ2/data`**,不是部署目录的 `data/`。 # 不设它的话 postgres 会在空目录上初始化一个全新的空库,测试点和题面图片也全都不在, @@ -107,9 +106,7 @@ services: - /tmp volumes: - ${DATA_DIR:-../data}/backend/test_case:/test_case:ro - # 判题机的运行状态目录。默认跟着 DATA_DIR 走,和旧栈是同一份 —— - # 一次性切换时无所谓(旧判题机已经停了),但**并行试跑时必须单独设 JUDGE_STATE_DIR**, - # 否则新旧两个 judger 同时往一个 run/log 目录里写。 + # 判题机的运行状态目录,默认跟着 DATA_DIR 走 - ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log - ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger environment: @@ -192,6 +189,5 @@ services: # Caddy 只往这里写访问日志,静态资源在镜像里 - ${DATA_DIR:-../data}/backend/log:/data/log ports: - # 并行试跑(oj2.xuyue.cc)时换一个端口,8080 还被旧 backend 占着。 - "0.0.0.0:${WEB_PORT:-8080}:8000" mem_limit: 256m diff --git a/docker/compose.school.yml b/docker/compose.school.yml index 548e268..9cb6d7d 100644 --- a/docker/compose.school.yml +++ b/docker/compose.school.yml @@ -48,7 +48,7 @@ services: - /tmp volumes: - ${DATA_DIR:-../data}/backend/test_case:/test_case:ro - # 并行试跑时必须单独设 JUDGE_STATE_DIR,否则新旧两个 judger 共用一个运行目录 + # 判题机的运行状态目录,默认跟着 DATA_DIR 走 - ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log - ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger environment: @@ -121,6 +121,5 @@ services: volumes: - ${DATA_DIR:-../data}/backend/log:/data/log ports: - # 并行试跑时换一个端口,81 还被旧 backend 占着 - "${WEB_PORT:-81}:8000" mem_limit: 256m diff --git a/docker/deploy.sh b/docker/deploy.sh index 229d435..1d8fbba 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -61,13 +61,6 @@ say "自检" [ -f docker/.env ] || die "docker/.env 不存在。内容见手册第三节,别用 .env.example 直接改名(里面的值全是空的)" -# 以前库和 redis 是外接旧栈的,靠这几个变量指过去;现在 compose 不再读它们。 -# 留在 .env 里不碍事,但会让人以为还在生效 -for v in DB_HOST DB_PORT REDIS_HOST REDIS_PORT; do - [ -z "$(grep -E "^$v=." docker/.env || true)" ] \ - || warn "docker/.env 里的 $v 已经不用了(库和 redis 由本栈起),可以删掉" -done - # --prebuilt 时产物就是构建输入。缺了的话 docker 只会甩一句 COPY 找不到文件, # 这里提前拦下并说清楚该怎么办。 if [ "${ARTIFACTS:-build}" = prebuilt ]; then @@ -109,20 +102,15 @@ data_dir=$(grep -E '^DATA_DIR=' docker/.env | tail -1 | cut -d= -f2- || true) [ -n "$data_dir" ] || die "必须显式设 DATA_DIR。 默认值 ../data 解析出来是 OJ2/data,那是个空目录,postgres 会在上面初始化一个全新的空库,而且不报任何错。" pgver_file="$data_dir/postgresql/18/docker/PG_VERSION" -if [ ! -e "$data_dir/postgresql" ] && [ -d "$data_dir/postgres" ]; then - die "$data_dir 下只有 postgres/(PostgreSQL 16 的老库,旧栈 compose 起的), -还没有 postgresql/18/docker。先把库搬进本栈:docker/move-db-into-oj2.sh(一次性的,见 docs/deploy.md)" -fi [ -f "$pgver_file" ] || die "找不到 $pgver_file —— DATA_DIR 指的不是一个已有的 postgres 数据目录。 就这么起下去会得到一个空库。确认 DATA_DIR=$data_dir 对不对。" pgver=$(cat "$pgver_file") [ "$pgver" = 18 ] || die "$data_dir/postgresql 是 PostgreSQL $pgver 的数据目录,但 compose 里是 postgres:18-alpine,起不来。" ok "数据目录 $data_dir/postgresql(PostgreSQL $pgver)" -# ③ 判题机运行目录必须和旧栈分开,否则两个 judger 往同一个目录里写 +# ③ 判题机运行目录:不设就是 $DATA_DIR/judge_server judge_dir=$(grep -E '^JUDGE_STATE_DIR=' docker/.env | tail -1 | cut -d= -f2- || true) -[ -n "$judge_dir" ] || die "JUDGE_STATE_DIR 没设 —— 试跑期间新旧两个判题机会共用运行目录" -ok "判题机运行目录 $judge_dir" +ok "判题机运行目录 ${judge_dir:-$data_dir/judge_server}" # ④ 判题镜像是自建的(上游 JudgeServer 停更,官方镜像的编译器停在 gcc-13), # registry 上没有这个 tag。忘了 docker load 的话,要到「起栈」那步 compose 去 pull diff --git a/docker/move-db-into-oj2.sh b/docker/move-db-into-oj2.sh deleted file mode 100755 index b4e2d02..0000000 --- a/docker/move-db-into-oj2.sh +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env bash -# -# 一次性:把服务器上的 postgres / redis 从旧栈那份 /root/OJDeploy/docker-compose.yml -# 搬进 OJ2 的 compose.debian.yml,顺带 PostgreSQL 16 → 18、Redis 7 → 8。 -# 搬完以后服务器上的全部容器都归 OJ2 管,旧 compose 文件里那两个服务就没用了。 -# -# cd /root/OJDeploy/OJ2 -# docker/move-db-into-oj2.sh # 服务器自己编镜像(慢,但不依赖 CI) -# docker/move-db-into-oj2.sh --prebuilt # 用 CI rsync 过来的产物 -# -# ## 跑之前 -# -# 1. **挑没课的时候。** 两个站点共用这个库,中间两边都用不了。镜像先在旧栈照常服务时 -# 编好,真正停机的只有「备份 → 恢复 → 起栈」那一段。 -# 2. **机房那台先停应用**(库在这边,机房连着它就恢复不了): -# docker compose -f docker/compose.school.yml --env-file docker/.env.school stop oj-api oj-worker -# 没停的话脚本会查出来还有谁连着库,报出 IP 后中止。 -# 3. **docker/.env 的 POSTGRES_PASSWORD 换成强口令**(openssl rand -hex 32)。 -# 原来的口令是默认的 onlinejudge,而这个账号是超级用户、5445 对公网开着 —— -# 知道默认口令的人就能拿到整个库,还能在容器里执行命令。新库直接用新口令建, -# 脚本不接受弱口令。机房的 .env.school 要改成同一个值。 -# -# ## 它做什么 -# -# 自检 → 编镜像 → 停本机 api/worker → 确认没人连着旧库 → 记行数 → 备份 -# → 停掉并删除旧的 postgres / redis 容器 → 起本栈的 PostgreSQL 18 / Redis 8 -# → 恢复 → 收集统计信息 → 核对行数和网络登录 → docker/deploy.sh --no-build -# -# 旧库的数据目录 `$DATA_DIR/postgres`(16)**一个字节都不动**,新库在 -# `$DATA_DIR/postgresql`。Redis 的 `$DATA_DIR/redis` 新旧共用,8 直接读 7 的 dump.rdb。 -# -# ## 恢复时口令为什么要剥掉 -# -# pg_dumpall 会带一句 `ALTER ROLE onlinejudge ... PASSWORD 'md5…'`,原样灌进去会把新库 -# 的口令改回旧的 md5 哈希。而 18 的镜像新建的库 pg_hba 只认 scram-sha-256,md5 存的 -# 口令**永远验不过** —— 容器里 docker exec psql 走本地 socket 是 trust,看着一切正常, -# 只有 api 和机房从网络连进来才炸。所以恢复时把那个 PASSWORD 子句删掉,库里留下的就是 -# 初始化时按 POSTGRES_PASSWORD 存的 SCRAM 哈希。 -# -# ## 中途失败 -# -# 停旧容器之前失败:什么都没动,`docker start oj-api oj-worker` 就回去了(容器还是原来的 -# 配置)。别用 `docker compose ... start`:新的 compose 里 api 依赖本栈的 oj-postgres, -# 这时它还不存在,compose 直接拒绝。 -# 之后失败:旧库目录原样在,回退见 docs/deploy.md「把库搬进 OJ2」。 - -[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@" - -set -euo pipefail - -cd "$(dirname "${BASH_SOURCE[0]}")/.." -COMPOSE=(docker compose -f docker/compose.debian.yml --env-file docker/.env) - -for arg in "$@"; do - case "$arg" in - --prebuilt) export ARTIFACTS=prebuilt ;; - *) echo "未知参数:$arg(可用:--prebuilt)" >&2; exit 2 ;; - esac -done - -say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } -ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } -warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } -die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; } -envget() { grep -E "^$1=" docker/.env | tail -1 | cut -d= -f2- || true; } - -# ---------------------------------------------------------------- 自检 -say "自检" - -[ -f docker/.env ] || die "docker/.env 不存在" - -data_dir=$(envget DATA_DIR) -[ -n "$data_dir" ] && [ -d "$data_dir" ] || die "DATA_DIR($data_dir)没设或不是目录。服务器上应该是 /root/OJDeploy/data" -[ ! -e "$data_dir/postgresql" ] || die "$data_dir/postgresql 已经存在 —— 已经搬过了? -如果是上次搬到一半失败留下的,确认里面没有要的东西,删掉它再跑。" -ok "数据目录 $data_dir" - -pg_pw=$(envget POSTGRES_PASSWORD) -[ "${#pg_pw}" -ge 16 ] && [ "$pg_pw" != onlinejudge ] || die "docker/.env 的 POSTGRES_PASSWORD 太弱(不到 16 位,或者还是默认的 onlinejudge)。 -这个账号是超级用户、5445 对公网开着。换一个:openssl rand -hex 32 -机房的 docker/.env.school 要改成同一个值。" -[[ "$pg_pw" =~ ^[A-Za-z0-9._~-]+$ ]] || die "POSTGRES_PASSWORD 里有 URL 不安全的字符(它会拼进 DATABASE_URL),用 openssl rand -hex 32 生成" -ok "POSTGRES_PASSWORD 是强口令" - -redis_pw=$(envget REDIS_PASSWORD) -[ -n "$redis_pw" ] || die "docker/.env 里没有 REDIS_PASSWORD" - -# 旧容器按发布的端口找,不按名字:旧 compose 起的容器叫什么不归我们管 -old_pg=$(docker ps --filter publish=5445 --format '{{.Names}}' | head -1) -old_redis=$(docker ps --filter publish=5446 --format '{{.Names}}' | head -1) -[ -n "$old_pg" ] || die "没找到发布 5445 的容器(旧的 postgres)。它没在跑?" -[ -n "$old_redis" ] || die "没找到发布 5446 的容器(旧的 redis)。它没在跑?" -# 「已经搬过了」靠这里的 16 和上面 postgresql/ 不存在来判断,不看容器是哪个 compose 起的: -# 旧栈那份文件原名 docker-compose.debian.yml,按文件名认会把它当成本栈的(线上撞过) -pgver=$(docker exec "$old_pg" psql -U onlinejudge -d onlinejudge -tAc 'show server_version_num') -[ "${pgver:0:2}" = 16 ] || die "$old_pg 是 PostgreSQL $pgver,这个脚本只认 16" -from() { docker inspect -f '{{.Config.Image}},{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$1"; } -ok "旧 postgres:$old_pg($(from "$old_pg"))" -ok "旧 redis:$old_redis($(from "$old_redis"))" - -redis_src=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$old_redis") -[ -n "$redis_src" ] || die "$old_redis 的 /data 没挂到宿主机目录上,会话和队列搬不过来" - -db_bytes=$(docker exec "$old_pg" psql -U onlinejudge -d postgres -tAc "select pg_database_size('onlinejudge')") -avail=$(( $(df -Pk "$data_dir" | awk 'NR==2 {print $4}') * 1024 )) -[ "$avail" -gt $(( db_bytes * 3 )) ] || die "磁盘余量 $((avail >> 20)) MiB,不到库大小($((db_bytes >> 20)) MiB)的三倍:新库一份、备份一份,还得留余地" -ok "库 $((db_bytes >> 20)) MiB,磁盘余量 $((avail >> 20)) MiB" - -judge_image=$("${COMPOSE[@]}" config | grep -m1 -E '^[[:space:]]*image: oj2-judge' | awk '{print $2}') -docker image inspect "$judge_image" >/dev/null 2>&1 || die "判题镜像 $judge_image 不在本机,见 docker/judge/README.md" - -# ---------------------------------------------------------------- 编镜像 -# 旧栈这时还在照常服务,编完才开始停机 -say "编镜像(旧栈照常服务中)" -"${COMPOSE[@]}" build oj-api oj-worker oj-web -docker pull -q postgres:18-alpine >/dev/null -docker pull -q redis:8-alpine >/dev/null -ok "postgres:18-alpine、redis:8-alpine 已拉取" - -# ---------------------------------------------------------------- 停机 -say "停本机的 api / worker(从这里开始站点不可用)" -"${COMPOSE[@]}" stop oj-api oj-worker - -# 机房的 api / worker 也连着这个库。停没停只能从库这边看 -clients="" -for _ in $(seq 15); do - clients=$(docker exec "$old_pg" psql -U onlinejudge -d postgres -tAc \ - "select string_agg(distinct coalesce(host(client_addr), 'local'), ', ') - from pg_stat_activity - where backend_type = 'client backend' and pid <> pg_backend_pid()") - [ -z "$clients" ] && break - sleep 2 -done -[ -z "$clients" ] || die "还有客户端连着旧库:$clients -多半是机房的 api / worker 没停。停掉它们再跑;要先恢复本机服务的话: - docker start oj-api oj-worker" -ok "没有客户端连着旧库" - -counts_sql="select (select count(*) from submission) || ' ' || (select count(*) from \"user\") || ' ' || (select count(*) from problem)" -before=$(docker exec "$old_pg" psql -U onlinejudge -d onlinejudge -tAc "$counts_sql") -ok "提交 / 用户 / 题目:$before" - -# ---------------------------------------------------------------- 备份 -say "备份" -backup_dir="${BACKUP_DIR:-$(dirname "$PWD")/backups}" -marker=$(mktemp) -CONTAINER="$old_pg" BACKUP_DIR="$backup_dir" docker/backup-db.sh -backup=$(find "$backup_dir" -maxdepth 1 -name 'db_backup_*.sql.gz' -newer "$marker" | sort | tail -1) -rm -f "$marker" -[ -n "$backup" ] || die "backup-db.sh 跑完了但找不到新的备份文件($backup_dir)" -ok "备份:$backup" - -# ---------------------------------------------------------------- 换容器 -say "停掉旧的 postgres / redis" -docker exec -e REDISCLI_AUTH="$redis_pw" "$old_redis" redis-cli save >/dev/null \ - || warn "redis save 失败(口令不对?),会话可能丢,学生重新登录一次就好" -docker stop "$old_pg" "$old_redis" >/dev/null -# 删掉而不只是停:新容器要用同样的名字和端口 -docker rm "$old_pg" "$old_redis" >/dev/null -ok "已删除容器 $old_pg、$old_redis(数据目录没动)" - -if [ "$redis_src" != "$data_dir/redis" ]; then - mkdir -p "$data_dir/redis" - [ -e "$data_dir/redis/dump.rdb" ] || cp -a "$redis_src/." "$data_dir/redis/" - ok "redis 数据从 $redis_src 拷到 $data_dir/redis" -fi - -say "起 PostgreSQL 18 / Redis 8" -"${COMPOSE[@]}" up -d oj-postgres oj-redis - -# 不用 pg_isready 等:初始化期间镜像会起一个只听 socket 的临时实例,pg_isready 走 -# socket 照样说 OK,这时候灌数据会灌进一个马上要被关掉的实例。走网络连就等得到正式的那个, -# 还顺带验了新口令能不能从网络登录(api 和机房就是这么连的) -tcp_check() { - docker exec -e PGPASSWORD="$pg_pw" oj-postgres sh -c \ - 'psql -h "$(hostname -i | cut -d" " -f1)" -U onlinejudge -d onlinejudge -tAc "select 1"' >/dev/null 2>&1 -} -for _ in $(seq 60); do tcp_check && break; sleep 2; done -tcp_check || die "PostgreSQL 18 两分钟没起来,看 docker logs oj-postgres" -ok "PostgreSQL 18 起来了,新口令能从网络登录" - -# ---------------------------------------------------------------- 恢复 -say "恢复" -log=$(mktemp) -started=$(date +%s) -gzip -cd "$backup" \ - | sed -E "/^ALTER ROLE onlinejudge WITH /s/ PASSWORD '[^']*'//" \ - | docker exec -i oj-postgres psql -q -U onlinejudge -d postgres >/dev/null 2>"$log" || true -# 「先删后建」的 dump 往新实例灌,这两条必然出现(见 backup-db.sh 头部),别的都不该有 -unexpected=$(grep 'ERROR' "$log" | grep -v -e 'current user cannot be dropped' -e 'role "onlinejudge" already exists' || true) -[ -z "$unexpected" ] || die "恢复时出了预期之外的错(完整日志 $log): -$unexpected" -rm -f "$log" -ok "恢复完成,用时 $(( $(date +%s) - started ))s" - -docker exec oj-postgres vacuumdb -U onlinejudge --all --analyze-in-stages -q -ok "统计信息已收集" - -after=$(docker exec oj-postgres psql -U onlinejudge -d onlinejudge -tAc "$counts_sql") -[ "$after" = "$before" ] || die "行数对不上:恢复前 $before,恢复后 $after" -ok "提交 / 用户 / 题目:$after,和恢复前一致" - -tcp_check || die "恢复之后新口令从网络登不上了 —— dump 里的口令没剥干净? - docker exec -it oj-postgres psql -U onlinejudge -d postgres -c '\\password onlinejudge'" -ok "新口令仍能从网络登录" - -# ---------------------------------------------------------------- 起栈 -say "起栈(交给 deploy.sh)" -docker/deploy.sh --no-build - -cat < export type RunnableLanguage = z.infer export type ProblemLanguage = z.infer -/** - * 旧语言名 → 现在的值。**库里已经没有旧值了**(0019 迁移清干净并核对过), - * 这张表挡的是数据之外的三条路: - * - * 1. 学生浏览器 localStorage 里存着上次选的 `Python3`(上线那一刻还在那儿); - * 2. 迁移之前就排进 BullMQ 的判题任务; - * 3. 万一要回滚 —— 旧版后端读 `Python` 会查不到配置,所以**回滚必须连数据一起回**, - * 别只滚代码。 - */ -const LANGUAGE_ALIASES: Record = { - Python2: 'Python', - Python3: 'Python', -} - -/** 把可能是旧值的语言名归一化;认不出来返回 null,由调用方决定怎么兜底。 */ -export function normalizeLanguage(value: unknown): ProblemLanguage | null { - if (typeof value !== 'string') return null - const parsed = problemLanguageSchema.safeParse(LANGUAGE_ALIASES[value] ?? value) - return parsed.success ? parsed.data : null +/** 认语言名(地址栏、localStorage 里来的);认不出来返回 null,由调用方决定怎么兜底。 */ +export function parseLanguage(value: unknown): ProblemLanguage | null { + return problemLanguageSchema.safeParse(value).data ?? null } diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index d60b85d..2ac6525 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -484,7 +484,7 @@ export const problemListSchema = paginatedSchema(problemListItemSchema) export const problemTypeFilterSchema = z.enum(['sql', 'reference', 'flowchart', 'ast']) /** - * 题目列表的排序。值沿用原来的(旧链接照样能用):空串 = 最新创建、create_time = 最早创建, + * 题目列表的排序:空串 = 最新创建、create_time = 最早创建, * ±submission_number / ±accepted_number 是做过 / 做对的**人数**,带 - 是从多到少 */ export const problemListSortSchema = z.enum(['', 'create_time', '-submission_number', 'submission_number', '-accepted_number', 'accepted_number'])