JY-OJ 在线判题系统规划方案(gofly + rsjudge)

版本:v1.1(详细设计稿,可驱动代码生成)
适用范围:jyoj 项目
技术基线:gofly 平台(Go 1.25 / Gin / GORM / MySQL / Redis)+ rsjudge 判题机(Rust / Linux 沙箱)

一、项目背景与目标

1.1 背景

现有 jyoj 平台基于 gofly 框架构建,已具备以下基础能力:

能力现状
多租户共享库共享表,tenant_id 字段隔离 + 域名识别中间件(见 多租户改造.md)
用户体系gf_student(学生)、gf_admin(平台管理)、gf_tenant_admin(租户运营)三套账号
业务模块学生登录、选课(courseselect)、课程任务(coursetask)、课程(course)等
前端web(平台管理后台)、saas(租户运营端)、app(学生 H5 端),均为 Vue3 + Vite
存储MySQL + Redis + 本地/云存储(已集成阿里云 OSS、七牛、腾讯 COS SDK)

本方案在此基础上建设一套对标 HUSTOJ 的在线判题(Online Judge)系统:

  • 平台侧(前台):复用 gofly 技术栈与多租户体系,一期由 web 平台管理端(全部 OJ 管理功能)与 app 学生端协同,租户端(saas)为二期接入;
  • 判题侧:采用现成开源判题机 rsjudge(NJUPT-SAST 开源项目,Rust 编写,已归置于 judge/rsjudge/),替代 HUSTOJ 的 C++ 判题机(judged / judge_client)。

1.2 建设目标

维度目标
功能题库管理、在线做题与提交、自动判题、比赛(ACM/OI)、作业(选题计分/成绩单)、榜单、排名、统计;兼容 HUSTOJ 题库(FPS)导入导出
性能单判题节点 ≥ 4 并发判题;提交后 5 秒内开始判题;支持判题节点水平扩容
安全判题沙箱多层防御(seccomp-BPF + capabilities + rlimit + 降权用户),杜绝提交代码逃逸/联网/破坏宿主
可靠性判题任务至少一次投递,节点宕机自动重派;判题状态可追溯
扩展判题节点注册制、语言工具链可配置;二期支持交互题、代码查重

1.3 与 HUSTOJ 的对标

维度HUSTOJ本方案
Web/平台PHP + MySQL 单体gofly(Gin + GORM)多租户平台,三端 Vue3 前端
判题机C++ judged/judge_client,ptrace + okcalls 白名单rsjudge(Rust 开源),seccomp-BPF + capabilities + rlimit + 降权用户
任务分发判题机轮询数据库(默认 1s 间隔)Redis Stream 队列 + gRPC 派发 + 租约重派
安全隔离setrlimit + ptrace,无 namespace/cgroup多层隔离,最小权限运行
扩展性单机为主,多判题机需手工部署节点启动注册(自报语言)+ 令牌鉴权,水平扩展
测试数据判题机本地目录 /home/judge/data平台经 gRPC 按需下发测试点包(zstd 分块,cases_id 版本控制)
题库互通FPS(freeproblemset)XML 格式支持 FPS 导入导出,可迁移存量题库
结果码数字码(4=AC、6=WA…)字符串枚举(AC/WA/TLE…),导入时自动映射

1.4 关键设计取舍

  1. 判题机为什么选用 rsjudge(现成开源)而不用 HUSTOJ 判题机

    • HUSTOJ 判题机依赖 ptrace 系统调用白名单,内核版本升级需维护 okcalls.h,维护成本高;
    • DB 轮询模式给数据库带来无谓压力,任务无法精细调度;
    • C++ 实现存在内存安全风险,判题机本身是高危组件;
    • rsjudge 是现成的 Rust 开源项目(Apache-2.0,受 go-judge 启发),seccomp-BPF + capabilities + rlimit 的沙箱设计内建,并原生提供 gRPC 接口(节点注册 / 提交判题 / 测试点下发),无需自行开发判题机。
  2. 为什么判题机与平台通过专用接口交互(而非直连数据库/Redis)

    • 判题节点可部署在独立安全区,只与平台交互,不暴露数据库凭证;
    • rsjudge 原生以 gRPC 与后端平台通信(注册、拉取测试点、回传判题结果),平台侧统一收口业务逻辑(统计、榜单、幂等),判题机保持无状态、可随意增删。
  3. 为什么兼容 FPS 格式:可直接迁移存量 HUSTOJ 题库,降低建库成本。
  4. 为什么复用多租户模型:租户(学校)之间的题库、比赛、作业、提交天然隔离,复用现有域名识别与 tenant_id 过滤机制。

二、总体架构设计

2.1 架构总览

