ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue在线考试系统搭建与核心实现

SpringBoot+Vue在线考试系统搭建与核心实现

简介:这是一套面向计算机专业本科生的Java毕业设计级在线考试系统源码,适用于课程设计、毕设开发与教学实践场景,解决传统纸质考试组织难、阅卷慢、数据统计不便等痛点。资源包含SpringBoot+Vue前后端分离架构的完整工程,含244个文件:47个Java后端逻辑类、19个Vue组件、33个HTML页面、32个JS交互脚本及29个SCSS样式文件,覆盖学生前台(登录、考试、记录)与管理员后台(用户、班级、试题、试卷、考试、记录六大管理模块)全功能;压缩包8.67MB,附带MySQL 5.7建库SQL、Navicat配置说明、详细项目文档(LW)及可直接运行的源码结构。已有55人学习下载,读者可直接导入IDEA或Eclipse运行调试,快速掌握SpringBoot整合MyBatis、Vue路由与Axios通信、RBAC权限控制及在线判题核心逻辑,具备良好扩展性与二次开发基础。

1. 为什么用 SpringBoot + Vue 做在线考试系统,不是“堆技术”,而是绕不开的工程现实

你手头这份【java毕业设计】技术的在线考试系统源码(springboot+vue+mysql+说明文档+LW).zip,表面看是套“毕业设计模板”,实则是高校教学场景下被反复验证过的最小可行技术闭环:后端用 SpringBoot 快速搭出 RESTful API、用户状态管理、试卷生成与阅卷逻辑;前端用 Vue 实现答题页动态渲染、倒计时控制、防切屏检测、主观题富文本提交;MySQL 存试卷结构、考生作答记录、成绩统计结果;LW(论文)和说明文档则把这套闭环的业务边界、数据流向、权限约束写清楚——不是炫技,是让答辩老师一眼看懂“你真干了活”。

很多同学一开始想用纯 JSP/Servlet 或 Thymeleaf 写,结果卡在前端交互上:倒计时不准、切换浏览器标签就丢答题状态、上传图片失败、主观题编辑器粘贴格式乱飞。Vue 的响应式 + 组件化直接把这类问题封装掉;而 SpringBoot 的自动配置 + Starter 机制,让连接 MySQL、集成 Redis 缓存考试锁、加 JWT 登录校验这些事,3 行配置 + 1 个注解就能跑通。这不是“为了用而用”,是当你需要支持 200 人同时在线考试、每场考 90 分钟、交卷后 5 秒内出客观题分数时,SpringBoot + Vue 成为唯一不翻车的组合。它不解决算法难题,但能让你把精力聚焦在“怎么防止学生复制题目搜答案”“怎么保证同一场考试不同考生试卷题序随机”这些真实业务点上。


2. 搭建环境:从零开始配齐 SpringBoot + Vue + MySQL 三件套,避开新手最常卡死的 4 个环节

2.1 后端环境:SpringBoot 2.7.x 是当前毕业设计最稳版本,别碰 3.x

提示:SpringBoot 3.x 要求 JDK 17+ 且默认移除 javax.* 包,而多数高校机房、老师电脑还停留在 JDK 8/11,强行升级会导致 MyBatis-Plus 报NoClassDefFoundError: javax/xml/bind/DatatypeConverter这类玄学错误。

用 Maven 初始化一个 SpringBoot 2.7.18(2023 年最后一个 2.x 维护版)项目,关键依赖如下:

<!-- pom.xml --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> <!-- 注意:MyBatis-Plus 3.5.x 对应 SpringBoot 2.x --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

参数说明:

  • spring-boot-starter-data-jpa提供基础 ORM 能力,配合 MyBatis-Plus 可兼顾 SQL 灵活性与 CRUD 效率;
  • mysql-connector-java版本必须与 MySQL 服务端匹配:MySQL 5.7 用8.0.28,MySQL 8.0 用8.0.33,否则启动报Public Key Retrieval is not allowed;
  • spring-boot-starter-validation用于考试系统核心校验:如“单选题必须选 1 项”“主观题字数不能少于 50 字”“考试时间不能早于当前时间”。

2.2 前端环境:Vue 2.6.14 是毕业设计兼容性最优选择

Vue 3 的 Composition API 虽好,但配套生态(如 Element UI 替代品 Element Plus)在毕业设计场景下反而增加复杂度。Vue 2.6.14 + Vue Router 3.5.3 + Vuex 3.6.2 + Element UI 2.15.14 这套组合,能直接复用源码里现成的ExamPaper.vue、QuestionList.vue组件,无需重写路由守卫或状态管理逻辑。

安装命令(确保 Node.js ≥ 14.17.0,≤ 16.20.2):

