简介:这是一套基于Java、SSM框架与Vue前后端分离实现的在线考试系统完整项目包,面向计算机专业毕业设计、课程设计学生及Java Web入门开发者,提供可直接运行、便于二次开发的考试管理方案。系统分为考生端与管理员端:考生可完成在线考试、查询成绩并接收公告与考试通知,管理员可维护用户、试题、试卷及考试记录,功能链条完整。
压缩包共534个文件、约15.54MB,包含Java源码、Vue组件、SQL脚本、XML配置及界面素材,并附数据库与论文材料,目录按前后端模块组织,便于查阅部署。已有159人学习,适合需要功能演示、论文支撑与排错参考的读者,可据此理解SSM与Vue的协作方式并搭建本地环境。
1. 在线考试系统源码拆开看:SSM + Vue 这套毕业设计资源到底给了什么
在线考试系统是毕业设计和课程设计里被选得最多的题目之一,出问题的比例也最高——很多人拿到一份 Java 源码,解压之后不知道怎么跑,数据库导进去报一串错,Vue 前端起不来,最后只能靠截图和论文描述凑合交差。手里这份基于 SSM(Spring + SpringMVC + MyBatis)后端、Vue 前端的在线考试系统,配套 MySQL 数据库脚本和一篇成稿毕业论文,实际是三份资产叠在一起:一个能启动的前后端分离 Web 工程、一份带初始数据的数据库脚本、一份按章节铺开的论文。它适合三类人:正在定题、需要一套能跑通的原型支撑答辩的;已经定了题、手里只有半截代码想找完整参考的;以及想拿它当基线做二次开发,加防作弊、加题型、加统计报表的。下面不铺概念,从目录结构、数据库表、核心链路一路拆到部署时会踩到的坑。
2. 工程结构与环境准备:SSM 三层怎么分层,数据库怎么导入
2.1 为什么这类项目选 SSM 而不是 Spring Boot
先把选型理由说清楚,不然容易在答辩时被问住。SSM 指的是 Spring + SpringMVC + MyBatis 这一套组合,它本身不代表“先进”,但它在课程设计语境里有三个很实际的优势。第一,分层非常显式:Controller 接参数、Service 写业务和事务、Mapper 写 SQL,每一层都是一个独立的包,论文里画分层架构图的时候能一一对应到类名,不会出现图画得很漂亮、代码里找不到落点的情况。第二,配置是可见的:spring-mvc.xml、spring-service.xml、mybatis-config.xml 这些 XML 文件把 Bean 的装配过程摊在明面上,导师问到“这个 Service 是怎么被注入进来的”“事务是怎么生效的”,你可以直接翻到对应配置行。第三,MyBatis 的 SQL 写在 XML 里,答辩追问 #{ } 和 ${ } 的区别、一级缓存和二级缓存,都能落到具体文件上。
相比之下,Spring Boot 的自动装配会把大量配置藏起来,对已经熟悉的人省事,对只做一次课程设计的人反而不好在答辩时讲清楚。所以选型上没有优劣,只有跟目标匹配不匹配。
前后端分离是另一个要点。Vue 前端是一个独立的 Node 工程,构建之后产出一堆静态文件,和后端 Tomcat 是两个进程、两个端口。这就直接引出第一个绕不开的问题:跨域。常见做法有两种,一是后端在 SpringMVC 里配一个 CORS 的全局配置或者给 Controller 加注解,二是在前端 dev server 里配 proxy 把 /api 前缀转发到后端端口。前者上线时还要保留,后者只在开发期有用,很多人只在开发期配了 proxy,打包以后接口全挂,这是典型的“本地好好的、一部署就废”。
提示:先确认这份工程用的是 CORS 还是 proxy。打开前端的 request.js 或 vue.config.js,看 baseURL 是绝对地址还是 /api 前缀,这一步决定了后面部署时要不要额外加一层反向代理。
2.2 目录结构与正确的启动顺序
解压之后先别急着点运行,把目录树看一遍,能省掉后面一半的排查时间。这类工程通常是 Maven 多模块或者单模块两种结构,先看根目录有没有 pom.xml,再看里面有没有<modules>。
exam-system ├── exam-parent # 后端根工程,可能是单模块也可能是多模块 │ ├── src/main/java │ │ └── com/xxx/exam │ │ ├── controller # 接口层,只做参数接收与结果返回 │ │ ├── service # 业务层,事务边界在这里 │ │ │ └── impl │ │ ├── mapper # MyBatis Mapper 接口 │ │ ├── entity # 与表一一对应的实体类 │ │ ├── common # 统一返回体、常量、工具类 │ │ └── interceptor # 登录拦截器、权限拦截器 │ ├── src/main/resources │ │ ├── mapper # MyBatis 的 XML SQL 文件 │ │ ├── spring-*.xml # 各层 Spring 配置 │ │ ├── mybatis-config.xml │ │ └── jdbc.properties # 数据源四要素 │ └── src/main/webapp │ └── WEB-INF/web.xml # 老式 Web 工程的入口 ├── exam-vue # Vue 前端工程 │ ├── src/api # 接口封装 │ ├── src/views # 页面 │ ├── src/router # 路由与路由守卫 │ └── vue.config.js # 代理配置、打包输出目录 └── sql └── exam_system.sql # 建库建表 + 初始账号与题库数据启动顺序必须是:先导数据库,再起后端,最后起前端。反过来一定会踩坑,因为前端的登录页一打开就会去调后端接口,后端没起来,页面就是一个空白的错误页,你会误以为是前端代码坏了。
# 1. 后端打包,跳过测试(课程设计工程常常带一份跑不通的测试类) mvn clean package -DskipTests # 2. 把产物丢进 Tomcat,注意 war 的文件名决定访问路径 cp target/exam-web.war "$CATALINA_HOME/webapps/ROOT.war" "$CATALINA_HOME/bin/startup.sh" # 3. 前端装依赖并起开发服务器 cd exam-vue npm install --registry=https://registry.npmmirror.com npm run serve-DskipTests是跳过测试阶段,只编译和打包,避免测试类里写死的本地路径把你卡住。--registry指向国内镜像源,纯粹是为了装依赖不超时,装完之后可以在 .npmrc 或 package-lock 里固定下来,免得换台机器又慢回去。war 改名成 ROOT.war 的作用是把访问路径从/exam-web变成/,这样前端里写死的相对路径不用改。
2.3 数据库导入与数据源配置
数据库是这类项目里最容易出问题的一环,因为它同时牵扯字符集、时区、驱动版本三件事。先用命令行进 MySQL,把库建好再导脚本。
# 建库时就指定字符集,不要等导完再改 mysql -uroot -p --default-character-set=utf8mb4 CREATE DATABASE exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE exam_system; # 导入脚本,注意路径用绝对路径 SOURCE /absolute/path/to/sql/exam_system.sql;utf8mb4而不是utf8,是因为 MySQL 里的 utf8 实际只有三个字节,存不了某些特殊符号,题目里一旦出现这些字符就会截断。SOURCE是 MySQL 客户端的内置命令,只认绝对路径,相对路径经常报文件不存在。
导完之后去改jdbc.properties,这个文件是数据源四要素的唯一来源。
# 驱动类:MySQL 8 用 cj 包,5.7 用 com.mysql.jdbc.Driver jdbc.driver=com.mysql.cj.jdbc.Driver # 关键参数逐个说:编码、时区、SSL、公钥获取 jdbc.url=jdbc:mysql://127.0.0.1:3306/exam_system\ ?useUnicode=true\ &characterEncoding=utf8\ &serverTimezone=Asia/Shanghai\ &useSSL=false\ &allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=改成你自己的密码serverTimezone不配的话,MySQL 8 驱动在启动时会直接抛时区异常,这是最高频的启动失败原因。useSSL=false关掉本地连接时的 SSL 握手,省一次警告。allowPublicKeyRetrieval=true是配合 MySQL 8 默认的加密插件用的,不配的话第一次连接会被拒绝。参数改完记得确认spring-dao.xml里读的是这个 properties 文件,而不是写死在 XML 里的另一组账号密码——很多工程两处都有,改了一处以为生效了,实际读的是另一处。
3. 核心考务链路:组卷、答题、判分三条线的一次完整跑通
3.1 表结构与组卷策略
这套系统的业务核心其实收敛在六张表上,把这六张表的关系理顺,后面所有接口都是围绕它们做增删改查。
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户(管理员 / 教师 / 学生) | id、username、password、role |
| question | 题库 | id、subject_id、type、content、answer、score |
| paper | 试卷 | id、name、total_score、duration |
| paper_question | 试卷与题目关联 | paper_id、question_id、sort、score |
| exam_record | 考试记录 | id、paper_id、user_id、start_time、submit_time、status |
| answer_record | 答题明细 | id、exam_record_id、question_id、user_answer、is_correct、get_score |
组卷分两种模式:手工组卷是按教师勾选的题目生成 paper_question 记录;随机组卷是按学科、题型、数量从题库里抽。随机抽题的第一版通常长这样:
-- 按题型分组随机抽题,注意 ORDER BY RAND() 的性能边界 SELECT id, content, type, answer, score FROM question WHERE subject_id = #{subjectId} AND type = #{type} AND status = 1 ORDER BY RAND() LIMIT #{num};ORDER BY RAND()的语义是对结果集每一行生成一个随机数再排序,数据量在几千行以内完全够用,题库上万行就会明显变慢,因为无法走索引。课程设计量级的题库不用担心,但答辩时如果被问到性能,你要能答出替代方案:先查出该题型下所有 id,在应用层用洗牌或者取随机下标的方式抽取,再回表查详情;或者维护一张按学科分组的随机映射表。这属于“知道边界在哪”的加分项。
另一个必须处理的问题是去重。随机组卷如果分多次查询抽题,不同题型之间是有可能抽到同一道题的(比如一道题被标成了多题型),所以生成 paper_question 之前要在内存里用 Set 做一次去重,数据库侧再给(paper_id, question_id)加唯一索引兜底。
3.2 在线答题与自动交卷
答题这条线最容易做错的地方是:把倒计时只放在前端。很多现成代码是前端拿到一个 duration 就开始setInterval倒数,时间到了自动调交卷接口。这样能跑,但学生按 F5 刷新一下页面,倒计时重头再来,等于可以无限答题。
正确做法是开考时就在 exam_record 里落一条记录,把start_time写进去,剩余时间由后端算。
@Service public class ExamRecordServiceImpl implements ExamRecordService { @Resource private ExamRecordMapper examRecordMapper; @Resource private PaperMapper paperMapper; /** * 开始考试:同一场考试只允许有一条进行中的记录 */ @Override @Transactional(rollbackFor = Exception.class) public ExamRecord start(Integer paperId, Integer userId) { // 先查有没有未提交的记录,有就续用,避免重复开考 ExamRecord exist = examRecordMapper.selectRunning(paperId, userId); if (exist != null) { return exist; } Paper paper = paperMapper.selectById(paperId); ExamRecord record = new ExamRecord(); record.setPaperId(paperId); record.setUserId(userId); record.setStartTime(new Date()); record.setStatus(ExamStatus.RUNNING.getCode()); examRecordMapper.insert(record); return record; } /** * 计算剩余秒数:后端算,前端只负责展示 */ @Override public long remainSeconds(ExamRecord record, Paper paper) { long used = (System.currentTimeMillis() - record.getStartTime().getTime()) / 1000; long total = paper.getDuration() * 60L; return Math.max(total - used, 0L); } }@Transactional的 rollbackFor 指定为 Exception,是因为默认只对 RuntimeException 回滚,业务里抛的受检异常会导致事务不回滚,这是答辩常见追问。selectRunning加了一层“续用已有记录”的逻辑,比每次开考都插新记录更贴合真实考场。remainSeconds把时间计算收到后端,前端每次拉试卷详情的时候带上剩余秒数,刷新页面也只会拿到更少的剩余时间,防刷新的目的就达到了。
交卷接口同样要在服务端再校验一次时间:如果remainSeconds已经归零,服务端应该强制提交,并把超时之后产生的答案(如果有)按规则处理掉。
3.3 客观题自动判分与主观题人工阅卷
判分逻辑本身不难,难在把所有题型的分支覆盖全,并且分数计算不出现小数误差。单选题和多选题可以统一处理:把学生的答案和标准答案都按选项字母排序后比较,这样多选题选择顺序不同也能判对。
/** * 客观题自动判分,返回本次得分 */ public BigDecimal judge(Question q, String userAnswer, BigDecimal fullScore) { if (userAnswer == null || userAnswer.trim().isEmpty()) { return BigDecimal.ZERO; } String[] std = normalize(q.getAnswer()).split(","); String[] usr = normalize(userAnswer).split(","); // 多选常见两种规则:全对得分、漏选得半分,用配置项决定 if (q.getType() == QuestionType.MULTI) { Set<String> stdSet = new HashSet<>(Arrays.asList(std)); Set<String> usrSet = new HashSet<>(Arrays.asList(usr)); if (stdSet.equals(usrSet)) { return fullScore; } if (halfScoreEnabled && stdSet.containsAll(usrSet) && usr.length < std.length) { return fullScore.divide(BigDecimal.valueOf(2), 1, RoundingMode.HALF_UP); } return BigDecimal.ZERO; } // 单选、判断直接全等比较 return Arrays.equals(std, usr) ? fullScore : BigDecimal.ZERO; } private String normalize(String s) { return s.replaceAll("\\s+", "").toUpperCase(); }BigDecimal而不是 double,是因为分数求和时 double 会出现 0.1 + 0.2 这种精度问题,最后总分显示成 59.999999。divide必须指定精度和舍入模式,否则除不尽会抛 ArithmeticException。normalize先去掉所有空白再转大写,是防学生答案里带空格或者小写导致误判。
主观题走的是人工阅卷:教师端按考试记录拉出所有主观题答案,逐条打分写回 answer_record,最后汇总到 exam_record 的 total_score。这里要注意的是,客观题判完之后就要把is_correct和get_score落库,不要等到人工阅卷完成才统一写,否则中途有人查成绩会看到分数是空的。
3.4 成绩统计与导出
成绩查询是这类系统里使用频率最高的功能,学生看自己的,教师看整场的。两个查询的条件不同,但底层可以是同一条 SQL 加不同的 where。
-- 单场考试的成绩分布:按分数段聚合 SELECT CASE WHEN total_score >= 90 THEN '90-100' WHEN total_score >= 80 THEN '80-89' WHEN total_score >= 70 THEN '70-79' WHEN total_score >= 60 THEN '60-69' ELSE '60以下' END AS segment, COUNT(*) AS cnt FROM exam_record WHERE paper_id = #{paperId} AND status = 2 -- 2 表示已批阅 GROUP BY segment ORDER BY segment DESC;用 CASE WHEN 在 SQL 层做分桶比在 Java 里循环统计更省事,也更容易在论文里写出“基于 SQL 聚合的成绩分析”。注意status要过滤掉未批阅的记录,否则平均分会被 0 拉低。
4. 论文与源码对照:把 ER 图、用例图、答辩追问对到具体类上
4.1 论文目录与源码模块的映射
这套资源里论文是成稿的,但直接照抄的风险在于:抄的人多了,答辩时说不清自己代码在哪。正确用法是拿论文当骨架,然后逐章去源码里找落点,找不到落点的章节就改或者删。
| 论文章节 | 对应源码位置 | 要能说出的东西 |
|---|---|---|
| 需求分析 / 用例图 | controller 包下的方法列表 | 每个用例对应哪个接口,谁有权限调 |
| 系统设计 / 功能模块图 | service 包下的接口划分 | 模块边界是怎么切的,为什么这么切 |
| 数据库设计 / ER 图 | entity 包 + mapper XML | 每个实体对应哪张表,外键在哪 |
| 详细设计 / 流程图 | service impl 里的关键方法 | 一次组卷、一次交卷的调用链 |
| 系统实现 / 界面截图 | Vue 的 views 目录 | 截图对应哪个 vue 文件、哪个路由 |
这张表建议自己填一遍,填不出来的格子就是答辩时会被问住的点。
4.2 ER 图、表结构与实体类的三角核对
ER 图上画了五六个实体、七八条关系,最容易出问题的地方是图上有、表里没有,或者表里有、图上看不出来。核对方法很笨但有效:打开 entity 包,每个类对应一张表,逐个字段对下去。
具体要看三件事。第一,主键和外键。ER 图上的连线在数据库里应该体现为某个字段,比如 answer_record 里的 exam_record_id,图上如果画了关系但表里没有这个字段,说明是逻辑外键、代码里用 join 维护的,要能解释清楚。第二,一对多和多对多的方向。paper 和 question 是多对多,中间表 paper_question 必须在 ER 图上出现,否则组卷逻辑讲不通。第三,冗余字段。很多工程会在 exam_record 里冗余一个 total_score,方便查询时不用每次聚合 answer_record,这是合理的设计取舍,答辩时可以主动说出来,比被追问出来强。
4.3 答辩时会被追问的几个点
根据这套技术栈,问题基本集中在几个方向,提前准备比临场编强得多。
一是“为什么用 MyBatis 不用 JPA”。答法不是比较优劣,而是说清楚这个项目的查询以多条件动态拼接为主,MyBatis 的<if>、<foreach>在写组卷、成绩筛选这种条件不固定的查询时更直接,SQL 可控性高。
二是“事务加在哪一层”。答在 Service 层,因为 Service 是一个完整业务动作的边界,比如“交卷”这个动作里既有更新 exam_record 又有批量插入 answer_record,必须一起成功或一起失败。Controller 不承担业务,Mapper 只对单表负责。
三是“登录状态怎么保持”。这类工程常见两种:Session 存服务端,或者登录后发一个 token、前端放在请求头里。如果是后者,要能说清楚拦截器是怎么校验 token 的,以及为什么刷新页面之后还能保持登录。
四是“并发考试会不会有问题”。诚实答:课程设计量级是几十人同时在线,主要风险在自动交卷时的重复提交,处理方式是给交卷接口加幂等判断(status 已经是已提交就直接返回成功),而不是上分布式锁。
5. 部署联调避坑:5 个高频翻车点的现象、成因与解法
5.1 环境与部署类
现象一:Tomcat 启动日志干净,访问首页 404 或白屏。
原因通常有两个。一是 war 包名带版本号,比如exam-web-1.0.war,Tomcat 解压出来的 context path 就是/exam-web-1.0,你访问/当然 404。二是前端打包产物没放进去,后端起来了但根目录下没有 index.html。
解法是在 pom 里把 finalName 固定住,或者直接把 war 改名成 ROOT.war;前端npm run build之后把 dist 目录里的内容整体拷到 webapp 根目录下,注意是拷内容不是拷 dist 文件夹本身。
现象二:接口一直返回 401 或重定向到登录页。
原因是拦截器把静态资源和登录接口也拦了。SSM 里的拦截器通常配在 spring-mvc.xml 的<mvc:interceptors>下,如果<mvc:mapping path="/**"/>后面没配 exclude,登录请求自己也被拦,就形成了死循环。
解法是把/login、/static/**、/error这些路径加进<mvc:exclude-mapping>,或者在前端 axios 的响应拦截器里判断状态码,401 时统一跳登录页并清掉本地存的 token。
5.2 数据与业务逻辑类
现象三:题目内容、选项全变成问号。
这是三处字符集不一致的典型表现:数据库建库时用了 latin1 或默认字符集、JDBC url 里没写 characterEncoding、Tomcat 的 Connector 没配 URIEncoding。只要有一处不对,中文就会在某个环节被截断。
解法是三层一起改:CREATE DATABASE ... CHARACTER SET utf8mb4,url 里加useUnicode=true&characterEncoding=utf8,Tomcat 的 server.xml 里 Connector 加URIEncoding="UTF-8"。改完要把已经乱码的数据重新导一遍,因为乱码是不可逆的。
现象四:随机组卷出来的试卷里出现重复题目,或者题目数量对不上。
原因有两个层面。表层是抽题时多次查询没有统一去重,深层是题库里同一道题被录入了两次(content 相同但 id 不同)。前者在代码里用 Set 去重、数据库加(paper_id, question_id)唯一索引;后者需要先跑一条查重 SQL 把重复题找出来人工合并。
-- 找出题干完全相同的重复题目 SELECT content, COUNT(*) AS cnt, GROUP_CONCAT(id) AS ids FROM question GROUP BY content HAVING cnt > 1;现象五:考试中途刷新页面,倒计时重置,可以重新答题。
原因就是前面说的,倒计时只活在前端。解法是把 start_time 落到 exam_record,剩余时间由后端算,前端每次进答题页都重新拉一次。这里还有个细节:如果后端算出来已经超时,应该在拉取试卷的接口里直接返回“已结束”,而不是返回剩余 0 秒让前端自己判断,否则学生改一下前端代码就能继续答。
5.3 前后端联调类
跨域和 token 丢失通常一起出现。前端的请求头里带了自定义的 Authorization 字段,浏览器的预检请求(OPTIONS)会先发一次,如果后端 CORS 配置里没允许这个头,预检就失败,控制台看到的是跨域错误,实际原因是响应头缺Access-Control-Allow-Headers。
解法有两个方向:后端 CORS 配置里把allowedHeaders显式列出 Authorization 和 Content-Type,同时allowCredentials和allowedOrigins不能同时用通配符*,必须写具体来源;或者干脆在部署时用 Nginx 做同源反向代理,把/api转发到后端端口,跨域问题从根上消失。课程设计阶段建议后者,配置简单,也不用改代码。
注意:改 CORS 之后一定要清一次浏览器缓存再测,预检结果会被缓存,不清的话你会以为改了没生效。
6. 二次开发与验收:改三个地方,让这套源码真正讲得清
拿到成稿代码以后,最忌讳的是原封不动交上去。不是为了查重,而是答辩时你必须能说出“哪部分是我做的”。改动不用大,三个地方就够让这套代码变成你自己的。
第一个是加一道防切屏记录。在答题页监听visibilitychange事件,每次页面隐藏就往 answer_record 或者一张独立的 behavior_log 表里插一条记录,教师端阅卷时能看到这场考试切换了几次页面。实现上只需要一个前端监听加一个后端接口,工作量小,但答辩时是一个很具体的“我做了扩展”的抓手。
// 答题页挂载时注册,离开时注销 document.addEventListener('visibilitychange', () => { if (document.hidden) { // 用 sendBeacon 保证页面切走时请求也能发出去 navigator.sendBeacon('/api/exam/behavior', JSON.stringify({ recordId: this.examRecordId, type: 'BLUR', time: Date.now() })); } });用sendBeacon而不是普通 axios,是因为切屏瞬间页面可能被挂起,普通异步请求会被浏览器丢弃,beacon 是专门为这种“发完就不管”的场景设计的。
第二个是加一种题型。比如在判断题基础上加一个“填空题”,改动点很明确:question 表的 type 加一个枚举值、前端题库编辑页加一个表单分支、判分逻辑里加一个忽略大小写和首尾空格的字符串比对。这三个点改完,你对整个数据流的理解就完整了,答辩问“系统支持哪些题型、新增题型要改哪里”,你能直接说出来。
第三个是补一份自己的验收清单。交付或者答辩之前,照着下面这张表跑一遍,比临时抱佛脚强得多。
| 检查项 | 通过标准 |
|---|---|
| 冷启动 | 清空数据库重导脚本,不改任何配置能启动 |
| 三种角色登录 | 管理员、教师、学生各自能进到自己的首页 |
| 组卷闭环 | 随机组卷生成的试卷题目数量、总分与设定一致 |
| 考试闭环 | 开考、答题、交卷、判分全流程走通,刷新不丢进度 |
| 超时处理 | 把 duration 改成 1 分钟,验证超时后无法继续答题 |
| 成绩查询 | 学生只能看到自己的,教师能看到全场的 |
我自己的习惯是每次改完代码,不管改多小,都从「清库重导 → 起后端 → 起前端 → 三角色登录 → 走一遍完整考试」这条路走一遍,看着笨,但能拦下八成“本地好好的、换台机器就崩”的问题。这套 SSM + Vue 的在线考试系统源码最大的价值不在于它多完善,而在于它的每一层都是可见的、可改的,你能顺着一条请求从 Vue 的 api 文件一路追到 mapper 的 XML,这个透明度对毕业设计来说是刚需。希望帮到你。
本文还有配套的精品资源,点击获取