┌─────────────────────────── 前端(Vue3) ───────────────────────────┐
│   web 平台管理后台        saas 租户运营端         app 学生H5端      │
│   (Arco Design)           (Arco Design)           (Vant)           │
└──────────────────────────────┬─────────────────────────────────────┘
                               │ HTTPS  /api/{admin|tenant|student}/oj/*
┌──────────────────────────────▼─────────────────────────────────────┐
│                     gofly 应用服务(Gin + GORM)                    │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐  │
│  │ admin/oj    │ │ tenant/oj   │ │ student/oj  │ │ judge 调度模块│  │
│  │ OJ 统一管理  │ │ 租户端(二期) │ │ 做题/提交    │ │ 队列·租约·派发│  │
│  └─────────────┘ └─────────────┘ └─────────────┘ └──────┬───────┘  │
└────────┬──────────────┬──────────────────────────────────┼──────────┘
         │              │                                  │ gRPC(注册/测试点/判题)
   ┌─────▼─────┐  ┌─────▼─────┐              ┌─────────────▼──────────┐
   │  MySQL    │  │  Redis    │              │  rsjudge 判题集群(Rust) │
   │ 业务数据  │  │ 队列/缓存 │              │  node-1  node-2  node-N │
   └───────────┘  │ 队列/榜单 │              │ 沙箱: seccomp+rlimit    │
                  └───────────┘              └─────────────┬──────────┘
   ┌────────────────────────┐                             │ 按需拉取测试点包
   │ 本地磁盘 / OSS 对象存储 │◄────────────────────────────┘ (经平台,zstd 分块)
   │ 测试点数据 / 提交代码   │
   └────────────────────────┘

2.2 技术选型

层组件技术说明
前端-平台管理webVue3 + Arco Design + Vite(现有)新增 OJ 管理菜单与页面
前端-租户运营saasVue3 + Arco Design + Vite(现有)一期无 OJ 改动(自助管理为二期预留)
前端-学生端appVue3 + Vant H5(现有)新增做题/提交/榜单页面
后端goflyGo 1.25 + Gin + GORM + gorm/gen新增 oj 业务模块与 judge 调度模块
数据库MySQL 8.0新表 gf_oj_*沿用 tenant_id 隔离、软删除规范
缓存/队列Redis 7Stream 任务队列、榜单缓存复用 go-redis/v9
文件存储本地/OSS/七牛/COS测试点、源码备份复用现有 SDK 与上传配置
判题机rsjudgeRust(edition 2024);tokio / tonic(gRPC) / axum / libseccomp / nix;Apache-2.0现成开源项目(NJUPT-SAST/rsjudge),独立 Linux 服务,Cargo workspace 工程
部署Docker + Nginx 判题机建议宿主直跑或以特权容器运行

2.3 gofly 服务端模块划分(新增)

gofly/app/
├── admin/oj/          # 平台管理端(一期 OJ 全部管理功能):题库(题目/测试点/SPJ + 标签)、比赛(contest.go:编排/报名审核/封榜)、作业(homework.go:选题/计分/成绩)、提交记录、榜单、判题机节点、语言
├── student/oj/        # 学生端:题目列表/详情、提交、我的提交、比赛与排名(contest.go)、我的作业与得分(homework.go)
├── judge/             # rsjudge 对接:gRPC 服务端(节点注册/测试点下发)、任务派发与结果收集
├── tenant/oj/         # (二期预留)租户运营端自助管理:本租户题库、比赛、提交记录、榜单
└── controller.go      # 注册以上模块(judge 模块另起 gRPC 监听,不走 HTTP 路由)

路由规则沿用框架约定:包路径 + 结构体名小写 + 方法名首字母小写(保留驼峰),统一 /api 前缀,例如:
app/admin/oj 中 type Problem 的 GetList 方法 → GET /api/admin/oj/problem/getList(详见 6.1.3)。

与现有中间件的集成注意点:

  1. judge 模块的 gRPC 服务(节点注册/测试点下发)监听独立端口,不经过 HTTP 路由与 TenantResolver/useToken 中间件,鉴权在 gRPC 拦截器中完成;
  2. 判题节点为平台级资源,接口不区分租户;/api/admin/oj/judgeNode/* 管理接口照常走 admin JWT;
  3. OJ 全部表查询沿用 tenant_id 过滤(含 Redis 缓存 Key 前缀 tenant:{id}:)。

2.4 与现有平台的集成点

集成点方案
多租户OJ 业务表保留 tenant_id;一期由平台端(web)集中管理,题库/比赛/作业按全局内容(tenant_id=0)组织、租户级归属随二期细化;语言表与判题机节点为平台级
用户体系学生提交关联 gf_student.id;OJ 管理界面统一在 web(admin 端),租户端自助管理为二期预留
作业与竞赛平台级内容(tenant_id=0,不依赖其他业务模块):作业发布后对全体学生可见,比赛按报名方式控制可见性(设计与流程见 3.4~3.6)
上传/附件测试点文件、题目配图复用现有上传接口与 OSS 配置;大文件测试点走独立目录
消息通知(可选)判题完成通过站内信/短信(已有 easysms)通知,比赛开始提醒

三、功能规划

3.1 功能全景(按端)

端功能
平台管理端(web)题库管理(题目编辑 + 测试点 + SPJ + 标签、FPS 导入/导出、上下架)、比赛管理(ACM/OI、题目编排、公开/密码/私有报名审核、封榜)、作业管理(选题编排、计分规则、成绩单/导出)、提交记录(重判、成绩导出)、榜单管理与固化、全局语言配置、判题机节点管理(注册/令牌/监控)、判题任务日志、系统统计
租户运营端(saas)一期不承担 OJ 管理功能(集中于 web);本租户自助管理(题库/比赛/提交/榜单)为二期预留
学生端(app/H5)题目列表(标签/难度/做题状态筛选)、题目详情(Markdown + 样例)、在线提交(多语言、限流)、提交记录与判题明细(状态轮询)、比赛列表/报名/实时榜单、我的作业与得分、总榜与个人统计
判题机(rsjudge)启动向平台注册(自报语言/系统信息)、接收平台派发的判题任务(gRPC 流式)、按需拉取测试点包(zstd 分块)、沙箱隔离与编译运行、资源限制与测量、结果比对(标准/SPJ)、流式回传结果(幂等)
注:租户端(saas)OJ 自助管理、交互题、代码查重、消息通知、判题状态 SSE 推送等属二期规划(见 2.4),未列入上表。

3.2 核心业务流程(提交判题)

  1. 学生在 app/H5 选择语言并提交代码;
  2. student/oj 校验(登录态、比赛时间、提交频率、代码长度)后写入 gf_oj_submission(result=PD)+ gf_oj_submission_code;
  3. 平台将任务 XADD 到 Redis Stream oj:judge:queue,同时写入 gf_oj_judge_task(status=排队),调度器选取空闲节点准备派发;
  4. 调度器以 gRPC 调用 rsjudge 的 JudgeService.Submit(语言、源码、problem_id、cases_id),并流式接收判题事件;
  5. rsjudge 按 cases_id 经平台 CasesService.FetchCases 拉取测试点包(zstd 分块,版本变化自动获取最新)→ 沙箱编译 → 逐测试点运行 → 比对(标准比对/SPJ);
  6. rsjudge 流式回传:编译开始 → 编译信息 → 逐测试点结果 → 汇总(cases_summary);平台据流式事件更新"编译中/运行中"状态与明细;
  7. 平台事务更新 gf_oj_submission / gf_oj_submission_case / 统计表;比赛提交触发榜单缓存刷新;
  8. 学生端轮询提交状态(1~2s)展示:等待→编译中→运行中→最终结果(二期可加 SSE 推送);
  9. 超时未完成的任务由平台扫描器重派(最多 3 次,否则置 SE)。

3.3 判题结果定义(Verdict)

枚举含义说明
PD等待判题排队/派发中
COMPILING编译中
RUNNING运行中
AC通过全部测试点通过
PE格式错误默认关闭,可在题目级启用
WA答案错误输出比对失败
TLE时间超限CPU 时间超过 time_limit × 语言倍率
MLE内存超限内存峰值超过 memory_limit × 语言倍率
OLE输出超限输出超过 output_limit
RE运行错误非零退出/信号终止(含栈溢出)
CE编译错误编译信息截断后回传
SE系统错误判题机异常/重试耗尽
HACKED / CANCELLED被 Hack / 已取消预留(比赛 Hack、重判取消)

3.4 作业与竞赛总体设计

三个做题场景共享同一套"题库 + 提交 + rsjudge 判题"链路,差异只在入口、约束与统计方式:

场景入口时间约束成绩/排行一期端
练习(题库做题)OJ 题目列表无仅做题状态app
作业作业列表截止时间前计分每题得分 + 总成绩web 管理 + app
竞赛比赛比赛时间窗内计分ACM 罚时榜 / OI 总分榜web 管理 + app

数据流总览:

题库 gf_oj_problem
   │ 选题
   ├────────────► 作业 gf_oj_homework ──► gf_oj_homework_problem(分值/排序)
   └────────────► 比赛 gf_oj_contest  ──► gf_oj_contest_problem(题号别名/分值)
                        │
   学生提交 ──► gf_oj_submission(homework_id / contest_id 标记来源)
                        │
                  rsjudge 判题(复用 3.2 链路)
                        │
        ┌───────────────┴───────────────┐
   作业:刷新 gf_oj_homework_score     比赛:刷新 Redis 实时榜单
        (截止前最高分 / 末次得分)     (封榜感知,赛后固化 gf_oj_contest_rank)

提交与计分口径:

事项口径
提交时间以 gf_oj_submission.created_at 为准(不受判题延迟影响)
重判影响重判完成后对作业成绩/比赛榜单幂等重算(按 submission_id 去重)
重判与封榜封榜期间来源的提交,即便重判为 AC 也不进公开榜
作业与比赛题重叠允许同一题目同时出现在多个作业/比赛,成绩按来源独立记录
比赛题目可见性开始前不返回题目内容(仅题数与题号);进行中/结束后按状态返回

3.5 作业模块设计

作业为平台级内容(tenant_id=0),与练习、比赛共用提交判题链路。

可见性与成绩单:

  • 学生视角:已发布且未结束的作业在 app“我的作业”中列出(含截止倒计时、完成进度、总分);
  • 成绩单按学生维度汇总(每题得分 + 总分),管理员可导出;
  • 未发布作业对学生不可见;不在时间窗内(未开始/已结束)不可提交(服务端强制校验);
  • 扩展预留:若后续需要限定参与范围(班级/指定名单),在作业维度增加“参与名单”即可,不影响本设计主体。

作业流程:

  1. 教师(web)创建作业:从题库选题 → 配置每题分值与计分规则 → 设定起止时间 → 发布(status=1);
  2. 学生(app):已发布作业在“我的作业”中列出,可查看题目列表与我的得分/状态;
  3. 学生提交:复用 OJ 提交判题链路,gf_oj_submission.homework_id 标记来源;校验作业已发布、当前时间在 [start_time, end_time] 内、提交限流;
  4. 判题完成:更新 gf_oj_homework_score:

    • 计分规则 0(默认):取截止前最高分(score = max(score, 本次得分));
    • 计分规则 1:末次提交得分覆盖;
    • 同时维护 submit_count、first_ac_at;
  5. 截止(或教师手动结束):作业置 status=2;成绩单固化可导出;截止后提交不计分(可选配置为直接拒绝提交)。

3.6 竞赛模块设计

比赛流程:

  1. 教师(web)创建比赛:赛制(ACM/OI)、起止时间、报名方式(公开/密码/私有)、封榜分钟 → 编排题目 → 发布;
  2. 学生(app):公开赛直接进入;密码赛输入密码通过后进入;私有赛提交报名 → 教师审核通过后进入;
  3. 赛内提交:contest_id 标记来源;校验比赛进行中(开始前/结束后拒绝提交);
  4. 判题完成 → 榜单引擎刷新 Redis 实时榜(Key:tenant:0:rank:contest:{cid}):

    • ACM:通过数降序、罚时升序(罚时 = 首次 AC 用时 + 20min × AC 前错误次数);
    • OI:各题最高分(或末次)求和降序;
    • 封榜:end_time - freeze_minutes 之后的提交不计入公开榜,仅参与最终榜计算;
  5. 比赛结束:自动解封,固化最终快照至 gf_oj_contest_rank(is_final=1);榜单可导出。

四、rsjudge 判题机与集成设计

rsjudge 为现成开源项目(NJUPT-SAST/rsjudge,Apache-2.0),已归置于本仓库 judge/rsjudge/。本章描述其项目现状、平台侧集成设计与部署要求。

4.1 rsjudge 简介与集成目标

项目简介(现状):rsjudge 是一款用 Rust 编写的在线评测沙箱服务器,受 go-judge 启发、服务于 SASTOJ,采用 Cargo workspace 多 crate 结构(见 4.3);原生提供 gRPC 三组接口(节点注册 / 提交判题 / 测试点下发,见 4.7),另支持 REST(当前为骨架)与 RabbitMQ(feature 可启停);语言与沙箱行为由 TOML 文件配置(executors.toml、seccomp.toml、services.toml)。项目当前为早期版本(0.1.0):判题核心逻辑(Submit/SelfTest)上游仍在开发中,集成需跟踪其进度(见 9.1 与十章风险)。

集成目标(判题环节质量要求):

  • 安全:提交代码视为不可信输入,沙箱过滤与权限降级,无网络、无宿主文件访问、有资源上限;
  • 准确:CPU 时间/内存测量误差可控,TLE/MLE 判定公平,语言倍率可配;
  • 高效:单一节点多并发判题,编译与运行分离超时;
  • 稳健:任务至少一次派发、结果幂等、节点故障自动重派;
  • 可运维:单二进制 + 配置文件分发,systemd/Docker 均可托管。

4.2 技术栈(项目现状)

类别项目现状说明
语言/版本Rust(edition 2024,MSRV 1.96)Cargo workspace 工程
异步运行时tokio服务与任务并发
gRPCtonic + protobuf(proto:register / judge / cases)与平台的主交互通道
RESTaxum(当前为骨架,feature 可启停)预留
AMQPRabbitMQ(feature 可启停)预留
沙箱libseccomp(seccomp-BPF 白名单)、capctl(capabilities)、nix(降权/rlimit/rusage)rsjudge-runner
语言配置TOML(executors.toml:命令模板 + 编译选项;支持多编译器版本)与平台语言表映射(见 4.6)
比对评判rsjudge-judger输出比对与评判
许可Apache-2.0现成开源项目

4.3 工程结构(Cargo workspace,项目现状)

judge/rsjudge/
├── Cargo.toml                # workspace 定义
├── src/                      # 入口:CLI(--config-dir,默认 ./config)、配置加载、日志
├── crates/
│   ├── rsjudge-grpc/         # gRPC 服务/客户端(proto:register/judge/cases),tonic
│   ├── rsjudge-rest/         # RESTful API(axum,当前为骨架)
│   ├── rsjudge-amqp/         # RabbitMQ 集成(feature 可启停)
│   ├── rsjudge-runner/       # 沙箱执行器:降权用户、capabilities、seccomp、rlimit、资源测量
│   ├── rsjudge-judger/       # 输出比对与评判
│   ├── rsjudge-traits/       # 公共 trait 定义
│   └── rsjudge-utils/        # 公共工具(日志、资源测量等)
├── config-demo/              # 配置样例:services.toml / executors.toml / seccomp.toml
├── packaging/                # .deb / .rpm 打包
└── xtask/                    # 构建与打包辅助任务(cargo xtask)

4.4 沙箱机制与安全基线

4.4.1 隔离机制(rsjudge-runner 现状)

层技术作用
系统调用过滤seccomp-BPF(libseccomp,默认拒绝 + 白名单,seccomp.toml 可配)阻断 socket/ptrace/mount 等危险调用,限制联网与提权途径
权限控制capabilities 管控(capctl:保留/丢弃边界能力)最小权限运行,setuid 提权失效
用户降权以低权限用户运行(run_as/user 模块)提交代码不以 root 执行
资源限制rlimit(nix resource)+ 进程测量(rusage)CPU/内存/进程数/文件大小上限
进程管控进程组 + 超时击杀 + 临时目录清理防挂死与残留(异常自愈)

4.4.2 cgroup v2 资源控制(部署侧评估项)

rsjudge 当前以 seccomp + capabilities + rlimit + rusage 测量为主,尚未接入 cgroup(项目依赖中 libcgroups 未启用)。如需更精确的内核级资源控制,可在部署或后续版本中评估接入 cgroup v2(每个判题任务一个 cgroup):

/sys/fs/cgroup/rsjudge/<task_id>/<slot>/
├── memory.max      # 内存硬上限 = memory_limit × 语言倍率
├── memory.peak     # 内存峰值(MLE 判定依据)
├── cpu.max         # 如 "100000 100000" = 1 核配额
├── pids.max        # 如 64
├── cpu.stat        # usage_usec(CPU 时间兜底测量)
└── cgroup.kill     # 超限后整组击杀(原子)

4.4.3 rlimit 兜底参数

限制项值说明
RLIMIT_CPUtime_limit + 2sCPU 时间硬杀兜底
RLIMIT_ASmemory_limit + 64MB地址空间(解释器类语言单独放宽)
RLIMIT_STACK16MB(可配)栈溢出→RE
RLIMIT_FSIZEoutput_limit写出文件上限
RLIMIT_NPROC64进程数
RLIMIT_NOFILE128文件描述符
RLIMIT_CORE0禁止 core dump

4.4.4 资源测量与判定

指标测量方式判定
CPU 时间wait4 rusage(用户+系统)cpu_time > time_limit × 倍率 → TLE
墙钟时间父进程计时器wall > max(time_limit×3, 3×cpu) → TLE(防 sleep 挂死)
内存峰值rusage maxrss(兜底 /proc/<pid>/status VmHWM)peak > memory_limit × 倍率 → MLE
输出大小管道读取侧限流(如 16MB)超限杀进程 → OLE
退出状态退出码 / 信号非零退出或异常信号 → RE(栈溢出、SIGKILL 超限除外按对应枚举)

4.4.5 编译阶段

  • 编译以低权限用户执行,同样受沙箱约束但限制放宽(CPU 10~20s、内存 1GB、输出 1MB);
  • 编译命令由语言配置模板渲染(如 g++ -O2 -std=c++17 main.cpp -o main);
  • 编译错误信息截取前 16KB 回传 compile_info;
  • 编译产物仅存于该任务临时目录,任务结束整体清理。

4.5 判题流程与状态机

PD(排队) ──派发──► 【编译】 ──CE──► CE
                     │成功
                     ▼
              逐测试点【运行+比对】 ──► 汇总
                     │
        ┌────────────┼─────────────┬────────────┐
       AC           WA           TLE/MLE/OLE     RE
                     │
                     ▼
              流式回传(幂等) ──► 平台更新 + 榜单刷新
  • 测试点默认遇错继续(保证每点明细完整,便于学生定位);可在题目层配置"遇错即停";
  • 每测试点独立超时与资源测量,取各点最大值作为提交整体耗时/内存;
  • SPJ 模式下,用户输出交 SPJ 程序判定(SPJ 以更宽松资源限制运行,受信任)。

4.6 多语言支持(rsjudge executors.toml ↔ 平台语言表映射)

判题侧语言以节点 rsjudge 的 executors.toml 为准(编译/运行命令在节点本地配置);平台侧 gf_oj_language 表负责:提交页可选语言、时间/内存倍率、以及“平台语言 code → rsjudge 语言声明”的映射(派发时经 SubmitRequest.language 下发)。

平台 ↔ rsjudge 语言映射表(一期内置 7 种,初始数据随建表 SQL 写入):

平台 code平台名称扩展名rsjudge 语言名(judge_name)下发 configs(options JSON)时间倍率内存倍率
cC11.cC{"O2":true,"version":"C11"}1.01.0
cpp17C++17.cppC++{"O2":true,"version":"C++17"}1.01.0
javaJava 17.javaJava{}2.02.0
python3Python 3.pyPython{}3.02.0
goGo.goGo{}1.51.0
rustRust.rsRust{}1.01.0
nodeNode.js.jsJavaScript{}2.02.0
  • rsjudge executors.toml 内置语言名固定为:C / C++ / C# / Dart / Go / Java / JavaScript / Kotlin / Python / Ruby / Rust / TypeScript / Swift / Zig(见 judge/rsjudge/config-demo/executors.toml);options 可选项来自节点注册上报的 LanguageDecl(如 C++ 的 version 枚举 C++98/11/14/17/20);
  • gf_oj_language 表字段:judge_name(rsjudge 语言名)、options(上面 JSON);原 compile_cmd/run_cmd 仅作展示参考(实际命令以节点 executors.toml 为准),ext 用于提交页提示与源码文件名展示;
  • 语言可用性 = 平台启用 ∩ 至少一个在线节点注册声明支持该语言(提交页按此动态展示);
  • Java -Xmx 等内存注入由 rsjudge 运行时处理(平台仅下发题目 memory_limit);编译耗时长的语言由节点编译超时配置处理。

4.7 与平台通信协议(gRPC 细则)

协议以 rsjudge 的 proto 定义为准(judge/rsjudge/crates/rsjudge-grpc/proto/),共三组服务;平台侧 Go 代码由 proto 生成(生成方式见 6.4)。

服务总表:

proto 服务方向实现方说明
register.v1.RegisterService.Registerrsjudge → 平台平台节点启动注册:上报支持语言(名称 + 可配置项声明)、服务端口、系统信息;平台返回节点 id(落 gf_oj_judge_node)
judge.v1.JudgeService.Submit / SelfTest平台 → rsjudgersjudge提交判题 / 自测:语言、源码、problem_id、cases_id;流式返回:编译开始 → 编译信息 → 逐测试点结果 → 汇总
cases.v1.CasesService.FetchCasesrsjudge → 平台平台按 problem_id 拉取测试点包(zstd 压缩 tarball 分块,每块 < 1MiB,metadata 携带 cases_id 版本)

Register 消息(平台接收):

字段类型说明
languagesrepeated LanguageDeclname(rsjudge 语言名)+ configs(可配置项:boolean 型或 enum 型 + variants 列表)
portint32节点 gRPC 监听端口;平台结合连接 peer IP 组装 grpc_addr = <peer_ip>:<port> 存库(后续调度回调地址)
system_infostring系统信息(JSON 文本,落库展示,版本信息从其中解析或单独字段)

响应 RegisterResponse.id(int32)= 平台节点记录 id。

Submit 消息(平台发送 / 流式接收):

方向字段说明
请求language(Language:name + configs)、code、problem_id、cases_idlanguage 按 4.6 映射下发
响应 oneofcompile_start(Empty)编译开始
响应 oneofcompile_info(exit_status/stdout/stderr)exit_status≠0 → CE(解释型语言为语义错误)
响应 oneofcase_info(case_id/exit_status/result/score)单测试点结果,可能乱序,按 case_id 落库;当前不含耗时/内存字段
响应 oneofcases_summary(result/score)全部测试点汇总,流随之结束
差异项:case_info 当前不含耗时/内存,gf_oj_submission.time_used/memory_used 一期先写 0,待上游扩展字段后填充(集成对齐项,见 9.4);SelfTestSummary 含 execution_time/memory_usage(自测为二期功能)。

JudgeResult 枚举映射(proto → 平台 verdict):

proto 枚举数值平台 verdict
JUDGE_RESULT_ACCEPTED1AC
JUDGE_RESULT_COMPILE_ERROR2CE
JUDGE_RESULT_WRONG_ANSWER3WA
JUDGE_RESULT_PRESENTATION_ERROR4PE
JUDGE_RESULT_RUNTIME_ERROR5RE
JUDGE_RESULT_TIME_LIMIT_EXCEEDED6TLE
JUDGE_RESULT_MEMORY_LIMIT_EXCEEDED7MLE
JUDGE_RESULT_OUTPUT_LIMIT_EXCEEDED8OLE
JUDGE_RESULT_UNSPECIFIED0SE(不应出现,按系统错误处理)

cases_id 规则(版本控制):

  • cases_id 取 gf_oj_problem.case_version(正整数,问题级递增);
  • 触发 case_version+1 的操作:测试点新增/删除/替换/启停、题目时间/内存/输出限制变更、SPJ 代码/语言变更、judge_mode 变更;
  • 判题机本地缓存以 (problem_id, cases_id) 为键,版本变化自动重新拉取;FetchCases 响应 trailing metadata 携带实际 cases_id,与请求不符时按实际值更新缓存。

测试点包(FetchCases 响应体 = zstd 压缩的 tar 流,结构见附录 11.3):

manifest.json         # 题目配置与测试点清单(problem_id/cases_id/限额/judge_mode/spj/cases 数组)
cases/{case_id}.in    # 测试点输入(case_id 即 case_index,从 1 开始)
cases/{case_id}.ans   # 期望输出(标准比对用;SPJ 模式可为空)
spj/checker.{ext}     # SPJ 源码(judge_mode=1 时存在,语言由 manifest.spj_lang 指明)

鉴权与节点身份:

  • 管理端预注册节点(见 6.2 judgeNode)并生成 node_token(昵文展示一次,库内存 sha256);
  • 节点侧在 gRPC metadata 携带 x-node-token(明文),平台拦截器 sha256 比对后解析归属记录;注册时以令牌匹配预注册记录 → 更新 grpc_addr/languages/system_info → status=1(在线);
  • 令牌匹配不到记录且不允许自动注册 → 拒绝(PERMISSION_DENIED);
  • 现状说明(已更新):节点侧 register/cases 客户端已在 judge/rsjudge 就地补全(9.5 第 8 批):注册携带 x-node-token metadata 并 30s 心跳保活,FetchCases 校验响应 metadata cases_id;与平台拦截器的令牌流程以联调实测为准(见 9.4)。
  • 现状说明(judge 服务,已更新):Submit/SelfTest 服务端已在 judge/rsjudge 就地补全(9.5 第 8 批):编译(builder 降权)→ 逐测试点沙箱运行(runner 降权 + seccomp + rlimit + rusage 测量)→ 比对 → 流式回传;SPJ(judge_mode=1)一期不实现、遇之明确报错;proto 未改动,集成时仍以锁定版本的实际 proto 为准(见十章风险)。

4.8 任务调度、租约与容错

调度参数:

参数值说明
队列Redis Stream oj:judge:stream消费组 judge-dispatchers;任务同时落库 gf_oj_judge_task 可审计(DB 为状态源,Stream 为唤醒通道)
租约lease_expire_at = now + 60s + 测试点数 × time_limit(ms) × time_factor × 3 / 1000(下限 180s,上限 30 分钟)派发时写入;取整到秒
重派扫描每 15s 一次(条件更新抢占,多实例安全,见 6.5.4)租约过期:retry_count<3 → 任务置 status=4(重派)并重新 XADD;否则置 status=5 + error_msg,submission.result=SE
节点掉线last_active 超 90s 未刷新(注册/派发成功均刷新)→ 节点 status=0(离线)在途任务租约到期后自动重派
幂等见 6.5 事件处理表;submission 已到终态后收到的事件直接丢弃(仅记日志)重派后旧节点迟到事件同样丢弃
取消重判/重派时向原节点发 Submit 连接 cancel(客户端主动断流,尽力而为)不阻塞流程

派发流程(调度器循环,多实例安全):

  1. XREADGROUP 阻塞领取任务消息(含 task_id、submission_id);查任务表确认 status=0(排队);
  2. 选择目标节点:status=1(在线) ∧ running_count < max_concurrency ∧ languages JSON 含所需 judge_name;多候选取 running_count 最小、再按 load_avg 最小;
  3. 无可用节点:不 XACK(消息留在 PEL),由 6.5.1 补偿机制(XAUTOCLAIM)稍后重取;
  4. 调用节点 JudgeService.Submit(4.7):置任务 status=1、node_id、lease_expire_at、dispatched_at;node.running_count+1;node.last_active=now;
  5. 流式事件按 6.5 处理表更新 submission/submission_case;
  6. 收到 cases_summary:事务更新 submission 终态与任务 status=3(完成)+ XACK;node.running_count-1;触发统计/榜单/作业计分(6.5 后置处理);
  7. 流中断/超时/节点报错:任务保持 status=1,node.running_count-1 与 error_msg 记录,等租约扫描重派。
并发控制:节点 running_count 由平台维护(派发 +1、完成/失败/租约重派时 -1),节点无需上报;max_concurrency 取自节点注册信息或管理端配置。

4.9 部署形态

  • 运行环境:Linux(seccomp 需内核 ≥ 4.5,见项目 README),以非特权用户运行,工作目录独立;
  • 分发:从源码 cargo build --release、预编译二进制或 .deb/.rpm 包(packaging/);Docker 镜像内置语言工具链(gcc/g++、OpenJDK、Python3、Go、Rust、Node);
  • 扩容:新节点部署后配置接入参数(平台 gRPC 地址、令牌),启动时自动注册;节点无状态,可随意增删;
  • 测试点缓存:按 cases_id 版本缓存测试点包(版本变化自动重新拉取),缓存策略以项目实现为准;
  • 可观测性:节点运行日志,平台侧记录派发/事件/异常审计(gf_oj_judge_task)。

五、数据库设计

5.1 设计原则

  • 表名前缀 gf_oj_,与现有 gf_ 体系一致;
  • 业务表带 tenant_id(int(11) default 0)实现租户隔离;语言表、判题节点表为平台级(不加租户);
  • 沿用现有规范:status、created_at、updated_at、deleted_at(软删除);
  • 高频查询建联合索引(如 tenant_id + problem_id + result);
  • 源码与提交主表分离(列表查询不拖大字段);
  • 新表通过 gorm/gen 生成 dao/model/*.gen.go 与 dao/query/ 代码。

5.2 表清单总览

表名用途租户隔离
gf_oj_problem题目是
gf_oj_problem_case测试点(输入/输出文件引用、分值)通过题目
gf_oj_problem_tag / gf_oj_tag题目标签是
gf_oj_submission提交记录是
gf_oj_submission_code提交源码(1:1)-
gf_oj_submission_case每测试点判题明细-
gf_oj_contest比赛(ACM/OI、封榜)是
gf_oj_contest_problem比赛题目(题号/分值)-
gf_oj_contest_user比赛报名(私有赛)-
gf_oj_contest_rank榜单快照(封榜/赛后固化)-
gf_oj_homework作业(标题/说明/起止时间/计分规则)是
gf_oj_homework_problem作业题目(题号/分值/排序)-
gf_oj_homework_score作业成绩(学生×题目得分)-
gf_oj_user_problem做题状态(是否解决/尝试次数)是
gf_oj_judge_node判题节点(令牌/注册信息/负载)平台级
gf_oj_judge_task判题任务调度日志(租约/重派)-
gf_oj_language语言配置(命令模板/倍率/启停)平台级

5.3 核心表结构

-- 题目表
CREATE TABLE `gf_oj_problem` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL DEFAULT 0 COMMENT '租户ID',
  `title` varchar(200) NOT NULL COMMENT '题目标题',
  `description` mediumtext COMMENT '题目描述(Markdown)',
  `input_desc` text COMMENT '输入说明',
  `output_desc` text COMMENT '输出说明',
  `samples` text COMMENT '样例JSON [{input,output}]',
  `hint` text COMMENT '提示',
  `source` varchar(200) NOT NULL DEFAULT '' COMMENT '题目来源',
  `difficulty` tinyint(2) NOT NULL DEFAULT 1 COMMENT '难度:1-5',
  `time_limit` int(11) NOT NULL DEFAULT 1000 COMMENT '时间限制(ms)',
  `memory_limit` int(11) NOT NULL DEFAULT 256 COMMENT '内存限制(MB)',
  `output_limit` int(11) NOT NULL DEFAULT 16 COMMENT '输出限制(MB)',
  `judge_mode` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=标准,1=SPJ,2=交互(预留)',
  `spj_lang` varchar(20) NOT NULL DEFAULT '' COMMENT 'SPJ语言code',
  `spj_code` text COMMENT 'SPJ代码',
  `case_version` int(11) NOT NULL DEFAULT 1 COMMENT '测试数据版本(变更递增,控制判题机缓存)',
  `allow_languages` text COMMENT '允许语言JSON(空=全部)',
  `ac_count` int(11) NOT NULL DEFAULT 0 COMMENT '通过数',
  `submit_count` int(11) NOT NULL DEFAULT 0 COMMENT '提交数',
  `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=公开,1=隐藏,2=草稿',
  `author_id` int(11) NOT NULL DEFAULT 0 COMMENT '创建人',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_status` (`tenant_id`,`status`),
  KEY `idx_tenant_title` (`tenant_id`,`title`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ题目表';

-- 测试点表
CREATE TABLE `gf_oj_problem_case` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `problem_id` int(11) NOT NULL COMMENT '题目ID',
  `case_index` int(11) NOT NULL COMMENT '测试点序号(从1开始)',
  `input_file` varchar(500) NOT NULL DEFAULT '' COMMENT '输入文件路径或OSS对象Key',
  `output_file` varchar(500) NOT NULL DEFAULT '' COMMENT '输出文件',
  `input_sha256` varchar(64) NOT NULL DEFAULT '',
  `output_sha256` varchar(64) NOT NULL DEFAULT '',
  `score` int(11) NOT NULL DEFAULT 10 COMMENT '分值',
  `is_pretest` tinyint(1) NOT NULL DEFAULT 0 COMMENT '预测试点(提交后可见明细)',
  `enabled` tinyint(1) NOT NULL DEFAULT 1,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_problem_index` (`problem_id`,`case_index`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ测试点表';

-- 提交表
CREATE TABLE `gf_oj_submission` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL DEFAULT 0,
  `problem_id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL COMMENT '用户ID(学生=gf_student.id)',
  `user_type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=学生,2=管理员/教师',
  `contest_id` int(11) NOT NULL DEFAULT 0 COMMENT '比赛ID(0=练习)',
  `homework_id` int(11) NOT NULL DEFAULT 0 COMMENT '作业ID(0=非作业,与contest_id互斥)',
  `language` varchar(20) NOT NULL COMMENT '语言code',
  `code_length` int(11) NOT NULL DEFAULT 0,
  `result` varchar(12) NOT NULL DEFAULT 'PD' COMMENT '判题结果枚举',
  `score` int(11) NOT NULL DEFAULT 0,
  `time_used` int(11) NOT NULL DEFAULT 0 COMMENT '最大用时(ms)',
  `memory_used` int(11) NOT NULL DEFAULT 0 COMMENT '最大内存(KB)',
  `compile_info` text COMMENT '编译信息',
  `judge_node` varchar(50) NOT NULL DEFAULT '' COMMENT '判题节点',
  `ip` varchar(45) NOT NULL DEFAULT '',
  `judged_at` datetime DEFAULT NULL,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_problem` (`tenant_id`,`problem_id`,`result`),
  KEY `idx_tenant_user` (`tenant_id`,`user_id`,`created_at`),
  KEY `idx_contest` (`contest_id`),
  KEY `idx_homework` (`homework_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ提交表';

-- 提交源码表(1:1)
CREATE TABLE `gf_oj_submission_code` (
  `submission_id` int(11) NOT NULL,
  `code` mediumtext,
  PRIMARY KEY (`submission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ提交源码表';

-- 每测试点明细
CREATE TABLE `gf_oj_submission_case` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `submission_id` int(11) NOT NULL,
  `case_index` int(11) NOT NULL,
  `result` varchar(12) NOT NULL,
  `time_used` int(11) NOT NULL DEFAULT 0,
  `memory_used` int(11) NOT NULL DEFAULT 0,
  `score` int(11) NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_submission_case` (`submission_id`,`case_index`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ测试点判题明细';

-- 比赛表
CREATE TABLE `gf_oj_contest` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL DEFAULT 0,
  `title` varchar(200) NOT NULL,
  `description` text,
  `rule_type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=ACM罚时制,1=OI总分制',
  `access_type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=公开,1=密码,2=私有(报名)',
  `password` varchar(50) NOT NULL DEFAULT '',
  `start_time` datetime NOT NULL,
  `end_time` datetime NOT NULL,
  `freeze_minutes` int(11) NOT NULL DEFAULT 0 COMMENT '封榜时长(分钟),0=不封榜',
  `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=未开始,1=进行中,2=已结束',
  `created_by` int(11) NOT NULL DEFAULT 0,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_time` (`tenant_id`,`start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ比赛表';

-- 比赛题目
CREATE TABLE `gf_oj_contest_problem` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `contest_id` int(11) NOT NULL,
  `problem_id` int(11) NOT NULL,
  `display_id` varchar(4) NOT NULL COMMENT '题号:A,B,C...',
  `score` int(11) NOT NULL DEFAULT 100 COMMENT 'OI制分值',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_contest_problem` (`contest_id`,`problem_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ比赛题目表';

-- 比赛报名表(私有赛)
CREATE TABLE `gf_oj_contest_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `contest_id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL COMMENT '学生ID',
  `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '0=待审核,1=通过,2=拒绝',
  `signed_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_contest_user` (`contest_id`,`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ比赛报名表';

-- 比赛榜单快照表
CREATE TABLE `gf_oj_contest_rank` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `contest_id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL,
  `rank_no` int(11) NOT NULL COMMENT '名次',
  `solved_count` int(11) NOT NULL DEFAULT 0 COMMENT '通过题数(ACM罚时制)',
  `penalty` int(11) NOT NULL DEFAULT 0 COMMENT '罚时(秒,ACM罚时制)',
  `total_score` int(11) NOT NULL DEFAULT 0 COMMENT '总分(OI总分制)',
  `detail` text COMMENT '逐题明细JSON',
  `is_final` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1=赛后解封最终快照',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_contest` (`contest_id`,`is_final`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ比赛榜单快照表';

-- 作业表
CREATE TABLE `gf_oj_homework` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL DEFAULT 0 COMMENT '租户ID',
  `title` varchar(200) NOT NULL COMMENT '作业标题',
  `description` text COMMENT '作业说明',
  `start_time` datetime DEFAULT NULL COMMENT '开始时间(NULL=立即)',
  `end_time` datetime NOT NULL COMMENT '截止时间',
  `score_rule` tinyint(1) NOT NULL DEFAULT 0 COMMENT '计分规则:0=截止前最高分,1=末次提交',
  `total_score` int(11) NOT NULL DEFAULT 0 COMMENT '总分(0=各题分值之和)',
  `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=草稿,1=已发布,2=已结束',
  `created_by` int(11) NOT NULL DEFAULT 0 COMMENT '创建人(管理员ID)',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_status` (`tenant_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ作业表';

-- 作业题目关联表
CREATE TABLE `gf_oj_homework_problem` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `homework_id` int(11) NOT NULL,
  `problem_id` int(11) NOT NULL,
  `display_id` varchar(4) NOT NULL DEFAULT '' COMMENT '题号:A,B,C...',
  `score` int(11) NOT NULL DEFAULT 100 COMMENT '该题分值',
  `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_homework_problem` (`homework_id`,`problem_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ作业题目表';

-- 作业成绩表
CREATE TABLE `gf_oj_homework_score` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `homework_id` int(11) NOT NULL,
  `student_id` int(11) NOT NULL COMMENT '学生ID',
  `problem_id` int(11) NOT NULL,
  `score` int(11) NOT NULL DEFAULT 0 COMMENT '该题得分(按计分规则)',
  `submission_id` int(11) NOT NULL DEFAULT 0 COMMENT '计分对应的提交ID',
  `first_ac_at` datetime DEFAULT NULL COMMENT '首次通过时间',
  `submit_count` int(11) NOT NULL DEFAULT 0 COMMENT '提交次数',
  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_hw_student_problem` (`homework_id`,`student_id`,`problem_id`),
  KEY `idx_hw_student` (`homework_id`,`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ作业成绩表';

-- 判题节点表(平台级)
CREATE TABLE `gf_oj_judge_node` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `node_name` varchar(50) NOT NULL COMMENT '节点名称(管理端预注册指定)',
  `node_token_hash` varchar(64) NOT NULL DEFAULT '' COMMENT '接入令牌sha256(明文仅生成时返回一次)',
  `grpc_addr` varchar(64) NOT NULL DEFAULT '' COMMENT '节点gRPC地址(peer_ip:port,注册时写入)',
  `ip` varchar(45) NOT NULL DEFAULT '',
  `version` varchar(20) NOT NULL DEFAULT '',
  `cpu_cores` int(11) NOT NULL DEFAULT 0,
  `max_concurrency` int(11) NOT NULL DEFAULT 0,
  `running_count` int(11) NOT NULL DEFAULT 0 COMMENT '平台维护的在途判题数',
  `languages` text COMMENT '注册上报语言JSON(LanguageDecl列表)',
  `system_info` text COMMENT '注册上报系统信息',
  `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=离线,1=在线,2=禁用',
  `load_avg` decimal(4,2) NOT NULL DEFAULT 0,
  `last_active` datetime DEFAULT NULL COMMENT '最后活跃时间(注册/派发成功刷新)',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_node_name` (`node_name`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ判题节点表';

-- 语言配置表(平台级)
CREATE TABLE `gf_oj_language` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL COMMENT '语言名称,如C++17',
  `code` varchar(20) NOT NULL COMMENT '语言编码,如cpp17',
  `judge_name` varchar(50) NOT NULL DEFAULT '' COMMENT 'rsjudge语言名,如C++',
  `options` varchar(500) NOT NULL DEFAULT '' COMMENT 'rsjudge配置JSON,如{"O2":true,"version":"C++17"}',
  `ext` varchar(10) NOT NULL DEFAULT '' COMMENT '源码扩展名',
  `compile_cmd` varchar(500) NOT NULL DEFAULT '' COMMENT '编译命令(仅展示,实际以节点executors.toml为准)',
  `run_cmd` varchar(500) NOT NULL DEFAULT '' COMMENT '运行命令(仅展示)',
  `time_factor` decimal(4,2) NOT NULL DEFAULT 1.00 COMMENT '时间倍率',
  `memory_factor` decimal(4,2) NOT NULL DEFAULT 1.00 COMMENT '内存倍率',
  `enabled` tinyint(1) NOT NULL DEFAULT 1,
  `sort` int(11) NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ语言配置表';

-- 语言初始数据(与 4.6 映射表一致)
INSERT INTO `gf_oj_language` (`name`,`code`,`judge_name`,`options`,`ext`,`time_factor`,`memory_factor`,`enabled`,`sort`) VALUES
('C11','c','C','{"O2":true,"version":"C11"}','.c',1.00,1.00,1,1),
('C++17','cpp17','C++','{"O2":true,"version":"C++17"}','.cpp',1.00,1.00,1,2),
('Java 17','java','Java','{}','.java',2.00,2.00,1,3),
('Python 3','python3','Python','{}','.py',3.00,2.00,1,4),
('Go','go','Go','{}','.go',1.50,1.00,1,5),
('Rust','rust','Rust','{}','.rs',1.00,1.00,1,6),
('Node.js','node','JavaScript','{}','.js',2.00,2.00,1,7);

-- 判题任务调度表(审计+租约)
CREATE TABLE `gf_oj_judge_task` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `task_id` varchar(40) NOT NULL COMMENT '任务ID(uuid)',
  `submission_id` int(11) NOT NULL,
  `node_id` int(11) NOT NULL DEFAULT 0 COMMENT '派发节点(0=未派发)',
  `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=排队,1=已派发,2=判题中,3=完成,4=重派,5=失败',
  `retry_count` tinyint(1) NOT NULL DEFAULT 0,
  `lease_expire_at` datetime DEFAULT NULL COMMENT '租约到期时间',
  `error_msg` varchar(500) NOT NULL DEFAULT '',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  `dispatched_at` datetime DEFAULT NULL,
  `finished_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_task_id` (`task_id`),
  KEY `idx_status_lease` (`status`,`lease_expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ判题任务调度表';

-- 用户做题状态(统计/榜单辅助)
CREATE TABLE `gf_oj_user_problem` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL DEFAULT 0,
  `user_id` int(11) NOT NULL,
  `problem_id` int(11) NOT NULL,
  `solved` tinyint(1) NOT NULL DEFAULT 0,
  `submit_count` int(11) NOT NULL DEFAULT 0,
  `wrong_count` int(11) NOT NULL DEFAULT 0,
  `first_ac_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_problem` (`tenant_id`,`user_id`,`problem_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ用户做题状态表';
-- 标签表
CREATE TABLE `gf_oj_tag` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL DEFAULT 0 COMMENT '租户ID',
  `name` varchar(50) NOT NULL COMMENT '标签名',
  `color` varchar(20) NOT NULL DEFAULT '' COMMENT '展示颜色',
  `sort` int(11) NOT NULL DEFAULT 0,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_tenant_name` (`tenant_id`,`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ标签表';

-- 题目-标签关联表
CREATE TABLE `gf_oj_problem_tag` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `problem_id` int(11) NOT NULL,
  `tag_id` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_problem_tag` (`problem_id`,`tag_id`),
  KEY `idx_tag` (`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OJ题目标签关联表';

以上全部建表脚本(含 4.6 语言初始数据)统一落在 doc/sql/oj.sql;建表后运行 gofly/dao/cmd/main.go 生成 dao/model、dao/query 代码(命名规则见 5.5)。

5.4 榜单实现

赛制规则实现
ACM排名 = 通过题数降序、罚时升序;罚时 = 首次 AC 用时 + 20min × 该题 AC 前错误次数Redis ZSET 缓存实时榜单(Key:tenant:{id}:rank:contest:{cid}),判题完成事件驱动刷新
OI排名 = 各题最高分之和(可选"末次提交制")同上
封榜比赛结束前 freeze_minutes 分钟内提交不更新公开榜单榜单刷新时忽略封榜时段结果;赛后自动解封并固化快照至 gf_oj_contest_rank
作业计分每题取截止前最高分(或末次提交),总分 = Σ 各题得分gf_oj_homework_score 随判题完成实时更新,截止/手动结束后固化

5.5 gorm/gen 命名映射(生成代码依据)

建表后运行 gofly/dao/cmd/main.go 生成模型与查询代码(命名策略:去 gf_ 前缀 → 下划线转大驼峰,见 dao/cmd/main.go;tinyint → int8,deleted_at → gorm.DeletedAt):

表model 结构体(dao/model/oj_*.gen.go)query 访问(dao.Query().Xxx)
gf_oj_problemmodel.OjProblemOjProblem
gf_oj_problem_casemodel.OjProblemCaseOjProblemCase
gf_oj_tagmodel.OjTagOjTag
gf_oj_problem_tagmodel.OjProblemTagOjProblemTag
gf_oj_submissionmodel.OjSubmissionOjSubmission
gf_oj_submission_codemodel.OjSubmissionCodeOjSubmissionCode
gf_oj_submission_casemodel.OjSubmissionCaseOjSubmissionCase
gf_oj_contestmodel.OjContestOjContest
gf_oj_contest_problemmodel.OjContestProblemOjContestProblem
gf_oj_contest_usermodel.OjContestUserOjContestUser
gf_oj_contest_rankmodel.OjContestRankOjContestRank
gf_oj_homeworkmodel.OjHomeworkOjHomework
gf_oj_homework_problemmodel.OjHomeworkProblemOjHomeworkProblem
gf_oj_homework_scoremodel.OjHomeworkScoreOjHomeworkScore
gf_oj_user_problemmodel.OjUserProblemOjUserProblem
gf_oj_judge_nodemodel.OjJudgeNodeOjJudgeNode
gf_oj_judge_taskmodel.OjJudgeTaskOjJudgeTask
gf_oj_languagemodel.OjLanguageOjLanguage
生成的 .gen.go 文件不手改;业务扩展方法另建非生成文件(如 dao/model/oj_ext.go);表结构变更后重新运行生成命令即可。

六、gofly 服务端接口规划

6.1 通用约定(生成代码依据)

6.1.1 响应格式与分页

所有 HTTP 接口统一走 gofly 响应结构(gofly/utils/gf/gf_restful_api.go):

{ "code": 0, "message": "ok", "data": {}, "exdata": null, "token": "", "time": 1760000000 }
  • code:0=成功、1=失败(HTTP 状态码均为 200),失败原因放 message;
  • 成功:gf.Success().SetMsg("ok").SetData(data).Regin(ctx);失败:gf.Failed().SetMsg(err.Error()).Regin(ctx);
  • 分页入参统一 page(默认 1)、pageSize(默认 10,上限 100);分页出参 data 统一 {"page","pageSize","total","items"}(与 app/admin/student/index.go 的 GetList 一致);
  • 时间字段统一输出 "YYYY-MM-DD HH:mm:ss" 字符串;状态字段用 0/1/2 数字。

6.1.2 鉴权、上下文与参数

端鉴权上下文键说明
adminJWT(useToken 中间件)+ RBACuid、username、tenant_id权限码 oj:{模块}:{动作}(如 oj:problem:save)在角色中配置;仅需登录免权限的方法登记到结构体 NoNeedAuths
studentJWTuid(=gf_student.id)、tenant_idstudent 模块免 RBAC
judge(gRPC)metadata x-node-token节点注册会话拦截器校验,见 6.4
  • 参数统一 param := gf.RequestParam(ctx)(兼容 JSON body / form / query),取值 gf.Int32(param["x"])、gf.String(param["x"]);
  • 写操作强制注入租户 param["tenant_id"] = ctx.GetInt64("tenant_id");所有查询条件必须包含 tenant_id;
  • 分页查询统一 FindByPage(dao.Offset(pageNo, pageSize), pageSize);过滤条件拼 []dao.Condition(参考 app/admin/student/index.go)。

6.1.3 路由生成规则与文件组织

路由由 gf.Register(&结构体{}) 反射自动生成(utils/gf/gf_router.go),统一加 /api 前缀(utils/router/router.go 的 Bind()):

  • 路径 = 包路径 /app/ 之后部分 + 结构体名小写(Index 省略)+ 方法名首字母小写(保留驼峰);如 app/admin/oj/problem.go 的 Problem.GetList → GET /api/admin/oj/problem/getList;
  • HTTP 方法:Get*→GET、Del*→DELETE、Put*→PUT、其余→POST;以 GetPost 开头同时注册 GET/POST;方法名以 ParId 结尾时路径追加 /:id;
  • 新增文件后在 app/admin/controller.go、app/student/controller.go 以 _ "gofly/app/admin/oj" 形式导入即完成注册。
gofly/app/admin/oj/                # 平台管理端(本期全部 OJ 管理功能,需 RBAC)
  problem.go  tag.go  language.go  # 题库(测试点/SPJ/FPS)、标签、语言配置
  contest.go  homework.go          # 比赛、作业
  submission.go  rank.go           # 提交记录/重判/导出、榜单
  judgeNode.go                     # 判题节点管理
gofly/app/student/oj/              # 学生端(免 RBAC,仅登录)
  problem.go  submission.go  contest.go  homework.go  rank.go
gofly/app/judge/                   # 判题调度与 gRPC(后台模块,无 HTTP 路由)
  grpc_server.go                   # RegisterService / CasesService 服务端实现
  dispatch.go                      # Redis Stream 消费 + 节点选择 + Submit 流式客户端
  registry.go                      # 节点注册会话、心跳、在线状态维护
  pb/                              # protoc 生成代码(见 6.4.5)

Handler 统一模板(生成代码按此结构):

type Problem struct {
    NoNeedLogin []string
    NoNeedAuths []string
}

func init() { gf.Register(&Problem{}) }

func (api *Problem) GetList(ctx *gf.GinCtx) {
    param, _ := gf.RequestParam(ctx)
    pageNo := gf.Int(ctx.DefaultQuery("page", "1"))
    pageSize := gf.Int(ctx.DefaultQuery("pageSize", "10"))
    tenantID := gf.Int32(ctx.GetInt64("tenant_id"))
    // ... 业务(强制 tenant_id 条件)
    gf.Success().SetMsg("获取列表成功").SetData(gf.Map{
        "page": pageNo, "pageSize": pageSize, "total": total, "items": list,
    }).Regin(ctx)
}

6.2 平台管理端(admin)接口明细

统一前缀 /api/admin/oj,全部需登录 + RBAC 权限码(oj:{模块}:{动作})。入参与出参 key 与 5.3 表字段一一对应(下划线命名)。

6.2.1 problem.go —— 题库 / 测试点 / SPJ / FPS

方法(HTTP)路由入参data 出参要点
GetList(GET)/problem/getListkeyword、difficulty、tag_id、status、page、pageSize分页 {items:[{id,title,difficulty,tags:[{id,name,color}],status,ac_count,submit_count,created_at}]}标题模糊匹配;tags 二次查询回填
GetInfo(GET)/problem/getInfoid题目全字段 + tag_ids:[] + allow_languages:[]编辑回显;JSON 文本字段转数组
Save(POST)/problem/saveid(0=新增)、title、description、input_desc、output_desc、samples(JSON 文本)、hint、source、difficulty、time_limit、memory_limit、output_limit、judge_mode、spj_lang、spj_code、allow_languages(JSON 数组文本)、status、tag_ids(JSON 数组文本){id}事务:写题 + 全删重插 problem_tag;新增注入 tenant_id/author_id
Del(DELETE)/problem/delids(逗号分隔)-软删题目;级联软删测试点
UpdateStatus(POST)/problem/updateStatusids、status(0 公开/1 隐藏/2 草稿)-上下架切换
GetCaseList(GET)/problem/getCaseListproblem_id{total,items:[{id,case_index,score,is_pretest,enabled,input_size,output_size}]}不返回测试点内容
GetCase(GET)/problem/getCaseproblem_id、case_index{input,output}单点内容回显;超 1MB 拒绝直读
SaveCase(POST)/problem/saveCaseproblem_id、case_index(0=追加)、score、is_pretest、enabled、input、output{id,case_index}写存储 + sha256 落库;case_version+1(失效节点缓存)
DelCase(DELETE)/problem/delCaseproblem_id、case_index-删存储、重排序号、case_version+1
UploadCases(POST)/problem/uploadCasesform-data:problem_id、file(zip){imported,errors:[]}zip 内按 1.in / 1.ans 命名;全部成功才入库并 version+1
ImportFPS(POST)/problem/importFPSform-data:file(xml){imported,skipped,errors:[]}FPS 解析入库(见 11.2)
ExportFPS(GET)/problem/exportFPSidsxml 文件流(blob)web 端按 blob 下载
CheckSPJ(POST)/problem/checkSPJjudge_mode、spj_lang、spj_code{ok,compile_info}可选:SPJ 编译校验
存储路径规范:oj/{tenant_id}/{problem_id}/cases/{case_index}.in|.ans、SPJ oj/{tenant_id}/{problem_id}/spj/checker.{ext};本地磁盘与 OSS 由现有上传配置决定(复用 datacenter/upfile 能力)。

6.2.2 tag.go —— 标签管理

方法(HTTP)路由入参data 出参要点
GetList(GET)/tag/getListkeyword、page、pageSize分页 {items:[{id,name,color,sort,problem_count}]}problem_count 二次聚合
Save(POST)/tag/saveid、name、color、sort{id}uk_tenant_name 冲突返回失败提示
Del(DELETE)/tag/delid-同步清 problem_tag

6.2.3 language.go —— 语言配置(平台级,无 tenant_id)

方法(HTTP)路由入参data 出参要点
GetList(GET)/language/getList-{items:[语言表全字段]}含禁用项;学生端另有过滤接口
Save(POST)/language/saveid、name、code、judge_name、options(JSON 文本)、ext、time_factor、memory_factor、enabled、sort{id}code 唯一;judge_name/options 必须与节点 executors.toml 对齐(4.6)
UpdateStatus(POST)/language/updateStatusid、enabled-停用后学生端不可选
GetNodeLanguages(GET)/language/getNodeLanguages-{nodes:[{node_id,node_name,status,languages}]}对照节点上报语言核对配置

6.2.4 contest.go —— 比赛管理

方法(HTTP)路由入参data 出参要点
GetList(GET)/contest/getListkeyword、status、page、pageSize分页 {items:[比赛表字段+problem_count]}status 由 start/end_time 推算落库
GetInfo(GET)/contest/getInfoid比赛全字段 + problems:[{problem_id,display_id,score,title}]编排回显
Save(POST)/contest/saveid、title、description、rule_type、access_type、password、start_time、end_time、freeze_minutes{id}access_type=1 时密码必填;保存后重算 status
Del(DELETE)/contest/delids-级联删编排/报名/榜单快照
SaveProblems(POST)/contest/saveProblemscontest_id、problems(JSON 文本 [{problem_id,display_id,score}])-覆盖式编排;display_id 重复校验
GetUsers(GET)/contest/getUserscontest_id、status、page、pageSize分页 {items:[{user_id,student_no,name,status,signed_at}]}报名审核列表
AuditUser(POST)/contest/auditUsercontest_id、user_id、status(1 通过/2 拒绝)-私有赛审核
SetFreeze(POST)/contest/setFreezecontest_id、frozen(0 解封/1 封榜)-手动封榜/解封;默认按 end_time-freeze_minutes 自动
Finalize(POST)/contest/finalizecontest_id{rows}赛后固化最终榜(contest_rank.is_final=1),供导出与统计
GetRank(GET)/contest/getRankcontest_id、page、pageSize分页 {items:[{rank_no,user_id,name,solved_count,penalty,total_score,detail}]}管理端可见封榜数据

6.2.5 homework.go —— 作业管理

方法(HTTP)路由入参data 出参要点
GetList(GET)/homework/getListkeyword、status、page、pageSize分页 {items:[作业表字段+problem_count]}
GetInfo(GET)/homework/getInfoid作业全字段 + problems:[{problem_id,display_id,score,sort,title}]编排回显
Save(POST)/homework/saveid、title、description、start_time、end_time、score_rule、total_score{id}end_time 必填且大于 start_time
Del(DELETE)/homework/delids-软删;级联删编排/成绩
Publish(POST)/homework/publishid-草稿→发布;校验题目 ≥1 且测试点齐备
Finish(POST)/homework/finishid-手动结束;触发成绩固化
SaveProblems(POST)/homework/saveProblemshomework_id、problems(JSON 文本 [{problem_id,display_id,score,sort}])-覆盖式编排
GetScores(GET)/homework/getScoreshomework_id、keyword、page、pageSize分页 {items:[{student_id,student_no,name,total,detail:[{problem_id,score,submit_count,first_ac_at}]}]}从 homework_score 聚合
ExportScores(GET)/homework/exportScoreshomework_idxlsx 文件流(blob)复用 excelize 导出模式(参考现有学生导出)

6.2.6 submission.go —— 提交记录 / 重判 / 导出

方法(HTTP)路由入参data 出参要点
GetList(GET)/submission/getListproblem_id、user_keyword、result、language、contest_id、homework_id、start_time、end_time、page、pageSize分页 {items:[{id,user_id,user_name,problem_id,problem_title,language,result,score,time_used,memory_used,created_at}]}联查题目标题与用户名
GetInfo(GET)/submission/getInfoid提交全字段 + code + cases:[{case_index,result,time_used,memory_used,score}]源码从 submission_code 读
Rejudge(POST)/submission/rejudgeids{queued}仅管理员/教师;按当前 case_version 重新入队;批量需限速
Export(GET)/submission/export同 GetList 筛选参数xlsx 文件流(blob)成绩导出

6.2.7 rank.go —— 总榜与统计

方法(HTTP)路由入参data 出参要点
GetRank(GET)/rank/getRankpage、pageSize分页 {items:[{rank_no,user_id,name,solved_count,submit_count,ac_rate}]}按 user_problem 聚合
GetUserStat(GET)/rank/getUserStatuser_id{solved_count,submit_count,ac_rate,by_difficulty:[{difficulty,solved,total}]}用户统计面板

6.2.8 judgeNode.go —— 判题节点管理

方法(HTTP)路由入参data 出参要点
GetList(GET)/judgeNode/getListpage、pageSize分页 {items:[{id,node_name,ip,grpc_addr,version,cpu_cores,max_concurrency,running_count,load_avg,status,languages,last_active}]}status 由心跳/离线扫描维护
Create(POST)/judgeNode/createnode_name、max_concurrency{id,node_token}令牌明文仅本响应返回一次,库里存 sha256(node_token_hash)
ResetToken(POST)/judgeNode/resetTokenid{node_token}重置令牌,旧令牌立即失效
UpdateStatus(POST)/judgeNode/updateStatusid、status(1 启用/2 禁用)-禁用后调度器不再派发新任务
Del(DELETE)/judgeNode/delids-删除节点记录
GetTasks(GET)/judgeNode/getTasksnode_id、status、page、pageSize分页 {items:[{task_id,submission_id,status,retry_count,lease_expire_at,error_msg,created_at}]}判题任务审计(gf_oj_judge_task)

6.3 学生端(student)接口明细

统一前缀 /api/student/oj,全部需登录(student 模块免 RBAC);uid 为学生 ID,查询强制 tenant_id + user_id 限定。

6.3.1 problem.go —— 做题

方法(HTTP)路由入参data 出参要点
GetList(GET)/problem/getListkeyword、difficulty、tag_id、my_status(0 未做/1 尝试未过/2 已通过)、page、pageSize分页 {items:[{id,title,difficulty,tags,my_status,ac_count,submit_count}]}仅 status=0 公开题;my_status 联 user_problem
GetInfo(GET)/problem/getInfoid题面字段(description/input_desc/output_desc/samples/hint/source/difficulty/time_limit/memory_limit)+ languages:[{name,code,ext}] + my_status不返回 spj_code、case_version、测试点信息;languages = language 表(enabled=1) ∩ allow_languages

6.3.2 submission.go —— 提交与状态

方法(HTTP)路由入参data 出参要点
Submit(POST)/submission/submitproblem_id、language(code)、code、contest_id(可选)、homework_id(可选){id}校验链:题目可见性/语言允许/长度 ≤ 64KB/限流(Redis SET NX EX 10)→ 写 submission + submission_code + user_problem 计数 → XADD 入队 → 立即返回 id
GetStatus(GET)/submission/getStatusid{result,score,time_used,memory_used,compile_info,cases:[{case_index,result,time_used,memory_used,score}]}轻量轮询接口(前端 2s/次,直到非 PD/COMPILING/RUNNING)
GetList(GET)/submission/getListproblem_id、result、page、pageSize分页 {items:[{id,problem_id,problem_title,result,score,language,time_used,memory_used,created_at}]}仅本人
GetInfo(GET)/submission/getInfoid提交全字段 + code + cases仅本人可看

6.3.3 contest.go —— 比赛

方法(HTTP)路由入参data 出参要点
GetList(GET)/contest/getListstatus、page、pageSize分页 {items:[{id,title,rule_type,access_type,start_time,end_time,status,signed}]}signed=当前用户是否已通过审核
GetInfo(GET)/contest/getInfoid比赛字段 + signed + problems:[{display_id,problem_id,title,my_result,my_score}](开赛后可见)密码赛未登记时仅返回公告字段
Register(POST)/contest/registercontest_id、password(可选){status}(0 待审核/1 已通过)公开赛直接通过;密码赛校密码;私有赛写待审核
GetRank(GET)/contest/getRankcontest_id、page、pageSize分页 {items:[{rank_no,user_id,name,solved_count,penalty,total_score,detail}]}读 Redis ZSET 实时榜;封榜期间冻结

6.3.4 homework.go —— 作业

方法(HTTP)路由入参data 出参要点
GetList(GET)/homework/getListstatus、page、pageSize分页 {items:[{id,title,start_time,end_time,status,problem_count,my_score}]}仅已发布(含已结束);my_score 由 homework_score 汇总
GetInfo(GET)/homework/getInfoid作业字段 + problems:[{display_id,problem_id,title,my_score,my_submit_count,best_submission_id}]截止后只读
GetMyScore(GET)/homework/getMyScorehomework_id{total,detail:[{problem_id,score,submit_count,first_ac_at,best_submission_id}]}个人成绩详情

6.3.5 rank.go —— 排名与统计

方法(HTTP)路由入参data 出参要点
GetRank(GET)/rank/getRankpage、pageSize分页 {items:[{rank_no,user_id,name,solved_count,submit_count}]}按 user_problem 聚合
GetMyStat(GET)/rank/getMyStat-{solved_count,submit_count,ac_rate,by_difficulty:[{difficulty,solved,total}]}用 uid 统计

6.4 judge 模块(gRPC + 调度器)实现规格

6.4.0 服务实现分工

proto 服务/方法实现方平台侧文件用途
register.v1.RegisterService/Register平台(gRPC 服务端)app/judge/grpc_server.gorsjudge 启动注册;验令牌、更新节点信息、返回节点 id
cases.v1.CasesService/FetchCases平台(gRPC 服务端)app/judge/grpc_server.go按 problem_id 打包测试点(zstd tarball)流式下发(响应 metadata 带 cases_id)
judge.v1.JudgeService/Submitrsjudge(平台为客户端)app/judge/dispatch.go调度器派发判题,流式接收事件
judge.v1.JudgeService/SelfTestrsjudge(平台为客户端)二期“自测运行”(一期不做)

启动方式(app/judge 包提供 Start,由路由初始化处调用):

// app/judge/grpc_server.go
func Start(grpcPort int) {
    lis, err := net.Listen("tcp", fmt.Sprintf(":%d", grpcPort))
    if err != nil { gf.Log().Error(err); return }
    s := grpc.NewServer(
        grpc.UnaryInterceptor(tokenInterceptor),   // Register 鉴权
        grpc.StreamInterceptor(tokenStreamInterceptor), // FetchCases 鉴权
    )
    pb.RegisterRegisterServiceServer(s, &registerServer{})
    pb.RegisterCasesServiceServer(s, &casesServer{})
    go s.Serve(lis)
    go StartDispatcher()  // dispatch.go:Redis Stream 消费 + 租约扫描(6.5)
    go StartNodeMonitor() // registry.go:节点掉线扫描(last_active 超时置离线)
}
  • gRPC 端口独立于 HTTP(如 grpc_port = 50061,写入 resource/config/app.conf 并在 appcfg 读取);
  • 生产环境启用 TLS(grpc.Creds)可选,内网/同安全区部署时允许明文。

6.4.1 proto 生成与依赖

  • gofly/go.mod 新增:google.golang.org/grpc、google.golang.org/protobuf;
  • 源 proto:judge/rsjudge/crates/rsjudge-grpc/proto/(register/v1、judge/v1、cases/v1 三组);生成到 gofly/app/judge/pb/:
cd gofly/app/judge
protoc -I ../../../judge/rsjudge/crates/rsjudge-grpc/proto \
  --go_out=pb --go_opt=paths=source_relative \
  --go-grpc_out=pb --go-grpc_opt=paths=source_relative \
  register/v1/register.proto judge/v1/judge.proto cases/v1/cases.proto
  • 上游 proto 未声明 go_package:生成时用 --go_opt=Mregister/v1/register.proto=gofly/app/judge/pb/register/v1(judge/cases 同理),或维护一份补了 option go_package 的 proto 副本(如 gofly/app/judge/proto_src/),后续以上游为准同步;
  • 生成物 pb/**/*.pb.go 不手改;依赖 protoc、protoc-gen-go、protoc-gen-go-grpc(版本记入部署手册)。