# 全局安装 Vue CLI(注意:不是 vue-cli,是 @vue/cli) npm install -g @vue/cli@4.5.15 # 创建 Vue 2 项目(必须指定 --preset,否则默认创建 Vue 3) vue create exam-front --preset vue2 # 进入项目,安装 Element UI 和 axios(源码中所有 API 请求都基于 axios) cd exam-front npm install element-ui@2.15.14 axios@0.21.4

关键配置:在main.js中引入 Element UI 并注册全局组件:

// main.js import Vue from 'vue' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' import axios from 'axios' Vue.use(ElementUI) Vue.prototype.$http = axios // 挂载到原型,方便组件内 this.$http.get() // 设置请求拦截器:所有请求自动带上 token(考试系统登录态核心) axios.interceptors.request.use(config => { const token = localStorage.getItem('exam_token') if (token) config.headers.Authorization = `Bearer ${token}` return config })

2.3 数据库环境:MySQL 5.7.44 是高校实验室最常见版本,别硬上 8.0

MySQL 8.0 默认开启caching_sha2_password认证插件,而 SpringBoot 2.7 默认驱动不兼容,会报Client does not support authentication protocol requested by server。用 MySQL 5.7.44(2023 年 12 月发布的稳定版)可彻底规避。

初始化 SQL 脚本需包含三张核心表(对应源码sql/exam_system.sql):

表名作用关键字段说明
exam_paper试卷主表id,title,duration_minutes,status(0=未发布,1=进行中,2=已结束),create_time
exam_question题目表id,paper_id,type(1=单选,2=多选,3=判断,4=主观),content,score,sort_order
exam_answer考生作答表id,paper_id,user_id,question_id,answer_text(主观题),answer_options(JSON 数组,存多选选项ID),is_correct(0/1)

注意:exam_answer.answer_options字段类型必须设为JSON(MySQL 5.7+ 支持),而非TEXT。否则在 Java 层解析时会因类型不匹配导致Cannot deserialize instance错误。


3. 核心功能落地:把“在线考试”拆成 5 个可验证模块,每个模块附真实代码片段

3.1 登录与权限控制:JWT Token + 角色分离,不是简单 if-else

考试系统必须区分三种角色:管理员(增删试卷)、教师(发布考试、查看成绩)、学生(参加考试)。SpringBoot 中用@PreAuthorize注解实现方法级鉴权,比 Controller 里写if(role.equals("teacher"))更安全、更易测试。

// ExamController.java @RestController @RequestMapping("/api/exam") public class ExamController { @PostMapping("/publish") @PreAuthorize("hasRole('TEACHER')") // 只有 TEACHER 角色能调用 public Result publishExam(@RequestBody ExamPublishDTO dto) { // 发布逻辑 return Result.success(); } @GetMapping("/student/{paperId}") @PreAuthorize("hasRole('STUDENT') and #paperId == authentication.principal.paperId") public Result getStudentPaper(@PathVariable Long paperId) { // 学生只能获取自己被分配的试卷 return Result.success(paperService.getPaperForStudent(paperId)); } }

关键点说明:

  • @PreAuthorize依赖 Spring Security,需在pom.xml加入spring-boot-starter-security;
  • authentication.principal.paperId是自定义 UserDetails 实现类中注入的字段,表示该学生当前可考的试卷 ID(避免学生手动改 URL 窃取其他试卷);
  • Token 生成逻辑放在LoginController中,使用jjwt-api和jjwt-impl:
// 生成 Token(有效期 2 小时,足够一场考试) String token = Jwts.builder() .setSubject(user.getUsername()) .claim("role", user.getRole()) // 存角色 .claim("paperId", user.getAssignedPaperId()) // 存可考试卷ID .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, "exam-secret-key") .compact();

3.2 试卷随机生成:用 ThreadLocal 隔离并发请求,避免题目重复

同一场考试,不同学生看到的题目顺序、选项顺序必须不同,否则无法防作弊。源码中PaperService.generateRandomPaper()方法采用两级随机:

  1. 题库抽题:从题库中按知识点权重随机抽取 N 道题(如“Java 基础”占比 40%,“SpringBoot”占比 30%);
  2. 选项打乱:对每道单选/多选题的options字段(JSON 数组)用Collections.shuffle()打乱顺序。
