忙里偷闲想把一套 SSM 在线考试系统的源码重新理顺了一遍,正好有人问到“这类系统到底怎么从头做”,就把整个项目的设计思路、核心代码和踩坑记录整理出来。这套系统用 Spring + SpringMVC + MyBatis 三件套搭起来,功能覆盖用户登录、题库管理、自动组卷、在线答题、自动阅卷、成绩导出,很适合拿来练手、做毕业设计,也适合刚接触 SSM 框架的人把“框架怎么串联业务”这件事彻底搞懂。
如果你正愁项目里那几张表不知道该怎么建,或者自动组卷和交卷判分不知道怎么写,那这篇应该能让你一次性看明白。我尽量用实际操作过的角度来讲,每一步都会说清楚为什么这么写,免得你照着网上一堆教程拼了半天还是跑不通。
1. 项目概述与整体设计思路
1.1 为什么用 SSM 做在线考试系统
SSM 虽然老,但作为主流 Java Web 组合仍然非常适合这类中小型管理系统。Spring 管对象和事务,SpringMVC 接收请求、做参数绑定,MyBatis 帮我们操作数据库,三者分工非常清晰。做在线考试系统这种“管理后台 + 在线业务”双层结构的项目,用这套组合写起来顺手,跑起来也稳。
另外一个实际原因是:这类项目大概率会被人拿去做课程设计或者面试项目,SSM 的知名度高,网上资料多,遇到问题好搜。相比之下,Spring Boot 虽然比 SSM 更省事,但很多老系统、旧教材还在用 SSM,你能用 SSM 说清楚“请求怎么进来、事务怎么控制、SQL 怎么执行”这三个问题,再切 Spring Boot 会非常快。这也是我推荐你从这个项目入门的原因。
在线考试系统的核心业务链路并不复杂:管理员或老师维护试题,学生登录系统参加考试,答题结束后系统自动判分,最后能查成绩。难点在于组卷的规则设计、答题状态的保存,以及成绩计算时不能出错。把这些业务点和 SSM 的各个层次对应起来,整个项目的结构就清楚了。
1.2 功能模块梳理与角色边界
我用这套系统划分了三个角色:管理员、教师、学生。管理员负责用户管理和全局配置,教师负责题库与试卷管理,学生负责参与考试和查看成绩。实际编码时,管理员和教师可以共用一套后台界面,用权限字段区分按钮和接口的可见性。
具体功能我列一下,方便你对照自己的需求做裁剪:
- 登录注册:用户通过用户名密码登录,MD5 加盐存储密码,拦截器校验登录状态。
- 题库管理:支持新增、修改、删除题目,题目需要有题型、难度、知识点和标准答案等字段。
- 试卷管理:创建试卷时设置标题、考试时长、总分,试卷可以关联题目。
- 在线考试:学生进入考场后加载试卷,答题过程中实时保存进度,倒计时结束自动交卷。
- 自动阅卷:客观题自动比对答案,主观题由教师手动评分,最后汇总成绩。
- 成绩管理:学生可查看自己的得分和正确答案,教师可导出成绩单。
每个模块都能对应到一张表或者几个接口,这个项目不像大型平台那么复杂,但胜在“五脏俱全”。我建议你在开发前先把角色边界画清楚,否则后面做权限控制时容易乱。最简单的方法是建一张用户表,用 role 字段区分,而不是为每个角色建一张表。后面你改需求的时候会感谢这个决定。
1.3 技术选型细节与环境版本
项目踩的版本组合我直接给出,避免你在环境上浪费时间。JDK 用 8,数据库用 MySQL 5.7,Tomcat 用 8.5,Maven 管理依赖。Spring 用 5.1.x,SpringMVC 和 MyBatis 跟着 Spring 版本走,MyBatis 用 3.5.x,连接池用 Druid,页面模板用 JSP。这套组合在 Windows 和 Linux 下都能正常跑,你要是装的是 MySQL 8 也没关系,只要把驱动的com.mysql.jdbc.Driver换成com.mysql.cj.jdbc.Driver就行,但注意要加上时区参数,否则会报时间戳错误。
不建议一上来就追新版本,尤其是 Spring 6 和 JDK 17 的组合,很多教程里的配置写法已经变了。做这类教学型项目,稳定比时髦重要。
2. 数据库设计与核心表结构
2.1 题库与试卷怎么建模
题库是整个系统最核心的数据基础。我建了一张类似题目表的结构,字段大致如下:
CREATE TABLE question ( id INT PRIMARY KEY AUTO_INCREMENT, subject VARCHAR(100) NOT NULL COMMENT '科目', type INT NOT NULL COMMENT '1单选 2多选 3判断 4主观', content TEXT NOT NULL COMMENT '题干', options TEXT COMMENT '选项,JSON或|分隔', answer VARCHAR(500) COMMENT '标准答案', difficulty INT COMMENT '1简单 2中等 3困难', score INT DEFAULT 5 COMMENT '每题分值', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这里 options 字段需要多说一句。很多新手会把每个选项做成一列,比如 optionA、optionB、optionC、optionD,遇到多选题和不定项就傻眼了。我用的是|分隔符把选项拼成一个长字符串,像A. 选项内容|B. 选项内容|C. 选项内容|D. 选项内容。解析的时候按分隔符切分就行,扩展性和存储成本都合理。
试卷表则单独建一张paper表,主要保存试卷标题、考试时长、总分、创建人和创建时间。试卷和题目的关联用paper_question中间表来维护,字段包括paper_id、question_id和该题在试卷中的分值。千万不要在没有中间表的情况下把题目 ID 用逗号拼进试卷表,那样后面查询和修改难度非常大。
2.2 用户权限与成绩表设计
用户表user的字段按常规设计就好:id、username、password、real_name、role、create_time。密码必须加密存储,我用的 MD5 加固定盐,虽然 MD5 不算安全,但练手足够。你要想更严谨一点可以换 BCrypt,只是引入的 jar 包多,学习成本高一点。
考试记录表exam_record是所有业务表的串联核心。它至少要包含id、user_id、paper_id、start_time、end_time、duration_seconds、status和score。每个学生参加一场考试就生成一条记录,状态字段用来标记是“考试中”还是“已交卷”。如果学生中途刷新页面,前端可以请求这个记录,找到自己答到哪一题。
答题明细表answer_detail负责保存每一道题的作答内容,字段是record_id、question_id、user_answer、is_correct、score。这样设计的好处是将来做成绩复盘、错题分析非常方便。我见过一些人为了省事把所有答案拼成一个 JSON 塞到一个字段,说心里话,那是在给自己挖坑,后面要统计每道题的正确率时,解析 JSON 会把你折磨疯。
2.3 建表时最容易踩的坑
有几个我在开发中反复遇到的问题,先给你打个预防针。
第一个是字段长度。题干、选项、答案这类文本必须用TEXT或者VARCHAR(1000),别用VARCHAR(255)硬扛。真实的考试题目里,阅读理解题的文字量说完就超 255 是非常常见的事情。
第二个是时间字段的统一。数据库里的日期时间尽量全部用DATETIME,不要一个表用DATE,一个表用TIMESTAMP。尤其是交卷的时候两段时间相减,类型不统一会导致计算时长出错。
第三个是索引。exam_record的user_id、paper_id一定要建索引,这两个字段是联表查询最常用的条件。数据少无所谓,等你有几百个学生、上百场考试之后,没索引的表查询起来 CPU 会让你体会一下什么叫煎熬。
这三条几乎是新手项目里出现频率最高的表设计问题,你从一开始就避开,后面开发会顺利很多。
3. 后端核心逻辑实现
3.1 登录认证与拦截器怎么做
登录部分的写法比较套路化,但有一个细节我在不少项目的代码里都没见到处理到位,那就是登录状态校验。我用 SpringMVC 的拦截器来做,配置在spring-mvc.xml中,拦截所有/student/**、/teacher/**、/admin/**路径,放行/login、/register、/css/**、/js/**等静态资源。
拦截器代码主逻辑模板如下:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } // 如果是教师端路径,检查角色权限 String uri = request.getRequestURI(); if (uri.contains("/teacher/") && !"2".equals(user.getRole())) { response.sendRedirect(request.getContextPath() + "/unauthorized"); return false; } return true; } }这套写法的好处是页面和接口都能被统一拦截,不用在每个 Controller 里重复写判断。你要记得把放行路径配全,尤其是静态资源不拦截,否则整个页面加载出来没有 CSS 样式,排查半天才发现是拦截器把资源也拦了。
密码校验这块,注册的时候算好加盐的 MD5 值,登录的时候把输入密码再算一次然后比对。注意比对的是摘要值,不是明文,这个别弄反了。
3.2 自动组卷与随机抽题的逻辑
组卷是这个系统里我认为最值得写清楚的部分。需求是:老师创建一张试卷时,指定单选、多选、判断、主观题的数量以及总分,然后系统从题库里随机抽题组成一张新卷。
我先说一个很多教学代码里的错误做法:在页面加载试卷时实时随机抽题。这样会导致学生每次刷新页面看到的题目都可能不一样,甚至交卷后判分的题目和答题时不同,属于严重 bug。正确做法是:考试开始前先把组卷结果固化下来,把抽到的题目快照保存到关联表,后面所有操作只跟这张快照走。
我组卷时选择了统计各类题型已有数量,再循环随机取题的方式。核心代码如下:
public List<Integer> selectQuestionsByTypeAndCount(String subject, int type, int count, int excludeDifficulty) { // 查询对应科目和题型下的所有题目ID List<Integer> allIds = questionMapper.findIdsBySubjectAndType(subject, type); // 随机打乱 Collections.shuffle(allIds); return allIds.subList(0, Math.min(count, allIds.size())); }在这段逻辑上我加了一层保底判断:如果题库里某种题型的数量不够预设数量,直接向前端抛异常,提示老师补充题目或调整试卷结构。别让我这个系统在实际运行里出现“只抽到 2 题,试卷却要求 5 题”的尴尬状况,我后来就是靠这个校验救了场。
组卷完成之后,我把paper_question的关联记录一次性批量插入,并把试卷状态设置为“已组卷”。这一步用事务包起来,保证要么全部插入成功,要么全部回滚。否则中间某条插入失败,试卷就处于半成品状态,学生拿到后会看到缺失题目。
3.3 自动阅卷与主观题评分方案
自动判分只适用于客观题,包括单选、多选和判断。我逐题读取answer_detail里的user_answer,和question表的answer比较,完全一样就判对。
多选题我当时为比较逻辑专门写了一个小函数:先把用户答案和标准答案按字符排序,再比较拼接结果,这样可以避免“选了 A、C”和“选了 C、A”被误判为不同答案。比如:
public boolean compareMultiAnswer(String userAnswer, String standardAnswer) { char[] ua = userAnswer.replace(",", "").toCharArray(); char[] sa = standardAnswer.replace(",", "").toCharArray(); Arrays.sort(ua); Arrays.sort(sa); return Arrays.equals(ua, sa); }主观题没法机器判,只能做人工评分。我给教师端单独写了一个待评卷列表,教师点进每一条记录后能看到学生答案,输入得分后保存。等所有主观题评分完成后,再去累加总分更新exam_record.score。这套流程在业务上说得通,技术上实现也不复杂。
自动判分和人工评分的数据变更都放在事务中,防止中间出现异常弄得分数算一半。阅卷完成后给前端提示“已评完”,成绩才会向学生开放。要是有学生交卷后马上看到成绩,而我主观题还没来得及评,那就不合理了。这里的“评卷状态”控制尤其重要,我在开发初期就因为没加状态判断,差点闹了笑话。
4. 前端交互与整体串联
4.1 答题页面与倒计时实现
在线答题页我做得比较简单,左侧是题号和答题卡,右侧是题目内容和选项区。倒计时用 JavaScript 的setInterval来做,同时把考试开始时间存在服务端。每次刷新页面时前端会重新调用接口,拿到“剩余秒数 = 试卷时长 - 已过去秒数”。倒计时归零后自动禁用答题按钮,并触发交卷请求。
倒计时看似简单,其实有个样式细节需要提醒你:长时间运行setInterval会有累计误差,久了时间会快几秒。我是拿系统时间做基准来校准,setInterval只负责每秒刷新显示,不负责累计。这个做法做出来的倒计时即使跑了 50 分钟也依然准确。
我曾在测试中开着页面不管它,过了 30 分钟回来看发现倒计时比实际慢了两秒。后面找到原因就是因为一直在本地减变量,而不是每次读取系统时间。所以前端显示一定要用“目标时间 - 当前时间”来算,不要自己攒一个变量。
4.2 交卷与超时处理的边界条件
交卷这块需要处理的状态比较多。我先列一下,你写的时候可以对着检查:
- 正常交卷:学生点击交卷按钮,前端确认后 POST 提交所有答案。
- 超时交卷:倒计时归零,前端自动发请求交卷,用户无感知。
- 异常刷新:学生在中途刷新页面,已答内容要从数据库里恢复。
- 重复交卷:学生连续点了两次交卷按钮,后端要拒绝第二次,防止成绩被覆盖。
我在后端专门设了一个status字段来区分这些情况。第一次交卷时把status从“考试中”改为“已交卷”,同时记录end_time。第二次交卷请求到达后,如果发现状态已经是已交卷,直接返回“该考试已交卷”的提示,不做任何数据修改。
超时交卷的细节值得多写几行代码:前端发来的每个题目答案都要判断是否为空,空答案统一存成空字符串,不要落下半截数据导致判分时空指针。这个细节我当时也是 debug 半天才发现是空值导致的问题。
4.3 前后端数据交互方式
整个系统的前后端交互我统一走了 AJAX + JSON 的模式。页面加载试卷时,接口返回包括题目列表、选项、题型、每题分值,但不含标准答案。交卷后如果学生查看成绩,服务端再返回正确答案和该题得分,这样做可以防止学生通过浏览器开发者工具提前抓到正确答案。
你可能会问,JSP 时代不是可以直接渲染吗?为什么还要用 JSON?我的经验是,在线答题这种交互强烈的页面,用 AJAX 能在不刷新整个页面的情况下更新题号状态和倒计时,用户体验好很多。而且 JSON 结构便于前端做数据结构化处理,JSP 的 JSTL 标签在这种场景反而显得啰嗦。
所以这个项目采用了 JSP 渲染整体布局界面,页面内部参与交互的数据全部走 AJAX 接口。各司其职,开发时也少了很多“标签一时不知道该怎么写”的麻烦。
5. 常见问题与排查记录
5.1 中文乱码问题
这个是老生常谈,但几乎每个用 SSM 的人都会遇到一遍。我排查的顺序是:JSP 页面编码、请求编码过滤器和数据库连接字符集。缺一个都不行。
JSP 页面顶部设置pageEncoding="UTF-8",表单提交时使用accept-charset="UTF-8"。服务器端必须在 web.xml 里配置编码过滤器,我直接用的是 Spring 提供的CharacterEncodingFilter,设置forceEncoding=true。数据库连接 URL 上必须带上useUnicode=true&characterEncoding=utf-8。三个地方全做对,才不会出现那种“数据库里是乱码、页面上是问号”的反人类画面。
有一个我印象特别深的坑:数据库表字段排序规则是utf8_general_ci没问题,但如果某张表的字段是latin1_swedish_ci,存进去的中文照样乱。建表时一定要把整表字符集指定为utf8mb4,顺手一起处理了 emoji 存不进的问题。
5.2 MyBatis 联表查询与 N+1 问题
我在做成绩列表时,需要联查exam_record、paper、user三张表获取学生姓名、试卷标题和分数。最初为了方便,我在代码里循环查每个记录的用户信息,结果 100 条成绩数据发出了 101 条 SQL。这就是典型的 N+1 问题,测试时数据少感觉不到,数据一多就显得又慢又拖沓。
后面我改成了 SQL 联表一次性查出所有必需字段,用结果映射直接封装到 VO 中。一个 List 查出来就是完整数据。如果你的代码也出现“循环里查数据库”的迹象,基本可以断定有 N+1 风险,优化思路就是尽量用批量查询,把多次单条查询合并成一条 IN 查询。
MyBatis 的嵌套结果映射很强大,但我建议新手不要一上来就写复杂嵌套映射。先用连表查询返回平铺结果集,再在 Java 层组装 VO,这样出现问题容易排查,代码也更好懂。
5.3 并发与交卷数据一致性
在线考试虽然一般不会有极高并发,但有段时间我压测的时候发现一个隐患:两个几乎同时到达的交卷请求,可能会因为线程并发导致成绩记录被后写入的请求覆盖。这是典型的数据一致性问题。
我在交卷接口上做了两层保险。第一层是数据库层面的条件更新,例如:
UPDATE exam_record SET status = 1, end_time = NOW(), score = #{score} WHERE id = #{recordId} AND status = 0如果影响行数为 0,说明已经被交过卷了,直接返回失败。第二层是在代码里加了一次状态检查,两者结合,双保险。另外,如果项目中涉及并发更高,可以加 synchronized 或者分布式锁,但就这个系统目前的量级,数据库条件更新的方式已经足够且干净。
5.4 报表导出遇到的编码问题
成绩导出到 Excel 用的是 POI。写文件时如果直接生成 CSV,用 Excel 打开中文会乱码。这里处理技巧是生成 UTF-8 BOM 格式的 CSV,或者用 POI 生成真正的.xlsx文件。我用的是 POI 的XSSFWorkbook,中文显示完全正常,代码也不复杂。要是嫌 POI 太重,简单场景可以使用 CSV 加 BOM,也能奏效。
你可别小看导出这个功能,看似简单,实际在评审演示时特别给力。评委一问“你的系统能不能导出成绩报表”,你直接演示几秒生成 Excel,印象分会高很多。
6. 源码部署与二次开发指南
6.1 从零跑起这套项目
我在文章里分享的这套源码,拿到手先在 MySQL 里执行sql/init.sql建库建表,然后导入默认数据。默认数据会带一个管理员账号和两个测试学生账号,你可以在user表里看到。
接着改jdbc.properties里的数据库连接,把用户名密码改成你自己的。项目用 Maven 打包成 WAR 包,扔到 Tomcat 的webapps下,启动后访问http://localhost:8080/ssm-exam/就能看到登录页。
建议第一次跑通流程时,先用管理员登录创建一套简单试卷,再切到学生账号参加考试。走一遍完整的“建题—组卷—考试—判分—查成绩”链路,胜过看十遍代码。因为很多 bug 都是在这个流程试出来的。
需要提醒的是,Tomcat 的运行环境必须配好 JDK8,不然会在启动时报不支持版本的问题。另外maven-compiler-plugin的 source/target 设置为 1.8,前后保持一致,否则编译出来的 class 版本和你实际运行时不一致,也会闹幺蛾子。
6.2 这个系统还能往哪些方向扩展
如果你不满足于现有功能,我给你几个实际可做的扩展方向,每一个都有足够的开发量,也适合拿来当面试加分项:
第一,把题库做成按科目、章节、知识点多级分类。现在题库只有一个 subject 字段,加一个category_id关联树形结构,组卷时就能按知识点分布选题目,这更贴近真实考试的命题逻辑。
第二,加入考试环境监控。前端定时上报当前页面是否失去焦点,或者在考试中采集切屏次数,超过一定次数后标记试卷异常,由教师端审核是否需要重考。这个功能虽然不算难,但能在真实场景里有效降低作弊概率。
第三,做成前后端分离版。保留现有业务逻辑,后端提供 REST 接口,前端用 Vue 重写一遍页面。这个改造工程量大,但对学习 SpringMVC 向 Spring Boot + Vue 转换非常有帮助。
第四,把成绩分析做得更细。比如统计选择题各选项的选择比例、各知识点的得分率、学生历次考试成绩折线图。这些功能依赖现有表结构都能实现,而且视觉效果容易出彩。
6.3 个人体会与使用建议
一套基于 SSM 的在线考试系统,说难不难,说简单也不简单。它在业务上完整覆盖了一个在线学习系统的核心链路,在技术上串起了 Spring、SpringMVC、MyBatis、MySQL、前端页面、权限控制、并发处理和报表导出这些高频知识点。把它彻底吃透,你再去面试的时候聊到 Java Web 整个体系,基本不会虚。
我最大的体会是:这种项目的价值不在“能跑”,而在你亲自踩过多少坑。中文乱码、N+1 查询、并发交卷、倒计时漂移,每一个问题背后都藏着真实业务场景里的工程考量。源码我可以直接白嫖拿走,但把这些问题的因果关系真正捋清楚,才是这套源码对你最大的意义所在。
如果你按这篇把系统搭起来了,建议顺手做一件事:拿真实试卷数据导入题库,让几个同学真实地考一场。当系统把成绩排名自动算出来的时候,你会觉得之前所有折腾都值了。