6.4.2 RegisterService 实现(grpc_server.go + registry.go)

请求字段(register.proto):languages:[LanguageDecl]、port、system_info。处理流程:

  1. 拦截器校验 metadata x-node-token(sha256 比对 node_token_hash);失败返回 Unauthenticated 并记审计日志;
  2. 由令牌定位预注册节点记录;grpc_addr = peer_ip + ":" + req.port(供后续 Submit 连接);
  3. 更新节点:languages = JSON(req.Languages)(含 configs 声明)、system_info = req.SystemInfo(尽力解析 JSON 提取 version/cpu_cores,解析失败则留原值)、status=1(在线)、last_active=now;
  4. 返回 RegisterResponse{id: 节点表ID}。
节点重启后会重新注册(幂等 upsert);平台 Create 预注册时生成令牌,节点配置中携带(见 4.7 鉴权方案)。

6.4.3 CasesService.FetchCases 实现(测试点打包下发)

  1. 拦截器同 6.4.2;由 problem_id 读题目(不存在返回 NotFound),取 case_version;
  2. 读 enabled 测试点列表(按 case_index 排序),从存储读每个 .in/.ans(本地/OSS);
  3. 内存打包 tar(结构见 4.7:manifest.json + cases/{index}.in|.ans + spj/checker.{ext}),zstd 压缩;
  4. grpc.SetHeader(ctx, metadata.Pairs("cases_id", strconv.Itoa(caseVersion)));
  5. 按 ≤ 1MiB 分块 Send(FetchCasesResponse{chunk});发送失败即终止(节点会重取);
  6. 平台侧缓存(可选):Redis oj:cases:pkg:{problem_id}:{case_version} 存整包,减少重复打包/读存储。

