简介:这是一套基于Java技术栈开发的在线考试系统完整源码,面向具备一定Java Web基础、希望学习企业级项目架构或二次开发考试平台的开发者。后端采用Spring MVC、MyBatis与FreeMarker组合,前端则融合Bootstrap、jQuery及MVVM框架Vue.js,覆盖了从服务端渲染到前后端分离的典型实践,适合作为课程设计、毕业设计或技术练手的参考项目。压缩包共332个文件,约10.33MB,其中47个Java源文件承载核心业务逻辑,22个HTML页面与58个JS脚本构建交互界面,69个CSS及2个LESS、2个SCSS文件负责样式,另有14个XML配置、1个SQL建表脚本及若干图片、字体资源,结构完整。目前已有1111人学习下载。通过研读源码,读者可掌握考试系统的模块划分、数据库设计、权限控制与前后端数据交互思路,并借鉴Vue与jQuery混用的工程组织方式,为自身项目开发积累可复用的排错与架构经验。
1. 拿到一份 Java 在线考试系统源码,先别急着改代码
很多人拿到Java在线考试系统源码.zip的第一反应是解压、找启动类、点运行,然后被一堆报错劝退。我带过几个刚入行的 Java 工程师做这类二次开发,最常见的翻车现场不是代码写错,而是根本没搞清楚这套系统在业务上要解决什么。在线考试系统的核心链路其实就四条:题库管理、组卷策略、考试过程控制、自动判分与成绩归档。你拿到手的源码,本质上就是这四条链路的工程化实现,只是不同作者的取舍不同。
这套东西适合谁?一是课程设计或毕设需要交差的学生,二是中小型培训机构想快速搭一套内部测评工具,三是想通过读一套完整业务系统源码来补 Spring Boot + MyBatis 实战经验的 Java 工程师。它不适合直接拿去做高并发、防作弊要求极高的正式考试平台,那是另一个量级的工程。这篇笔记就按「先跑通、再拆解、后改造」的顺序,把这份源码从解压到能改出自己功能的全过程讲清楚,中间会穿插我踩过的坑和参数设置经验。
2. 环境准备与最小可运行验证:把 zip 变成能访问的登录页
2.1 先确认技术栈,别用错 JDK 版本
在线考试系统源码绝大多数是 Spring Boot + MyBatis-Plus + MySQL + 前端模板(Thymeleaf 或 Vue 打包后塞进 static)。在动手之前,先解压看三样东西:pom.xml里的spring-boot-starter-parent版本、application.yml里的数据库配置、src/main/resources下有没有sql初始化脚本。这三样决定了你后面所有操作。
我一般会先执行一条命令把依赖树拉下来,顺便验证 Maven 能不能正常解析:
# 进入项目根目录,跳过测试编译,只拉依赖 mvn dependency:resolve -DskipTests逻辑说明:这一步不启动应用,只让 Maven 把pom.xml里声明的所有依赖下载到本地仓库。如果这里就报Could not resolve dependencies,说明要么网络仓库配置有问题,要么某个依赖版本在中央仓库不存在。参数-DskipTests是防止某些项目在 resolve 阶段触发测试编译。
参数说明:如果你的项目用的是 Gradle,把命令换成./gradlew dependencies。JDK 版本看pom.xml里的<java.version>,常见是 1.8 或 11。用 JDK 17 去跑一个为 1.8 写的 Spring Boot 2.x 项目,大概率会遇到module java.base does not open java.lang这类反射报错,别硬扛,装个对应版本。
2.2 数据库初始化:三个必须核对的字段
找到sql文件夹里的建表脚本,导入 MySQL。这里有个血泪经验:很多源码的建表语句里,create_time和update_time用的是datetime而不是timestamp,如果你用 MyBatis-Plus 的自动填充功能,实体类里字段类型必须和数据库对齐,否则插入时会报Data truncation。
-- 典型题库表结构,注意字段类型和注释 CREATE TABLE `question` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `subject_id` bigint(20) NOT NULL COMMENT '科目ID', `question_type` tinyint(4) NOT NULL COMMENT '1单选 2多选 3判断 4简答', `content` text NOT NULL COMMENT '题干', `options` json DEFAULT NULL COMMENT '选项,JSON数组', `answer` varchar(255) NOT NULL COMMENT '正确答案', `score` int(11) DEFAULT '5' COMMENT '分值', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_subject` (`subject_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:options字段用json类型存选项,比用逗号分隔字符串更规范,但要求 MySQL 5.7 以上。question_type用tinyint而不是varchar,是为了后面组卷时按类型抽题能用索引。answer字段对于多选题,常见做法是存"A,B,C"这种格式,判分时先排序再比对。
参数说明:utf8mb4是必须的,否则题干里出现生僻字或数学符号会乱码。score默认值设 5 分,实际项目里应该由组卷策略动态决定,这里只是兜底。
2.3 启动类与端口冲突排查
改完application.yml里的spring.datasource.url、username、password,直接运行启动类。如果控制台报Port 8080 was already in use,别急着重启电脑,先查是谁占了端口:
# Linux/Mac 查端口占用 lsof -i :8080 # Windows 查端口占用 netstat -ano | findstr :8080逻辑说明:找到 PID 后,要么 kill 掉那个进程,要么在application.yml里把server.port改成 8081。我一般会改成 9090,避开常见冲突段。
参数说明:server.servlet.context-path如果源码里设了/exam,那你访问的地址就是http://localhost:9090/exam,别只访问根路径然后说 404。
3. 核心模块拆解:题库、组卷、判分三条链路怎么读
3.1 题库管理:从 Excel 导入到 JSON 选项的转换
题库模块的入口通常在QuestionController,重点看两个方法:importQuestions和pageQuery。导入功能很多源码用的是 EasyExcel 或 POI,读进来的行数据要转成Question实体。这里最容易翻车的是选项字段的转换。
// 把 Excel 里的 "A.选项一|B.选项二|C.选项三" 转成 JSON 数组 public String parseOptions(String rawOptions) { if (StringUtils.isBlank(rawOptions)) { return null; } String[] parts = rawOptions.split("\\|"); List<Map<String, String>> optionList = new ArrayList<>(); for (String part : parts) { // 按第一个点号分割,避免选项内容里也有点号 int dotIndex = part.indexOf('.'); if (dotIndex > 0) { Map<String, String> opt = new HashMap<>(); opt.put("key", part.substring(0, dotIndex).trim()); opt.put("value", part.substring(dotIndex + 1).trim()); optionList.add(opt); } } return JSON.toJSONString(optionList); }逻辑说明:用indexOf('.')而不是split("\\."),是因为选项内容本身可能包含点号,比如「1.5 倍」。key存 A/B/C,value存选项文本,前端渲染时直接遍历这个 JSON 数组。
参数说明:rawOptions的分隔符要和 Excel 模板里的一致,常见是|或换行符。如果源码里写死了|,你导入的 Excel 就必须用|分隔,否则整行选项会变成一个。
3.2 组卷策略:随机抽题的 SQL 与权重控制
组卷是在线考试系统最核心也最容易写乱的部分。常见做法是在PaperService里根据试卷规则(每个题型抽几道、每题几分)去题库里随机查。很多源码直接用ORDER BY RAND(),数据量小的时候没问题,上万道题就会明显变慢。
-- 按科目和题型随机抽 N 道题,避免全表 RAND() SELECT * FROM question WHERE subject_id = #{subjectId} AND question_type = #{type} AND id >= (SELECT FLOOR(RAND() * (SELECT MAX(id) FROM question WHERE subject_id = #{subjectId} AND question_type = #{type}))) ORDER BY id LIMIT #{count};逻辑说明:先算出该科目该题型下的最大 ID,然后随机取一个起始 ID,再往后取 N 条。这样能走主键索引,比ORDER BY RAND()快一个数量级。缺点是抽出的题在 ID 上连续,如果题库是按知识点顺序录入的,可能抽到同一知识点的题。要解决这个问题,可以在WHERE里再加一个knowledge_point的随机条件。
参数说明:count是每种题型要抽的数量,来自试卷规则表。subjectId和type是组卷时的入参。如果源码里用的是ORDER BY RAND(),你可以按这个思路优化,但要注意改完后测试抽题分布是否均匀。
3.3 自动判分:单选、多选、判断的差异化处理
判分逻辑在ExamService.submitPaper里。单选和判断直接字符串比对,多选必须先排序再比对,否则"A,B,C"和"C,B,A"会被判错。
public boolean judgeAnswer(String userAnswer, String correctAnswer, int questionType) { if (userAnswer == null || correctAnswer == null) { return false; } if (questionType == 2) { // 多选 String[] userArr = userAnswer.split(","); String[] correctArr = correctAnswer.split(","); Arrays.sort(userArr); Arrays.sort(correctArr); return Arrays.equals(userArr, correctArr); } // 单选和判断直接忽略大小写和首尾空格 return userAnswer.trim().equalsIgnoreCase(correctAnswer.trim()); }逻辑说明:多选排序后比对,能兼容用户以任意顺序选择。单选和判断用equalsIgnoreCase是为了防止前端传"a"而数据库存"A"这种低级问题。
参数说明:questionType的枚举值要和数据库里question_type字段的定义一致。如果源码里多选判分用的是contains,那用户选"A,B,C,D"而正确答案是"A,B"时会被误判为正确,这是必须改的 bug。
4. 避坑与排查:源码跑不起来时先看这五条
4.1 启动报Table 'xxx' doesn't exist
现象:应用启动时控制台刷Table 'exam.question' doesn't exist。 原因:数据库连接成功,但建表脚本没执行,或者执行到了错误的 database 里。 解决:确认application.yml里url的 database 名和建表脚本里的USE语句一致。我习惯在导入 SQL 前先手动CREATE DATABASE exam DEFAULT CHARSET utf8mb4;,再USE exam;然后执行建表。
4.2 登录页能打开但验证码不显示
现象:访问登录页,验证码图片位置是裂图或空白。 原因:验证码生成依赖的kaptcha或hutool-captcha没配好,或者前端请求的验证码接口路径被 Spring Security 拦截了。 解决:检查SecurityConfig里有没有放行/captcha或/code这类路径。如果是前后端分离项目,还要看跨域配置里有没有允许图片接口的GET请求。
4.3 提交试卷后成绩为 0
现象:考试能正常提交,但成绩查询里全是 0 分。 原因:判分时用户答案和正确答案的字段没对上,常见于前端传的是选项 ID 而数据库存的是选项文本。 解决:在submitPaper方法里打日志,把userAnswer和correctAnswer都打印出来,一眼就能看出格式差异。如果是选项 ID 对文本,要么改前端传文本,要么在判分前先查一次选项表做映射。
4.4 MyBatis-Plus 分页查询返回全部数据
现象:题库列表接口没传page和size,结果返回了全表数据。 原因:分页插件没配置,或者Page对象没作为第一个参数传给 Mapper。 解决:检查有没有MybatisPlusInterceptor的PaginationInnerInterceptorBean。如果有,再看 Controller 里是不是直接return questionService.list();而不是questionService.page(new Page<>(page, size), wrapper);。
4.5 考试倒计时结束后没有自动交卷
现象:前端倒计时归零,页面没反应,用户还能继续答题。 原因:自动交卷逻辑写在前端setInterval里,但用户切到其他标签页后浏览器会节流定时器。 解决:后端在开始考试时记录start_time,每次保存答案时校验now - start_time > duration,超时直接拒绝保存并触发交卷。前端倒计时只做展示,不做最终裁决。
5. 二次开发进阶:把固定试卷改成随机组卷并加防重复提交
5.1 从固定试卷到随机组卷的改造点
很多源码的试卷是提前在paper表里存好题目 ID 列表的,这种叫固定试卷。要改成随机组卷,核心是加一张paper_rule表,记录每个题型抽几道、每题几分、从哪个科目抽。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| paper_name | varchar(100) | 试卷名称 |
| subject_id | bigint | 科目 ID |
| question_type | tinyint | 题型 |
| question_count | int | 抽取数量 |
| score_per_question | int | 每题分值 |
改造步骤:一是在PaperService里新增generatePaperByRule方法,遍历规则表,按 3.2 的 SQL 抽题;二是把抽到的题目 ID 和试卷 ID 写入paper_question关联表;三是考试开始时,如果试卷是随机组卷,就实时生成一份快照,避免多个考生抽到同一套题导致答案泄露。
5.2 防重复提交:用 Redis 做考试会话锁
在线考试最怕同一个考生开两个浏览器窗口同时答题,或者网络卡顿时疯狂点提交。我一般用 Redis 的setIfAbsent做一把会话锁。
// 考试开始时加锁,key 为 exam:session:{userId}:{paperId} public boolean startExam(Long userId, Long paperId) { String lockKey = "exam:session:" + userId + ":" + paperId; Boolean success = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(120)); if (Boolean.FALSE.equals(success)) { throw new BusinessException("您已有正在进行的考试,请勿重复进入"); } return true; } // 交卷成功后释放锁 public void releaseExamLock(Long userId, Long paperId) { String lockKey = "exam:session:" + userId + ":" + paperId; redisTemplate.delete(lockKey); }逻辑说明:setIfAbsent是原子操作,能保证同一考生同一试卷只能有一个活跃会话。过期时间设 120 分钟,比考试时长多 30 分钟,防止考生交卷后锁没释放导致下次进不去。
参数说明:Duration.ofMinutes(120)里的 120 要根据实际考试时长调整。如果源码里没有 Redis,可以用数据库唯一索引替代,但性能会差一些。
5.3 验证改造是否生效的三个检查点
改完随机组卷和会话锁后,别急着上线。先做三个验证:第一,用两个不同账号同时进入同一场考试,确认抽到的题目不一样;第二,用同一个账号开两个浏览器窗口,确认第二个窗口被拦截;第三,考试中途关掉浏览器再重新进入,确认能恢复到之前的答题进度(这需要你在保存答案时把进度也存到 Redis 或数据库)。
我自己的习惯是,每次改完组卷逻辑,都会手动抽 10 次试卷,把题目 ID 打印出来看分布,确认没有连续抽到同一知识点的题。这个笨办法帮我省过好几次线上事故。希望帮到你。
本文还有配套的精品资源,点击获取