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 关键设计取舍
判题机为什么选用 rsjudge(现成开源)而不用 HUSTOJ 判题机
- HUSTOJ 判题机依赖 ptrace 系统调用白名单,内核版本升级需维护
okcalls.h,维护成本高; - DB 轮询模式给数据库带来无谓压力,任务无法精细调度;
- C++ 实现存在内存安全风险,判题机本身是高危组件;
- rsjudge 是现成的 Rust 开源项目(Apache-2.0,受 go-judge 启发),seccomp-BPF + capabilities + rlimit 的沙箱设计内建,并原生提供 gRPC 接口(节点注册 / 提交判题 / 测试点下发),无需自行开发判题机。
- HUSTOJ 判题机依赖 ptrace 系统调用白名单,内核版本升级需维护
为什么判题机与平台通过专用接口交互(而非直连数据库/Redis)
- 判题节点可部署在独立安全区,只与平台交互,不暴露数据库凭证;
- rsjudge 原生以 gRPC 与后端平台通信(注册、拉取测试点、回传判题结果),平台侧统一收口业务逻辑(统计、榜单、幂等),判题机保持无状态、可随意增删。
- 为什么兼容 FPS 格式:可直接迁移存量 HUSTOJ 题库,降低建库成本。
- 为什么复用多租户模型:租户(学校)之间的题库、比赛、作业、提交天然隔离,复用现有域名识别与
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 技术选型
| 层 | 组件 | 技术 | 说明 |
|---|---|---|---|
| 前端-平台管理 | web | Vue3 + Arco Design + Vite(现有) | 新增 OJ 管理菜单与页面 |
| 前端-租户运营 | saas | Vue3 + Arco Design + Vite(现有) | 一期无 OJ 改动(自助管理为二期预留) |
| 前端-学生端 | app | Vue3 + Vant H5(现有) | 新增做题/提交/榜单页面 |
| 后端 | gofly | Go 1.25 + Gin + GORM + gorm/gen | 新增 oj 业务模块与 judge 调度模块 |
| 数据库 | MySQL 8.0 | 新表 gf_oj_* | 沿用 tenant_id 隔离、软删除规范 |
| 缓存/队列 | Redis 7 | Stream 任务队列、榜单缓存 | 复用 go-redis/v9 |
| 文件存储 | 本地/OSS/七牛/COS | 测试点、源码备份 | 复用现有 SDK 与上传配置 |
| 判题机 | rsjudge | Rust(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)。
与现有中间件的集成注意点:
judge模块的 gRPC 服务(节点注册/测试点下发)监听独立端口,不经过 HTTP 路由与TenantResolver/useToken中间件,鉴权在 gRPC 拦截器中完成;- 判题节点为平台级资源,接口不区分租户;
/api/admin/oj/judgeNode/*管理接口照常走 admin JWT; - 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 核心业务流程(提交判题)
- 学生在
app/H5 选择语言并提交代码; student/oj校验(登录态、比赛时间、提交频率、代码长度)后写入gf_oj_submission(result=PD)+gf_oj_submission_code;- 平台将任务
XADD到 Redis Streamoj:judge:queue,同时写入gf_oj_judge_task(status=排队),调度器选取空闲节点准备派发; - 调度器以 gRPC 调用 rsjudge 的
JudgeService.Submit(语言、源码、problem_id、cases_id),并流式接收判题事件; - rsjudge 按
cases_id经平台CasesService.FetchCases拉取测试点包(zstd 分块,版本变化自动获取最新)→ 沙箱编译 → 逐测试点运行 → 比对(标准比对/SPJ); - rsjudge 流式回传:编译开始 → 编译信息 → 逐测试点结果 → 汇总(
cases_summary);平台据流式事件更新"编译中/运行中"状态与明细; - 平台事务更新
gf_oj_submission/gf_oj_submission_case/ 统计表;比赛提交触发榜单缓存刷新; - 学生端轮询提交状态(1~2s)展示:等待→编译中→运行中→最终结果(二期可加 SSE 推送);
- 超时未完成的任务由平台扫描器重派(最多 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“我的作业”中列出(含截止倒计时、完成进度、总分);
- 成绩单按学生维度汇总(每题得分 + 总分),管理员可导出;
- 未发布作业对学生不可见;不在时间窗内(未开始/已结束)不可提交(服务端强制校验);
- 扩展预留:若后续需要限定参与范围(班级/指定名单),在作业维度增加“参与名单”即可,不影响本设计主体。
作业流程:
- 教师(web)创建作业:从题库选题 → 配置每题分值与计分规则 → 设定起止时间 → 发布(
status=1); - 学生(app):已发布作业在“我的作业”中列出,可查看题目列表与我的得分/状态;
- 学生提交:复用 OJ 提交判题链路,
gf_oj_submission.homework_id标记来源;校验作业已发布、当前时间在 [start_time, end_time] 内、提交限流; 判题完成:更新
gf_oj_homework_score:- 计分规则 0(默认):取截止前最高分(
score = max(score, 本次得分)); - 计分规则 1:末次提交得分覆盖;
- 同时维护
submit_count、first_ac_at;
- 计分规则 0(默认):取截止前最高分(
- 截止(或教师手动结束):作业置
status=2;成绩单固化可导出;截止后提交不计分(可选配置为直接拒绝提交)。
3.6 竞赛模块设计
比赛流程:
- 教师(web)创建比赛:赛制(ACM/OI)、起止时间、报名方式(公开/密码/私有)、封榜分钟 → 编排题目 → 发布;
- 学生(app):公开赛直接进入;密码赛输入密码通过后进入;私有赛提交报名 → 教师审核通过后进入;
- 赛内提交:
contest_id标记来源;校验比赛进行中(开始前/结束后拒绝提交); 判题完成 → 榜单引擎刷新 Redis 实时榜(Key:
tenant:0:rank:contest:{cid}):- ACM:通过数降序、罚时升序(罚时 = 首次 AC 用时 + 20min × AC 前错误次数);
- OI:各题最高分(或末次)求和降序;
- 封榜:
end_time - freeze_minutes之后的提交不计入公开榜,仅参与最终榜计算;
- 比赛结束:自动解封,固化最终快照至
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 | 服务与任务并发 |
| gRPC | tonic + protobuf(proto:register / judge / cases) | 与平台的主交互通道 |
| REST | axum(当前为骨架,feature 可启停) | 预留 |
| AMQP | RabbitMQ(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_CPU | time_limit + 2s | CPU 时间硬杀兜底 |
| RLIMIT_AS | memory_limit + 64MB | 地址空间(解释器类语言单独放宽) |
| RLIMIT_STACK | 16MB(可配) | 栈溢出→RE |
| RLIMIT_FSIZE | output_limit | 写出文件上限 |
| RLIMIT_NPROC | 64 | 进程数 |
| RLIMIT_NOFILE | 128 | 文件描述符 |
| RLIMIT_CORE | 0 | 禁止 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) | 时间倍率 | 内存倍率 |
|---|---|---|---|---|---|---|
| c | C11 | .c | C | {"O2":true,"version":"C11"} | 1.0 | 1.0 |
| cpp17 | C++17 | .cpp | C++ | {"O2":true,"version":"C++17"} | 1.0 | 1.0 |
| java | Java 17 | .java | Java | {} | 2.0 | 2.0 |
| python3 | Python 3 | .py | Python | {} | 3.0 | 2.0 |
| go | Go | .go | Go | {} | 1.5 | 1.0 |
| rust | Rust | .rs | Rust | {} | 1.0 | 1.0 |
| node | Node.js | .js | JavaScript | {} | 2.0 | 2.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.Register | rsjudge → 平台 | 平台 | 节点启动注册:上报支持语言(名称 + 可配置项声明)、服务端口、系统信息;平台返回节点 id(落 gf_oj_judge_node) |
judge.v1.JudgeService.Submit / SelfTest | 平台 → rsjudge | rsjudge | 提交判题 / 自测:语言、源码、problem_id、cases_id;流式返回:编译开始 → 编译信息 → 逐测试点结果 → 汇总 |
cases.v1.CasesService.FetchCases | rsjudge → 平台 | 平台 | 按 problem_id 拉取测试点包(zstd 压缩 tarball 分块,每块 < 1MiB,metadata 携带 cases_id 版本) |
Register 消息(平台接收):
| 字段 | 类型 | 说明 |
|---|---|---|
languages | repeated LanguageDecl | name(rsjudge 语言名)+ configs(可配置项:boolean 型或 enum 型 + variants 列表) |
port | int32 | 节点 gRPC 监听端口;平台结合连接 peer IP 组装 grpc_addr = <peer_ip>:<port> 存库(后续调度回调地址) |
system_info | string | 系统信息(JSON 文本,落库展示,版本信息从其中解析或单独字段) |
响应 RegisterResponse.id(int32)= 平台节点记录 id。
Submit 消息(平台发送 / 流式接收):
| 方向 | 字段 | 说明 |
|---|---|---|
| 请求 | language(Language:name + configs)、code、problem_id、cases_id | language 按 4.6 映射下发 |
| 响应 oneof | compile_start(Empty) | 编译开始 |
| 响应 oneof | compile_info(exit_status/stdout/stderr) | exit_status≠0 → CE(解释型语言为语义错误) |
| 响应 oneof | case_info(case_id/exit_status/result/score) | 单测试点结果,可能乱序,按 case_id 落库;当前不含耗时/内存字段 |
| 响应 oneof | cases_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_ACCEPTED | 1 | AC |
| JUDGE_RESULT_COMPILE_ERROR | 2 | CE |
| JUDGE_RESULT_WRONG_ANSWER | 3 | WA |
| JUDGE_RESULT_PRESENTATION_ERROR | 4 | PE |
| JUDGE_RESULT_RUNTIME_ERROR | 5 | RE |
| JUDGE_RESULT_TIME_LIMIT_EXCEEDED | 6 | TLE |
| JUDGE_RESULT_MEMORY_LIMIT_EXCEEDED | 7 | MLE |
| JUDGE_RESULT_OUTPUT_LIMIT_EXCEEDED | 8 | OLE |
| JUDGE_RESULT_UNSPECIFIED | 0 | SE(不应出现,按系统错误处理) |
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-tokenmetadata 并 30s 心跳保活,FetchCases 校验响应 metadatacases_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(客户端主动断流,尽力而为) | 不阻塞流程 |
派发流程(调度器循环,多实例安全):
XREADGROUP阻塞领取任务消息(含 task_id、submission_id);查任务表确认 status=0(排队);- 选择目标节点:
status=1(在线)∧running_count < max_concurrency∧ languages JSON 含所需 judge_name;多候选取running_count最小、再按load_avg最小; - 无可用节点:不 XACK(消息留在 PEL),由 6.5.1 补偿机制(XAUTOCLAIM)稍后重取;
- 调用节点
JudgeService.Submit(4.7):置任务 status=1、node_id、lease_expire_at、dispatched_at;node.running_count+1;node.last_active=now; - 流式事件按 6.5 处理表更新 submission/submission_case;
- 收到
cases_summary:事务更新 submission 终态与任务 status=3(完成)+ XACK;node.running_count-1;触发统计/榜单/作业计分(6.5 后置处理); - 流中断/超时/节点报错:任务保持 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_problem | model.OjProblem | OjProblem |
gf_oj_problem_case | model.OjProblemCase | OjProblemCase |
gf_oj_tag | model.OjTag | OjTag |
gf_oj_problem_tag | model.OjProblemTag | OjProblemTag |
gf_oj_submission | model.OjSubmission | OjSubmission |
gf_oj_submission_code | model.OjSubmissionCode | OjSubmissionCode |
gf_oj_submission_case | model.OjSubmissionCase | OjSubmissionCase |
gf_oj_contest | model.OjContest | OjContest |
gf_oj_contest_problem | model.OjContestProblem | OjContestProblem |
gf_oj_contest_user | model.OjContestUser | OjContestUser |
gf_oj_contest_rank | model.OjContestRank | OjContestRank |
gf_oj_homework | model.OjHomework | OjHomework |
gf_oj_homework_problem | model.OjHomeworkProblem | OjHomeworkProblem |
gf_oj_homework_score | model.OjHomeworkScore | OjHomeworkScore |
gf_oj_user_problem | model.OjUserProblem | OjUserProblem |
gf_oj_judge_node | model.OjJudgeNode | OjJudgeNode |
gf_oj_judge_task | model.OjJudgeTask | OjJudgeTask |
gf_oj_language | model.OjLanguage | OjLanguage |
生成的.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 鉴权、上下文与参数
| 端 | 鉴权 | 上下文键 | 说明 |
|---|---|---|---|
| admin | JWT(useToken 中间件)+ RBAC | uid、username、tenant_id | 权限码 oj:{模块}:{动作}(如 oj:problem:save)在角色中配置;仅需登录免权限的方法登记到结构体 NoNeedAuths |
| student | JWT | uid(=gf_student.id)、tenant_id | student 模块免 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/getList | keyword、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/getInfo | id | 题目全字段 + tag_ids:[] + allow_languages:[] | 编辑回显;JSON 文本字段转数组 |
| Save(POST) | /problem/save | id(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/del | ids(逗号分隔) | - | 软删题目;级联软删测试点 |
| UpdateStatus(POST) | /problem/updateStatus | ids、status(0 公开/1 隐藏/2 草稿) | - | 上下架切换 |
| GetCaseList(GET) | /problem/getCaseList | problem_id | {total,items:[{id,case_index,score,is_pretest,enabled,input_size,output_size}]} | 不返回测试点内容 |
| GetCase(GET) | /problem/getCase | problem_id、case_index | {input,output} | 单点内容回显;超 1MB 拒绝直读 |
| SaveCase(POST) | /problem/saveCase | problem_id、case_index(0=追加)、score、is_pretest、enabled、input、output | {id,case_index} | 写存储 + sha256 落库;case_version+1(失效节点缓存) |
| DelCase(DELETE) | /problem/delCase | problem_id、case_index | - | 删存储、重排序号、case_version+1 |
| UploadCases(POST) | /problem/uploadCases | form-data:problem_id、file(zip) | {imported,errors:[]} | zip 内按 1.in / 1.ans 命名;全部成功才入库并 version+1 |
| ImportFPS(POST) | /problem/importFPS | form-data:file(xml) | {imported,skipped,errors:[]} | FPS 解析入库(见 11.2) |
| ExportFPS(GET) | /problem/exportFPS | ids | xml 文件流(blob) | web 端按 blob 下载 |
| CheckSPJ(POST) | /problem/checkSPJ | judge_mode、spj_lang、spj_code | {ok,compile_info} | 可选:SPJ 编译校验 |
存储路径规范:oj/{tenant_id}/{problem_id}/cases/{case_index}.in|.ans、SPJoj/{tenant_id}/{problem_id}/spj/checker.{ext};本地磁盘与 OSS 由现有上传配置决定(复用datacenter/upfile能力)。
6.2.2 tag.go —— 标签管理
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetList(GET) | /tag/getList | keyword、page、pageSize | 分页 {items:[{id,name,color,sort,problem_count}]} | problem_count 二次聚合 |
| Save(POST) | /tag/save | id、name、color、sort | {id} | uk_tenant_name 冲突返回失败提示 |
| Del(DELETE) | /tag/del | id | - | 同步清 problem_tag |
6.2.3 language.go —— 语言配置(平台级,无 tenant_id)
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetList(GET) | /language/getList | - | {items:[语言表全字段]} | 含禁用项;学生端另有过滤接口 |
| Save(POST) | /language/save | id、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/updateStatus | id、enabled | - | 停用后学生端不可选 |
| GetNodeLanguages(GET) | /language/getNodeLanguages | - | {nodes:[{node_id,node_name,status,languages}]} | 对照节点上报语言核对配置 |
6.2.4 contest.go —— 比赛管理
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetList(GET) | /contest/getList | keyword、status、page、pageSize | 分页 {items:[比赛表字段+problem_count]} | status 由 start/end_time 推算落库 |
| GetInfo(GET) | /contest/getInfo | id | 比赛全字段 + problems:[{problem_id,display_id,score,title}] | 编排回显 |
| Save(POST) | /contest/save | id、title、description、rule_type、access_type、password、start_time、end_time、freeze_minutes | {id} | access_type=1 时密码必填;保存后重算 status |
| Del(DELETE) | /contest/del | ids | - | 级联删编排/报名/榜单快照 |
| SaveProblems(POST) | /contest/saveProblems | contest_id、problems(JSON 文本 [{problem_id,display_id,score}]) | - | 覆盖式编排;display_id 重复校验 |
| GetUsers(GET) | /contest/getUsers | contest_id、status、page、pageSize | 分页 {items:[{user_id,student_no,name,status,signed_at}]} | 报名审核列表 |
| AuditUser(POST) | /contest/auditUser | contest_id、user_id、status(1 通过/2 拒绝) | - | 私有赛审核 |
| SetFreeze(POST) | /contest/setFreeze | contest_id、frozen(0 解封/1 封榜) | - | 手动封榜/解封;默认按 end_time-freeze_minutes 自动 |
| Finalize(POST) | /contest/finalize | contest_id | {rows} | 赛后固化最终榜(contest_rank.is_final=1),供导出与统计 |
| GetRank(GET) | /contest/getRank | contest_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/getList | keyword、status、page、pageSize | 分页 {items:[作业表字段+problem_count]} | |
| GetInfo(GET) | /homework/getInfo | id | 作业全字段 + problems:[{problem_id,display_id,score,sort,title}] | 编排回显 |
| Save(POST) | /homework/save | id、title、description、start_time、end_time、score_rule、total_score | {id} | end_time 必填且大于 start_time |
| Del(DELETE) | /homework/del | ids | - | 软删;级联删编排/成绩 |
| Publish(POST) | /homework/publish | id | - | 草稿→发布;校验题目 ≥1 且测试点齐备 |
| Finish(POST) | /homework/finish | id | - | 手动结束;触发成绩固化 |
| SaveProblems(POST) | /homework/saveProblems | homework_id、problems(JSON 文本 [{problem_id,display_id,score,sort}]) | - | 覆盖式编排 |
| GetScores(GET) | /homework/getScores | homework_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/exportScores | homework_id | xlsx 文件流(blob) | 复用 excelize 导出模式(参考现有学生导出) |
6.2.6 submission.go —— 提交记录 / 重判 / 导出
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetList(GET) | /submission/getList | problem_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/getInfo | id | 提交全字段 + code + cases:[{case_index,result,time_used,memory_used,score}] | 源码从 submission_code 读 |
| Rejudge(POST) | /submission/rejudge | ids | {queued} | 仅管理员/教师;按当前 case_version 重新入队;批量需限速 |
| Export(GET) | /submission/export | 同 GetList 筛选参数 | xlsx 文件流(blob) | 成绩导出 |
6.2.7 rank.go —— 总榜与统计
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetRank(GET) | /rank/getRank | page、pageSize | 分页 {items:[{rank_no,user_id,name,solved_count,submit_count,ac_rate}]} | 按 user_problem 聚合 |
| GetUserStat(GET) | /rank/getUserStat | user_id | {solved_count,submit_count,ac_rate,by_difficulty:[{difficulty,solved,total}]} | 用户统计面板 |
6.2.8 judgeNode.go —— 判题节点管理
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetList(GET) | /judgeNode/getList | page、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/create | node_name、max_concurrency | {id,node_token} | 令牌明文仅本响应返回一次,库里存 sha256(node_token_hash) |
| ResetToken(POST) | /judgeNode/resetToken | id | {node_token} | 重置令牌,旧令牌立即失效 |
| UpdateStatus(POST) | /judgeNode/updateStatus | id、status(1 启用/2 禁用) | - | 禁用后调度器不再派发新任务 |
| Del(DELETE) | /judgeNode/del | ids | - | 删除节点记录 |
| GetTasks(GET) | /judgeNode/getTasks | node_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/getList | keyword、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/getInfo | id | 题面字段(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/submit | problem_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/getStatus | id | {result,score,time_used,memory_used,compile_info,cases:[{case_index,result,time_used,memory_used,score}]} | 轻量轮询接口(前端 2s/次,直到非 PD/COMPILING/RUNNING) |
| GetList(GET) | /submission/getList | problem_id、result、page、pageSize | 分页 {items:[{id,problem_id,problem_title,result,score,language,time_used,memory_used,created_at}]} | 仅本人 |
| GetInfo(GET) | /submission/getInfo | id | 提交全字段 + code + cases | 仅本人可看 |
6.3.3 contest.go —— 比赛
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetList(GET) | /contest/getList | status、page、pageSize | 分页 {items:[{id,title,rule_type,access_type,start_time,end_time,status,signed}]} | signed=当前用户是否已通过审核 |
| GetInfo(GET) | /contest/getInfo | id | 比赛字段 + signed + problems:[{display_id,problem_id,title,my_result,my_score}](开赛后可见) | 密码赛未登记时仅返回公告字段 |
| Register(POST) | /contest/register | contest_id、password(可选) | {status}(0 待审核/1 已通过) | 公开赛直接通过;密码赛校密码;私有赛写待审核 |
| GetRank(GET) | /contest/getRank | contest_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/getList | status、page、pageSize | 分页 {items:[{id,title,start_time,end_time,status,problem_count,my_score}]} | 仅已发布(含已结束);my_score 由 homework_score 汇总 |
| GetInfo(GET) | /homework/getInfo | id | 作业字段 + problems:[{display_id,problem_id,title,my_score,my_submit_count,best_submission_id}] | 截止后只读 |
| GetMyScore(GET) | /homework/getMyScore | homework_id | {total,detail:[{problem_id,score,submit_count,first_ac_at,best_submission_id}]} | 个人成绩详情 |
6.3.5 rank.go —— 排名与统计
| 方法(HTTP) | 路由 | 入参 | data 出参 | 要点 |
|---|---|---|---|---|
| GetRank(GET) | /rank/getRank | page、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.go | rsjudge 启动注册;验令牌、更新节点信息、返回节点 id |
cases.v1.CasesService/FetchCases | 平台(gRPC 服务端) | app/judge/grpc_server.go | 按 problem_id 打包测试点(zstd tarball)流式下发(响应 metadata 带 cases_id) |
judge.v1.JudgeService/Submit | rsjudge(平台为客户端) | app/judge/dispatch.go | 调度器派发判题,流式接收事件 |
judge.v1.JudgeService/SelfTest | rsjudge(平台为客户端) | 二期 | “自测运行”(一期不做) |
启动方式(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, ®isterServer{})
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。处理流程:
- 拦截器校验 metadata
x-node-token(sha256 比对node_token_hash);失败返回Unauthenticated并记审计日志; - 由令牌定位预注册节点记录;
grpc_addr = peer_ip + ":" + req.port(供后续 Submit 连接); - 更新节点:
languages = JSON(req.Languages)(含 configs 声明)、system_info = req.SystemInfo(尽力解析 JSON 提取 version/cpu_cores,解析失败则留原值)、status=1(在线)、last_active=now; - 返回
RegisterResponse{id: 节点表ID}。
节点重启后会重新注册(幂等 upsert);平台 Create 预注册时生成令牌,节点配置中携带(见 4.7 鉴权方案)。6.4.3 CasesService.FetchCases 实现(测试点打包下发)
- 拦截器同 6.4.2;由
problem_id读题目(不存在返回NotFound),取case_version; - 读 enabled 测试点列表(按 case_index 排序),从存储读每个
.in/.ans(本地/OSS); - 内存打包 tar(结构见 4.7:
manifest.json + cases/{index}.in|.ans + spj/checker.{ext}),zstd 压缩; grpc.SetHeader(ctx, metadata.Pairs("cases_id", strconv.Itoa(caseVersion)));- 按 ≤ 1MiB 分块
Send(FetchCasesResponse{chunk});发送失败即终止(节点会重取); - 平台侧缓存(可选):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 Key | oj: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 派发主流程
- XREADGROUP 取任务 → 读 submission;已是终态则直接 XACK(幂等丢弃);
- 选节点:
status=1 AND running_count<max_concurrency,按(running_count/max_concurrency) ASC, load_avg ASC取首个;无可用节点 → 留 pending 稍后重试(不 ACK); - 占坑:
UPDATE gf_oj_judge_node SET running_count=running_count+1 WHERE id=? AND running_count<max_concurrency(乐观并发保护,失败重选); - 写
gf_oj_judge_task:status=1(已派发)、node_id、lease_expire_at = now + 租约时长(4.8 公式)、dispatched_at; - 调 Submit 流式处理(6.4.4);
- 终态:条件更新
submission→ 触发事件(6.5.3)→ judge_task.status=3(完成)→running_count-1→ XACK; - 异常:超时/断流 → judge_task.status=4(重派)、retry_count+1、重新 XADD;
retry_count≥3→ status=5(失败)、submission.result=SE。
6.5.3 判题完成事件(副作用)
OnSubmissionFinished(submission)(终态落库后调用,重判同样触发、按 submission_id 幂等):
| 副作用 | 说明 |
|---|---|
更新 user_problem | submit_count+1;AC 且未 solved → solved=1、first_ac_at;非 AC → wrong_count+1 |
更新 gf_oj_problem | submit_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:stream | Stream | 判题任务队列(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}:detail | Hash | 榜单明细(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 内直跑:
- WSL2 Ubuntu 安装语言工具链(gcc/g++、python3、openjdk、golang、rust、nodejs)并构建/运行 rsjudge(
judge/rsjudge/,--config-dir指向配置目录); - 配置 rsjudge 的平台接入地址指向 Windows 侧 gofly 的 gRPC 端口(
<windows-ip>:端口); - 本地 MySQL/Redis 可用 Docker Compose 或复用现有开发库。
- WSL2 Ubuntu 安装语言工具链(gcc/g++、python3、openjdk、golang、rust、nodejs)并构建/运行 rsjudge(
8.2 生产拓扑
| 节点 | 规格建议 | 说明 |
|---|---|---|
| gofly 应用 | 2C4G × 2(Nginx 负载) | 无状态,可水平扩展 |
| MySQL | 4C8G,主从 | 业务数据 |
| Redis | 2C4G | 队列/榜单 |
| 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 与 query | doc/sql、gofly/dao/ | 表可迁移、dao 可用 |
| 2 | Redis Stream 队列封装(投递/消费/租约/重派扫描器) | gofly/utils 或 app/judge | 任务可投递可消费 |
| 3 | judge 模块 gRPC 服务端骨架(Register/Cases)+ 令牌拦截器;节点注册落库与调度派发器 | app/judge | 依 proto 可编译的骨架 |
| 4 | admin(平台管理端)题目 CRUD、测试点上传/管理、FPS 导入导出 | app/admin/oj | 题库管理可用 |
| 5 | student 提交接口(校验+限流+落库+入队)、提交列表/详情(状态轮询) | app/student/oj | 提交可入库排队 |
| 6 | web 题目管理页面(含测试点管理) | web/src/views | 管理端页面可用 |
批次二:rsjudge 集成与端到端联调(关键路径,约 4~5 天,依赖上游进度)
| 序号 | 任务 | 涉及 | 说明 |
|---|---|---|---|
| 7 | rsjudge 部署与配置(WSL2/容器):工具链、services/executors/seccomp 三份配置 | judge/rsjudge/ | 人工部署,AI 辅助配置与排查 |
| 8 | 平台侧 gRPC 实现:Register/Cases 服务端调通、令牌拦截器;调度器 Submit 流式客户端 | app/judge | AI 生成 + 联调修正;全计划关键路径 |
| 9 | 判题链路端到端(C/C++/Python3):语言配置对齐、编译/运行/比对核实 | 联调 | 人工执行(WSL2 ↔ Windows gofly) |
| 10 | 流式事件落库与状态机(编译中/运行中/明细/汇总)、幂等处理 | app/judge、app/student/oj | AI 生成 + 幂等验证 |
| 11 | 异常路径联调:超时重派、节点异常、重判 | 联调 | 人工构造异常场景 |
注:rsjudge 当前为早期版本,Submit/SelfTest服务端尚未实现。若上游实现滞后,批次二先完成“节点注册 + 测试点下发 + 调度与展示”链路,判题执行能力随上游补齐(见十章风险),平台侧不受阻塞。
批次三:业务模块完善(AI 批量生成,约 2~3 天)
| 序号 | 任务 | 说明 |
|---|---|---|
| 12 | Java/Go/Rust/Node 语言接入(含倍率与内存注入) | 配置化生成,逐语言验证 |
| 13 | SPJ 支持(编译与驱动协议)、OLE/PE 判定 | AI 生成 + 样例验证 |
| 14 | admin/oj/contest.go:CRUD、题目编排、报名审核、封榜/解封/固化(表/dao 已在批次一就绪) | AI 批量生成 |
| 15 | 榜单引擎(Redis ZSET 实时榜 + 封榜 + 赛后固化);student/oj/contest.go:比赛列表/详情/报名/榜单 | 逻辑复杂,生成后重点验证封榜口径 |
| 16 | admin/oj/homework.go(CRUD/选题/成绩/导出)、student/oj/homework.go(我的作业/详情/成绩);成绩固化与计分规则校验 | AI 批量生成 |
| 17 | web 比赛管理/报名审核/榜单页、作业管理/成绩页;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 关键前置条件
- Linux 判题环境(WSL2/Docker/物理机)与各语言工具链版本冻结;
- OSS 私有桶(或本地存储方案)与测试点存储结构确认;
- HUSTOJ 存量题库(如有)准备用于 FPS 导入验证;
- 语言倍率、输出上限等默认参数评审;
- 作业/竞赛计分口径评审:截止判定、补交策略、封榜与重判交互;
- rsjudge 版本基线:锁定 tag/commit fork 维护(
judge/rsjudge);Submit/SelfTest与 register/cases 客户端已就地补全并通过 WSL2 编译/单测验证(9.5 第 8 批);上游持续跟踪,冲突时择机回馈上游(见十章风险)。
9.5 代码生成执行清单(文件级,按批次)
供 AI 按序生成;生成顺序即依赖顺序,每批产出可独立编译/验证。
第 1 批:建表与 dao(→ 9.2 批次一任务 1)
doc/sql/oj.sql:5.3 全部 18 张表 + 语言初始数据(单文件执行);- 运行
gofly/dao/cmd/main.go生成dao/model/oj_*.gen.go、dao/query/oj_*.gen.go(对照 5.5 命名映射核对)。
第 2 批:judge 模块骨架(→ 任务 2/3)
gofly/go.mod增依赖;生成app/judge/pb/**(6.4.1);app/judge/registry.go:令牌缓存、VerifyToken、StartNodeMonitor(6.4.5);app/judge/grpc_server.go:Start、tokenInterceptor、registerServer、casesServer(6.4.2/6.4.3);app/judge/dispatch.go:队列入队/消费/补偿 + 派发主流程 + OnSubmissionFinished + 租约扫描(6.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:
- 配置层:
LanguageDef增src_ext/src_rename/out_rename/out_extension与 getter;GrpcConfig增platform_addr/node_token/cases_cache_dir/work_dir;config-demo两文件补示例; - 判题核心(
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判定口径与汇总); - gRPC 层(
crates/rsjudge-grpc/src/):client/register.rs(语言声明含 Bool/Enum 配置、端口、sysinfo JSON、x-node-token、30s 心跳与退避重试)、client/cases.rs(FetchCases 流式收集、cases_idmetadata 校验、(problem_id, cases_id)缓存与临时目录原子安装)、server.rs(Submit/SelfTest 事件流实现)、lib.rs(多地址监听 + 启动注册任务); - 入口:
src/lib.rs在存在[grpc]配置时启动 gRPC 服务; - 验证(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-grpc52 项全绿(43+9); - 边界: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 节点 | 入库位置 / 转换 |
|---|---|
title | gf_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 参考资料
- HUSTOJ:https://github.com/zhblue/hustoj (判题流程、FPS 格式、结果码)
- isolate(Codeforces 沙箱):https://github.com/ioi/isolate (cgroup/namespace/rlimit 组合范式)
- rsjudge(现成开源判题沙箱服务器,本项目采用):https://github.com/NJUPT-SAST/rsjudge
- judge0:https://github.com/judge0/judge0 (多语言编译运行配置)
- youki(Rust 容器运行时):https://github.com/containers/youki (Rust 沙箱工程实践)
- libseccomp(seccomp-BPF 库,rsjudge 使用):https://github.com/seccomp/libseccomp
- hydro / go-judge(现代 OJ 判题机参考):https://github.com/criyle/go-judge