6.4.4 Submit 调度客户端(dispatch.go)

// 关键:调用节点 Submit,流式消费事件
stream, err := client.Submit(ctx, &judgepb.SubmitRequest{
    Language:  &judgepb.Language{Name: lang.JudgeName, Configs: parseOptions(lang.Options)},
    Code:      code,
    ProblemId: int32(problemID),
    CasesId:   int32(caseVersion),
})
for {
    resp, err := stream.Recv()
    if err == io.EOF { break }
    if err != nil { /* 节点异常:重派(见 6.5) */ }
    switch ev := resp.SubmitResponseType.(type) {
    case *judgepb.SubmitResponse_CompileStart: // submission.result = COMPILING
    case *judgepb.SubmitResponse_CompileInfo:  // 写 compile_info;exit_status≠0 → CE
    case *judgepb.SubmitResponse_CaseInfo:     // 写 submission_case 明细(按 case_id 对齐 case_index)
    case *judgepb.SubmitResponse_CasesSummary: // 汇总 result/score → 写 submission 终态(6.5)
    }
}
  • 调用超时:租约时长内有效(4.8 公式);超时/断流视为失败 → 重派;
  • 落库幂等:submission_case 按 (submission_id, case_index) upsert;submission 终态用条件更新(WHERE result IN ('PD','COMPILING','RUNNING'))防重复写入;
  • case_info 无耗时/内存字段(上游 CaseInfo 仅 case_id/exit_status/result/score):明细 time_used/memory_used 暂记 0,列为集成对齐项(4.7);
  • CasesSummary.result 枚举 0(UNSPECIFIED) 视为异常,落 SE 并告警。

