
简介这是一套基于Java的在线考试系统完整开发资料采用SSM框架搭配Vue前端实现适合计算机专业学生用于毕业设计或课程设计也适合想练习前后端分离项目的中级开发者。系统分为前台与后台两大模块考生端可完成首页浏览、个人中心、考试成绩查询、公告与考生通知查看及在线考试管理员端则涵盖用户管理、成绩管理、公告通知、考生通知、试题与试卷管理以及考试管理等核心功能。压缩包共534个文件约15.54MB包含95个Java源文件与95个class文件、43个Vue组件、34个XML配置、19个JS脚本以及SQL建库脚本、bat启动脚本和论文文档等源码、数据库与论文齐全。项目基于Maven构建需JDK 1.8、Tomcat 7至9及MySQL 5.7或8.0环境运行。目前已有159人学习读者可据此快速搭建可运行系统理解SSM与Vue的整合思路并直接参考论文结构完成毕设撰写。1. 在线考试系统为什么成了毕设选题里的“硬通货”每年到了毕设选题季总有一批同学在“做个小工具”和“做个管理系统”之间反复横跳。我的观察是如果你打算走 Java 后端路线又希望项目能同时满足“功能完整、技术栈主流、答辩好讲、代码量可控”这四个条件那基于 SSM 框架的在线考试系统几乎是命中率最高的选择。它不像电商秒杀那样对高并发有硬性要求也不像推荐系统那样依赖算法调参核心逻辑就是“出题、组卷、考试、判分、统计”这条闭环业务边界清晰但该有的技术点一个不少权限控制、事务管理、前后端分离、数据库设计、定时任务、文件导入导出。这个选题真正值钱的地方在于它逼着你去处理“考试”这个场景里特有的约束。比如考试时间到了必须自动交卷这个动作不能依赖前端倒计时因为用户关掉浏览器倒计时就失效了再比如同一份试卷不同学生看到的题目顺序应该不同防止邻座抄袭还有主观题和客观题判分逻辑完全不同客观题自动判主观题要留给老师批阅。这些细节才是答辩时老师愿意听的东西也是你简历上能写“负责考试流程状态机设计”而不是“增删改查”的底气。适合谁做如果你已经学过 Java Web能看懂 Controller、Service、Mapper 三层结构但还没独立做过一个完整项目这个方向能让你把零散的知识点串成一条线。如果你已经工作但想补一个拿得出手的练手项目它同样合适因为 SSM 虽然不算新但企业里大量存量系统还在用理解这套东西对维护老项目有直接帮助。下面我会按“先跑通再优化”的思路把从环境搭建到考试核心逻辑的完整路径拆开讲中间会重点说几个我踩过的坑。2. 从零搭起 SSM Vue 的工程骨架2.1 后端分层与依赖版本怎么定SSM 指的是 Spring SpringMVC MyBatis这套组合的版本兼容性是个玄学问题。我一般会锁定一组经过验证的版本避免在依赖冲突上浪费时间。Spring 用 5.3.xMyBatis 用 3.5.xMyBatis-Spring 用 2.0.x数据库驱动根据 MySQL 版本选 8.0.x 的mysql-connector-java。如果你用 Maven 管理直接在pom.xml里把版本号写成属性方便统一升级。!-- pom.xml 关键依赖版本号用属性管理 -- properties spring.version5.3.30/spring.version mybatis.version3.5.13/mybatis.version mybatis.spring.version2.0.7/mybatis.spring.version mysql.version8.0.33/mysql.version /properties dependencies !-- Spring 核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency !-- SpringMVC -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- MyBatis -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version${mybatis.spring.version}/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version${mysql.version}/version /dependency !-- 连接池用 Druid 或 HikariCP 都行 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency /dependencies逻辑说明把版本号抽成属性是为了后面换版本时只改一处。MyBatis-Spring 这个包容易被漏掉它的作用是把 MyBatis 的SqlSessionFactory交给 Spring 管理没有它你就得手动开 session事务也没法用注解控制。参数上注意 MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver写错了启动直接报错。2.2 数据库表设计别把题目和试卷混在一起在线考试系统的表设计有个常见误区把题目直接塞进试卷表的一个字段里用 JSON 存。这样做查询是方便但后面想统计“某道题的正确率”就非常痛苦。我建议至少拆成五张核心表用户表、题目表、试卷表、试卷题目关联表、考试记录表。-- 题目表所有题目存在这里用 type 区分题型 CREATE TABLE question ( id bigint NOT NULL AUTO_INCREMENT, content text NOT NULL COMMENT 题干, type tinyint NOT NULL COMMENT 1单选 2多选 3判断 4简答, options json DEFAULT NULL COMMENT 选项JSON数组, answer varchar(500) NOT NULL COMMENT 标准答案, score int NOT NULL DEFAULT 0 COMMENT 分值, subject_id bigint NOT NULL COMMENT 所属科目, PRIMARY KEY (id), KEY idx_subject (subject_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 试卷题目关联表试卷和题目多对多同时记录题目顺序 CREATE TABLE paper_question ( id bigint NOT NULL AUTO_INCREMENT, paper_id bigint NOT NULL, question_id bigint NOT NULL, sort int NOT NULL DEFAULT 0 COMMENT 题目在试卷中的顺序, PRIMARY KEY (id), UNIQUE KEY uk_paper_question (paper_id,question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 考试记录表一个学生一次考试一条记录 CREATE TABLE exam_record ( id bigint NOT NULL AUTO_INCREMENT, paper_id bigint NOT NULL, user_id bigint NOT NULL, start_time datetime NOT NULL, submit_time datetime DEFAULT NULL, score int DEFAULT NULL COMMENT 总分主观题未批阅时为 NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0进行中 1已交卷 2已批阅, PRIMARY KEY (id), KEY idx_user_paper (user_id,paper_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明paper_question表里的sort字段很关键它决定了题目在试卷上的展示顺序。如果你想实现“每个学生题目顺序不同”可以在生成考试记录时把题目顺序打乱后存到另一张exam_question_order表里而不是改paper_question因为后者是试卷的固定属性。exam_record的status字段是状态机的核心后面自动交卷和批阅都靠它判断。2.3 前端 Vue 工程初始化与跨域处理前端用 Vue CLI 或 Vite 创建项目都行我习惯用 Vite启动快。关键是配置代理解决开发阶段的跨域问题不要在后端加CrossOrigin到处飞那样上线后还得删。// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { // 所有 /api 开头的请求转发到后端 /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })逻辑说明changeOrigin: true是为了让后端收到的 Host 头是目标地址有些安全校验会检查这个。rewrite把/api前缀去掉这样后端接口不用统一加前缀保持干净。前端请求时统一用/api/exam/list这种写法上线后由 Nginx 做同样的转发即可代码不用改。3. 考试核心流程组卷、开考、自动交卷3.1 随机组卷的两种策略与实现组卷有两种常见需求一种是老师手动挑题一种是按规则随机抽题。手动挑题就是往paper_question里插记录没什么好说的。随机抽题要按题型和分值从题库里抽这里有个坑如果直接用ORDER BY RAND()数据量大了性能会崩。我一般用“先查 ID 再随机取”的方式。// PaperService.java 随机组卷核心逻辑 public void randomGeneratePaper(Long paperId, MapInteger, Integer typeCountMap) { // typeCountMap: 题型 - 抽取数量例如 {1:10, 2:5, 3:5} for (Map.EntryInteger, Integer entry : typeCountMap.entrySet()) { Integer type entry.getKey(); Integer count entry.getValue(); // 第一步只查 ID不查全字段 ListLong ids questionMapper.selectIdsByType(type); if (ids.size() count) { throw new BizException(题库中该题型数量不足); } // 第二步内存中打乱取前 count 个 Collections.shuffle(ids); ListLong selected ids.subList(0, count); // 第三步批量插入关联表 paperQuestionMapper.batchInsert(paperId, selected); } }逻辑说明selectIdsByType只返回主键数据量再大内存也扛得住。Collections.shuffle用的是默认随机源如果对随机性有更高要求可以用SecureRandom但考试场景没必要。batchInsert要在 MyBatis 的 XML 里用foreach拼批量插入比循环单条插入快一个数量级。参数上注意subList返回的是视图如果后面还要改ids会有问题这里直接用来插入所以没事。3.2 开考接口的幂等与时间校验学生点击“开始考试”时后端要做三件事检查是否已经开考过、创建考试记录、返回试卷内容。这里必须做幂等否则学生狂点按钮会创建多条记录。// ExamService.java 开考逻辑 Transactional(rollbackFor Exception.class) public ExamStartVO startExam(Long paperId, Long userId) { // 先查是否已有进行中的记录 ExamRecord exist examRecordMapper.selectByUserAndPaper(userId, paperId); if (exist ! null exist.getStatus() 0) { // 已经开考直接返回剩余时间和试卷 return buildExamVO(exist); } if (exist ! null exist.getStatus() ! 0) { throw new BizException(该试卷已交卷不能重复考试); } // 检查考试时间窗口 Paper paper paperMapper.selectById(paperId); Date now new Date(); if (now.before(paper.getStartTime()) || now.after(paper.getEndTime())) { throw new BizException(不在考试时间内); } // 创建新记录 ExamRecord record new ExamRecord(); record.setPaperId(paperId); record.setUserId(userId); record.setStartTime(now); record.setStatus(0); examRecordMapper.insert(record); return buildExamVO(record); }逻辑说明Transactional保证插入记录和后续操作要么全成功要么全回滚。幂等判断放在最前面已开考的直接返回不重复创建。时间窗口校验用paper表里的start_time和end_time这两个字段在组卷时由老师设定。buildExamVO里要计算剩余时间用paper.getDuration() - (now - record.getStartTime())单位是分钟。3.3 自动交卷定时任务还是延迟队列自动交卷是考试系统里最容易翻车的地方。我见过有人用前端setTimeout倒计时时间到了调交卷接口结果学生把浏览器一关倒计时没了考试记录永远停在“进行中”。正确做法是后端兜底。方案一定时任务扫描。每分钟扫一次exam_record表把status0且start_time duration now的记录批量交卷。实现简单但有一分钟误差。方案二延迟队列。开考时往队列里发一条延迟消息到期后触发交卷。精度高但引入 MQ 增加复杂度。对于毕设和课程设计我建议用定时任务够用且好讲。用 Spring 的Scheduled就行。// AutoSubmitTask.java Component public class AutoSubmitTask { Autowired private ExamRecordMapper examRecordMapper; Autowired private ExamService examService; // 每分钟执行一次 Scheduled(cron 0 * * * * ?) public void autoSubmit() { // 查出所有进行中且已超时的记录 ListExamRecord timeoutList examRecordMapper.selectTimeoutRecords(); for (ExamRecord record : timeoutList) { try { // 复用正常交卷逻辑标记为自动交卷 examService.submitExam(record.getId(), true); } catch (Exception e) { // 单条失败不影响其他记录记录日志 log.error(自动交卷失败, recordId{}, record.getId(), e); } } } }逻辑说明cron表达式0 * * * * ?表示每分钟的第 0 秒执行。selectTimeoutRecords的 SQL 里用DATE_ADD(start_time, INTERVAL duration MINUTE) NOW()来判断超时duration从paper表关联查出来。submitExam的第二个参数auto用来区分是学生手动交卷还是系统自动交卷自动交卷时主观题默认给 0 分并标记待批阅。注意定时任务要加EnableScheduling注解在配置类上否则不生效。4. 避坑与排查那些让我熬夜的报错4.1 事务不生效方法内部调用是元凶现象在ExamService里写了一个submitExam方法加了Transactional但在同一个类的另一个方法里直接this.submitExam()调用事务死活不回滚。原因Spring 的声明式事务基于 AOP 代理只有从外部调用代理对象的方法才会被拦截。类内部this调用走的是原始对象不经过代理。解决把submitExam抽到另一个 Service 里或者注入自身代理。我一般抽出去结构更清晰。4.2 前端倒计时和服务器时间对不上现象学生电脑时间比服务器快 5 分钟前端倒计时显示还有 5 分钟但后端已经判定超时自动交卷了学生一脸懵。原因前端倒计时用的是本地Date.now()没有和服务器同步。解决开考接口返回服务器当前时间和剩余秒数前端用setInterval基于这个基准递减不要用本地时间。每次心跳请求时再校准一次。4.3 MyBatis 返回 JSON 字段变成字符串现象question表的options字段是 JSON 类型Java 里用ListString接收结果报类型转换异常。原因MyBatis 默认把 JSON 字段当字符串处理不会自动反序列化。解决写一个自定义 TypeHandler继承BaseTypeHandlerListString在setNonNullParameter里用 Jackson 序列化在getNullableResult里反序列化。然后在 Mapper XML 里指定typeHandler。4.4 批量插入时主键冲突现象随机组卷时同一道题被抽到两次插入paper_question报唯一键冲突。原因Collections.shuffle后取子列表如果题库里 ID 有重复比如视图关联导致就会重复。解决在selectIdsByType的 SQL 里加DISTINCT或者在 Java 里用Set去重后再 shuffle。4.5 交卷后成绩为 null 导致前端报错现象学生交卷后成绩栏显示空白控制台报Cannot read property toFixed of null。原因主观题未批阅时score字段为 null前端直接调toFixed就崩了。解决后端返回时把 null 转成 0 或者返回一个pending标记前端根据标记显示“待批阅”。我一般返回{score: null, status: 2}前端判断status再决定显示什么。5. 让系统更耐用的几个进阶技巧5.1 用状态机管住考试记录的生命周期考试记录的状态流转是进行中(0) → 已交卷(1) → 已批阅(2)。看起来简单但如果不加约束可能出现“已批阅”又被改回“进行中”的脏数据。我习惯在 Service 层写一个状态校验方法每次更新前先检查当前状态是否允许目标操作。// ExamRecordStatus.java 状态枚举与校验 public enum ExamRecordStatus { IN_PROGRESS(0), SUBMITTED(1), REVIEWED(2); private final int code; ExamRecordStatus(int code) { this.code code; } public int getCode() { return code; } // 校验状态流转是否合法 public static boolean canTransfer(int from, int to) { if (from IN_PROGRESS.code to SUBMITTED.code) return true; if (from SUBMITTED.code to REVIEWED.code) return true; return false; } }逻辑说明canTransfer只允许两种流转进行中到已交卷、已交卷到已批阅。其他一律拒绝。在submitExam和reviewExam方法开头调用这个校验不合法就抛异常。这样即使前端传了错误的参数后端也能兜住。5.2 主观题批阅的分数校验与总分重算主观题批阅时老师输入的分数不能超过题目分值。这个校验要在后端做前端限制只是体验优化。// ReviewService.java 批阅单题 public void reviewQuestion(Long recordId, Long questionId, Integer score) { // 查题目分值 Question question questionMapper.selectById(questionId); if (score 0 || score question.getScore()) { throw new BizException(分数必须在 0 到 question.getScore() 之间); } // 更新答题记录的得分 answerMapper.updateScore(recordId, questionId, score); // 重算总分 int total answerMapper.sumScoreByRecord(recordId); examRecordMapper.updateScore(recordId, total); // 检查是否所有主观题都已批阅 int unreviewed answerMapper.countUnreviewed(recordId); if (unreviewed 0) { examRecordMapper.updateStatus(recordId, ExamRecordStatus.REVIEWED.getCode()); } }逻辑说明sumScoreByRecord用SUM(score)聚合注意要过滤掉未批阅的记录score IS NOT NULL。countUnreviewed统计score IS NULL且题型为主观题的数量。只有全部批阅完才把状态推到“已批阅”否则保持“已交卷”。这样学生查成绩时能看到“待批阅”而不是一个不完整的分数。5.3 用 AOP 记录考试操作日志答辩时老师可能会问“怎么防止学生考试中途刷新页面导致数据丢失”。除了前端做本地缓存后端可以记录关键操作日志方便排查问题。// ExamLogAspect.java 切面记录开考和交卷 Aspect Component public class ExamLogAspect { Autowired private ExamLogMapper examLogMapper; AfterReturning(execution(* com.example.service.ExamService.startExam(..))) public void logStart(JoinPoint jp) { Object[] args jp.getArgs(); ExamLog log new ExamLog(); log.setUserId((Long) args[1]); log.setPaperId((Long) args[0]); log.setAction(START); log.setCreateTime(new Date()); examLogMapper.insert(log); } AfterReturning(execution(* com.example.service.ExamService.submitExam(..))) public void logSubmit(JoinPoint jp) { Object[] args jp.getArgs(); ExamLog log new ExamLog(); log.setRecordId((Long) args[0]); log.setAction(SUBMIT); log.setCreateTime(new Date()); examLogMapper.insert(log); } }逻辑说明切点表达式匹配ExamService的startExam和submitExam方法。AfterReturning保证只在方法成功返回后记录抛异常时不记。日志表可以定期清理避免无限增长。这个功能不是核心但答辩时能体现你对可观测性的理解。5.4 数据库连接池的监控配置Druid 自带监控页面配置一下就能看到 SQL 执行情况和连接池状态。在application.yml或 Spring 配置类里加上spring: datasource: druid: stat-view-servlet: enabled: true url-pattern: /druid/* login-username: admin login-password: admin filter: stat: enabled: true log-slow-sql: true slow-sql-millis: 1000逻辑说明stat-view-servlet开启监控页面访问/druid/就能看到。log-slow-sql把执行超过 1 秒的 SQL 记到日志里方便优化。注意上线后要改默认密码或者关掉监控页面否则有安全风险。5.5 一个我反复用的调试习惯每次改完 Mapper XML 里的 SQL我不会直接重启整个项目而是先写一个单元测试跑一下。用SpringBootTest加Transactional测试完自动回滚不污染数据库。SpringBootTest Transactional class QuestionMapperTest { Autowired private QuestionMapper questionMapper; Test void testSelectIdsByType() { ListLong ids questionMapper.selectIdsByType(1); assertNotNull(ids); // 打印出来看看有没有重复 System.out.println(单选ID数量: ids.size()); System.out.println(去重后数量: new HashSet(ids).size()); } }逻辑说明Transactional在测试方法上会让整个测试跑在一个事务里结束后自动回滚。System.out虽然不优雅但调试时比日志快。这个习惯帮我省了很多次“改一行 SQL 重启一分钟”的时间。希望帮到你。本文还有配套的精品资源点击获取