ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java+MySQL构建高并发在线评测系统(OJ)实战指南

Java+MySQL构建高并发在线评测系统(OJ)实战指南 简介这是一套面向计算机专业本科生与课程设计学习者的Web在线评测系统完整实现基于Java技术栈构建适用于算法训练、编程教学及OJ平台二次开发等场景。系统采用Spring MVCHibernateMySQL架构集成Lucene实现题目检索支持用户答题评测、师生课程管理、作业布置与统计分析并内置论坛模块具备良好的可扩展性与工程实践参考价值。资源包共872个文件含161个Java源码、191个JS脚本、44个JSP页面、31个CSS样式及68个PNG/114个GIF等前端资源另有XML配置、CLASS编译文件及少量文档PDF/DOC/MD整体21.11MB结构完整、模块清晰。目前已有204人学习下载提供可直接运行的完整后端逻辑、前后端交互示例AjaxJSON、典型控制器与服务类如HomeworkController、POJAjax及IDE项目配置文件.asta便于理解MVC分层设计与在线判题核心流程。1. 为什么一个“可扩展的程序在线评测系统”必须用 Java MySQL 而不是 Python SQLite你见过学生交代码后等 3 分钟才出结果的 OJ 吗见过 200 人同时提交判题队列崩成红色告警的后台吗见过老师想加个“内存限制动态调整”功能却要重写整个判题核心、连带改掉前端渲染逻辑的绝望吗——这不是教学平台的偶然故障而是架构选型失当的必然结果。基于 Java MySQL 实现Web可扩展的程序在线评测系统本质是在高并发提交、多语言沙箱隔离、细粒度资源控制、长期运行稳定性这四重压力下对工程落地能力的一次综合校验。它不追求炫技但拒绝妥协Java 的强类型、线程模型、JVM 生态如 Spring Boot 的自动装配、MyBatis 的 SQL 映射、Quartz 的定时调度天然支撑服务分层与横向扩容MySQL 的事务一致性、行级锁、主从复制能力是题库管理、用户状态、判题日志、排行榜更新等强一致性场景不可替代的底座。这个系统不是给单机练手用的玩具而是能承载校级 ACM 训练营、企业内部编程考核、甚至省级竞赛初赛的真实负载。适合正在从 Spring Boot 单体项目迈向分布式判题服务的中级 Java 工程师也适合需要把课程设计升级为可持续维护教学平台的高校教师团队——它不教你怎么写冒泡排序但会告诉你当第 1001 次提交撞上数据库连接池耗尽时该看哪三行日志、调哪两个参数、加哪一层缓存。2. 从零搭建最小可运行骨架Spring Boot MyBatis Thymeleaf 三件套落地实录一个真正能跑起来的 OJ不是先堆判题沙箱而是先让“用户登录 → 查看题目 → 提交代码 → 看到 AC/RE/TLE”这条主链路在本地秒级闭环。我们跳过所有炫技组件用最稳、最易调试、文档最全的组合打地基Spring Boot 2.7.x兼容 JDK 8避开 Spring Boot 3 的 Jakarta EE 9 迁移坑、MyBatis-Plus 3.5.x简化 CRUD避免手写 XML、Thymeleaf 3.1服务端模板规避前后端分离带来的跨域和部署复杂度。这套组合不追求“最新”但保证你在 Windows 开发机、CentOS 7 服务器、Docker 容器里都能一键拉起且所有依赖在 Maven Central 可查、可审计。2.1 初始化工程与关键依赖注入用 Spring Initializr 创建基础项目勾选 Spring Web、Lombok、MySQL Driver、Validation然后手动补全 MyBatis-Plus 和 Thymeleaf!-- pom.xml 片段 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency注意MyBatis-Plus 3.5.x 是最后一个支持 JDK 8 的大版本若你用 JDK 17请切到 3.5.5 并确认TableField注解行为无变化。Thymeleaf 默认开启 HTML 模板缓存spring.thymeleaf.cachetrue开发阶段务必设为false否则改完 HTML 刷新页面没反应——这是新手第一坑。2.2 数据库建模紧扣 OJ 核心实体的 5 张表设计OJ 不是博客系统它的数据模型必须围绕“题、人、交、判、榜”五要素展开。我们放弃过度设计用 5 张表撑起全部业务表名主要字段说明problemid,title,description,time_limit,memory_limit,input_sample,output_sample,visible题目表visible0表示未公开用于教师组卷userid,username,password,email,rolestudent/teacher/admin用户表密码必须 BCrypt 加密存储role字段驱动权限路由submissionid,user_id,problem_id,code,language,status,execute_time,memory_used,created_at提交记录表status枚举值PENDING,JUDGING,AC,WA,TLE,MLE,RE,CEjudge_logid,submission_id,log_content,error_output判题日志表存储编译错误详情或运行时 stderr便于 debugranklistuser_id,solved_count,total_submit,last_submit_time排行榜快照表避免每次请求都GROUP BY统计用定时任务每 5 分钟刷新建表 SQL 必须显式指定ENGINEInnoDB和CHARSETutf8mb4支持 emoji 题目描述并为高频查询字段加索引-- submission 表关键索引 CREATE INDEX idx_sub_user_prob ON submission(user_id, problem_id); CREATE INDEX idx_sub_status ON submission(status); CREATE INDEX idx_sub_pending ON submission(status, created_at) WHERE status PENDING;逻辑说明idx_sub_pending是个条件索引MySQL 8.0 支持专为判题服务拉取待处理任务而设。它让SELECT * FROM submission WHERE statusPENDING ORDER BY created_at LIMIT 10的执行计划从全表扫描降为索引范围扫描QPS 提升 3 倍以上。别小看这个索引——当并发提交量超过 500 TPS 时它就是数据库不告警的底线。2.3 用户认证与题目列表页Thymeleaf 模板与 Controller 的精准对接先实现最朴素的首页未登录用户看到题目列表仅标题和难度登录后显示“提交”按钮和已通过标记。Controller 层只做两件事查题、查用户状态。Controller public class ProblemController { Autowired private ProblemService problemService; Autowired private UserService userService; GetMapping(/problems) public String listProblems(Model model, HttpServletRequest request) { // 从 session 获取当前用户实际应走 Spring Security此处简化 User currentUser (User) request.getSession().getAttribute(currentUser); model.addAttribute(currentUser, currentUser); // 查询所有 visible1 的题目 ListProblem problems problemService.list( new QueryWrapperProblem().eq(visible, 1) ); model.addAttribute(problems, problems); return problem/list; // 对应 templates/problem/list.html } }对应 Thymeleaf 模板list.html中的关键片段!-- templates/problem/list.html -- div classproblem-card th:eachp : ${problems} h3 th:text${p.title}题目标题/h3 p th:text${#strings.abbreviate(p.description, 100)}题目描述摘要/p div classstatus !-- 登录用户才显示状态 -- span th:if${currentUser ! null} th:classappend${p.solvedByUser.containsKey(currentUser.id)} ? solved : unsolved span th:if${p.solvedByUser.containsKey(currentUser.id)}✓ 已通过/span span th:unless${p.solvedByUser.containsKey(currentUser.id)}○ 未尝试/span /span /div a th:if${currentUser ! null} th:href{/submit/{id}(id${p.id})} classbtn btn-primary提交代码/a /div参数说明th:each是 Thymeleaf 的循环指令th:if控制元素显隐th:href自动生成带路径参数的 URL。这里刻意不引入 Spring Security因为真实 OJ 的权限模型比 RBAC 复杂得多例如某题只对特定班级开放、某次比赛限时解锁过早引入框架反而模糊了业务边界。记住模板里不写 Java 逻辑只做展示映射Controller 不做判题只做数据搬运——这是保持可测试性的铁律。3. 判题核心引擎Java 进程沙箱 MySQL 状态同步的双保险机制判题不是“执行一段代码”而是“在受控环境中执行、捕获输出、比对结果、记录资源消耗、更新状态”的原子操作。它必须满足隔离性防止恶意代码删库、可观测性知道是 TLE 还是 RE、可重入性失败后能重试、可监控性CPU/内存使用率实时上报。我们不用 Docker 或 Kubernetes 做沙箱初期太重而用 Java 自带的ProcessBuilder Linuxcgroups通过 shell 脚本调用构建轻量级进程沙箱并用 MySQL 的FOR UPDATE锁保证状态更新不丢。3.1 判题服务的启动与任务拉取基于轮询的可靠消费模式判题服务是一个独立的 Spring BootComponent它不依赖 Web 层只专注一件事从submission表中捞PENDING状态的记录判完后更新为最终状态。我们采用“简单轮询 乐观锁”而非消息队列因为初期 QPS 100消息中间件反而增加运维负担。Component public class JudgeScheduler { Autowired private SubmissionService submissionService; Autowired private JudgeEngine judgeEngine; // 判题核心逻辑 Scheduled(fixedDelay 1000) // 每秒扫一次 public void pollAndJudge() { // 用 SELECT ... FOR UPDATE 锁住一条待判记录 Submission pending submissionService.getOne( new QueryWrapperSubmission() .eq(status, PENDING) .orderByAsc(created_at) .last(LIMIT 1 FOR UPDATE) ); if (pending null) return; try { // 执行判题含超时控制 JudgeResult result judgeEngine.judge(pending); // 原子更新只更新 status、execute_time、memory_used且仅当原 status 仍为 PENDING boolean updated submissionService.update( new UpdateWrapperSubmission() .eq(id, pending.getId()) .eq(status, PENDING) // 乐观锁条件 .set(status, result.getStatus()) .set(execute_time, result.getExecuteTime()) .set(memory_used, result.getMemoryUsed()) .set(judge_log_id, result.getLogId()) ); if (!updated) { // 被其他判题进程抢先更新放弃本次判题 log.warn(Submission {} already judged by another process, pending.getId()); } } catch (Exception e) { log.error(Judge failed for submission {}, pending.getId(), e); // 更新为系统错误状态便于人工介入 submissionService.updateStatus(pending.getId(), SE); } } }逻辑说明FOR UPDATE在 MySQL 中对选中的行加写锁确保同一时间只有一个判题进程能拿到同一条记录。UPDATE ... WHERE id? AND statusPENDING是典型的乐观锁写法——如果另一进程已更新该记录updated返回false当前进程直接放弃避免重复判题。这种设计牺牲了极少量吞吐锁等待但换来 100% 的状态一致性比最终一致性的消息队列更适合 OJ 这种强状态场景。3.2 Java 进程沙箱用 ProcessBuilder cgroups 限制资源的实操脚本判题的核心是执行用户代码。我们以 C 为例Java/Python 同理用g编译后在cgroups限制下运行#!/bin/bash # /opt/oj/judge.sh SUBMISSION_ID$1 CODE_PATH/opt/oj/code/$SUBMISSION_ID.cpp BIN_PATH/opt/oj/bin/$SUBMISSION_ID INPUT_PATH/opt/oj/input/$SUBMISSION_ID.in OUTPUT_PATH/opt/oj/output/$SUBMISSION_ID.out TIME_LIMIT1000 # ms MEMORY_LIMIT65536 # KB # 创建临时 cgroup CGROUP_NAMEoj_$SUBMISSION_ID sudo cgcreate -g cpu,memory:$CGROUP_NAME # 设置 CPU 时间配额防止死循环占满 CPU sudo cgset -r cpu.cfs_quota_us100000 $CGROUP_NAME # 100ms sudo cgset -r cpu.cfs_period_us100000 $CGROUP_NAME # 设置内存上限单位bytes sudo cgset -r memory.max$((MEMORY_LIMIT * 1024)) $CGROUP_NAME # 编译 g -o $BIN_PATH $CODE_PATH 2/opt/oj/log/$SUBMISSION_ID.compile.err if [ $? -ne 0 ]; then echo CE /opt/oj/result/$SUBMISSION_ID exit 1 fi # 执行并重定向 IO sudo cgexec -g cpu,memory:$CGROUP_NAME \ timeout --signalKILL $((TIME_LIMIT/1000)).$(printf %03d $((TIME_LIMIT%1000))) \ $BIN_PATH $INPUT_PATH $OUTPUT_PATH 2/opt/oj/log/$SUBMISSION_ID.run.err EXIT_CODE$? case $EXIT_CODE in 0) echo AC ;; 124) echo TLE ;; # timeout 137) echo MLE ;; # OOM killed *) echo RE ;; # 其他非零退出码 esac /opt/oj/result/$SUBMISSION_IDJava 侧调用此脚本public JudgeResult executeCpp(Submission sub) { String scriptPath /opt/oj/judge.sh; ProcessBuilder pb new ProcessBuilder(scriptPath, String.valueOf(sub.getId())); pb.redirectErrorStream(true); // 合并 stderr 到 stdout try { Process p pb.start(); int exitCode p.waitFor(5, TimeUnit.SECONDS); // 最多等 5 秒 if (exitCode ! 0) { throw new RuntimeException(Judge script failed with exit code exitCode); } // 读取判题结果 String result Files.readString(Paths.get(/opt/oj/result/, sub.getId().toString())); return parseResult(result, sub); } catch (Exception e) { return new JudgeResult(SE, 0, 0); } }参数说明timeout --signalKILL是关键它确保即使程序忽略SIGTERM也会被SIGKILL强制终止cfs_quota_us/cfs_period_us控制 CPU 时间片比ulimit -t更精准memory.max是 cgroups v2 的写法CentOS 7 默认 v1需用memory.limit_in_bytes。血泪经验不要用Runtime.getRuntime().exec()直接拼字符串调用脚本极易被用户代码中的; rm -rf /注入——必须用ProcessBuilder并显式传参。3.3 结果比对与状态回写AC/WA/TLE 的判定逻辑与 MySQL 事务保障判题脚本只返回粗粒度状态AC/TLE/RE真正的“是否正确”由 Java 层比对标准输出与用户输出完成。这一步必须放在数据库事务内确保“比对成功”与“状态更新”原子性Transactional public JudgeResult compareAndSave(Submission sub, String userOutputPath) { String stdOutput readFile(/opt/oj/stdout/ sub.getProblemId() .out); String userOutput readFile(userOutputPath); // 忽略行末空格、多余空行OJ 经典比对规则 String cleanStd normalizeOutput(stdOutput); String cleanUser normalizeOutput(userOutput); if (cleanStd.equals(cleanUser)) { // AC更新 submission同时更新用户 solved_count submissionService.updateStatus(sub.getId(), AC); ranklistService.incrementSolvedCount(sub.getUserId(), sub.getProblemId()); return new JudgeResult(AC, getExecuteTime(), getMemoryUsed()); } else { submissionService.updateStatus(sub.getId(), WA); return new JudgeResult(WA, getExecuteTime(), getMemoryUsed()); } } private String normalizeOutput(String s) { return s.replaceAll(\\s$, ).replaceAll(\\n, \n).trim(); }避坑点normalizeOutput必须处理 Windows 换行符\r\n否则 Linux 下判题会误判 WAranklistService.incrementSolvedCount内部用UPDATE ranklist SET solved_count solved_count 1 WHERE user_id ?避免先查再更新的并发问题事务注解Transactional必须作用于 Service 方法不能放在 Controller——这是 Spring 事务生效的前提。4. 可扩展性落地水平拆分判题节点、读写分离、排行榜缓存的三步演进“可扩展”不是一句口号而是当单台服务器扛不住时你能用 3 小时加一台机器就让吞吐翻倍。我们不预设微服务而是从单体出发用最务实的三步法实现平滑扩容第一步判题服务无状态化支持多实例部署第二步MySQL 主从分离读操作走从库第三步排行榜用 Redis 缓存降低数据库压力。每一步都可独立上线互不影响。4.1 判题服务无状态化用 Redis 分布式锁替代单机轮询原Scheduled方案在多实例下会重复判题。解决方案用 Redis 的SET key value NX EX seconds命令实现分布式锁确保同一时刻只有一个判题实例在工作Component public class DistributedJudgeScheduler { Autowired private RedisTemplateString, String redisTemplate; Autowired private SubmissionService submissionService; Scheduled(fixedDelay 1000) public void distributedPoll() { String lockKey judge:lock; String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 执行原判题逻辑 pollAndJudgeOnce(); } finally { // Lua 脚本安全释放锁防止误删其他实例的锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), lockValue); } } } private void pollAndJudgeOnce() { // 同前但去掉 Transactional由调用方保证 Submission pending submissionService.getPendingSubmission(); if (pending ! null) { judgeEngine.judge(pending); } } }逻辑说明setIfAbsent的NXNot eXists保证锁唯一性EX 30防止死锁。释放锁必须用 Lua 脚本因为“读锁值→判断→删除”是三个命令非原子操作——若 A 读到锁值后崩溃B 就可能删掉 A 的锁。这个方案让判题服务从 1 台扩到 N 台只需部署相同代码无需改一行业务逻辑。4.2 MySQL 读写分离用 ShardingSphere-JDBC 实现透明路由当SELECTQPS 超过 2000主库 CPU 常驻 90%就必须读写分离。我们选用 ShardingSphere-JDBC轻量、无代理、纯 Java它在应用层做 SQL 路由对业务代码零侵入# application.yml spring: shardingsphere: props: sql-show: false datasource: names: ds-master,ds-slave-0 ds-master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://master:3306/oj?useSSLfalseserverTimezoneAsia/Shanghai username: root password: pwd ds-slave-0: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://slave0:3306/oj?useSSLfalseserverTimezoneAsia/Shanghai username: root password: pwd rules: - !READWRITE_SPLITTING type: STATIC dataSources: pr_ds: write-data-source-name: ds-master read-data-source-names: ds-slave-0配置后所有Select方法自动走从库Update/Insert走主库。验证方法在从库执行SHOW PROCESSLIST能看到大量SELECT连接主库则只有INSERT/UPDATE。ShardingSphere 的优势在于它不修改你的 SQL也不要求你改 DAO 层——你甚至可以先只配一个从库观察流量分发效果再逐步加从库。4.3 排行榜缓存用 Redis Sorted Set 实现毫秒级排名更新ranklist表的ORDER BY solved_count DESC LIMIT 100查询在百万用户时会变慢。解决方案用 Redis 的ZADD维护有序集合ZRANGE直接取 Top 100Service public class RanklistService { Autowired private RedisTemplateString, String redisTemplate; // 用户 AC 一道题时调用 public void updateRanking(Long userId, Integer score) { String key ranklist:global; redisTemplate.opsForZSet().add(key, String.valueOf(userId), score.doubleValue()); // 设置过期时间避免内存无限增长 redisTemplate.expire(key, Duration.ofHours(24)); } // 前端调用 public ListRankItem getTop100() { SetZSetOperations.TypedTupleString tuples redisTemplate.opsForZSet().reverseRangeWithScores(ranklist:global, 0, 99); return tuples.stream() .map(t - new RankItem(Long.parseLong(t.getValue()), t.getScore().longValue())) .collect(Collectors.toList()); } }参数说明ZADD的score是solved_countvalue是user_idreverseRangeWithScores按分数降序取expire防止冷数据堆积。关键技巧排行榜缓存不是完全取代数据库而是“读缓存写双写”。每次updateRanking同时更新 Redis 和 MySQLranklist表保证最终一致性——当 Redis 故障时前端降级读 DB体验略有延迟但功能不丢。5. 避坑指南10 个让 OJ 项目半途而废的真实陷阱与解法做 OJ 最痛苦的不是写不出判题逻辑而是卡在某个看似 trivial 的细节上查三天文档无果最后发现是某个默认配置在作祟。以下是我在 3 所高校、2 家企业的 OJ 项目中踩过的坑按发生频率排序每条都附带复现方式和根因定位法。5.1 现象用户提交 C 代码判题脚本返回 CE但compile.err文件为空原因g编译错误输出到了stderr而脚本中2/opt/oj/log/xxx.compile.err重定向了stderr但g在某些错误如头文件缺失时会把部分信息打到stdout。解决统一重定向捕获所有输出g -o $BIN_PATH $CODE_PATH /opt/oj/log/$SUBMISSION_ID.compile.err5.2 现象MySQL 主从延迟导致排行榜页面显示“已通过”但数据库里还是PENDING原因ranklist表更新和submission状态更新不在同一事务主库写完后从库同步有延迟而排行榜读的是从库。解决排行榜缓存更新必须在submission状态变为AC后触发且用Transactional保证两者原子性或排行榜页面加readPreferenceprimaryMongoDB/强制走主库ShardingSphere 可配置hint。5.3 现象Thymeleaf 模板中th:if${user.role admin}总是false但数据库里role确实是admin原因MySQL 的ENUM类型在 JDBC 中默认映射为Integer而不是String。user.role实际是1不是admin。解决在user表中将role改为VARCHAR(20)或在实体类User中用Enumerated(EnumType.STRING)注解。5.4 现象Scheduled判题任务在生产环境不执行本地 IDE 却正常原因Spring Boot 默认不启用定时任务需显式添加EnableScheduling到主启动类。解决在Application.java上加注解SpringBootApplication EnableScheduling // ← 必加 public class OjApplication { ... }5.5 现象用户上传含中文的代码文件Linux 下g编译报invalid byte sequence原因文件编码是 GBK而g默认按 UTF-8 解析。解决统一代码存储为 UTF-8并在编译时指定编码g -finput-charsetUTF-8 -o $BIN_PATH $CODE_PATH额外提示所有用户代码文件必须用FileUtils.writeStringToFile(file, content, UTF-8)保存不能用new FileWriter()默认用系统编码。这是 Java Web 项目处理中文的通用铁律。6. 进阶技巧用 JUnit 5 Testcontainers 实现判题逻辑的 100% 自动化回归测试OJ 的核心价值在于“判得准”而“判得准”必须靠测试兜底。手工测 10 道题漏掉一个边界 case 就可能引发线上事故。我们用 JUnit 5 Testcontainers 构建一套可复现、可 CI 的判题测试体系每个题目类型C/Java/Python都有独立测试类每个测试用例包含标准输入、期望输出、时限、内存限制且全程在真实 MySQL 和 Redis 容器中运行。这不是单元测试而是端到端的契约测试。6.1 测试环境初始化用 Testcontainers 启动 MySQL 和 RedisSpringBootTest Testcontainers public class JudgeIntegrationTest { Container static MySQLContainer? mysql new MySQLContainer(mysql:8.0) .withDatabaseName(oj_test) .withUsername(test) .withPassword(test); Container static GenericContainer? redis new GenericContainer(redis:7-alpine) .withExposedPorts(6379); DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, mysql::getJdbcUrl); registry.add(spring.datasource.username, mysql::getUsername); registry.add(spring.datasource.password, mysql::getPassword); registry.add(spring.redis.host, () - redis.getHost()); registry.add(spring.redis.port, () - redis.getFirstMappedPort()); } }逻辑说明Testcontainers注解让 JUnit 在测试前自动拉起 MySQL 和 Redis 容器DynamicPropertySource动态覆盖application.yml中的数据源配置确保测试用独立数据库不污染开发环境。容器启动时间约 8 秒但换来的是 100% 环境一致性。6.2 判题逻辑测试用真实输入输出验证 AC/WA/TLE为 C 题目编写测试用例Test void shouldReturnAC_whenInputMatchesExpectedOutput() { // Given: 插入题目、用户、提交记录 Problem problem new Problem().setTitle(AB).setTimeLimit(1000).setMemoryLimit(65536); problemService.save(problem); User user new User().setUsername(test).setPassword(123); userService.save(user); Submission sub new Submission() .setUserId(user.getId()) .setProblemId(problem.getId()) .setCode(#include iostream\nusing namespace std;\nint main(){int a,b;cinab;coutab;return 0;}) .setLanguage(cpp); submissionService.save(sub); // When: 执行判题 judgeScheduler.pollAndJudgeOnce(); // 触发单次判题 // Then: 检查 submission 状态和输出 Submission result submissionService.getById(sub.getId()); assertThat(result.getStatus()).isEqualTo(AC); assertThat(result.getExecuteTime()).isLessThan(500L); // 小于时限 } Test void shouldReturnTLE_whenLoopExceedsTimeLimit() { // Given: 一个死循环的 C 代码 String infiniteLoopCode #include iostream\nint main(){while(1);return 0;}; Submission sub new Submission() .setUserId(1L) .setProblemId(1L) .setCode(infiniteLoopCode) .setLanguage(cpp); submissionService.save(sub); // When judgeScheduler.pollAndJudgeOnce(); // Then Submission result submissionService.getById(sub.getId()); assertThat(result.getStatus()).isEqualTo(TLE); }参数说明每个测试用例都是一个完整业务闭环插数据 → 触发判题 → 查结果。pollAndJudgeOnce()是判题服务的裸方法绕过定时调度确保测试可控。关键技巧测试数据用Sql脚本初始化如init-test-data.sql比硬编码更清晰所有测试用例加Tag(integration)CI 时可单独执行。6.3 持续集成流水线GitHub Actions 中的自动化测试与部署把测试融入 CI是防止 regressions 的最后一道防线。.github/workflows/ci.yml示例name: OJ CI Pipeline on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Set up JDK 11 uses: actions/setup-javav3 with: java-version: 11 distribution: temurin - name: Build with Maven run: mvn -B clean package -DskipTests - name: Run Integration Tests run: mvn test -Dgroupsintegration env: DOCKER_HOST: unix:///var/run/docker.sock - name: Upload coverage to Codecov uses: codecov/codecov-actionv3落地细节mvn test -Dgroupsintegration只运行Tag(integration)的测试DOCKER_HOST环境变量让 Testcontainers 能在 GitHub Runner 中调用 DockerCodecov 生成覆盖率报告重点关注JudgeEngine和SubmissionService的分支覆盖。我的习惯每天早上第一件事是看 CI 报告如果 integration test 红了当天不 merge 任何 PR——宁可慢不能错。OJ 的判题逻辑一旦出错影响的是几百学生的作业成绩没有后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表