6.4.5 鉴权拦截器与节点会话

func tokenInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
    md, _ := metadata.FromIncomingContext(ctx)
    token := firstOrEmpty(md.Get("x-node-token")) // 约定 metadata key
    node := registry.VerifyToken(token, peerIP(ctx))
    if node == nil {
        return nil, status.Error(codes.Unauthenticated, "invalid node token")
    }
    return handler(context.WithValue(ctx, nodeKey{}, node), req)
}
  • registry.VerifyToken:内存 map[sha256]node(启动时从 gf_oj_judge_node 加载,Create/ResetToken/删除时刷新);拒绝 status=2(禁用)节点;last_active 节流刷新(最多 30s 一次写库);
  • StartNodeMonitor:每 30s 扫描 status=1 且 last_active 超 90s 的节点置离线(status=0),并触发其未完成任务重派(6.5);
  • 节点规模小(<100)全表扫描足够,不引入额外注册中心。

6.5 调度器流程与事件映射

6.5.1 Redis Stream 队列

项值
Stream Keyoj:judge:stream(全局单流,任务体内带 tenant_id)
消费者组judge-dispatchers(gofly 每实例一个 consumer,name=实例ID)
消息字段task_id(uuid)、submission_id、tenant_id、problem_id、case_version、language_code、priority(0 练习/1 作业/2 比赛)
消费XREADGROUP GROUP judge-dispatchers {consumer} COUNT 1 BLOCK 2000 STREAMS oj:judge:stream >
补偿每 10s XAUTOCLAIM 认领 pending ≥ 60s 的消息(消费者宕机恢复)
XACK仅在 submission 终态落库后 ACK;重派时 ACK 旧消息并重新 XADD

