测试博客-基于微服务的在线判题系统

【在线判题系统全维度测试报告:功能与自动化双轨验证】

项目:bite-oj 基于微服务的在线判题系统 | 作者:方文卓 | 记录日期:2026-09-28

测试对象:网关 + 5 个微服务 + 2 套前端(管理端 / 用户端)+ Docker 判题沙箱

记录方式:真机环境实测记录 + 可复跑工程(oj-test 测试工程与本报告同源,run-all.cmd 一键复跑)

🌈个人主页: 方文卓的博客

🔥热门专栏: 微服务实战 | 软件测试 | Docker 代码沙箱

💫个人格言: "没有罗马,那就自己创造罗马~"

文章目录
项目名称 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 发验证码(Redis p:c:{手机号},5 分钟有效,当日上限 3 次)→ POST /friend/user/code/login 校验后签发令牌
  • 令牌:HS512 签名 JWT,payload 只放 userId 与随机 userKey;登录态 LoginUser{identity, nickName, headImage} 存 Redis logintoken{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:优先读 Redis e: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&currentTime=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 即可复跑;标注为「待复测」的用例本轮未执行,不代表已通过。

相关推荐
xfan_me2 小时前
维修保养记录精准版 API 对接实战指南
java·大数据·python
Keven-zhou2 小时前
不用先学前端框架,Java后端也能独立交付项目?飞算JavaAI实测
java
海上小飞龙2 小时前
改一个数,右边全得重算,这题怎么扛住两万次查询
java·c++·python
Leo.yuan2 小时前
从“看全局“到“评成效“:央国企穿透式监管六步链路,哪些厂商能真正闭环
java·大数据·人工智能
小马哥程序开发2 小时前
[点赞收藏免费领取 · 项目源码]57105基于Spring Boot的充电桩管理系统的设计与实现
java·spring boot·源码·课程设计·毕设·大作业·课设
九皇叔叔3 小时前
【09】SpringBoot4 MyBatisPlus 增删改查(CRUD)
java·mybatis·mybatisplus
天远Date Lab3 小时前
微服务架构实战:基于天远学历信息高级版构建自动化人才准入网关
人工智能·微服务·架构·自动化
白杨尚青3 小时前
C++入门篇(十):string(上)——认识string:构造与三大遍历(一条龙讲透operator[]、迭代器、auto、范围for)
java·开发语言·c++·笔记·stl
anew___4 小时前
《从零手写操作系统 (11):文件系统初探——initrd与VFS抽象层》
java·javascript·算法