简介这是一套基于Java、SSM框架与Vue前后端分离实现的在线考试系统完整项目包面向计算机专业毕业设计、课程设计学生及Java Web入门开发者提供可直接运行、便于二次开发的考试管理方案。系统分为考生端与管理员端考生可完成在线考试、查询成绩并接收公告与考试通知管理员可维护用户、试题、试卷及考试记录功能链条完整。压缩包共534个文件、约15.54MB包含Java源码、Vue组件、SQL脚本、XML配置及界面素材并附数据库与论文材料目录按前后端模块组织便于查阅部署。已有159人学习适合需要功能演示、论文支撑与排错参考的读者可据此理解SSM与Vue的协作方式并搭建本地环境。1. 在线考试系统源码拆开看SSM Vue 这套毕业设计资源到底给了什么在线考试系统是毕业设计和课程设计里被选得最多的题目之一出问题的比例也最高——很多人拿到一份 Java 源码解压之后不知道怎么跑数据库导进去报一串错Vue 前端起不来最后只能靠截图和论文描述凑合交差。手里这份基于 SSMSpring 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 --registryhttps://registry.npmmirror.com npm run serve-DskipTests是跳过测试阶段只编译和打包避免测试类里写死的本地路径把你卡住。--registry指向国内镜像源纯粹是为了装依赖不超时装完之后可以在 .npmrc 或 package-lock 里固定下来免得换台机器又慢回去。war 改名成 ROOT.war 的作用是把访问路径从/exam-web变成/这样前端里写死的相对路径不用改。2.3 数据库导入与数据源配置数据库是这类项目里最容易出问题的一环因为它同时牵扯字符集、时区、驱动版本三件事。先用命令行进 MySQL把库建好再导脚本。# 建库时就指定字符集不要等导完再改 mysql -uroot -p --default-character-setutf8mb4 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.drivercom.mysql.cj.jdbc.Driver # 关键参数逐个说编码、时区、SSL、公钥获取 jdbc.urljdbc:mysql://127.0.0.1:3306/exam_system\ ?useUnicodetrue\ characterEncodingutf8\ serverTimezoneAsia/Shanghai\ useSSLfalse\ allowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password改成你自己的密码serverTimezone不配的话MySQL 8 驱动在启动时会直接抛时区异常这是最高频的启动失败原因。useSSLfalse关掉本地连接时的 SSL 握手省一次警告。allowPublicKeyRetrievaltrue是配合 MySQL 8 默认的加密插件用的不配的话第一次连接会被拒绝。参数改完记得确认spring-dao.xml里读的是这个 properties 文件而不是写死在 XML 里的另一组账号密码——很多工程两处都有改了一处以为生效了实际读的是另一处。3. 核心考务链路组卷、答题、判分三条线的一次完整跑通3.1 表结构与组卷策略这套系统的业务核心其实收敛在六张表上把这六张表的关系理顺后面所有接口都是围绕它们做增删改查。表名作用关键字段user用户管理员 / 教师 / 学生id、username、password、rolequestion题库id、subject_id、type、content、answer、scorepaper试卷id、name、total_score、durationpaper_question试卷与题目关联paper_id、question_id、sort、scoreexam_record考试记录id、paper_id、user_id、start_time、submit_time、statusanswer_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) { SetString stdSet new HashSet(Arrays.asList(std)); SetString 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.warTomcat 解压出来的 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 utf8mb4url 里加useUnicodetruecharacterEncodingutf8Tomcat 的 server.xml 里 Connector 加URIEncodingUTF-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这个透明度对毕业设计来说是刚需。希望帮到你。本文还有配套的精品资源点击获取