6.5.2 派发主流程

  1. XREADGROUP 取任务 → 读 submission;已是终态则直接 XACK(幂等丢弃);
  2. 选节点:status=1 AND running_count<max_concurrency,按 (running_count/max_concurrency) ASC, load_avg ASC 取首个;无可用节点 → 留 pending 稍后重试(不 ACK);
  3. 占坑:UPDATE gf_oj_judge_node SET running_count=running_count+1 WHERE id=? AND running_count<max_concurrency(乐观并发保护,失败重选);
  4. 写 gf_oj_judge_task:status=1(已派发)、node_id、lease_expire_at = now + 租约时长(4.8 公式)、dispatched_at;
  5. 调 Submit 流式处理(6.4.4);
  6. 终态:条件更新 submission → 触发事件(6.5.3)→ judge_task.status=3(完成)→ running_count-1 → XACK;
  7. 异常:超时/断流 → judge_task.status=4(重派)、retry_count+1、重新 XADD;retry_count≥3 → status=5(失败)、submission.result=SE。

6.5.3 判题完成事件(副作用)

OnSubmissionFinished(submission)(终态落库后调用,重判同样触发、按 submission_id 幂等):

副作用说明
更新 user_problemsubmit_count+1;AC 且未 solved → solved=1、first_ac_at;非 AC → wrong_count+1
更新 gf_oj_problemsubmit_count+1;首次 AC → ac_count+1
作业成绩homework_id>0:按 score_rule(截止前最高分/末次)更新 homework_score(5.4)
比赛榜单contest_id>0:更新 Redis ZSET 实时榜(ACM 罚时/OI 总分);封榜期内不更新公开榜