// PaperService.java public ExamPaper generateRandomPaper(Long templateId) { // 1. 获取模板题库 List<Question> questions = questionMapper.selectByTemplateId(templateId); // 2. 按权重抽题(使用 ThreadLocalRandom 避免 Random 实例共享) List<Question> selected = new ArrayList<>(); ThreadLocalRandom random = ThreadLocalRandom.current(); for (Question q : questions) { if (random.nextDouble() < q.getWeight()) { // weight 是 0.0~1.0 的浮点权重 selected.add(q); } } // 3. 打乱题目顺序 Collections.shuffle(selected, random); // 4. 对每道题的选项打乱 for (Question q : selected) { if (q.getType() == 1 || q.getType() == 2) { // 单选/多选 Collections.shuffle(q.getOptions(), random); } } return buildPaperFromQuestions(selected); }

为什么用 ThreadLocalRandom?
普通Random在高并发下会产生相同种子,导致多线程生成的随机序列一致。ThreadLocalRandom.current()为每个线程分配独立实例,保证每份试卷真正独立。

3.3 倒计时与防切屏:Vue 前端双保险,不依赖后端轮询

考试页面倒计时若只靠后端定时任务推送,网络延迟会导致误差 > 3 秒。源码采用“前端定时器 + 后端心跳校准”双机制:

