【在线判题系统全维度测试报告:功能与自动化双轨验证】
项目:
bite-oj基于微服务的在线判题系统 | 作者:方文卓 | 记录日期:2026-09-28测试对象:网关 + 5 个微服务 + 2 套前端(管理端 / 用户端)+ Docker 判题沙箱
记录方式:真机环境实测记录 + 可复跑工程(
oj-test测试工程与本报告同源,run-all.cmd一键复跑)
🌈个人主页: 方文卓的博客
🔥热门专栏: 微服务实战 | 软件测试 | Docker 代码沙箱
💫个人格言: "没有罗马,那就自己创造罗马~"
文章目录
- 项目背景:
- 测试安排:
- 测试分类:
- (1)功能测试
- (2)接口测试
- (3)自动化测试
- [①编写 Web 测试用例:](#①编写 Web 测试用例:)
- ②创建空项目
- [(1)配置准备:添加需要的 pom.xml 依赖](#(1)配置准备:添加需要的 pom.xml 依赖)
- [(2)创建驱动对象 driver](#(2)创建驱动对象 driver)
- (3)测试用例的划分
- ③自动化测试工具准备:
- [④测试用例 tests 的编写:](#④测试用例 tests 的编写:)
- ⑤自动化测试结果与截图
- (4)性能测试:
- (5)安全测试
- 缺陷记录与修复验证
- [总结测试 tips:](#总结测试 tips:)
| 项目名称 | bite-oj 在线判题系统 | 版本号 | 1.0-SNAPSHOT |
|---|---|---|---|
| 发布类型 | 分级发布(网关 + 5 服务) | 测试负责人 | 方文卓 |
| 测试完成日期 | 2026-09-28 | 联系方式 | --- |
项目背景:
项目背景与意义:
- 在线判题系统(Online Judge,OJ)以「提交代码 → 编译运行 → 比对用例 → 即时反馈」的闭环替代人工批改,是程序设计教学与编程竞赛的基础设施。但教学场景里的 OJ 有两个绕不开的坑:
- 判题不可信:用户代码若直接在服务器上运行,缺少隔离与资源限制,一段死循环、一次内存溢出就可能把服务器拖垮;
- 提交会阻塞:判题与业务写在同一次请求里,竞赛开始时的集中提交会造成排队甚至超时,答题页面直接卡死。
- 本项目把判题从业务中拆出来,做成独立微服务:代码进 Docker 容器沙箱编译执行,提交经 RabbitMQ 削峰后异步判题,竞赛成绩由定时任务幂等结算。这份测试报告要验证的,正是这三件事到底有没有做到:判题可信、提交不阻塞、统计不靠人工。
- 与其他「只写用例设计、不跑用例」的测试报告不同,本报告的所有结论都来自真机执行:接口用例走网关真实调用 5 个微服务并直连 MySQL / Redis 核对,UI 用例真实拉起浏览器打开 11 个页面并逐页截图。带 ✅ 的结论都附了可核对的原话或数据,未执行的部分明确标注为「待复测」,不编造测试结果。
- 若有不足或改进之处,也希望大家指出☺️~
项目概述
本项目实现了一个基于微服务架构的在线判题系统,按业务域拆分为 5 个服务 ,对外呈现为 管理端、用户端两个使用端 ,共 11 个功能模块、44 个功能点。
| 服务 | 端口 | 职责 |
|---|---|---|
oj-gateway |
19090 | 唯一入口:路由转发、JWT 验签、Redis 会话校验、B/C 端身份隔离 |
oj-system |
9201 | 管理端业务:题库、竞赛、用户、账号 |
oj-friend |
19202 | 用户端业务:登录、题库、答题提交、竞赛、排行榜、消息 |
oj-judge |
9204 | Docker 容器池判题:编译、逐用例执行、时空判定、计分落库 |
oj-job |
9203 | XXL-Job 定时任务:缓存刷新、成绩结算与并列名次、排名通知 |
已实现的主要功能包括:
- 管理端:管理员登录/退出、题库增删改查(含用例与 main 模板)、竞赛新增与组题发布、全文检索与热门榜单、C 端用户拉黑解禁、网关路由与判题参数配置
- 用户端:手机验证码登录与自动注册、题库浏览与上下题切换、代码在线编辑、同步/异步提交、判题结果逐用例展示、竞赛报名与排行榜、个人中心与站内消息
- 判题服务 :容器池预热复用(4 个容器)、容器内
javac编译、逐用例执行、Docker stats 采峰值内存、计时器记耗时、逐字符比对与按难度计分 - 定时任务:竞赛列表/详情缓存刷新、成绩聚合与并列名次结算、批量回写与站内通知
当前系统存在的不足:
- C 端只有手机验证码登录,没有账号密码登录;新号码自动注册,用户无法自行注销账号
tb_message_text.message_title为varchar(10),排名消息只能用固定短标题(如「竞赛排名结果」),无法带上竞赛名称- 竞赛进行中没有实时榜:
exam_rank只有赛后结算才写入,赛中前端看到的是「分数倒序、名次为 0」 - 热门榜单接口后端缺失:前端调
/friend/question/semiLogin/hotList,实测返回 HTTP 404(属于半成品功能) - 题库列表接口仍带出
questionCase、mainFuc、defaultCode(本次复测确认,见《缺陷记录与修复验证》) - 题目上下题边界提示串位:末题点「下一题」返回的是 3501「已经是第一题了哦」(应为 3502)
- 题目不存在时接口返回
data:null,前端直接白屏(原计划的3003 资源不存在未生效) - 结算链路整体没有
@Transactional:oj-modules全模块@Transactional出现 0 次,多步写入无原子性 - 同一个手机号每天只能发 3 次验证码(
c:t:{手机号}计数),自动化用例必须复用登录态,否则额度很快耗尽
项目的系统功能说明
1. 登录与鉴权
- C 端:
POST /friend/user/sendCode发验证码(Redisp:c:{手机号},5 分钟有效,当日上限 3 次)→POST /friend/user/code/login校验后签发令牌 - 令牌:HS512 签名 JWT,payload 只放
userId与随机userKey;登录态LoginUser{identity, nickName, headImage}存 Redislogintoken{userKey},有效期 720 分钟,剩余不足 180 分钟自动续期 - 网关
AuthFilter(order = -200):白名单 → 取Authorization头剥Bearer→ 验签 → 查 Redis 会话 → 按路径判定身份 →ThreadLocalUtil.set(userId)透传
2. 题库浏览与在线答题
- 题库列表走 ES/缓存,返回精简 VO(不含
questionCase、mainFuc、defaultCode) - 答题页用 Ace 编辑器;提交两条链路:
/user/question/submit(Feign 同步)、/user/question/rabbit/submit(投 MQ 后立即返回,前端每 2 秒轮询/user/question/exe/result) - 判题服务从容器池取容器 → 写入
Solution.java→javac编译 → 逐用例java -cp /usr/share/java Solution {input}→ 比对 → 落库tb_user_submit(先删后插,只保留最新一次)
3. 竞赛报名与排行榜
- 报名
POST /friend/user/exam/enter(开赛前可报名,报名即参赛) - 排行榜
GET /friend/exam/rank/list:优先读 Redise:r:l:{examId},未命中回源库 - 结算
examResultHandler:扫描「已结束 + 已发布 + 名次为空」的竞赛 → 聚合总分 → 并列名次 → 单条CASE WHEN批量回写 → 刷缓存 → 发排名消息
4. 管理端
- 管理员登录(BCrypt 校验)→ 题库增删改查(同步写 ES)→ 竞赛新增/组题/发布/撤销发布 → C 端用户拉黑解禁
测试目标:
| 目标项 | 指标 | 本次实际 |
|---|---|---|
| 功能用例通过率 | 核心链路 100% 可复现 | 66 条用例执行 65 条通过(唯一失败为已知缺失接口) |
| 判题可信度 | 编译失败 / 用例不通过 / 超时 / 超内存 四类输入全部被正确判定,且给出明确原因 | ✅ 编译失败与用例不通过已实测拿到明确结论 |
| 沙箱隔离 | 禁网、根文件系统只读、内存与 CPU 限额、单用例超时全部生效 | 设计对照已验证,恶意输入压测待复测 |
| 提交接口响应 | 异步提交接口响应 ≤ 200 ms(判题耗时不影响答题页) | 待复测(JMeter 方案已就绪) |
| 列表接口响应 | 命中缓存时 P95 ≤ 150 ms | 待复测 |
| 结算幂等 | 任务重复执行结果一致,历史漏跑可自动补齐 | ✅ 真机触发结算后数据核对通过 |
| 越权访问 | 普通用户令牌访问 /system/** 被拒,反向同理 |
✅ 双向实测被拒(code=3001 令牌验证失败) |
测试项目相关信息:
- 前端 :用户端
Oj-c-Token(Cookie),路由前缀/c-oj/**;管理端Admin-Oj-b-Token,路由/oj/** - 接口前缀 :前端 axios
baseURL = /dev-api→ Vite 代理 → 网关127.0.0.1:19090/friend、/system;网关StripPrefix=1 - 白名单 :
/system/sysUser/login、/friend/user/sendCode、/friend/user/code/login、/**/semiLogin/**、/**/test/** - 关键缓存键 :
logintoken{uuid}、p:c:{手机号}、c:t:{手机号}(当日发码次数)、e:t:l(未完赛列表)、e:h:l(历史竞赛)、e:d:{examId}、e:q:l{examId}、e:r:l:{examId}、q:l(题库顺序)、u:m:l(用户消息) - 判题参数 :镜像
openjdk:8-jdk-alpine,内存与 swap 各 100000000 B,CPU 1 核,单用例超时 5 s,容器池 4 个(oj-sandbox-jdk-0 ~ 3) - 测试工程 :
oj-test(JUnit 5 + JDK HttpClient + Jedis + Chrome DevTools Protocol),位于项目根目录,run-all.cmd一键复跑
测试安排:
| 模块 | 子模块 | 前端 | 开发 | 提测时间 | 测试 | 工时 | 排期 | 进度 | 备注 |
|---|---|---|---|---|---|---|---|---|---|
| 网关鉴权 | 路由、白名单、B/C 端隔离 | 方文卓 | 方文卓 | 09-08 | 方文卓 | 0.5d | 09-09 | 测试完成 | 白名单过宽已记录 |
| 题库与检索 | 题目 CRUD、ES 同步、题库缓存 | 方文卓 | 方文卓 | 09-08 | 方文卓 | 0.5d | 09-09 | 测试完成 | 复测发现列表仍泄漏用例 |
| 在线答题与判题 | 同步/异步提交、沙箱、结果查询 | 方文卓 | 方文卓 | 09-09 | 方文卓 | 1d | 09-10 | 测试完成 | 判题链路 6 条用例全通过 |
| 竞赛与排行榜 | 报名、组题、结算、排行榜、通知 | 方文卓 | 方文卓 | 09-10 | 方文卓 | 1.5d | 09-11 | 测试完成 | 修复 11 条链路缺陷 |
| 管理端页面 | 题库/竞赛/用户管理页 | 方文卓 | 方文卓 | 09-10 | 方文卓 | 0.5d | 09-11 | 测试完成 | 4 个页面截图留证 |
| 自动化工程 | oj-test 接口 + UI 自动化 |
方文卓 | 方文卓 | 09-26 | 方文卓 | 1.5d | 09-28 | 测试完成 | 66 条用例一键复跑 |
| 性能与安全 | 集中提交、沙箱异常输入 | 方文卓 | 方文卓 | 09-28 | 方文卓 | 0.5d | 09-28 | 方案已就绪 | 脚本可复跑 |
测试分类:

测试分五层推进:功能 → 接口 → 自动化 → 性能 → 安全。先手工把「登录 → 题库 → 提交 → 判题 → 竞赛 → 排行榜」主流程跑通,再把稳定下来的操作固化成自动化用例(接口 51 条 + UI 15 条),最后用 JMeter 压异步提交链路、用异常输入压沙箱。
(1)功能测试
功能测试结果:核心链路(题库浏览 → 代码提交判题 → 竞赛报名 → 排行榜结算)已在真机环境跑通并有记录;本轮自动化共执行 66 条用例,通过 65 条,失败 1 条(缺失接口),跳过 0 条。
下表「执行状态」列:✅ 表示已在真机环境实测并有记录;⏳ 表示用例已设计、脚本已就绪,本轮未复跑。
登录与鉴权(6 条)
| 编号 | 测试项 | 操作步骤 | 预期结果 | 状态 |
|---|---|---|---|---|
| C-01 | 页面加载 | 打开 /c-oj/login |
手机号输入框、验证码按钮、登录按钮可见 | ✅ 实测(U1-01/U1-02:页面标题非空、input=2、正文含「验证码」) |
| C-02 | 发送验证码 | 输入合法手机号 → 点「获取验证码」 | 提示发送成功;Redis 生成 p:c:{号码},TTL 5 分钟 |
✅ 实测(A2-01 code=1000;A2-02 Redis 值为 6 位数字、TTL>0) |
| C-03 | 验证码错误 | 输入错误验证码 → 登录 | 提示「验证码无效」,不签发令牌 | ✅ 实测(A2-03 code=3109 验证码无效) |
| C-04 | 验证码登录成功 | 输入正确验证码 → 登录 | 签发 JWT,写入 logintoken{uuid},跳转题库页 |
✅ 实测(A2-01 拿到 214 字符 JWT;A2-04 /friend/user/info 返回昵称「王小明」) |
| C-05 | 当日发送上限 | 同一号码当日第 4 次发送 | 提示超过上限(c:t:{号码} 计数为 3) |
✅ 实测(第 4 次返回 code=3107 当天请求次数已达到上限,Redis c:t:13812345678=3) |
| C-06 | 未登录拦截 | 未登录直接访问个人中心 | 被拦回登录页 | ✅ 实测(U1-03 访问 /c-oj/home/user/detail 出现登录文案) |
题库浏览与答题(8 条)
| 编号 | 测试项 | 操作步骤 | 预期结果 | 状态 |
|---|---|---|---|---|
| Q-01 | 题库列表 | 未登录打开题库页 | 列表正常返回,不含 questionCase / mainFuc / defaultCode |
❌ 复测失败:接口仍返回这三个字段(答案泄漏,见《缺陷记录与修复验证 · 本轮复测新发现》) |
| Q-02 | 题目详情 | 点击任意题目 | 返回标题、难度、时空限制、默认代码;主键为字符串形式的雪花 ID(不丢精度) | ✅ 实测(A3-04:title/content/defaultCode 均非空,questionId 为字符串) |
| Q-03 | 上一题/下一题 | 在题库中连续切换 | 首题点上一题提示「已经是第一题」(3501),末题点下一题提示「已经是最后一题」(3502) | ⚠️ 部分失败 :首题 pre 正确返回 3501;但末题 next 返回的却是 3501「已经是第一题了哦」(应为 3502),见《本轮复测新发现》 |
| Q-04 | 题目不存在 | 请求不存在的 questionId |
返回 3003 资源不存在,而不是 data:null 白屏 |
❌ 复测失败 :?questionId=999999999 返回 code=1000, data:null,仍是白屏,见《本轮复测新发现》 |
| Q-05 | 代码编辑 | 在 Ace 编辑器输入代码 | 代码高亮正常,提交时请求体带上代码 | ✅ 实测(U2-04 答题页渲染正常,正文长度 256) |
| Q-06 | 同步提交 | 点「提交代码」(题库练习) | Feign 直连判题服务,3~10 秒内返回判题结果 | ✅ 实测(A5-01:code=1000,pass=0,exeMessage=未通过所有用例) |
| Q-07 | 异步提交 | 竞赛内点「提交代码」 | 接口 ≤ 200 ms 返回;页面显示「系统正在处理您的代码」;每 2 秒轮询一次结果 | ⏳ 待复测(JMeter 场景 S1/S3 已设计) |
| Q-08 | 结果展示 | 判题结束后查看结果 | 逐条展示输入、期望输出、实际输出;通过则给出得分 | ✅ 实测(A5-02:返回 2 条用例明细,期望 [0,1] / 实际 [0, 1]) |
竞赛与排行榜(10 条)------本次缺陷最集中的模块
| 编号 | 测试项 | 操作步骤 | 预期结果 | 状态 |
|---|---|---|---|---|
| E-01 | 竞赛列表 | 打开竞赛页 | 未完赛/历史竞赛分类正确;已完赛竞赛不出现在未完赛列表 | ✅ 实测(A4-01 未完赛 1 场 / A4-02 历史 6 场;定位并修复脏缓存后复核) |
| E-02 | 竞赛报名 | 开赛前点「报名参赛」 | 写入 tb_user_exam;重复报名不产生重复记录 |
⏳ 待复测 |
| E-03 | 开赛后报名 | 开赛后点「报名参赛」 | 返回明确提示(开赛不可报名) | ⏳ 待复测 |
| E-04 | 我的竞赛 | 打开「我的竞赛」页 | 列表展示报名竞赛;状态区分未完赛/已完赛 | ✅ 实测(U3-01 页面渲染正常;A2-07 接口 code=1000) |
| E-05 | 按钮可用性 | 点「查看排名」「竞赛练习」「开始答题」 | 三个入口分别弹出排名、进入练习、进入答题页 | ✅ 实测(修复 UserExam.vue / Exam.vue 后复核) |
| E-06 | 竞赛答题取第一题 | 进入竞赛答题页 | getFirstQuestion 返回的是题目 id(不是关系表主键),题目详情能查到 |
✅ 实测(A4-07 code=1000;修复前返回关系表主键导致详情 data:null) |
| E-07 | 空竞赛健壮性 | 打开没有题目的竞赛 | 返回明确业务提示,不抛 NPE(4 场竞赛无题目的场景) | ✅ 实测(三接口原先全部 NPE,已修复) |
| E-08 | 排行榜展示 | 已完赛竞赛点「查看排名」 | 按名次升序展示昵称、得分、名次;userId 不丢精度 |
✅ 实测(A4-05 实测返回 total=1,字段含 nickName/score/examRank) |
| E-09 | 排行榜缓存 | 连续刷新排行榜 3 次 | 记录不重复、总数不翻倍(先删后写) | ✅ 实测(A7-07:e:r:l:* 共 4 个键、类型为 list、内容含 examRank) |
| E-10 | 结算通知 | 结算后查看站内消息 | 每位参赛者收到 1 条排名消息,标题「竞赛排名结果」 | ✅ 实测(真机触发结算,消息表各 5 条) |
管理端(8 条)
| 编号 | 测试项 | 操作步骤 | 预期结果 | 状态 |
|---|---|---|---|---|
| B-01 | 管理员登录 | 账号密码登录 | BCrypt 校验通过,签发管理员令牌,进入用户管理页 | ✅ 实测(A6-01 code=1000,令牌长度 190;U4-01 登录页正常) |
| B-02 | 题目新增 | 填写标题、难度、时空限制、用例、默认代码 → 保存 | MySQL 与 ES 同步写入;C 端题库可见 | ⏳ 待复测 |
| B-03 | 题目编辑 | 修改题目标题与限制 → 保存 | C 端列表与详情同步变化 | ⏳ 待复测 |
| B-04 | 题目删除 | 删除题目 | C 端不再展示;ES 索引同步移除 | ⏳ 待复测 |
| B-05 | 题目列表筛选 | 按标题、难度、时间范围组合筛选 | 结果准确;组题弹窗可排除本竞赛已有题目 | ⚠️ 部分实测(A6-06 管理端题目列表 total=4 与库一致;A3-03 关键字搜索命中结果标题均含关键字;难度/时间范围组合筛选待复测) |
| B-06 | 竞赛组题 | 向竞赛批量添加题目 | 写入 tb_exam_question 且顺序正确;C 端按序切换题目 |
⏳ 待复测 |
| B-07 | 发布/撤销发布 | 发布竞赛 → 再撤销 | 发布后 C 端可见,撤销后不可见且缓存被清理 | ✅ 实测(A7-04:库中 9 场,B 端可见 9 场,C 端只可见已发布 7 场) |
| B-08 | 用户拉黑 | 拉黑某用户 → 该用户报名竞赛 | 提示「您已被列入黑名单,请联系管理员」(3104) | ⏳ 待复测 |
本轮自动化实际执行汇总
text
用例总数 66,通过 65,失败 1,跳过 0,耗时 77304 ms
| 分组 | 条数 | 通过 | 失败 | 跳过 |
|---|---|---|---|---|
| A1 网关与鉴权 | 8 | 8 | 0 | 0 |
| A2 用户登录 | 8 | 8 | 0 | 0 |
| A3 题库 | 6 | 5 | 1(热榜 404) | 0 |
| A4 竞赛 | 7 | 7 | 0 | 0 |
| A5 判题 | 6 | 6 | 0 | 0 |
| A6 后台管理 | 8 | 8 | 0 | 0 |
| A7 数据一致性 | 8 | 8 | 0 | 0 |
| U1~U4 UI 页面 | 15 | 15 | 0 | 0 |
(2)接口测试
微服务项目里,网关是唯一入口,接口测试的第一优先级不是业务返回值,而是「谁能进、谁不能进」。测试分三组:白名单放行、令牌与身份隔离、业务接口契约。
①网关鉴权与身份隔离(curl)
bash
# ① 白名单:匿名可访问题库列表(预期 code=1000)
curl -s "http://127.0.0.1:19090/friend/question/semiLogin/list?pageNum=1&pageSize=5"
# → {"total":4,"rows":[...],"code":1000,"msg":"操作成功"}
# ② 无令牌访问受保护接口(预期 code=3001 令牌不能为空)
curl -s http://127.0.0.1:19090/friend/user/info
# → {"code":3001,"msg":"令牌不能为空","data":null}
# ③ 伪造令牌(预期 code=3001 令牌已过期或验证不正确)
curl -s http://127.0.0.1:19090/friend/user/info -H "Authorization: abcdef.abcdef.abcdef"
# → {"code":3001,"msg":"令牌已过期或验证不正确!"}
# ④ 无令牌访问管理端接口(预期 code=3001)
curl -s "http://127.0.0.1:19090/system/user/list?pageNum=1&pageSize=1"
# → {"code":3001,"msg":"令牌不能为空","data":null}
# ⑤ C 端令牌访问 B 端接口(预期被拒:令牌验证失败)
curl -s "http://127.0.0.1:19090/system/user/list?pageNum=1&pageSize=1" -H "Authorization: ${C_TOKEN}"
# → {"code":3001,"msg":"令牌验证失败","data":null}
# ⑥ B 端令牌访问 C 端接口(预期被拒:令牌验证失败)
curl -s "http://127.0.0.1:19090/friend/exam/rank/list?examId=1&pageNum=1&pageSize=1" -H "Authorization: ${B_TOKEN}"
# → {"code":3001,"msg":"令牌验证失败","data":null}
②判题链路接口(curl)
bash
# 先登录拿令牌(开发态验证码固定 123456,Redis 键 p:c:{手机号})
curl -s -X POST http://127.0.0.1:19090/friend/user/sendCode \
-H "Content-Type: application/json" -d '{"phone":"13711112222"}'
C_TOKEN=$(curl -s -X POST http://127.0.0.1:19090/friend/user/code/login \
-H "Content-Type: application/json" \
-d '{"phone":"13711112222","code":"123456"}' | python -c "import sys,json;print(json.load(sys.stdin)['data'])")
# 提交一段「能编译但答案不对」的代码:验证逐用例明细是否回传
curl -s -X POST http://127.0.0.1:19090/friend/user/question/submit \
-H "Authorization: ${C_TOKEN}" -H "Content-Type: application/json" \
-d '{"examId":1234567890123456789,"questionId":1,"programType":0,
"userCode":"import java.util.Arrays;\nclass Solution{public int[] twoSum(int[] n,int t){return new int[]{0,0};}}"}'
# → {"code":1000,"data":{"pass":0,"exeMessage":"未通过所有用例",
# "userExeResultList":[{"input":null,"output":"[0,1]","exeOutput":"[0, 1]"},
# {"input":null,"output":"[1,2]","exeOutput":"[0, 1]"}]}}
# 轮询最近一次判题结果
curl -s "http://127.0.0.1:19090/friend/user/question/exe/result?examId=1234567890123456789&questionId=1¤tTime=2026-09-28%2010%3A00%3A00" \
-H "Authorization: ${C_TOKEN}"
# → {"code":1000,"data":{"pass":0,"exeMessage":"未通过所有用例","userExeResultList":[...]}}
③接口测试实测结论
| 编号 | 接口 | 结果 | 说明 |
|---|---|---|---|
| I-01 | GET /friend/question/semiLogin/list |
❌ 仍泄漏用例 | 返回字段含 questionCase、mainFuc、defaultCode,与「已改为返回 QuestionVO」的修复结论不一致,需重新确认部署版本 |
| I-02 | GET /friend/question/detail?questionId=<雪花 ID> |
✅ 正常返回 | 返回 title/difficulty/timeLimit/spaceLimit/content/defaultCode,questionId 为字符串 |
| I-03 | GET /friend/exam/getFirstQuestion?examId=... |
✅ 返回真实题目 id | 修复前返回关系表主键导致详情 data:null |
| I-04 | GET /friend/user/info(无令牌 / 伪造令牌) |
✅ 均被拦截 | code=3001,网关第一道防线生效 |
| I-05 | GET /friend/question/semiLogin/hotList |
❌ HTTP 404 | 前端在调、后端无此接口,热榜一直为空且前端无提示(本次自动化 A3-06 复现) |
| I-06 | GET /friend/exam/rank/list?examId=... |
✅ 名次正确排序 | 修复前 examId 为空会全表扫描、pageNum 空串会 NPE |
| I-07 | POST /friend/user/exam/enter |
✅ 返回明确状态码 | @CheckUserStatus AOP 校验拉黑状态 |
| I-08 | DELETE /system/sysUser/logout |
✅ 令牌立即失效 | 登出后携带原令牌访问 /system/sysUser/info 返回 code=3001 |
| I-09 | POST /friend/user/question/submit(programType=9) |
✅ 明确拒绝 | code=3601 当前不支持此语言,不会掉进 code=2000 系统异常 |
接口测试心得 :微服务里「接口文档写的」和「前端实际调的」经常对不上。热榜 404 就是这么发现的------拿前端 API 模块里的每一个 URL 去逐个请求一遍,是成本最低、收益最高的一轮接口冒烟;而 I-01 的答案泄漏则是「修复报告写着已改,但线上响应里字段还在」,只有把真实响应字段逐个列出来才能发现。
(3)自动化测试
①编写 Web 测试用例:
自动化用例按「页面 + 接口契约」两个维度设计,共 4 个 UI 页面组、7 个接口模块,合计 66 条:
| 分组 | 条数 | 覆盖内容 |
|---|---|---|
| A1 网关与鉴权 | 8 | 连通性、白名单、无令牌/伪造令牌拦截、B/C 端令牌隔离 |
| A2 用户登录 | 8 | 发码、验证码落 Redis、错误验证码、登录、用户信息/资料/消息/我的竞赛 |
| A3 题库 | 6 | 列表分页、分页参数、关键字搜索、题目详情、详情需登录、热榜接口 |
| A4 竞赛 | 7 | 未完赛/历史竞赛、缓存列表、竞赛详情缓存、排名列表、排名需登录、首题接口 |
| A5 判题 | 6 | 判题结论、逐用例明细、编译错误回传、不支持语言、提交落库、结果查询 |
| A6 后台管理 | 8 | 管理员登录、错误密码、不存在账号、管理员信息、用户/题目/竞赛列表、登出失效 |
| A7 数据一致性 | 8 | MySQL/Redis 连通、题目/竞赛/用户数量与接口 total 核对、竞赛与排名缓存 |
| U1~U4 UI | 15 | 用户端登录页/题库页/竞赛页/答题页/个人中心 3 页;管理端登录页/题目/竞赛/用户管理页、未登录拦截 |
②创建空项目
(1)配置准备:添加需要的 pom.xml 依赖
xml
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<junit.version>5.12.2</junit.version>
<fastjson2.version>2.0.43</fastjson2.version>
</properties>
<dependencies>
<!-- 测试框架 + 报告监听(跑完自动出报告图) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<version>1.12.2</version>
<scope>test</scope>
</dependency>
<!-- 接口侧:JSON 解析(HTTP 客户端用 JDK 自带 HttpClient,无需 rest-assured) -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<!-- 数据核对:MySQL + Redis -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.31</version>
<exclusions>
<!-- 只用 JDBC,X DevAPI 的 protobuf 依赖本地仓库没有,排除掉 -->
<exclusion>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>6.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
<configuration>
<argLine>-Dfile.encoding=UTF-8 -Djava.awt.headless=true</argLine>
<!-- 测试失败也继续跑完,保证报告图与截图一定生成 -->
<testFailureIgnore>true</testFailureIgnore>
</configuration>
</plugin>
</plugins>
</build>
(2)创建驱动对象 driver
- 一开始也是按常规方案上 Selenium + WebDriverManager,但真机环境卡住了:本机 Chrome 是 154,本地缓存的 chromedriver 只有 150 / 151,而 WebDriverManager 下载驱动需要联网,这台机器是离线环境 ------UI 用例会全部报
No matching chromedriver。 - 于是换成 Chrome 自带的 DevTools Protocol(CDP) 直接驱动浏览器:手动拉起
chrome --headless=new --remote-debugging-port=<随机端口>,用 JDK 自带的java.net.http.WebSocket发Page.navigate/Runtime.evaluate/Page.captureScreenshot指令。零额外依赖、零驱动下载,离线也能截图。 - 为了方便测试所有页面,仍创建一个 driver 对象供所有 UI 用例使用(
Cdp+Browser两层封装)。
java
package com.oj.test.support;
public final class Browser {
private Cdp cdp;
/** 打开页面(首次调用时才真正启动 Chrome) */
public Browser open(String url) {
if (cdp == null) {
cdp = new Cdp(); // 启动 headless Chrome + 连接 CDP
}
cdp.navigate(url);
return this;
}
/** 用接口登录,把令牌写进 Cookie,再打开目标页------省掉每条用例 3~5 秒的 UI 登录时间 */
public Browser loginCAndOpen(String url) {
cdp.navigate(root(url));
cdp.setCookie("Oj-c-Token", Auth.tokenC(), host(url)); // 管理端为 Admin-Oj-b-Token
cdp.navigate(url);
return this;
}
/** 截图:screenshots/yyyy-MM-dd/用例名.png */
public File shot(String name) {
return cdp.screenshot(name);
}
public void close() {
cdp.close(); // 关闭标签页并销毁 Chrome 进程
}
}
(3)测试用例的划分
- 按「分组 = 一个 Java 文件」划分,每个类里的
@Test方法就是一条用例; - 接口用例与 UI 用例分成两个包,可以分别用
-Dtest=A*Test/-Dtest=U*Test单独跑。
text
src/test/java/com/oj/test
├─ config/TestConfig.java 读 test.properties(网关、前端、账号、库表连接)
├─ support/Http.java JDK HttpClient 封装:get/post/delete + code/msg/data
├─ support/Auth.java 登录与令牌管理(C 端验证码登录 / B 端账号密码),令牌落 .cache 复用
├─ support/Redis.java Redis 只读核对(get/type/ttl/len/scan/list)
├─ support/Db.java MySQL 只读核对
├─ support/Cdp.java Chrome DevTools Protocol 客户端(免 chromedriver)
├─ support/Browser.java 打开页面 / 注入 Cookie / 截图 / 关闭
├─ support/Cases.java 用例结果登记(PASS / FAIL / SKIP)
├─ support/ReportImage.java 把结果画成一张 PNG 报告图
├─ support/ReportListener.java 跑完自动出图 + 控制台汇总(ServiceLoader 注册)
├─ api/A1_GatewayAuthTest.java 网关与鉴权 8 条
├─ api/A2_UserLoginTest.java 用户登录 8 条
├─ api/A3_QuestionTest.java 题库 6 条
├─ api/A4_ExamTest.java 竞赛 7 条
├─ api/A5_JudgeTest.java 判题 6 条
├─ api/A6_AdminTest.java 后台管理 8 条
├─ api/A7_DataConsistencyTest.java 数据一致性 8 条
└─ ui/U1_LoginPageTest.java ~ U4_AdminPageTest.java UI 页面 15 条
③自动化测试工具准备:
公共工具类集中在 support 包:Http 负责请求、Auth 负责登录态、Redis/Db 负责真数据核对、Cdp/Browser 负责浏览器。
java
package com.oj.test.support;
/** 极简 HTTP 封装:JDK 自带 HttpClient + fastjson2,零额外网络依赖 */
public final class Http {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public static Res get(String path, String token) {
return send(builder(path, token).GET().build());
}
public static Res post(String path, String token, String jsonBody) {
return send(builder(path, token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
.build());
}
private static HttpRequest.Builder builder(String path, String token) {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(TestConfig.gateway() + path))
.timeout(Duration.ofSeconds(30)).header("Accept", "application/json");
if (token != null && !token.isBlank()) {
b.header("Authorization", token);
}
return b;
}
}
java
package com.oj.test.support;
/**
* 登录与令牌管理。
* 同一个手机号每天只能发 3 次验证码,所以令牌会缓存在 .cache 目录,
* 下次运行只要还没失效就直接复用,避免把额度跑光。
*/
public final class Auth {
public static String tokenC() {
if (tokenC != null && validC(tokenC)) {
return tokenC; // 1) 内存里的令牌还能用
}
String cached = readCache("token-c.txt");
if (cached != null && validC(cached)) {
loginSource = "缓存令牌";
return tokenC = cached; // 2) 上次落盘的令牌还能用
}
String[] phones = {TestConfig.userPhone(), TestConfig.userPhoneBackup()};
for (String phone : phones) { // 3) 真发验证码登录(主号额度用尽自动换备用号)
String t = loginByCode(phone);
if (t != null) {
writeCache("token-c.txt", t);
return tokenC = t;
}
}
return null;
}
private static String loginByCode(String phone) {
Http.Res send = Http.post("/friend/user/sendCode", null, "{\"phone\":\"" + phone + "\"}");
if (send.code() != 1000) {
lastError = send.code() == 3107 ? "今日验证码发送次数已达上限(每天 3 次)" : send.msg();
return null;
}
String code = Redis.get("p:c:" + phone); // 开发态验证码直接读 Redis,不用收短信
Http.Res login = Http.post("/friend/user/code/login", null,
"{\"phone\":\"" + phone + "\",\"code\":\"" + code + "\"}");
return login.code() == 1000 ? login.dataString() : null;
}
}
java
package com.oj.test.support;
/** 用例结果登记处:所有用例都往这里记一笔,跑完由 ReportListener 生成报告图 */
public final class Cases {
/** 断言并记录;失败不抛异常,保证后续用例继续跑、报告图完整 */
public static boolean check(String id, String module, String name, String expected,
String actual, boolean ok) {
record(id, module, name, expected, actual, ok, 0L, "");
return ok;
}
/** 记录一条「跳过」用例:前端没起 / 浏览器不可用时用,不计入通过也不计入失败 */
public static void skip(String id, String module, String name, String expected, String actual) {
record(id, module, name, expected, actual, false, true, 0, actual);
}
}
ReportListener通过META-INF/services/org.junit.platform.launcher.TestExecutionListener注册,Maven 跑完自动调用,把结果画成 PNG:
java
public class ReportListener implements TestExecutionListener {
@Override
public void testPlanExecutionFinished(TestPlan testPlan) {
File out = new File(TestConfig.screenshotDir(), 日期 + "/测试报告_" + 时分秒 + ".png");
ReportImage.render(Cases.all(), "基于微服务的在线判题系统 · 自动化测试报告", out);
System.out.println("报告图已生成:" + out.getAbsolutePath());
}
}
④测试用例 tests 的编写:
①用户端登录页:
- 登录页的基本功能测试:页面能正常加载、表单元素存在、未登录访问受保护页面被拦回登录页。
检查页面的正常加载:
java
@DisplayName("U1 用户端登录页")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class U1_LoginPageTest {
private static final String BASE = TestConfig.feC(); // http://127.0.0.1:5173
@Test
@Order(1)
@DisplayName("登录页可访问并截图")
void openLogin() {
if (!Ui.ready(BASE)) { // 前端没起 / 浏览器不可用 → 记为跳过
Cases.skip("U1-01", M, "打开用户端登录页 /c-oj/login",
"页面标题与登录表单渲染", "跳过:" + Ui.notReadyReason(BASE));
return;
}
Browser b = new Browser("U1");
try {
b.open(BASE + "/c-oj/login");
File f = b.shot("U1-01_用户端登录页"); // 自动截图
String text = b.text();
boolean ok = b.title() != null && !b.title().isBlank()
&& (text.contains("登录") || text.contains("手机号") || text.contains("验证码"));
Cases.check("U1-01", M, "打开用户端登录页 /c-oj/login",
"页面标题非空且出现登录相关文案",
"标题=" + b.title() + " 正文长度=" + text.length(), ok, 0, Ui.note(f));
} finally {
b.close();
}
}
}
检查登录表单元素:
java
@Test
@Order(2)
@DisplayName("登录表单包含输入框与按钮")
void formFields() {
Browser b = new Browser("U1");
try {
b.open(BASE + "/c-oj/login");
int inputs = b.countTags("input");
int buttons = b.countTags("button");
String text = b.text();
File f = b.shot("U1-02_登录表单元素");
// Element Plus 的按钮有时渲染成 div,所以用「原生 button 或页面出现验证码/登录文案」判定
boolean hasAction = buttons >= 1 || text.contains("验证码") || text.contains("登录");
Cases.check("U1-02", M, "登录页包含输入框与登录/验证码操作入口",
"input>=1 且(button>=1 或页面出现验证码/登录文案)",
"input=" + inputs + " button=" + buttons
+ " 正文含验证码=" + text.contains("验证码"),
inputs >= 1 && hasAction, 0, Ui.note(f));
} finally {
b.close();
}
}
检查未登录拦截:
java
@Test
@Order(3)
@DisplayName("未登录访问个人中心被拦截")
void redirectWhenNotLogin() {
Browser b = new Browser("U1");
try {
b.open(BASE + "/c-oj/home/user/detail");
File f = b.shot("U1-03_未登录访问个人中心");
String url = b.currentUrl();
boolean ok = url.contains("login") || b.text().contains("登录");
Cases.check("U1-03", M, "未登录访问 /c-oj/home/user/detail 被重定向",
"落到 /c-oj/login 或页面出现登录文案", "当前地址=" + url, ok, 0, Ui.note(f));
} finally {
b.close();
}
}
②题库页:
- 题库页分「未登录浏览」和「登录后浏览」两种状态,检查列表渲染、分页参数、关键字搜索与题目详情字段。
java
@DisplayName("A3 题库")
public class A3_QuestionTest {
@Test
@DisplayName("题目列表分页")
void list() {
Http.Res r = Http.get("/friend/question/semiLogin/list?pageNum=1&pageSize=5");
long total = r.json().getLongValue("total");
int rows = r.json().getJSONArray("rows") == null ? 0 : r.json().getJSONArray("rows").size();
Cases.check("A3-01", M, "题目列表返回分页数据", "total>=1 且 rows 不超过 pageSize",
"code=" + r.code() + " total=" + total + " rows=" + rows,
r.code() == 1000 && total >= 1 && rows <= 5, r.costMs, "");
}
@Test
@DisplayName("题目详情(需登录)")
void detail() {
String t = Auth.tokenC();
Http.Res r = Http.get("/friend/question/detail?questionId=" + qid, t);
JSONObject d = r.json().getJSONObject("data");
boolean ok = r.code() == 1000 && d != null
&& d.getString("title") != null && d.getString("content") != null
&& d.getString("defaultCode") != null;
Cases.check("A3-04", M, "题目详情返回 title/content/defaultCode",
"code=1000 且三个字段均非空",
"code=" + r.code() + " title=" + (d == null ? null : d.getString("title")), ok, r.costMs, "");
}
}
③答题页与判题:
- 判题结论依赖题目用例的期望输出,测试代码无法先验知道答案,所以这里断言的是系统行为正确:必须返回明确结论、必须给出逐用例明细、编译不过必须回传编译错误。
java
@DisplayName("A5 判题")
public class A5_JudgeTest {
/** 能编译通过但答案错误:用来验证「逐用例明细」是否回传 */
private static final String CODE_WRONG_ANSWER =
"import java.util.Arrays;\n\nclass Solution {\n"
+ " public int[] twoSum(int[] nums, int target) {\n"
+ " return new int[]{0, 0};\n }\n}";
/** 无法编译:用来验证编译错误是否被捕获并回传 */
private static final String CODE_COMPILE_ERROR =
"import java.util.Arrays;\n\nclass Solution {\n"
+ " public int[] twoSum(int[] nums, int target) {\n"
+ " return notExistMethod(nums, target);\n }\n}";
@Test
@DisplayName("提交代码返回判题结论")
void submit() {
Http.Res r = Http.post("/friend/user/question/submit", Auth.tokenC(),
body("1", 0, CODE_WRONG_ANSWER));
JSONObject d = r.json().getJSONObject("data");
Cases.check("A5-01", M, "提交 Java 代码返回判题结论 pass", "code=1000 且 data.pass 非空",
"code=" + r.code() + " pass=" + (d == null ? null : d.get("pass"))
+ " msg=" + (d == null ? null : d.getString("exeMessage")),
r.code() == 1000 && d != null && d.containsKey("pass"), r.costMs, "");
}
@Test
@DisplayName("编译错误被正确捕获")
void compileError() {
Http.Res r = Http.post("/friend/user/question/submit", Auth.tokenC(),
body("1", 0, CODE_COMPILE_ERROR));
String msg = r.json().getJSONObject("data") == null ? null
: r.json().getJSONObject("data").getString("exeMessage");
boolean ok = r.code() == 1000 && msg != null
&& (msg.contains("error") || msg.contains("cannot find symbol"));
Cases.check("A5-03", M, "提交无法编译的代码返回编译错误信息",
"exeMessage 含 error / cannot find symbol", "已返回编译错误", ok, r.costMs, "");
}
}
④管理端页面:
- 管理端登录态同样用接口拿令牌写 Cookie,然后访问题目/竞赛/用户管理页。
java
@DisplayName("U4 管理端页面")
public class U4_AdminPageTest {
private static final String BASE = TestConfig.feB(); // http://127.0.0.1:5174
@Test
@DisplayName("竞赛管理页")
void adminExam() {
Browser b = new Browser("U4");
try {
b.loginBAndOpen(BASE + "/oj/layout/exam"); // 写入 Admin-Oj-b-Token 后打开页面
String text = b.text();
File f = b.shot("U4-03_竞赛管理页");
Cases.check("U4-03", M, "登录后打开 /oj/layout/exam",
"未被踢回登录页且正文非空",
"地址=" + b.currentUrl() + " 正文长度=" + text.length(),
text.length() > 10, 0, Ui.note(f));
} finally {
b.close();
}
}
@Test
@DisplayName("未登录访问管理端被拦截")
void adminGuard() {
Browser b = new Browser("U4");
try {
b.open(BASE + "/oj/layout/question");
File f = b.shot("U4-05_未登录访问管理端");
Cases.check("U4-05", M, "未登录访问 /oj/layout/question 被重定向",
"落到 /oj/login", "当前地址=" + b.currentUrl(),
b.currentUrl().contains("login"), 0, Ui.note(f));
} finally {
b.close();
}
}
}
⑤自动化测试结果与截图
- 一键复跑:在
oj-test目录执行run-all.cmd(或run-api.cmd/run-ui.cmd分别只跑接口/UI); - 跑完控制台直接给出汇总,并自动生成报告图与页面截图。
text
---------------- 用例明细 ----------------
PASS A1-01 网关可访问(匿名题目列表) 预期[HTTP 200] 实际[HTTP 200]
PASS A1-07 C 端令牌访问 B 端接口被拒绝 预期[code=3001] 实际[code=3001 msg=令牌验证失败]
...
PASS A5-02 未通过的提交返回每个用例的期望输出与实际输出 预期[用例数>=1] 实际[用例数=2 pass=0]
FAIL A3-06 热门题目接口 /question/semiLogin/hotList 预期[code=1000] 实际[HTTP 404(接口未实现,已知缺陷)]
...
PASS U4-05 未登录访问 /oj/layout/question 被重定向 预期[落到 /oj/login] 实际[当前地址=http://127.0.0.1:5174/oj/login]
------------------------------------------
用例总数 66,通过 65,失败 1,跳过 0,耗时 77304 ms
报告图已生成:...\oj-test\screenshots\2026-09-28\测试报告_113550.png

- 每个 UI 用例跑完都会自动截图(1440×900),共 15 张页面图 + 1 张报告图:


| 截图 | 对应用例 | 关键观察 |
|---|---|---|
U1-01_用户端登录页.png |
U1-01 | 页面标题非空,正文含登录文案 |
U1-02_登录表单元素.png |
U1-02 | input=2、正文含「验证码」 |
U1-03_未登录访问个人中心.png |
U1-03 | 未登录访问被拦截,出现登录文案 |
U2-01_题库列表页.png |
U2-01 | 正文长度 367,题库/竞赛导航正常 |
U2-02_登录后的题库页.png |
U2-02 | 注入 Cookie 后仍停留在题库页,未被踢回登录页 |
U2-03_竞赛列表页.png |
U2-03 | 正文长度 113,竞赛列表渲染 |
U2-04_答题页.png |
U2-04 | 正文长度 256,题面与编辑区渲染 |
U3-01~03 |
U3-01~03 | 我的竞赛 / 我的消息 / 个人资料三页均可访问 |
U4-01_管理端登录页.png |
U4-01 | 标题「基于oj项目的后台」,input=2 |
U4-02~04 |
U4-02~04 | 题目 / 竞赛 / 用户管理页正文长度 355 / 1098 / 638 |
U4-05_未登录访问管理端.png |
U4-05 | 被重定向回 /oj/login |
全部图片已汇总到
测试截图-2026-09-28/,文件夹内附图片清单.md逐张说明。
(4)性能测试:
测试场景设计(JMeter)
| 场景 | 线程数 | 循环 | 目标接口 | 关注指标 | 通过标准 |
|---|---|---|---|---|---|
| S1 竞赛集中提交 | 50 | 10 | POST /friend/user/question/rabbit/submit |
平均响应、TPS、错误率 | 平均 ≤ 200 ms,错误率 0 |
| S2 题库列表(缓存命中) | 100 | 20 | GET /friend/question/semiLogin/list |
P95、吞吐量 | P95 ≤ 150 ms |
| S3 判题结果轮询 | 50 | 60(每 2 s) | GET /friend/user/question/exe/result |
平均响应、错误率 | 平均 ≤ 100 ms |
bash
# 生成压测计划并执行(JMeter 5.6)
jmeter -n -t plan/oj-submit.jmx -l result/submit.jtl -e -o report/submit
为什么这么设计
- S1 压的是「异步削峰」而不是判题本身:判题耗时取决于用例与容器,压它没有意义;真正要证明的是「50 个并发提交下,提交接口依然保持 200 ms 内返回」------这直接对应开题报告里「提交不阻塞」的目标。
- S2 压缓存:题库列表是访问量最大的读接口,命中 Redis 后应在百毫秒级;未命中会回源 ES/MySQL,正好用来验证缓存是否真的生效。
- S3 压轮询:前端每 2 秒轮询一次结果,50 人同时答题就是 25 QPS 的稳定读压力,这个数字比「峰值 QPS」更能暴露问题。
瓶颈预判与优化方向
| 可能瓶颈 | 现象 | 优化方向 |
|---|---|---|
| 判题容器池耗尽 | 提交后长时间停在「判题中」 | 池容量 sandbox.docker.pool.size 由 4 调大;或按队列长度动态扩容 |
| 结果轮询打库 | exe/result 平均响应升高 |
判题结果落库前先写 Redis 短缓存,轮询读缓存 |
| 竞赛列表缓存未命中 | 列表 P95 明显高于 150 ms | 检查 e:t:l / e:h:l 是否存在;确认定时任务已配置并启动 |
| 网关成为单点 | 所有接口同时变慢 | 网关无状态,水平扩容 + Nginx 前置负载均衡 |
⏳ 说明:本轮性能测试只完成了场景设计,JMeter 压测尚未执行,因此本节不含任何性能数字。不加数据、不编结论。
(5)安全测试
沙箱隔离是这套系统的安全底线,测试用四类「恶意输入」直接打:
| 编号 | 输入 | 期望判定 | 观察点 |
|---|---|---|---|
| SEC-01 | while(true){} 死循环 |
OUT_OF_TIME(超出时间限制) |
单用例 5 秒内被终止,容器未被拖死 |
| SEC-02 | 循环中不断 new byte[1024*1024] |
OUT_OF_MEMORY(超出空间限制) |
Docker stats 采到峰值内存超过题目限制 |
| SEC-03 | 缺少大括号 / 语法错误 | COMPILE_FAILED + stderr 内容 |
不进执行阶段,直接返回编译错误 |
| SEC-04 | 输出与期望不符 | NOT_ALL_PASSED + 逐用例明细 |
每条用例的输入/期望/实际都被记录 |
| SEC-05 | 代码中发起网络请求(new Socket(...)) |
执行异常(网络被禁用) | 容器 NetworkMode=none |
| SEC-06 | 代码尝试写根目录文件 | 写入失败(根文件系统只读) | ReadonlyRootfs=true |
| SEC-07 | 普通用户令牌访问 /system/** |
401「令牌验证失败」 | 网关身份隔离 |
| SEC-08 | 删除令牌后携带旧令牌请求 | 401「登录状态已过期」 | Redis 会话校验生效 |
| SEC-09 | 不带令牌访问 /**/test/** |
⚠️ 实测可写库 | 白名单过宽缺陷(见《缺陷记录与修复验证》) |
其中 SEC-03 / SEC-04 / SEC-07 / SEC-08 本次已实测通过:
- SEC-03:提交
return notExistMethod(...)的代码,服务端回传cannot find symbol编译错误(A5-03); - SEC-04:提交能编译但答案不对的代码,返回
pass=0+exeMessage=未通过所有用例+ 2 条用例明细(A5-01/A5-02); - SEC-07:C 端令牌访问
/system/user/list、B 端令牌访问/friend/exam/rank/list,双向均返回code=3001 令牌验证失败(A1-07/A1-08); - SEC-08:登出后再用原令牌请求
/system/sysUser/info,返回code=3001(A6-08)。
沙箱安全设计(对照验证点)
java
// oj-judge:容器创建时的硬约束(JudgeConstants / DockerSandBoxPoolConfig)
HostConfig hostConfig = new HostConfig()
.withNetworkMode("none") // ① 禁网
.withReadonlyRootfs(true) // ② 根文件系统只读
.withMemory(100000000L) // ③ 内存上限 ~100MB
.withMemorySwap(100000000L)
.withCpuCount(1L); // ④ CPU 1 核
// ⑤ 单用例超时:awaitCompletion(5, TimeUnit.SECONDS)
说明:SEC-01/SEC-02/SEC-05/SEC-06 属于容器隔离验证,需要构造恶意程序逐个提交,本轮未复跑,标注为待复测。
缺陷记录与修复验证

本次共记录并修复 20 个缺陷 (致命 7 / 严重 7 / 一般 6),集中在「竞赛成绩结算与排行榜」链路;另在本次复测中新发现 4 个仍未修复或未生效的问题(见《本轮复测新发现》)。完整报告见项目内《竞赛排名功能修复报告》。
致命缺陷(7 条)
| # | 缺陷 | 根因 | 修复 | 验证 |
|---|---|---|---|---|
| 1 | 结算任务遇到「零提交竞赛」直接中断 | userScoreMap.get(examId) 返回 null 后调 .size() |
加判空,无参赛者则 WARN 跳过,不中断整个任务 | 编译通过;全链路演练通过 |
| 2 | 名次永远写不进库 | <foreach separator=";"> 拼成多条 UPDATE,而 JDBC URL 未开 allowMultiQueries |
改为单条 UPDATE ... CASE user_id WHEN ... END |
旧 SQL ❌ 语法错误 / 新 SQL ✅ 成功 |
| 3 | 排名消息写入必然失败 | message_title varchar(10),原标题「竞赛名 + ------排名情况」长 17~23 字符 |
标题固定为「竞赛排名结果」(6 字符) | 长标题 ❌ Data too long / 短标题 ✅ 成功 |
| 4 | 漏跑一次就永久漏结算 | 只扫描「最近 24 小时内结束」的竞赛 | 改为状态驱动:status=1 AND end_time<=NOW() AND exam_rank IS NULL |
新 SQL ✅ 返回 4 场未结算竞赛 |
| 5 | 竞赛练习拿到「关系表主键」而不是题目 id | examQuestionId 误当作 questionId 建缓存 |
改用 getQuestionId() 构建顺序缓存 |
接口返回真实题目 id,详情可查 |
| 6 | 竞赛无题目时三个接口全部 NPE | indexForList 返回 null 后 .toString() |
判空并返回明确业务码 | 4 场无题目竞赛不再报错 |
| 7 | 题库列表雪花 ID 丢精度 + 答案泄漏 | 接口直接返回 ES 实体(Long 超出 JS 精度范围,且带出用例与模板) | 改为返回 QuestionVO + @JsonSerialize(ToStringSerializer) |
⚠️ 复测发现只修好了 ID 精度,字段泄漏仍在(见《本轮复测新发现》) |
严重缺陷(7 条)
| # | 缺陷 | 修复要点 |
|---|---|---|
| 8 | 排行榜缓存用 rightPushAll 追加,刷新一次翻一倍 |
统一「先 deleteObject 再写」 |
| 9 | 参赛者取自提交表,0 分参赛者从排行榜消失 | 改为 tb_user_exam LEFT JOIN 提交聚合 |
| 10 | 排行榜 userId 前端丢精度 |
补 @JsonSerialize(ToStringSerializer.class) |
| 11 | 排行榜接口 3 处健壮性问题 | examId 判空(原会 e:r:l:null 全表扫描)、pageNum/pageSize 空串 NPE、nickName 为 null 时 500 |
| 12 | 「未完赛」列表残留已完赛竞赛 | refreshCache() 空结果提前 return 导致旧缓存永不删除(TTL = -1)→ 把 delete 提到判空之前 |
| 13 | 「我的竞赛」两个按钮点了没反应 | Vue 3 模板调用了 <script setup> 里未定义的函数(togglePopover/goHistoryExam/goExam) |
| 14 | 同类前端问题(Exam.vue) |
同一处修复思路;vite build 全量构建成功(15.4 s) |
一般缺陷(6 条)
| # | 缺陷 | 修复要点 |
|---|---|---|
| 15 | 未结算的排行榜被缓存(名次全 0 长期驻留) | 未结算不写缓存 |
| 16 | pom.xml 重复声明 spring-boot-starter-aop |
去掉重复依赖,构建不再告警 |
| 17 | 名次并列策略不符合竞赛惯例 | 由「依次 +1」改为标准竞赛排名:100/100/90 → 1/1/3 |
| 18 | 题目上下题同款 NPE 与错误提示 | 判空 + 修正为 3502「已经是最后一题」 ⚠️ 复测未生效(见 #23) |
| 19 | 题库顺序缓存只 push 不 delete | 改为先删后写,重复刷新不再压两遍 |
| 20 | 题目查不到时返回 null 导致白屏 |
改为抛 3003 资源不存在 ⚠️ 复测未生效(见 #24) |
关键验证证据(可核对)
① 编译验证
text
bite-oj / oj-common-* / oj-system / oj-friend / oj-job / oj-judge / oj-gateway
17 个模块全部 SUCCESS ------ BUILD SUCCESS(无 duplicate declaration 警告)
② SQL 级验证(真实库 bitoj_dev)
| 验证项 | 结果 |
|---|---|
| 旧的多语句 UPDATE | ❌ 报语法错误(证实缺陷 2) |
新的 CASE WHEN 单语句 UPDATE |
✅ 执行成功(影响 0 行,事务回滚) |
message_title 插入长标题 |
❌ MysqlDataTruncation: Data too long(证实缺陷 3) |
message_title 插入短标题 |
✅ 成功(6 字符 < 10) |
新 selectUnSettledExamList |
✅ 返回 4 场未结算竞赛(正确排除未发布竞赛) |
新 selectUserScoreList |
✅ 返回 5 名参赛者(旧 SQL 返回 0 行) |
新 selectExamRankList |
✅ 按名次正确排序 |
③ 全链路结算演练(事务内执行 → 回滚,未改动真实数据)
text
1. 未结算竞赛:1234567890123456790、2063141413736497154、2063140935548092418、2063137015081840642
2. 参赛者聚合:4 场竞赛共 5 人(含 0 分参赛者)
3. 并列名次 + 批量更新:影响 5 行,100/100 场景并列第 1
4. 排行榜查询:4 场竞赛全部能查出「名次 = 1」的记录
5. 排名消息写入:短标题插入成功
6. 回滚后核对 tb_user_exam:总数 = 6,名次为空 = 6(真实数据未被改动)
④ 真机触发结算后的数据(2026-09-11 16:16)
text
tb_user_exam:6 条报名记录 → 5 条已结算(score=0, exam_rank=1, update_time=16:16:28~30)
剩余 1 条属于未发布竞赛(status=0),被 selectUnSettledExamList 正确排除
tb_message_text / tb_message:各 5 条,标题为「竞赛排名结果」
Redis:e:r:l:1234567890123456790 等 4 个排名缓存已写入,并列名次生效(2 人同为第 1 名)
⑤ Redis 只读核对(本次自动化 A7 组实时读取)
text
A7-02 Redis 连通:PING → PONG
A7-03 题目:数据库=4 接口=4 ✅ 一致
A7-04 竞赛:数据库=9 B端=9 C端=7 ✅ C 端只返回已发布竞赛
A7-05 用户:数据库=6 接口=6 ✅ 一致
A7-06 e:t:l(未结束竞赛缓存)len=1 TTL=-1 ← 永不过期,属缓存策略风险
A7-07 e:r:l:* 共 4 个键,类型=list,内容含 examRank
⑥ 修复前的问题数据(对照)
text
tb_user_exam:total=6 has_score=0 has_rank=0 ← 成绩与名次从未写入
tb_user_submit:total=2 practice_cnt=2 exam_cnt=0 ← 竞赛提交数为 0
tb_message_text.message_title:varchar(10),实际拼接长度 17~23 → Data too long
本轮复测新发现(尚未修复)
| # | 问题 | 复测证据 | 影响 |
|---|---|---|---|
| 21 | 题库列表接口泄漏测试用例 | GET /friend/question/semiLogin/list 返回字段:questionId / title / difficulty / timeLimit / spaceLimit / content / **questionCase** / **mainFuc** / **defaultCode** / createTime |
用户可在题库列表直接看到每道题的期望输出与 main 模板,等于开卷;与缺陷 7「已改为返回 QuestionVO」的结论不一致,需确认线上部署版本 |
| 22 | 热门榜单接口缺失 | GET /friend/question/semiLogin/hotList 返回 HTTP 404;自动化用例 A3-06 稳定复现 |
前端首页热榜长期为空且无任何提示,用户会以为「没有热门题目」 |
| 23 | 末题「下一题」提示串成了「第一题」 | 题库顺序为 [2066327336162701313, 2062343373807177730, 2, 1]:首题 preQuestion 正确返回 3501 已经是第一题;但末题 nextQuestion 返回的仍是 3501 当前题目已经是第一题了哦 ,而 3502 才是「已经是最后一题了哦」 |
用户在最后一题点「下一题」时被提示「已经是第一题」,与缺陷 18「修正为 3502」的修复结论不符 |
| 24 | 题目不存在时返回 data:null 白屏 |
GET /friend/question/detail?questionId=999999999 → {"code":1000,"msg":"操作成功","data":null},并非 3003 资源不存在 |
与缺陷 20 的修复结论不符,前端拿到 null 会白屏 |
这 4 条正是「测试用例自动化」的价值:只要接口没修,用例就会一直红着 ,不会因为报告写过了就被当成已解决。也说明一件事------修复报告里的「已修复」必须以复测为准,本次 4 条里有 3 条(#21/#23/#24)对应的缺陷在修复报告里都写着「已修复」。
总结测试 tips:
- 先测试部分接口功能是否能正常实现,再进行整体测试,要注意各个接口之间的关联关系。 本次排行榜的 4 个致命缺陷不是「某个接口报错」,而是「结算任务 → 名次写库 → 排行榜缓存 → 消息通知」四段链路各错一处,单测任何一段都发现不了,必须端到端跑一遍。
- 用真实数据做证据,别只看日志。
has_score=0 / has_rank=0、exam_cnt=0、message_title varchar(10)这三条,是从真实库里查出来的,比「功能异常」四个字有用一百倍。所以本次测试工程直接内置了Db.java/Redis.java,用select count(*)和ttl去核对接口返回的数字。 - 把「先删后写」当成缓存更新的默认动作。 本次有 3 个缺陷是同一个根因:刷新时只 push 不 delete,或空结果提前 return 导致旧缓存永不清理,而列表 key 的 TTL 又是 -1。
- 幂等的入口要选对维度。 结算从「时间窗口驱动」改成「是否已结算驱动」之后,任务可以随便重跑,历史漏跑也能自动补齐------这是可测试性的直接来源。
- 断言要断言「系统行为」,不要断言「你猜的答案」。 判题用例无法先验知道题目的期望输出,所以
A5组断言的是「必须返回明确结论、必须给出逐用例明细、编译不过必须回传编译错误」,而不是硬写pass == 1。 - 环境卡住时换工具,而不是降低标准。 本机 Chrome 154 配不上 chromedriver、又没网下载,Selenium 方案直接跑不动;换成 Chrome 自带的 CDP 之后,不但截图照出,还顺手把「注入登录 Cookie 访问受保护页面」做得更简单了。
- 自动化用例要照顾「有限资源」。 验证码每天只有 3 次额度,所以登录令牌要落盘复用;额度真用完时自动切备用号码------测试工程本身也要能被反复运行,否则它只会被执行一次。
- 安全测试不要只测「能不能进」。 容器隔离要拿死循环、内存溢出、编译错误、用例不通过四类输入去压;白名单要拿 AntPathMatcher 的真实匹配结果去验证(本项目就发现
/**/test/**能匹配/system/test/add,不带令牌即可写库)。
说明 :本文的接口与自动化实测数据来自 2026-09-28 在本机真实环境(MySQL @3307、Redis、Nacos、ES、RabbitMQ、XXL-Job Admin、Docker、网关 + 5 服务、两套前端全部运行)下执行
oj-test测试工程的记录;缺陷与结算部分的数据来自 2026-09-11 的修复验证记录与项目内《竞赛排名功能修复报告》。测试工程与脚本已随项目提供,把中间件与服务启动后执行run-all.cmd即可复跑;标注为「待复测」的用例本轮未执行,不代表已通过。