6.5.4 租约与重派扫描

  • 扫描器每 15s:gf_oj_judge_task 中 status IN (1,2) AND lease_expire_at < NOW() → status=4、节点 running_count-1、重新 XADD(retry_count+1);
  • 节点离线(6.4.5 监控):其名下 status IN (1,2) 的任务立即触发重派。

6.6 前端文件清单

6.6.1 web 平台管理端(Vue3 + Arco Design)

文件说明
src/api/oj/problem.ts题库/测试点/SPJ/FPS API(6.2.1)
src/api/oj/tag.ts、src/api/oj/language.ts标签、语言 API
src/api/oj/contest.ts比赛 API(编排/审核/封榜/固化)
src/api/oj/homework.ts作业 API(成绩/导出)
src/api/oj/submission.ts、src/api/oj/rank.ts提交/重判、总榜 API
src/api/oj/judgeNode.ts节点管理 API
src/views/oj/problem/index.vue题目列表(筛选/上下架/导入导出入口)
src/views/oj/problem/edit.vue题目编辑(Markdown + 样例 + SPJ,复用 codemirror)
src/views/oj/problem/cases.vue测试点管理(zip 上传、行内编辑、预测试点/分值)
src/views/oj/contest/index.vue、edit.vue、problems.vue、users.vue、rank.vue比赛列表/编辑/编排/报名审核/榜单
src/views/oj/homework/index.vue、edit.vue、scores.vue作业列表/编辑/成绩单
src/views/oj/submission/index.vue提交记录(筛选/代码查看/重判)
src/views/oj/rank/index.vue总榜
src/views/oj/judgeNode/index.vue节点监控(状态/负载/在途任务)
src/views/oj/language/index.vue语言配置(对照节点上报)
web 为服务端菜单驱动(fetchServerMenuConfig 动态引入组件):新页面在 gofly「系统管理 → 菜单管理」配置,组件路径指向 views/oj/...,权限码与 6.2 的 oj:* 一致。

6.6.2 app 学生 H5(Vue3 + Vant)

文件说明
src/api/oj.ts学生端 OJ API(6.3)
src/views/oj/problem/index.vue题目列表(标签/难度/我的状态)
src/views/oj/problem/detail.vue题目详情 + 语言选择 + 代码编辑提交(轻量编辑器)
src/views/oj/submission/index.vue、detail.vue我的提交(2s 轮询状态)
src/views/oj/contest/index.vue、detail.vue、rank.vue比赛列表/详情(报名)/榜单
src/views/oj/homework/index.vue、detail.vue我的作业与成绩
src/views/oj/rank/index.vue总榜与我的统计
app 为静态路由:在 app/src/router/index.ts 的 Layout children 中追加上述页面路由(与现有 home/mycourse/profile 并列);底部导航新增「OJ」入口。

6.6.3 saas 租户端