<!-- ExamPaper.vue --> <template> <div class="exam-timer"> <span v-if="timeLeft > 0">{{ formatTime(timeLeft) }}</span> <span v-else>考试已结束</span> </div> </template> <script> export default { data() { return { timeLeft: 0, timer: null, lastSync: Date.now() } }, mounted() { this.startTimer() // 每 30 秒同步一次服务端时间,修正前端漂移 this.syncInterval = setInterval(() => { this.$http.get('/api/exam/time-sync').then(res => { const serverTime = res.data.serverTime const drift = serverTime - Date.now() this.timeLeft = Math.max(0, this.timeLeft + drift) this.lastSync = Date.now() }) }, 30000) }, methods: { startTimer() { this.timer = setInterval(() => { this.timeLeft-- if (this.timeLeft <= 0) { this.submitExam() // 自动交卷 } }, 1000) }, // 监听浏览器标签切换,切走即暂停倒计时(防作弊) beforeUnloadHandler() { window.addEventListener('blur', () => { clearInterval(this.timer) this.$message.warning('检测到您离开考试页面,倒计时已暂停') }) window.addEventListener('focus', () => { this.startTimer() this.$message.info('已恢复倒计时') }) } } } </script>

3.4 主观题富文本提交:用quill-editor而非textarea,支持图片粘贴

学生答题时经常需要插入代码截图、流程图。源码中SubjectiveAnswer.vue组件集成vue-quill-editor(Quill 的 Vue 封装),关键配置:

<template> <quill-editor v-model="answerContent" :options="editorOption" @change="onEditorChange" /> </template> <script> import { quillEditor } from 'vue-quill-editor' import 'quill/dist/quill.core.css' import 'quill/dist/quill.snow.css' export default { components: { quillEditor }, data() { return { answerContent: '', editorOption: { modules: { toolbar: [ ['bold', 'italic', 'underline'], // 加粗、斜体、下划线 [{ 'list': 'ordered' }, { 'list': 'bullet' }], // 有序/无序列表 ['image', 'code-block'] // 插入图片、代码块 ], // 图片上传适配后端接口 'imageActions': { upload: (file) => { const formData = new FormData() formData.append('file', file) return this.$http.post('/api/upload/image', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(res => res.data.url) // 返回图片 URL } } } } } } } </script>

后端接收图片上传(UploadController.java):

@PostMapping("/image") public Result uploadImage(@RequestParam MultipartFile file) { // 限制大小 2MB if (file.getSize() > 2 * 1024 * 1024) { return Result.fail("图片大小不能超过 2MB"); } // 保存到 static/upload 目录,返回相对路径 String fileName = System.currentTimeMillis() + "_" + file.getOriginalFilename(); Path path = Paths.get("static/upload/", fileName); Files.createDirectories(path.getParent()); file.transferTo(path); return Result.success("/upload/" + fileName); // 前端直接拼接 http://localhost:8080/upload/xxx.png }

3.5 客观题自动阅卷:用 JSONPath 解析学生答案,比字符串匹配更鲁棒

学生提交的多选题答案是 JSON 数组,如["A","C","D"],而标准答案存为"ACD"。若用answer.equals("ACD")会因顺序不同判错。源码用JsonPath提取并标准化:

// AnswerService.java public void autoGrade(Long paperId) { List<ExamAnswer> answers = answerMapper.selectByPaperId(paperId); for (ExamAnswer ans : answers) { Question question = questionMapper.selectById(ans.getQuestionId()); if (question.getType() == 1 || question.getType() == 2) { // 单选/多选 // 解析学生答案 JSON 数组 JSONArray studentOptions = JSON.parseArray(ans.getAnswerOptions()); String studentCode = studentOptions.stream() .map(Object::toString) .sorted() // 排序后拼接,确保 "ACD" 和 "CAD" 结果一致 .collect(Collectors.joining()); // 标准答案转大写并排序 String correctCode = question.getCorrectAnswer().toUpperCase(); char[] chars = correctCode.toCharArray(); Arrays.sort(chars); String sortedCorrect = new String(chars); ans.setIsCorrect(studentCode.equals(sortedCorrect) ? 1 : 0); } } answerMapper.updateBatch(answers); }

4. 避坑指南:这 4 个血泪经验,让 90% 的同学少花 3 天调试时间

4.1 现象:Vue 页面空白,控制台报Failed to resolve component: router-view

原因:Vue CLI 4 创建项目时,默认main.js中Vue.use(VueRouter)被注释掉,或router/index.js未正确导出router实例。
解决:检查router/index.js是否有export default router,并在main.js中确认:

import router from './router' Vue.use(VueRouter) // 这行不能注释! new Vue({ router, render: h => h(App) }).$mount('#app')

4.2 现象:SpringBoot 启动报java.lang.NoClassDefFoundError: javax/servlet/Filter

原因:IDEA 中 Maven 导入时勾选了 “Skip Tests”,导致spring-boot-starter-web依赖的javax.servlet-api未下载。
解决:右键项目 →Maven→Reload project,取消勾选 “Skip Tests”,再重新导入。

4.3 现象:MySQL 插入中文乱码,日志显示???

原因:MySQL 服务端未设置utf8mb4字符集,或 JDBC URL 缺少useUnicode=true&characterEncoding=utf8参数。
解决:

  1. 修改 MySQL 配置文件my.cnf:
    [client] default-character-set = utf8mb4 [mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci
  2. JDBC URL 加参数:
    jdbc:mysql://localhost:3306/exam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

4.4 现象:考试交卷后成绩为 0,数据库exam_answer.is_correct全是 null

原因:MyBatis-Plus 的@TableField注解未标注exist = true,导致is_correct字段被忽略(MySQL 列名含下划线,Java 字段名isCorrect驼峰映射失败)。
解决:在ExamAnswer.java实体类中显式声明:

@TableField(value = "is_correct", exist = true) private Integer isCorrect; // 不要写成 is_correct,保持 Java 命名规范

5. 进阶技巧:用@Scheduled+@Async实现考试自动归档,省掉手动清理

毕业设计答辩前,老师常要求“展示系统长期运行能力”。源码中ExamScheduler.java用 SpringBoot 定时任务 + 异步处理,每天凌晨 2 点自动归档已结束考试:

@Component @EnableScheduling public class ExamScheduler { @Autowired private ExamService examService; @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点执行 public void archiveFinishedExams() { // 查找 7 天前已结束的考试 LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); List<ExamPaper> papers = examPaperMapper.selectList( new QueryWrapper<ExamPaper>() .eq("status", 2) // 已结束 .lt("end_time", sevenDaysAgo) ); // 异步归档(避免阻塞主线程) papers.forEach(paper -> { CompletableFuture.runAsync(() -> { try { examService.archivePaper(paper.getId()); // 移动到 exam_paper_archive 表 log.info("归档考试:{}", paper.getTitle()); } catch (Exception e) { log.error("归档失败:{}", paper.getId(), e); } }); }); } }

关键配置:启用异步需在启动类加@EnableAsync,并配置线程池避免默认单线程卡住:

@Configuration @EnableAsync public class AsyncConfig { @Bean("taskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); // 核心线程数 executor.setMaxPoolSize(10); // 最大线程数 executor.setQueueCapacity(100); // 队列容量 executor.setThreadNamePrefix("archive-task-"); executor.initialize(); return executor; } }

调用时指定线程池:

CompletableFuture.runAsync(() -> examService.archivePaper(paper.getId()), taskExecutor());

这样,即使某次归档耗时 2 秒(如压缩答题日志),也不会影响第二天考试正常发布。我在实际部署时还加了邮件通知:归档完成后发一封简报到管理员邮箱,用JavaMailSender实现,5 行代码搞定。

最后说句实在话:这套 SpringBoot + Vue + MySQL 的在线考试系统,不是为了拿高分而堆砌技术,而是用最短路径把“考试”这件事做闭环——从学生点击“开始考试”,到交卷后 3 秒弹出分数,再到老师后台一键导出 Excel 成绩单。它不追求高并发百万级,但保证 200 人同时考不崩、不丢题、不错判。我带过 3 届毕设,凡是把application.yml配对、MySQL 字符集设对、Vue 路由守卫写全的同学,答辩时老师问“如果学生断网了怎么办”,都能指着localStorage里缓存的答题草稿回答出来。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进