简介:面向计算机毕业设计或课程设计学生,一份基于Java的在线考试系统完整项目包,采用SSM框架与Vue前后端分离架构,覆盖前台用户考试、后台管理员试题与试卷管理等核心模块,可用于课题参考或二次开发。包体共534个文件,以Java源码、Vue组件、XML配置、MySql数据库脚本为主,同时包含编译后的class文件、一键启动与打包脚本、系统论文等,整体约15.54MB,目录结构清晰,便于在IDEA、Eclipse、Myeclipse等环境快速部署。已有159人学习浏览,对需要完整毕业设计范例的初学者和进阶开发者均有参考价值。资源附带的数据库脚本、接口实现和论文文档,能帮助理解在线考试系统的业务流程与设计思路,有效缩短从环境搭建到功能演示的周期。
1. 在线考试系统:为什么它不是一套「空壳 CRUD」
每年毕业设计题目里,在线考试系统都排在前列。但同一个题目做出来的东西差异极大:有的人交的是表单套数据库的增删改查,登录、管理、列表页面齐全,真正考试起来却要手动改数据库;有的人交的是完整闭环——出题、组卷、答题、判分、成绩单,全部在浏览器里走通。这份资源属于后者,它用 SSM 框架写 Java 后端,前端用 Vue,附带 MySQL 数据库脚本和一篇完整的毕业论文,定位就是给计算机毕业设计或课程设计准备的整包工程。你拿它跑通,改改界面、字段和流程,能省下大量搭框架的时间;你完全自己写,也可以拿它当一份细化到字段和接口级别的参考。
2. 技术选型与工程结构:SSM 加 Vue 的项目到底怎么分层
2.1 为什么用 SSM 而不是 Spring Boot
先回答一个常见的疑问:现在新项目不都用 Spring Boot 吗,为什么毕业设计还坚持 SSM?
因为 SSM 是 Spring + Spring MVC + MyBatis 的组合,它把 Spring 的依赖注入、面向切面编程,Spring MVC 的请求分发,以及 MyBatis 的 SQL 映射全部摊开放在 XML 和配置类里。对一个需要答辩讲解的项目来说,「看得见配置」反而是优点。评审老师追问 IOC 容器怎么初始化、DispatcherServlet 拦截了什么请求、SQL 写在哪,你都能直接指着对应文件说清楚。Spring Boot 的自动配置固然省事,但回答「原理是什么」的时候容易陷入黑匣子。
另一个现实原因是,很多学校对毕业设计的技术栈有隐性要求,SSM 属于目录里明确存在的一项。这个资源正好踩在这个需求上——后端用 SSM,前端用 Vue 做前后端分离,既不是老掉牙的 JSP 页面,也没有激进到引入微服务那套复杂度,规格对于一个本科生的课程设计或毕业设计刚好合适。
2.2 后端目录结构:从 controller 到 mapper 的请求链路
把工程解压后,后端是一个标准的 Maven 项目,目录结构大致是这样:
exam-system ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── exam │ │ │ ├── controller │ │ │ │ ├── LoginController.java │ │ │ │ ├── ExamController.java │ │ │ │ ├── QuestionController.java │ │ │ │ └── ScoreController.java │ │ │ ├── service │ │ │ │ ├── ExamService.java │ │ │ │ ├── QuestionService.java │ │ │ │ └── UserService.java │ │ │ ├── mapper │ │ │ │ ├── ExamMapper.java │ │ │ │ ├── QuestionMapper.java │ │ │ │ └── UserMapper.java │ │ │ ├── entity │ │ │ │ ├── User.java │ │ │ │ ├── Exam.java │ │ │ │ ├── Question.java │ │ │ │ └── ExamRecord.java │ │ │ └── common │ │ │ └── Result.java │ │ └── resources │ │ ├── spring │ │ │ ├── spring-mvc.xml │ │ │ ├── spring-mybatis.xml │ │ │ └── applicationContext.xml │ │ ├── mapper │ │ │ ├── UserMapper.xml │ │ │ ├── ExamMapper.xml │ │ │ └── QuestionMapper.xml │ │ ├── jdbc.properties │ │ └── log4j.properties │ └── webapp │ └── WEB-INF │ └── web.xml这个分层是教科书式的:controller 负责接收 HTTP 请求和参数校验,service 写业务逻辑,mapper 只做数据库交互,entity 是数据库表对应的实体类,Result 则统一封装接口返回结构。你在spring-mvc.xml里能看到组件扫描的包路径,在spring-mybatis.xml里能看到 SqlSessionFactory 怎么装配数据源和 mapper 映射文件。整个过程不需要 IDE 额外生成代码,所有 bean 的依赖关系都在配置里明明白白写着。
web.xml是整个应用的入口,它配置了 Spring 的 ContextLoaderListener 和 DispatcherServlet。请求进入后,DispatcherServlet 根据 URL 映射到具体 controller,controller 调 service,service 调 mapper,mapper 通过 XML 里的 SQL 操作数据库,结果再原路返回。链路清晰,答辩画流程图的时候基本不用打草稿。
2.3 数据库六张表:一次考试从出题到成绩的数据流转
数据库脚本在资源包里的sql目录下,核心是六张表。我把字段整理成了一张表,方便你对照理解它们之间的关系:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| sys_user | id, username, password, role | 存三种角色:教师、学生、管理员 |
| exam | id, name, start_time, end_time, duration, status | 定义一次考试的基本信息 |
| question | id, type, content, options, answer, score | 题目表,type 区分单选、多选、判断 |
| exam_question | id, exam_id, question_id | 考试与题目的多对多关联表 |
| exam_record | id, exam_id, user_id, answer_content, score | 答题记录,存用户每次作答 |
| exam_score | id, exam_id, user_id, total_score, submit_time | 最终成绩表 |
其中exam_record是最值得细看的表。它没有把每道题单独拆成一行,而是把用户对某次考试的全部作答序列化成一段字符串存在answer_content字段里,格式类似1:A,2:B,3:C。好处是表结构简单,插入时一次性完成;坏处是判分必须在 service 层解析字符串,不能依赖 SQL。这个项目选择在 Java 代码里完成解析和判分,我觉得是合理的,因为题目数量有限,单次考试几十道题,拆成多行反而让查询变复杂。
exam_question表则是组卷逻辑的枢纽。考试开始时,系统通过查这张表把某个 exam_id 关联的所有题目取出来,打乱顺序后返回给前端。这也意味着同一场考试的所有学生拿到的是同一批题,只是顺序不同。后面如果你想改成不同学生抽不同题,改这张表的查询条件就行。
3. 核心流程拆解:一次考试从进入考场到出成绩
3.1 登录鉴权与角色控制:三种身份怎么分流
登录接口的入口在LoginController,核心逻辑是用户名加密码查询用户,比对通过后把用户对象放进 session。代码大致是:
@Controller @RequestMapping("/api/auth") public class LoginController { @Autowired private UserService userService; @PostMapping("/login") @ResponseBody public Result login(@RequestBody User user, HttpSession session) { User dbUser = userService.findByUsername(user.getUsername()); if (dbUser == null || !dbUser.getPassword().equals(user.getPassword())) { return Result.error("用户名或密码错误"); } session.setAttribute("loginUser", dbUser); return Result.success(dbUser); } }这段代码里有两个关键点。第一,密码比对是明文直接 equals,这在真实生产环境是绝对不行的,至少要用 MD5 加盐。但对于课程设计,如果你的论文里诚实写了「密码以加密形式存储」,并且把 BCrypt 的引入列为后续改进方向,评审通常不会卡这里。第二,登录成功后的用户信息放在 session,而 session 是后端保存状态的,这和完全前后端分离用 Token 的方案不同。这个工程保留 session 机制,是为了沿用传统 SSM 的拦截器做权限控制,简单且不容易出错。
角色拦截在web.xml或 Spring 拦截器配置里定义。管理员访问用户管理接口,教师访问组卷接口,学生只能访问考试接口。拦截不到位的常见结果是学生直接通过 URL 访问管理页面,拿到数据。你二次开发时建议在拦截器里加上对HandlerMethod的注解扫描,而不是只判断路径前缀。
3.2 组卷逻辑:随机抽题还是顺序出题
试卷接口的实现在ExamController里,我来拆一下出题部分的逻辑:
@RequestMapping("/exam/{examId}/questions") @ResponseBody public Result getExamQuestions(@PathVariable Integer examId, HttpSession session) { User user = (User) session.getAttribute("loginUser"); if (user == null) { return Result.error("未登录"); } List<Question> questions = questionService.findByExamId(examId); Collections.shuffle(questions); for (Question q : questions) { q.setAnswer(null); // 前端答题时不能返回答案 } return Result.success(questions); }这段代码做了两件重要的事:第一,用Collections.shuffle打乱题目顺序,让考生之间不能靠相邻座位对比答案;第二,把每道题的正确答案字段置空,防止有人抓包直接看到 answer。这是考试系统最基本的安全底线,很多没做过的人容易漏掉返回答案这个细节,一抓接口就直接透明。
questionService.findByExamId内部是先查exam_question表拿到题目 ID 集合,再根据 ID 批量查question表。因为 MyBatis 的 foreach 标签可以解决 in 查询,所以这一层没有性能压力。你需要知道的是,如果想改成从题库随机抽 N 道题,在这个方法里把查询条件换个实现即可,不需要动 controller。
3.3 交卷与自动判分:字符串解析那点事
交卷接口是考试系统的核心。前端把答题结果组装成一个 JSON 数组提交上来,后端逐个解析判分。判分逻辑在 service 层:
@Override public ExamResult submitExam(Integer examId, Integer userId, List<AnswerItem> answers) { Exam exam = examMapper.findById(examId); if (exam == null || exam.getStatus() != 1) { throw new RuntimeException("考试不存在或未开始"); } List<Question> questions = questionMapper.findByExamId(examId); int totalScore = 0; StringBuilder answerContent = new StringBuilder(); for (AnswerItem item : answers) { Question q = findQuestion(questions, item.getQuestionId()); if (q == null) continue; answerContent.append(item.getQuestionId()) .append(":").append(item.getAnswer()).append(";"); if (q.getAnswer().equals(item.getAnswer())) { totalScore += q.getScore(); } } // 写入答题记录和成绩表 examRecordMapper.insert(new ExamRecord(examId, userId, answerContent.toString())); examScoreMapper.insert(new ExamScore(examId, userId, totalScore, new Date())); return new ExamResult(totalScore, questions.size()); }判分逻辑就是遍历学生提交的答案,每道题和正确答案做 String 比较,相等就累加分数。整个过程中没有任何复杂的算法,工作量主要在前端要正确地把答案按题目 ID 组装起来。多选和判断的答案也都存成文本,答案:C,D和答案:正确,统一走字符串比较,这让代码非常简洁。
这里有个值得注意的点:成绩是交卷那一刻算好的,不是批阅时再算。如果你未来要增加简答题这种主观题,这个结构就不够用了,需要引入「待批阅」状态,把主观题答案单独存表,让老师手动给分后合算总分。这是我后面会说到的二次开发方向。
3.4 前端 Vue 页面怎么对接后端
前端工程在项目根目录的web或frontend文件夹下。页面结构包含登录页、考试列表页、考试答题页、成绩页和管理后台。考试答题页是逻辑最复杂的部分,它用一个 Vue 实例来管理整场考试的状态:
new Vue({ el: '#app', data: { examId: '', questions: [], answers: {}, remainTime: 0, timer: null }, created() { // 进入页面时先拉题目,顺便启动倒计时 this.examId = getQueryParam('examId'); fetch(`/api/exam/${this.examId}/questions`) .then(res => res.json()) .then(data => { this.questions = data.data; this.remainTime = parseInt(localStorage.getItem('exam_duration')) * 60; this.startTimer(); }); }, methods: { startTimer() { this.timer = setInterval(() => { this.remainTime--; if (this.remainTime <= 0) { this.submitExam(); } }, 1000); }, submitExam() { clearInterval(this.timer); let answerList = []; for (let q of this.questions) { answerList.push({ questionId: q.id, answer: this.answers[q.id] || '' }); } fetch('/api/exam/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ examId: this.examId, answers: answerList }) }).then(res => res.json()).then(data => { alert('交卷成功,得分:' + data.data.totalScore); window.location.href = '/score.html'; }); } } });倒计时逻辑用 setInterval 每秒减一次。这里有一个血泪经验:用户刷新页面会导致计时器重置,所以生产级做法是每次进入页面时从服务器时间校准剩余秒数,而不是本地减。这个项目用的是更简单的方案——把考试时长放在 localStorage,进入时重新计算。如果你要改进,可以加一个 axios 拦截器,请求时带一个时间戳字段,后端在交卷接口校验实际用时是否超过考试时长,双保险。
4. 从零到跑通:本地启动这份工程的分步操作
4.1 环境准备:版本就按主流来,别追求新
这份工程的运行环境是常规的 Java Web 开发栈。我用的是 JDK 1.8,Maven 3.6,MySQL 5.7,Node.js 12 以上。注意 JDK 版本不要选太高,特别是不要直接上 JDK 17——SSM 项目大多基于 javax 命名空间,在更高 JDK 下可能遇到模块化导致的 ClassNotFound 异常,排查起来很麻烦。
前后端分离意味着要起两个服务,后端默认跑在 8080,前端 Vue 开发服务器跑在 8081。前端的请求通过代理转发到 8080,所以不需要在前端代码里写死后端地址,这个代理配置放在 Vue 工程的vue.config.js里,关键是下面这几行:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };代理的意思是,浏览器里访问http://localhost:8081/api/exam/list,开发服务器会把请求转发给http://localhost:8080/api/exam/list。这样前端页面里所有的请求都以/api开头,后续部署的时候只要改代理目标,或者让 Nginx 做同样的路径转发,代码本身不用动。
4.2 导入数据库和修改配置
把sql目录下的脚本用 MySQL 客户端导入,先建库再导入表结构和初始数据:
mysql -u root -p < exam_system.sql导入后,打开后端资源的jdbc.properties检查连接信息:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/exam_system?useUnicode=true&characterEncoding=utf8 jdbc.username=root jdbc.password=123456大部分启动失败都是因为这里的三项没对上:数据库名不一致、用户名密码不对、MySQL 端口不是 3306。改完记得检查 Maven 依赖里有没有放 MySQL 驱动。这个工程用的是旧版驱动路径com.mysql.jdbc.Driver,如果你本地装的是 MySQL 8,驱动名要改成com.mysql.cj.jdbc.Driver,同时pom.xml里的 mysql-connector-java 版本升到 8.x,否则启动直接报 ClassNotFound。
4.3 后端启动:IDEA 部署还是 Maven 命令
我自己习惯用 IDEA 直接跑。在 IDEA 里导入 Maven 项目后,配置 Tomcat,选择 Deployment 添加这个 webapp 的 artifact,然后启动。如果一切正常,控制台会输出 Spring、Spring MVC、MyBatis 的初始化日志,最后一行通常是 Tomcat started on port 8080。
如果你不想用 IDEA 的 Tomcat 插件,也可以用 Maven 内置的 jetty 插件跑,在pom.xml里配置好 jetty 插件后执行:
mvn jetty:run这种方式的好处是轻量,不用额外配置 Tomcat;坏处是 jetty 的目录结构和 Tomcat 有细微差异,比如某些 JSP 或静态资源路径解析不同。考虑到前端是 Vue 页面,基本不依赖后端 webapp 目录下的静态资源,两种方式都可以。
启动失败的排查顺序是:先看数据库连没连上,再看 mapper XML 是否编译进 classes 目录,最后看端口是否被占用。如果spring-mybatis.xml配置了 mapper 扫描路径而 XML 没在编译目录里,会报Invalid bound statement (not found),这个错误信息比较隐晦,第一次遇到很容易愣住。
4.4 前端启动与页面联调
前端工程需要先安装依赖。进入 frontend 目录执行:
npm install npm run serve依赖下载慢是常事,如果卡在某个包上,半天没动静,建议切换 npm 镜像源再试。启动成功后浏览器访问http://localhost:8081,能看到登录页面,说明前后端链路已经通了。
用脚本里预置的教师账号登录,可以创建考试并选题组卷;用学生账号登录,可以在考试列表中看到对应考试并点击进入答题。联调过程中最容易出现的问题是跨域,如果你没有配置代理而是直接在前端代码里写http://localhost:8080/api/...,浏览器会因跨域拦截请求。这个项目默认的代理配置能规避大部分跨域问题,所以你在改代码时留意别把请求地址写全了。
5. 避坑记录:跑这类 SSM 考试系统最容易翻车的五个问题
5.1 现象:Tomcat 启动后访问接口报 404,控制台没有异常
原因:DispatcherServlet的拦截路径配置的是*.do,而前端请求发的是/api/...,路径匹配不上,请求根本没进 controller。
解决:把web.xml里 servlet-mapping 的 url-pattern 改成/或者/api/*,同时确认spring-mvc.xml里启用了注解驱动。这类问题不报日志,排查方式是看控制台有没有RequestMappingHandlerMapping的映射记录,如果有,说明 mapping 注册成功,问题就在 servlet 的路径规则上。
5.2 现象:数据库明明连得上,启动却报Invalid bound statement (not found)
原因:MyBatis 的 mapper XML 文件没有被打进编译目录。很多 IDE 默认不把src/main/resources下的 XML 复制到 classes 目录,导致运行时找不到 SQL 映射。
解决:在pom.xml的<build>节点里显式声明把资源目录包含进去:
<build> <resources> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>加了之后清理并重新构建,再看 classes 目录下有没有 mapper XML 文件。从那以后我每次排查 MyBatis 问题第一件事就是确认 XML 在不在编译产物里,省了很多无效时间。
5.3 现象:学生交卷后前端页面停留在提交中,后端也没收到请求
原因:前端用setInterval做倒计时,用户在倒计时归零时连续触发两次提交函数,第一次请求还没返回,第二次又发出去,后端出现并发插入成绩,事务冲突或者重复数据。
解决:在submitExam方法里加一个布尔标志位,进入后立刻把标志位置为 false,整个方法执行期间所有再次触发直接 return。这种锁只在单页内有效,但已足够挡住倒计时边界这种最常见的触发场景。
5.4 现象:Vue 开发服务器启动正常,但所有请求都报 CORS 错误
原因:请求发到了http://localhost:8080而不是经过代理,也就是前端代码里的 axios baseURL 被写死了。因为代理只在开发服务器生效,一旦 baseURL 明确到具体端口,浏览器就把它当成跨域请求。
解决:检查 axios 配置里的 baseURL。正确做法是保留相对路径/api,让开发服务器代理转发。如果非要用完整地址,就在后端加一个 CORS 过滤器,允许前端来源访问。这个项目的正确姿势是用相对路径加代理,改代码时别把这个约定破坏掉。
5.5 现象:成绩查询页分数显示为空
原因:exam_score表插入成绩时,同时写入了总分数但没更新前端展示的缓存字段,或者前端拿到成绩后使用totalScore属性,而后端返回的 JSON 字段名却是score,名称不匹配导致取值失败。
解决:用浏览器开发者工具的 Network 面板看接口返回的原始 JSON 结构,字段名以实际返回为准。修改前端取值对象或后端实体类的 JSON 序列化注解,把两边统一。这类 bug 不算难查,但很浪费时间,所以我现在维护项目时先列接口返回字段清单,前后端各留一份。
6. 二次开发:如何把通用考试系统改成你自己的毕业设计
接手这份代码后,第一步不是急着加功能,而是把现有的用户角色和考试流程彻底跑一遍。通过工具录下每个接口的请求和响应,整理成接口清单。这样答辩或写论文时需要画系统结构图,接口图直接照着改标注就行。
实际改造我建议从三个方向挑一个:第一,增加主观题和人工批阅流程,这是改造成本适中、但答辩时最能讲出深度的一条路线;第二,引入考试随机组卷策略,让每个学生抽到的题目不同,这需要给question表加难度字段,组卷时按难度比例抽取;第三,把系统改成基于 Redis 的 Token 登录,替换 session,为后续小程序端做铺垫。
举个具体的改动例子。如果要支持主观题,你需要在question表加type字段的值,比如 4 代表简答题。判分时遇到 type=4 的题不再比对答案字符串,而是把学生的答案存入exam_record,同时给exam_score表加一个status字段标记「待批阅」。教师端新增一个批阅页面,列出待批阅的记录,教师输入每道主观题的得分后,系统把主观分加客观分得到最终成绩。这个流程一改,论文里能多写不少内容,而且每段都有代码支撑。
每次改完一个模块,记得回到后端跑一遍单测,然后从前端把整个流程走通。我最开始接手这类毕业设计项目的时候,总觉得「改小功能不用测全流程」,结果经常出现学生端没问题、教师端却报错的情况。从那以后我每次改完都会强制自己完整过一遍考试全流程——创建考试、组卷、学生登录、交卷、查成绩、教师看后台,这一套走完心里才有底。
希望帮到你。
本文还有配套的精品资源,点击获取