一期无改动;二期复制 web 的 OJ 管理页面挂到租户端,接口前缀切为 /api/tenant/oj/*。

6.7 Redis Key 规范

Key类型用途TTL
oj:judge:streamStream判题任务队列(6.5.1)无
oj:rate:submit:{user_id}String提交限流(SET NX EX 10)10s
tenant:{tid}:rank:contest:{cid}ZSET比赛实时榜(member=user_id,score=排名分)赛后 7d
tenant:{tid}:rank:contest:{cid}:detailHash榜单明细(user_id → 逐题 JSON)同上
oj:cases:pkg:{problem_id}:{case_version}String测试点整包缓存(6.4.3)24h

七、安全设计

7.1 判题安全

  • 沙箱防御(见 4.4):seccomp-BPF 白名单阻断 socket/ptrace/mount 等危险调用、capabilities 管控、低权限用户运行、rlimit 限制资源;
  • 判题节点运行在独立主机/安全区:出站仅访问平台 gRPC(注册/拉取测试点),入站仅开放判题端口供平台调度器调用(Submit),通信可启 TLS;
  • 节点令牌(node_token)仅存储哈希比对,泄露可即时重置;
  • 测试数据不落用户侧:输出比对全程在判题机本地完成,平台仅存每测试点结果,不回传完整输出(防测试点泄漏);
  • 提交源码在沙箱外只做存储与展示,判题机仅接收本次任务所需内容。

7.2 平台与业务安全

风险对策
恶意代码洪水提交提交限流(用户级 10s/次、题目级并发上限),复用 tollbooth 限流
超大源码代码长度上限 64KB(租户可配)
比赛作弊封榜、提交 IP 记录、(二期)代码相似度查重
数据越权所有查询强制 tenant_id 过滤;学生仅能查看自己的提交源码(比赛结束后可见他人)
重判滥用重判接口仅教师/管理员可用,批量重判走队列限速

7.3 鉴权体系

对象方式
学生/教师/管理员复用现有 JWT(useToken 中间件),学生走 student 模块免 RBAC
判题机节点gRPC metadata 携带接入令牌(judge 模块 gRPC 拦截器校验,令牌仅存哈希比对)
测试点下发平台存储于 OSS 私有桶/本地磁盘,经 CasesService 以 zstd 流式下发(节点侧不直连存储、不暴露公网直链)

八、部署方案

8.1 开发环境(Windows)

  • gofly 后端与前端(web/saas/app)在 Windows 直接开发;
  • rsjudge 仅支持 Linux(seccomp 等内核特性),开发调试使用 WSL2(Ubuntu) 或 Docker Desktop(WSL2 后端),推荐 WSL2 内直跑:

    1. WSL2 Ubuntu 安装语言工具链(gcc/g++、python3、openjdk、golang、rust、nodejs)并构建/运行 rsjudge(judge/rsjudge/,--config-dir 指向配置目录);
    2. 配置 rsjudge 的平台接入地址指向 Windows 侧 gofly 的 gRPC 端口(<windows-ip>:端口);
    3. 本地 MySQL/Redis 可用 Docker Compose 或复用现有开发库。

8.2 生产拓扑

节点规格建议说明
gofly 应用2C4G × 2(Nginx 负载)无状态,可水平扩展
MySQL4C8G,主从业务数据
Redis2C4G队列/榜单
rsjudge 判题节点4C8G 起独立安全区:出站访问平台 gRPC(注册/测试点),入站开放判题端口;按提交量扩容
存储OSS 私有桶 / 本地磁盘测试点、源码归档

8.3 rsjudge 配置形态(上游 config-demo)

rsjudge 从配置目录加载三份 TOML(--config-dir,默认 ./config,完整样例见 judge/rsjudge/config-demo/):

# services.toml —— 服务开关与监听(存在对应节即启用该服务)
[grpc]
listen = ["0.0.0.0:50051"]

# executors.toml —— 语言定义(节选 C++,命令模板 + 编译选项)
["C++"]
version = "$(g++ --version | head -n1)"
exec_type = "binary"
compile = "g++ {...flags} -fmax-errors=10 -lm -Wall -o {out_file} {src_file}"

["C++".options.version.variants]
"C++17" = "-std=c++17"

# seccomp.toml —— 系统调用过滤(默认拒绝 + 白名单)
[defaultAction]
action = "SCMP_ACT_ERRNO"
errnoRet = 38
平台接入参数(后端 gRPC 地址、接入令牌)以 rsjudge 实际版本的 CLI/配置项为准(上游仍在演进,接入前对照其 README 与 src/cli.rs)。

九、开发计划与里程碑

9.1 估算口径与开发模式

本计划按 AI 辅助开发(代码由 AI 批量生成 + 人工验证调试)模式估算,与原“单人传统开发”的区别:

  • AI 承担:样板代码批量生成(建表 SQL、dao、CRUD 接口、前端页面、gRPC 骨架与协议实现、FPS 解析等);
  • 人力承担:环境搭建、rsjudge 部署与配置、gRPC 联调、流式事件验证、资源测量校准、安全演练与压测——依赖真实执行与现象判断,无法被代码生成替代;
  • 压缩边界:“写代码”可压缩 70~90%;“跑起来、验证对”(rsjudge 集成与联调)压缩有限,且受上游 Submit/SelfTest 进度约束。

9.2 分批交付计划

批次一:平台骨架与提交链路(AI 批量生成,约 1~2 天)

序号任务涉及交付物
1建表 SQL(gf_oj_* 18 张,统一 doc/sql/oj.sql)+ gorm/gen 生成 model 与 querydoc/sql、gofly/dao/表可迁移、dao 可用
2Redis Stream 队列封装(投递/消费/租约/重派扫描器)gofly/utils 或 app/judge任务可投递可消费
3judge 模块 gRPC 服务端骨架(Register/Cases)+ 令牌拦截器;节点注册落库与调度派发器app/judge依 proto 可编译的骨架
4admin(平台管理端)题目 CRUD、测试点上传/管理、FPS 导入导出app/admin/oj题库管理可用
5student 提交接口(校验+限流+落库+入队)、提交列表/详情(状态轮询)app/student/oj提交可入库排队
6web 题目管理页面(含测试点管理)web/src/views管理端页面可用

批次二:rsjudge 集成与端到端联调(关键路径,约 4~5 天,依赖上游进度)

序号任务涉及说明
7rsjudge 部署与配置(WSL2/容器):工具链、services/executors/seccomp 三份配置judge/rsjudge/人工部署,AI 辅助配置与排查
8平台侧 gRPC 实现:Register/Cases 服务端调通、令牌拦截器;调度器 Submit 流式客户端app/judgeAI 生成 + 联调修正;全计划关键路径
9判题链路端到端(C/C++/Python3):语言配置对齐、编译/运行/比对核实联调人工执行(WSL2 ↔ Windows gofly)
10流式事件落库与状态机(编译中/运行中/明细/汇总)、幂等处理app/judge、app/student/ojAI 生成 + 幂等验证
11异常路径联调:超时重派、节点异常、重判联调人工构造异常场景
注:rsjudge 当前为早期版本,Submit/SelfTest 服务端尚未实现。若上游实现滞后,批次二先完成“节点注册 + 测试点下发 + 调度与展示”链路,判题执行能力随上游补齐(见十章风险),平台侧不受阻塞。

批次三:业务模块完善(AI 批量生成,约 2~3 天)

序号任务说明
12Java/Go/Rust/Node 语言接入(含倍率与内存注入)配置化生成,逐语言验证
13SPJ 支持(编译与驱动协议)、OLE/PE 判定AI 生成 + 样例验证
14admin/oj/contest.go:CRUD、题目编排、报名审核、封榜/解封/固化(表/dao 已在批次一就绪)AI 批量生成
15榜单引擎(Redis ZSET 实时榜 + 封榜 + 赛后固化);student/oj/contest.go:比赛列表/详情/报名/榜单逻辑复杂,生成后重点验证封榜口径
16admin/oj/homework.go(CRUD/选题/成绩/导出)、student/oj/homework.go(我的作业/详情/成绩);成绩固化与计分规则校验AI 批量生成
17web 比赛管理/报名审核/榜单页、作业管理/成绩页;app 比赛列表/详情/榜单页、我的作业/作业详情页、H5 学生端完善(题目列表/详情/提交/记录/个人统计)AI 批量生成
18判题节点监控页(web)、判题任务日志查询、重判(单条/批量)、提交统计面板AI 批量生成

批次四:验收、安全与加固(执行类工作,约 2~3 天,压缩空间小)

序号任务说明
19资源测量校准(与 HUSTOJ 对比误差)、极限用例测试必须实测,AI 辅助生成对比脚本
20沙箱逃逸演练(fork 炸弹/联网/提权/写宿主)、修复必须实测并人工判断
21并发压测(≥100 并发提交)、故障恢复演练(杀节点/断租约)必须实测
22文档:部署手册、判题机运维手册AI 生成初稿 + 人工修订

总计:约 2~3 周(AI 辅助估算);批次二为关键路径,其时长受 rsjudge 上游 Submit/SelfTest 进度约束。

MVP 范围(约 1~1.5 周):批次一 + 批次二 + C/C++/Python3 语言集,可支撑“题库 + 提交 + 标准判题 + 练习场景”的最小可用版本;上游判题功能可用即达成。

9.3 人机分工与加速预期

工作类型AI 负责人工负责相对传统估算
样板代码(SQL/dao/CRUD/前端页面/gRPC 骨架)批量生成审查、接入、调通压缩 70~90%
rsjudge 集成(部署/配置/联调)配置与排查脚本生成、协议问题定位WSL2 实际部署、现象反馈、上游跟踪压缩 30~50%
流式事件与状态机、队列容错生成与幂等自测异常场景构造与验证压缩 50~70%
安全演练与压测用例与脚本生成真实执行、判断结果压缩 20~30%
推进方式:每批次按“AI 生成整体代码 → 人工快速审查 → 真实环境跑起来 → 反馈报错/异常 → AI 迭代修复”循环推进;避免生成后不验证直接上线,也避免手工逐行编写样板代码。

9.4 关键前置条件

  1. Linux 判题环境(WSL2/Docker/物理机)与各语言工具链版本冻结;
  2. OSS 私有桶(或本地存储方案)与测试点存储结构确认;
  3. HUSTOJ 存量题库(如有)准备用于 FPS 导入验证;
  4. 语言倍率、输出上限等默认参数评审;
  5. 作业/竞赛计分口径评审:截止判定、补交策略、封榜与重判交互;
  6. rsjudge 版本基线:锁定 tag/commit fork 维护(judge/rsjudge);Submit/SelfTest 与 register/cases 客户端已就地补全并通过 WSL2 编译/单测验证(9.5 第 8 批);上游持续跟踪,冲突时择机回馈上游(见十章风险)。

9.5 代码生成执行清单(文件级,按批次)

供 AI 按序生成;生成顺序即依赖顺序,每批产出可独立编译/验证。

第 1 批:建表与 dao(→ 9.2 批次一任务 1)

  1. doc/sql/oj.sql:5.3 全部 18 张表 + 语言初始数据(单文件执行);
  2. 运行 gofly/dao/cmd/main.go 生成 dao/model/oj_*.gen.go、dao/query/oj_*.gen.go(对照 5.5 命名映射核对)。

第 2 批:judge 模块骨架(→ 任务 2/3)

  1. gofly/go.mod 增依赖;生成 app/judge/pb/**(6.4.1);
  2. app/judge/registry.go:令牌缓存、VerifyToken、StartNodeMonitor(6.4.5);
  3. app/judge/grpc_server.go:Start、tokenInterceptor、registerServer、casesServer(6.4.2/6.4.3);
  4. app/judge/dispatch.go:队列入队/消费/补偿 + 派发主流程 + OnSubmissionFinished + 租约扫描(6.5);
  5. 挂载:utils/router/router.go 初始化尾部调用 judge.Start(grpcPort)(端口读 appcfg)。

第 3 批:admin OJ 接口(→ 任务 4)
app/admin/oj/:problem.go(含 FPS 导入导出、zip 测试点)、tag.go、language.go、contest.go、homework.go、submission.go、rank.go、judgeNode.go(6.2 全部);app/admin/controller.go 追加 _ "gofly/app/admin/oj"。

第 4 批:student OJ 接口(→ 任务 5)
app/student/oj/:problem.go、submission.go(提交校验+限流+入队)、contest.go、homework.go、rank.go(6.3 全部);app/student/controller.go 追加 import。

第 5 批:web 前端(→ 任务 6 及批次三 web 页)
web/src/api/oj/*.ts(7 个)+ web/src/views/oj/**(6.6.1 全部);菜单与权限码在 gofly 后台配置。

第 6 批:app 前端(→ 任务 17)
app/src/api/oj.ts + app/src/views/oj/**(6.6.2)+ 路由追加。

第 7 批:联调修复(人工驱动,→ 批次二/四)
WSL2 部署 rsjudge(8.1)→ 平台预注册节点 → 验证 Register/FetchCases → C++ 首个 Submit 端到端 → 逐语言验证(批次三任务 12)→ 异常路径(重派/重判/离线)。

第 8 批:判题机侧代码生成(AI 生成,已完成;第 7 批联调的前置)

在 judge/rsjudge(上游 0.1.0 骨架,fork 维护)就地补全,不改 proto:

  1. 配置层:LanguageDef 增 src_ext/src_rename/out_rename/out_extension 与 getter;GrpcConfig 增 platform_addr/node_token/cases_cache_dir/work_dir;config-demo 两文件补示例;
  2. 判题核心(crates/rsjudge-judger/src/judger/):template.rs({src_file}/{out_file}/{tmp_dir}/{...flags} 渲染与 options→flags 合成)、manifest.rs(manifest 解析、zstd tar 解包、防路径穿越、原子落盘)、sandbox.rs(builder/runner 降权 + seccomp + rlimit + rusage 沙箱执行封装)、flow.rs(Submit/SelfTest 流程、classify_run 判定口径与汇总);
  3. gRPC 层(crates/rsjudge-grpc/src/):client/register.rs(语言声明含 Bool/Enum 配置、端口、sysinfo JSON、x-node-token、30s 心跳与退避重试)、client/cases.rs(FetchCases 流式收集、cases_id metadata 校验、(problem_id, cases_id) 缓存与临时目录原子安装)、server.rs(Submit/SelfTest 事件流实现)、lib.rs(多地址监听 + 启动注册任务);
  4. 入口:src/lib.rs 在存在 [grpc] 配置时启动 gRPC 服务;
  5. 验证(WSL2,rustc 1.98.1 / buf 1.73.0 / protoc 3.21.12):cargo fmt 干净、cargo check --workspace 通过(0 warning)、cargo test -p rsjudge-judger -p rsjudge-grpc 52 项全绿(43+9);
  6. 边界:SPJ(judge_mode=1)一期不实现、遇之报错;真实沙箱行为(降权/seccomp/rusage 判定)与端到端联调归第 7 批人工验证。

十、风险与对策

风险等级对策
rsjudge 上游实现进度依赖高Submit/SelfTest 上游尚未实现:锁定版本持续跟踪;平台侧对接(注册/测试点/调度)先行;必要时评估在 rsjudge 基础上补全(回馈上游或 fork 维护)
沙箱逃逸漏洞高多层防御(seccomp-BPF 白名单 + capabilities + rlimit + 降权用户);seccomp 配置按部署评审;上线前专项渗透演练;判题机独立安全区部署
内核/发行版差异导致沙箱特性不可用中部署前核查(内核 ≥ 4.5、seccomp 可用);WSL2/容器环境内核与参数固定并写入部署手册
判题精度与 HUSTOJ 不一致中资源测量校准阶段专项对比;语言倍率可调
判题节点资源被单任务拖垮中并发上限 + 编译/运行双重超时 + 输出限流
测试点下发带宽瓶颈低按 cases_id 版本缓存;平台与判题节点内网/同区域部署;zstd 压缩分块传输
Windows 开发体验割裂低WSL2 统一 Linux 判题环境;提供 docker-compose 一键开发环境
比赛期间判题排队积压中紧急预案:临时扩容节点;队列优先级(比赛任务优先/预测试点快速反馈)
封榜一致性中公开榜/最终榜口径差异;榜单引擎单出口生成,快照 is_final 标记区分
截止时间边界低“截止前提交、截止后判完”场景;计分以提交时间(created_at)为准
重判幂等低重判后作业成绩/榜单重算,按 submission_id 幂等更新

十一、附录

11.1 判题结果码映射(导入 HUSTOJ 数据用)

HUSTOJ 数字码含义rsjudge 枚举
0 / 1等待 / 等待重判PD
2编译中COMPILING
3运行中RUNNING
4正确AC
5格式错误PE
6答案错误WA
7时间超限TLE
8内存超限MLE
9输出超限OLE
10运行错误RE
11编译错误CE
12+扩展状态(测试运行/取消等版本差异)导入时统一映射为对应枚举或 SE
具体映射以导入数据实际版本为准,导入脚本中做归一化处理。

11.2 HUSTOJ 题库导入导出(FPS 格式)

  • FPS(freeproblemset)为 HUSTOJ 的 XML 题库格式,含 <item>(题目)+ <test_input>/<test_output>(测试点)+ spj 标识;
  • 本方案在 admin 端(web 平台管理)提供「FPS 导入/导出」:导入时解析 XML → 写 gf_oj_problem + gf_oj_problem_case(测试点落存储);导出反之;
  • 兼容后可双向迁移,便于存量 HUSTOJ 站点平滑切换。

FPS 报文示例(单题,含测试点):

<?xml version="1.0" encoding="UTF-8"?>
<fps version="1.2" url="http://hustoj.example.com/">
  <generator name="HUSTOJ"/>
  <item>
    <title>A+B Problem</title>
    <time_limit unit="s">1</time_limit>
    <memory_limit unit="mb">256</memory_limit>
    <description><![CDATA[计算 a + b。]]></description>
    <input><![CDATA[两个整数 a、b,空格分隔。]]></input>
    <output><![CDATA[输出 a + b 的值。]]></output>
    <sample_input><![CDATA[1 2]]></sample_input>
    <sample_output><![CDATA[3]]></sample_output>
    <test_input><![CDATA[1 2]]></test_input>
    <test_output><![CDATA[3]]></test_output>
    <test_input><![CDATA[100 -7]]></test_input>
    <test_output><![CDATA[93]]></test_output>
    <hint><![CDATA[]]></hint>
    <source>经典例题</source>
  </item>
</fps>

导入映射(对应 6.2.1 的 ImportFPS):

FPS 节点入库位置 / 转换
titlegf_oj_problem.title;重复时 skipped 计数
time_limit(s)×1000 → time_limit(ms)
memory_limit(mb)直接对应 memory_limit(mb),缺省 256;output_limit 不可导入,缺省 16MB
description / input / output / hint / source同名文本字段
sample_input + sample_output合并为 samples JSON [{"input":"...","output":"..."}]
成对的 test_input / test_output按出现顺序写 gf_oj_problem_case(case_index 从 1 递增,score 均分或默认 10)
spj 标记(如 <spj>1</spj>)judge_mode=1;SPJ 代码无法从 FPS 携入,需导入后另行上传(备注在导入报告中提示)

11.3 测试点包(cases tarball)结构与 manifest 示例

平台经 CasesService.FetchCases(6.4.3)下发的 zstd tarball 内部结构:

problem.tar.zst(zstd 压缩的 tar)
├── manifest.json
├── cases/
│   ├── 1.in
│   ├── 1.ans
│   ├── 2.in
│   └── 2.ans
└── spj/                  # 仅 judge_mode=1 时存在
    └── checker.cpp       # 文件名与扩展名按 spj_lang 确定

manifest.json 示例:

{
  "problem_id": 1024,
  "cases_id": 3,
  "judge_mode": 0,
  "time_limit": 1000,
  "memory_limit": 256,
  "output_limit": 16,
  "cases": [
    { "case_id": 1, "score": 10, "input": "cases/1.in", "output": "cases/1.ans" },
    { "case_id": 2, "score": 10, "input": "cases/2.in", "output": "cases/2.ans" }
  ],
  "spj": null
}
要点:cases_id 必须与响应 metadata 一致(节点按该 id 缓存,题面缓存失效靠 6.4.3 的版本递增);节点侧 manifest 解析已按本节结构实现(9.5 第 8 批,字段:problem_id/cases_id/judge_mode/time_limit(ms)/memory_limit(MB)/output_limit(MB)/cases[{case_id,score,input,output}]/spj{lang,path}),接入时以实际报文对齐。

11.4 参考资料

发表评论