简介:这是一套基于SSM框架开发的学生在线考试系统完整源码,面向计算机专业学生与Java初学者,适用于期末大作业、课程设计或毕业设计场景。系统划分学生、老师、管理员三种角色:学生可加入课程、参加考试并查询成绩,老师能添加试题、发布考卷、创建课程,管理员负责用户、考试与权限管理,并整合Shiro权限控制、Redis缓存、EasyUI界面与EasyPoi导出等功能。压缩包共7008个文件,以png图片、html页面、css样式、js脚本为主,另含java源码、jsp页面、xml配置、jar依赖及sql脚本等,整体约763.9MB,并附带用例图、ER图、数据库表设计图与详细代码注释。资源还包含运行指导视频及各类开发工具安装包,方便快速搭建环境。目前已有1073人学习,适合需要完整项目参考与实战练手的读者。
1. SSM学生在线考试系统:从“能跑”到“敢给学生用”之间隔着什么
每年期末季,总有教务老师抱着一摞纸质卷子找我吐槽:印卷、发卷、收卷、批改、登分,一套流程走下来两周起步,还容易在登分环节出错。SSM学生在线考试系统就是冲着这个场景来的——用 Spring + SpringMVC + MyBatis 这套经典组合,把出题、组卷、答题、判分、成绩查询串成一条线上链路。它适合课程设计、毕业设计,也适合中小型培训机构做内部测验。但我要先泼一盆冷水:网上能搜到的 SSM 考试系统 demo 一大把,真正敢让学生同时在线答题的没几个。差别不在功能列表,而在并发提交、断线重连、防作弊、判分一致性这些“看不见”的地方。这篇笔记就按我实际搭过的一套方案,把选型理由、表结构、核心代码、参数配置和踩过的坑一次讲清楚,让你少走弯路。
2. 技术选型与数据库设计:为什么这套组合现在还能打
2.1 SSM 三层架构在考试场景下的分工
很多人觉得 SSM 过时了,但放到考试系统这个场景里,它的分层反而特别清晰。Spring 管 Bean 和事务,SpringMVC 管请求路由和参数绑定,MyBatis 管 SQL 映射。考试系统最怕的是“提交答案时事务没兜住”,比如学生点了交卷,答案写了一半数据库连接断了,成绩就算废了。Spring 的声明式事务在这里就是后悔药——只要把交卷逻辑包在一个@Transactional方法里,答案写入和状态更新要么全成要么全败。
具体分工我一般这样划:Controller 层只做参数校验和视图返回,不写业务;Service 层负责组卷算法、判分逻辑、事务边界;DAO 层用 MyBatis 的 Mapper 接口,SQL 写在 XML 里方便调优。这样分层的好处是判分逻辑可以单独写单元测试,不用启动整个 Web 容器。
2.2 考试系统核心表结构设计
表设计是这类系统的地基,地基歪了后面全是坑。我见过最离谱的设计是把所有题目塞进一个question表,选项用逗号拼接存字符串,结果判分时得用split解析,性能差还容易出错。下面是我实际用的核心表结构,字段经过脱敏但结构完整。
| 表名 | 关键字段 | 说明 |
|---|---|---|
exam_paper | id, title, total_score, duration, start_time, end_time, status | 试卷主表,status 控制是否可作答 |
exam_question | id, paper_id, question_type, content, options, answer, score, sort_order | 题目表,options 用 JSON 存选项 |
exam_record | id, paper_id, student_id, start_time, submit_time, score, status | 考试记录,status 区分进行中/已交卷/超时 |
exam_answer | id, record_id, question_id, student_answer, is_correct, got_score | 逐题答案,判分后回写 |
student | id, username, password, real_name, class_id | 学生表,密码必须加盐哈希 |
options字段用 JSON 而不是逗号拼接,是因为 MySQL 5.7 以后原生支持 JSON 类型,查询和校验都方便。exam_answer单独建表而不是塞进exam_record,是为了支持逐题判分和错题回顾。
2.3 组卷策略:随机抽题与难度系数的平衡
组卷不是简单ORDER BY RAND()就完事。我一般按题型和难度两个维度抽题:先按question_type分组,再在每组内按difficulty字段分层随机。下面这段 SQL 是抽 10 道单选题、其中简单 6 道中等 3 道困难 1 道的写法。
-- 按难度分层抽题,避免全抽到难题或全抽到简单题 SELECT * FROM ( SELECT * FROM exam_question WHERE paper_id = #{paperId} AND question_type = 1 AND difficulty = 1 ORDER BY RAND() LIMIT 6 ) AS easy UNION ALL SELECT * FROM ( SELECT * FROM exam_question WHERE paper_id = #{paperId} AND question_type = 1 AND difficulty = 2 ORDER BY RAND() LIMIT 3 ) AS medium UNION ALL SELECT * FROM ( SELECT * FROM exam_question WHERE paper_id = #{paperId} AND question_type = 1 AND difficulty = 3 ORDER BY RAND() LIMIT 1 ) AS hard;逻辑说明:三个子查询分别按难度抽题,UNION ALL合并结果。参数#{paperId}是试卷 ID,question_type = 1代表单选。注意ORDER BY RAND()在数据量大时性能差,如果题库超过一万条,建议改用WHERE id >= (SELECT FLOOR(RAND() * MAX(id)) FROM exam_question)的方式。难度系数difficulty我设成 1/2/3 三档,实际使用时可以在 Service 层根据试卷总分动态调整各档数量。
3. 核心功能实现:从登录到交卷的完整链路
3.1 学生登录与考试权限校验
登录不是查一下用户名密码就完事。考试系统必须校验“这个学生有没有这场考试的权限”,否则学生猜到 paperId 就能进别人的考场。我的做法是在登录成功后把学生 ID 和班级 ID 写入 Session,进入考试前再查一次exam_paper的status和班级白名单。
// ExamController.java 进入考试前的权限校验 @RequestMapping("/exam/enter") public String enterExam(@RequestParam Integer paperId, HttpSession session) { Student student = (Student) session.getAttribute("currentStudent"); if (student == null) { return "redirect:/login"; // 未登录直接踢回登录页 } ExamPaper paper = examPaperService.getById(paperId); // 校验试卷状态:0-未发布 1-进行中 2-已结束 if (paper == null || paper.getStatus() != 1) { throw new BusinessException("考试未开始或已结束"); } // 校验班级白名单,paper.getClassIds() 存的是逗号分隔的班级ID if (!paper.getClassIds().contains(String.valueOf(student.getClassId()))) { throw new BusinessException("你不在本次考试名单中"); } // 检查是否已有进行中的记录,防止重复进入 ExamRecord exist = examRecordService.findOngoing(paperId, student.getId()); if (exist != null) { session.setAttribute("recordId", exist.getId()); return "redirect:/exam/paper"; // 断线重连,回到原记录 } // 新建考试记录 ExamRecord record = examRecordService.createRecord(paperId, student.getId()); session.setAttribute("recordId", record.getId()); return "redirect:/exam/paper"; }参数说明:paperId从 URL 传入,student从 Session 取。BusinessException是自定义异常,配合全局异常处理器返回友好提示。这里的关键是findOngoing方法——它查exam_record里status = 0且student_id和paper_id匹配的记录,实现断线重连。没有这一步,学生刷新页面就会生成新记录,最后交卷时数据全乱。
3.2 答题页面的题目渲染与答案暂存
答题页面我一般用 Thymeleaf 或 JSP 渲染,题目一次性加载到前端,答案用 AJAX 定时暂存。为什么不做成每翻一页请求一次?因为考试场景网络可能不稳定,频繁请求反而增加失败概率。一次性加载后,前端用localStorage做本地暂存,每 30 秒同步一次到后端。
// 前端答案暂存逻辑,每30秒同步一次 let answers = {}; // 本地答案缓存 // 选项点击时更新本地缓存 function selectOption(questionId, option) { answers[questionId] = option; localStorage.setItem('exam_answers', JSON.stringify(answers)); } // 定时同步到后端 setInterval(function() { const recordId = document.getElementById('recordId').value; fetch('/exam/saveAnswer', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ recordId: recordId, answers: answers }) }).then(res => res.json()) .then(data => { if (data.code !== 200) { console.warn('暂存失败,将在下次重试'); } }); }, 30000); // 30000毫秒 = 30秒逻辑说明:answers对象以questionId为键、选项为值。localStorage保证刷新页面后答案不丢。同步间隔设 30 秒是权衡——太短增加服务器压力,太长丢数据风险大。后端/exam/saveAnswer接口收到后先删exam_answer里该record_id的旧记录再批量插入,保证幂等。注意这里不要用INSERT ... ON DUPLICATE KEY UPDATE,因为题目可能被删除,直接删旧插新更干净。
3.3 自动判分与主观题处理
交卷后的判分是考试系统的核心。客观题自动判,主观题留给老师。我一般把判分逻辑放在 Service 层,用策略模式区分题型。
// ScoreService.java 判分核心逻辑 @Transactional(rollbackFor = Exception.class) public void autoScore(Integer recordId) { ExamRecord record = examRecordMapper.selectById(recordId); List<ExamAnswer> answerList = examAnswerMapper.selectByRecordId(recordId); int totalScore = 0; for (ExamAnswer answer : answerList) { ExamQuestion question = examQuestionMapper.selectById(answer.getQuestionId()); // 题型:1-单选 2-多选 3-判断 4-简答 if (question.getQuestionType() == 4) { answer.setIsCorrect(null); // 主观题标记待批改 answer.setGotScore(0); } else { boolean correct = question.getAnswer().trim() .equalsIgnoreCase(answer.getStudentAnswer().trim()); answer.setIsCorrect(correct ? 1 : 0); answer.setGotScore(correct ? question.getScore() : 0); totalScore += answer.getGotScore(); } examAnswerMapper.updateById(answer); } record.setScore(totalScore); record.setStatus(2); // 2-已交卷 record.setSubmitTime(new Date()); examRecordMapper.updateById(record); }参数说明:recordId是考试记录 ID。@Transactional保证判分和状态更新原子性。多选题的判分更复杂——我一般用“少选得一半分,错选不得分”的策略,实现时把答案拆成字符数组做集合比较。主观题isCorrect设为null,前端根据这个字段显示“待批改”。注意equalsIgnoreCase只适用于单选和判断,多选必须用集合比较,否则“AB”和“BA”会被判错。
4. 避坑与排查:那些让我熬夜的翻车现场
4.1 交卷时数据库连接超时导致答案丢失
现象:学生点交卷后页面卡住,刷新后发现答案没保存,考试记录还是“进行中”。
原因:交卷逻辑里做了太多事——判分、更新记录、写日志,一个事务里跑了十几条 SQL,超过了数据库连接超时时间。默认wait_timeout是 28800 秒,但连接池的maxWait可能只有几秒。
解决:把判分拆成异步任务。交卷接口只做两件事——保存答案、把记录状态改成“待判分”,然后立刻返回。判分用@Async异步执行,或者丢到消息队列。这样交卷响应时间从 3 秒降到 200 毫秒。连接池参数也要调,Druid 的maxWait设 5000 毫秒,validationQuery设SELECT 1。
4.2 考试时间到期后学生仍能提交
现象:考试设了 60 分钟,学生 65 分钟时还能点交卷,成绩有效。
原因:前端倒计时只是展示,后端没有校验start_time + duration。学生改系统时间或直接调接口就能绕过。
解决:后端在交卷接口里强制校验。exam_record的start_time加上duration分钟,和当前时间比较,超时直接拒绝并标记为“超时未交”。前端倒计时用服务器时间校准,不要用new Date()。
// 交卷前的时间校验 Date deadline = new Date(record.getStartTime().getTime() + paper.getDuration() * 60 * 1000L); if (new Date().after(deadline)) { throw new BusinessException("考试已超时,无法提交"); }4.3 多选题判分把“AB”和“BA”判成不同答案
现象:学生选了 A 和 B,标准答案也是 A 和 B,但判分显示错误。
原因:字符串直接比较,"AB".equals("BA")返回 false。
解决:把答案字符串拆成字符数组,排序后再比较。或者用Set做集合相等判断。
// 多选题判分:集合比较 Set<String> studentSet = new HashSet<>(Arrays.asList(answer.getStudentAnswer().split(""))); Set<String> correctSet = new HashSet<>(Arrays.asList(question.getAnswer().split(""))); boolean correct = studentSet.equals(correctSet);4.4 并发交卷导致成绩被覆盖
现象:两个学生同时交卷,A 的成绩变成了 B 的。
原因:判分方法里用了成员变量存totalScore,Spring 默认单例,多线程共享导致数据串了。
解决:所有变量都定义在方法内部,不要用成员变量。Service 类加@Scope("prototype")也可以,但更推荐无状态设计。这个坑我踩过两次,血泪经验就是——Service 层永远不要存请求相关的状态。
4.5 断线重连后答案重复插入
现象:学生网络闪断,重连后答案表里同一道题出现两条记录。
原因:暂存接口没有做幂等,每次同步都INSERT。
解决:exam_answer表加唯一索引UNIQUE KEY uk_record_question (record_id, question_id),插入用INSERT ... ON DUPLICATE KEY UPDATE。或者先DELETE再INSERT,我一般用后者,逻辑更清晰。
5. 性能压测与防作弊的进阶技巧
5.1 用 JMeter 模拟 200 人同时交卷
系统能不能扛住,压测说了算。我一般用 JMeter 建一个线程组,200 个线程,每个线程模拟登录、进入考试、答题、交卷的完整流程。重点看交卷接口的 TPS 和错误率。如果错误率超过 1%,就得查连接池和慢 SQL。
压测时把exam_record和exam_answer表清空,用 CSV 文件提供 200 个学生账号。交卷接口的响应时间控制在 500 毫秒以内算合格。如果超过,优先看判分逻辑是不是同步执行的,改成异步后通常能降到 200 毫秒以下。
5.2 防切屏与防复制的前端策略
考试系统不做防作弊等于裸奔。我一般用visibilitychange事件监听切屏,切一次记一次,超过 3 次自动交卷。复制粘贴用oncopy和onpaste拦截,但要注意——这些只能防君子,防不了开发者工具。真要严格,得用全屏模式加beforeunload拦截。
// 切屏检测 let switchCount = 0; document.addEventListener('visibilitychange', function() { if (document.hidden) { switchCount++; if (switchCount >= 3) { alert('切屏超过3次,系统将自动交卷'); document.getElementById('submitBtn').click(); } } });5.3 判分结果的二次校验
自动判分再准也可能有 bug。我习惯在判分完成后跑一次校验:把所有exam_answer的got_score加起来,和exam_record的score比对,不一致就告警。这个校验用定时任务每天跑一次,能发现大部分数据不一致问题。
另外,主观题批改后要重新计算总分。我一般把总分计算抽成一个独立方法recalcTotalScore(recordId),自动判分和人工批改都调它,避免两处逻辑不一致。
这套 SSM 学生在线考试系统我前后搭过三版,第一版功能全但并发一塌糊涂,第二版加了异步判分和断线重连,第三版才把防作弊和压测补上。我的习惯是——每次上线前用 JMeter 跑一遍 200 并发,跑通了才敢让学生用。希望帮到你。
本文还有配套的精品资源,点击获取