☰
SSM+Vue在线考试系统毕设实战:组卷、自动交卷与避坑指南
2026/10/12 7:03:38 网站建设 项目流程

简介:这是一套基于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.x,MyBatis 用 3.5.x,MyBatis-Spring 用 2.0.x,数据库驱动根据 MySQL 版本选 8.0.x 的mysql-connector-java。如果你用 Maven 管理,直接在pom.xml里把版本号写成属性,方便统一升级。

<!-- pom.xml 关键依赖,版本号用属性管理 --> <properties> <spring.version>5.3.30</spring.version> <mybatis.version>3.5.13</mybatis.version> <mybatis.spring.version>2.0.7</mybatis.spring.version> <mysql.version>8.0.33</mysql.version> </properties> <dependencies> <!-- Spring 核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <!-- SpringMVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis.spring.version}</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency> <!-- 连接池,用 Druid 或 HikariCP 都行 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 试卷题目关联表:试卷和题目多对多,同时记录题目顺序 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 考试记录表:一个学生一次考试一条记录 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明: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, Map<Integer, Integer> typeCountMap) { // typeCountMap: 题型 -> 抽取数量,例如 {1:10, 2:5, 3:5} for (Map.Entry<Integer, Integer> entry : typeCountMap.entrySet()) { Integer type = entry.getKey(); Integer count = entry.getValue(); // 第一步:只查 ID,不查全字段 List<Long> ids = questionMapper.selectIdsByType(type); if (ids.size() < count) { throw new BizException("题库中该题型数量不足"); } // 第二步:内存中打乱,取前 count 个 Collections.shuffle(ids); List<Long> 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表,把status=0且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() { // 查出所有进行中且已超时的记录 List<ExamRecord> 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 里用List<String>接收,结果报类型转换异常。

原因:MyBatis 默认把 JSON 字段当字符串处理,不会自动反序列化。

解决:写一个自定义 TypeHandler,继承BaseTypeHandler<List<String>>,在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() { List<Long> ids = questionMapper.selectIdsByType(1); assertNotNull(ids); // 打印出来看看有没有重复 System.out.println("单选ID数量: " + ids.size()); System.out.println("去重后数量: " + new HashSet<>(ids).size()); } }

逻辑说明:@Transactional在测试方法上会让整个测试跑在一个事务里,结束后自动回滚。System.out虽然不优雅,但调试时比日志快。这个习惯帮我省了很多次“改一行 SQL 重启一分钟”的时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询