SpringBoot+Vue+MyBatis+MySQL在线考试系统开发与部署全流程实战
发布时间:2026/10/1 13:55:01来源:尧图网络
SpringBoot Vue MyBatis MySQL这套组合几乎是国内Java后端开发者绕不开的一套“标准套餐”。如果把它用于在线考试系统那就更典型了它既要管理复杂的题库和试卷数据又要面对高并发交卷和阅卷的场景还有前后端分离带来的跨域、鉴权、部署等一系列实际问题。说实话很多朋友把这套系统当成毕业设计或练手项目但真正从零把它完整跑通并且踩过一遍部署的坑之后你对SpringBoot和Vue生态的理解会上一个台阶。这篇文章我不打算只贴代码我会按照我自己实际开发这套系统的思路把架构设计背后的理由、数据库怎么拆表、核心功能怎么实现、部署时容易踩哪些坑完整梳理一遍。无论你是准备用它做毕设还是想在公司内部搭建一套轻量级的考试平台这篇文章的实操经验都可以直接拿来参考。我会尽量把每个“为什么这么做”讲清楚而不是只告诉你“怎么做”。因为你在答辩或技术评审时最容易被追问的就是设计理由。1. 项目整体设计与架构思路1.1 为什么前后端分离是这类系统的正解先讲一个我早期做单体考试系统的经历。当时用的是JSP SpringMVC页面在服务端渲染考试的时候用户每点一下“下一题”就要刷新一次页面倒计时一旦刷新就重置了学生作弊直接用浏览器的后退键就能返回上一题。这个体验放到现在基本没法用。前后端分离之后最直接的变化是页面渲染完全交给了Vue后端只负责提供JSON数据接口。考试页面可以做成单页应用倒计时在浏览器里持续运行题目切换不刷新页面试卷状态一直保存在内存中。就算学生尝试后退或刷新我们也可以通过路由守卫和本地状态判断阻止他离开考试页面或者提示“离开考试将视为交卷”。另一个容易被忽略的好处是团队协作层面的。前端同学专心做页面交互后端同学专心写接口职责边界清晰。哪怕一个人开发整个系统分离之后代码结构也更清爽——Vue工程和后端工程各自独立互不干扰。部署时两边也可以独立更新后端改了接口只需要重启Java服务前端改了样式只需要重新部署静态文件不需要动服务端。当然代价也是存在的。最典型的就是跨域问题和登录状态的传递问题。以前是同一个Tomcat里又处理页面又处理接口Session天然共享分离之后前端静态资源可能运行在Nginx上后端接口运行在8080端口浏览器默认会拦截跨域请求。Session共享也需要额外处理。所以我们需要引入JWT这种无状态令牌把用户身份信息放在Token里每次请求由前端带过来后端验证。这个方案在考试场景下还有一个额外好处服务端不需要保存Session天然支持多实例部署后面做负载均衡也方便。1.2 角色划分与功能模块拆解在线考试系统的用户角色一般分三种管理员、教师、学生。这个划分直接决定了整个系统的权限模型和功能边界。管理员通常负责系统级的维护工作比如用户管理、班级管理、整体数据统计。教师负责教学考试相关的核心业务包括题库维护、试卷组卷、考试发布、主观题阅卷、成绩查看。学生是被服务的主体功能比较固定查看待考/已考列表、进入考试、答题、交卷、查看成绩和错题。把功能模块画出来看整个系统的主线其实非常清晰教师录入题目到题库然后从题库中选题组成试卷挑选学生或班级发布一场考试学生在规定的时间内进入考试并答题交卷后系统对客观题自动判分主观题由教师在后台手工给分最终成绩展示给学生和教师同时生成统计数据。由于本文写作时参考了若依框架前后端分离的设计理念我建议在实现时注意权限菜单的动态生成而不是把菜单写死在前端。大大一个原则前端显示什么菜单、能不能点击某个按钮必须由后端接口返回的权限集合决定。不然学生直接改前端代码就能看到教师菜单那权限就形同虚设了。在线考试系统中教师进入的是题库管理和组卷界面学生进入的是考试大厅这两套界面在入口处就应该按照角色分流。具体到路由层面就是根据登录用户角色动态注册路由这个在Vue Router中通过addRoute方法实现后面我会展开讲。1.3 技术选型背后的取舍这套系统的技术栈看起来是“标配”但每个组件其实都有过选择的纠结。SpringBoot版本我不建议一上来就选最新版。很多人喜欢用Spring Boot 3.x但它强制要求JDK 17而且很多老版本的第三方库不兼容比如一些自定义的MyBatis插件。在这套系统中Spring Boot 2.7.x是一个许多新手比较稳妥的起始版本它能配合JDK 8资料最丰富遇到问题几乎都能搜到现成的答案。如果你本机已经装了JDK 17甚至21也没必要降级直接用3.2.x即可但要注意MyBatis-Spring-Boot-Starter版本要用2.3.0以上否则启动会报错。MyBatis和MyBatis-Plus的选择也是很多人纠结的点。标题既然写了MyBatis我就以原生MyBatis为准来讲。实际开发中单表的增删改查和分页用MyBatis-Plus确实能省不少代码但原生MyBatis并没有我们想象的那么难写尤其是动态SQLif标签和foreach标签在组卷和批量插入这种复杂场景下反而更灵活可控。而且对于学习者来说手写SQL能让你对数据库操作有更深刻的理解这在面试时也是一个加分项。所以这篇文章给的示例代码全部基于原生MyBatis接口加XML映射文件不引入Plus。MySQL这边我建议用8.0版本。5.7虽然也够用但8.0的窗口函数、JSON类型支持更好尤其在成绩统计和题目选项存储上JSON字段能省掉很多额外的关联表。需要注意的是MySQL 8.0默认使用的是caching_sha2_password认证插件JDBC连接时需要额外配置allowPublicKeyRetrievaltrue和useSSLfalse否则就会出现连接报错。这个问题在部署阶段非常典型后面我在常见问题部分专门列出来。文件存储这块我选择了MinIO。考试系统里题目会包含图片、音频甚至视频不可能全部以Base64存到数据库里。MinIO是兼容S3协议的开源对象存储在本地部署非常轻量接入SpringBoot也很简洁一条依赖加几行配置就能跑起来。如果你不想引入MinIO也可以退而求其次用本地磁盘存储但那样做文件和业务耦合度高迁移和备份都会头疼。2. 数据库设计与核心表结构2.1 表结构总览与核心关系数据库设计如果是一团乱麻后面写代码时每走一步都会流血。在线考试系统的核心表其实就八张左右用户表、角色表、题目表、试卷表、试卷题目关联表、考试表、考试考生关联表、答题记录表、成绩表。我建议大家遵循一个设计原则先画出业务主链路再往链路上补表。主链路就是“教师建题库→创建试卷→发布考试→学生参加→系统判分→成绩展示”。这条链路上涉及的每一步都对应至少一张表。比如“教师建题库”最基础的是题目表题目还要分单选、多选、判断、简答等类型所以题目表里必须有type字段。“创建试卷”需要有一个载体记录试卷基本信息同时要有一张关联表记录这张卷子包含哪些题目。“发布考试”则需要考试表来记录起止时间、时长、考试状态还要有考试考生表来圈定参加范围。角色表与用户表之间是多对多因为一个用户理论上可以既当教师又当管理员。在具体实现中我用SpringSecurity或自己写拦截器时都基于这张关系表判断接口权限。为了避免用户表膨胀所有用户信息统一放在一张表里包括学生和教师只是角色不同。千万不要把学生和教师分到两张表否则登录逻辑和成绩关联查询都会变得非常别扭。2.2 题库与试卷表的关键设计题目表在我看来是整个系统设计中比较重要的一张表。它的字段虽然不多但每个字段都有讲究。我用过比较合理的设计如下CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, subject_id BIGINT COMMENT 所属科目, type TINYINT COMMENT 1单选 2多选 3判断 4简答, content TEXT COMMENT 题干, options TEXT COMMENT 选项JSON如[A.xxx,B.xxx], answer VARCHAR(255) COMMENT 正确答案多选用逗号分隔, analysis TEXT COMMENT 答案解析, difficulty TINYINT COMMENT 1易 2中 3难, score INT COMMENT 默认分值, creator_id BIGINT COMMENT 录入教师, create_time DATETIME );这里有一个很多人容易陷入的误区把选项拆成一张子表用question_option表来存。这样做确实符合数据库第三范式但在实际开发中着实有点“过度建模”。一个选项字符串本身不需要被其他业务表引用它只属于当前题目用JSON数组直接存储即可。查询时取出来由后端直接返回给前端前端JSON.parse就能渲染写起来省事得多。但如果你的系统有“选项级”的统计需求比如想分析有多少人选了错误选项B那拆表的收益就大于成本。考试系统初期不必这样设计。试卷与题目的关联表是另一个需要特别注重的存在很多人在这一块处理得草率把卷子里的题目直接以逗号分隔存到试卷表的一个字段里。这种做法在题目数量少时还能忍但当题目数量上百、或者需要针对同一份试卷判断某道题对应几分时你会痛苦得想把数据库推倒重来。正确做法是建一张paper_question表CREATE TABLE paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT, question_id BIGINT, sort_no INT COMMENT 题目在试卷中的序号, score INT COMMENT 本题分值可覆盖题目默认分 );这样就实现了试卷和题目的多对多同时还能保留每题在特定试卷中的分值和顺序。组卷时如果采用随机抽题也只需要向这张表批量插入记录即可。2.3 考试记录与答题明细表的细节考试表和答题记录表的设计直接关系到两个核心问题防止重复交卷以及判分的正确性。考试表我加了这样几个关键字段开始时间、结束时间、考试时长分钟、状态草稿/已发布/进行中/已结束。这里要提醒的是前后端的倒计时都不可信。前端的倒计时可以被修改后端的System.currentTimeMillis也可能因为用户手动调整浏览器时间而出现偏差这个偏差指的是服务器时间正常客户端时间被用户改了。所以后端每次校验时都以服务器时间为唯一基准前端只能展示剩余时间不能决定何时强制交卷。更稳妥的方式是用Redis存储每场考试每个考生的结束时间但如果没有Redis数据库记录一个start_time每次提交时用start_time duration与当前服务器时间比对也可以。答题记录表是判分的核心。很多人的第一版设计是只有当题做完才存一条记录但学生在考试过程中应该能随时修改答案所以我们需要一张“过程表”来保存最新的答题状态CREATE TABLE answer_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT, student_id BIGINT, question_id BIGINT, answer VARCHAR(500), status TINYINT COMMENT 0未答 1已答, update_time DATETIME, UNIQUE KEY uk_exam_stu_ques (exam_id, student_id, question_id) );每次前端点击一个选项就调用一次保存接口upsert到这张表。交卷时系统按考试试卷题目的顺序批量读取答题记录与标准答案比对得出客观分。这里我用的是“过程表”和“结果表”并存的方式结果表即score表记录总分、客观分、主观分以及交卷时间。如果直接把每次答题临时状态存到最终成绩表里一方面冗余另一方面交卷时统计逻辑会非常混乱。2.4 索引与性能优化建议数据库量级到了一定程度性能问题才会暴露。但对于在线考试系统我建议在设计初期就把索引规划好这样后期不需要大规模重构。核心索引有三类。第一类是查询类索引比如题目表按subject_id、type、difficulty建立的联合索引这能支撑教师按科目和难度筛选题目。第二类是业务唯一索引answer_record表里exam_id student_id question_id的唯一索引防止同一道题在数据库里出现多条过程记录。第三类是状态类索引考试表里的status和end_time建立索引方便系统定时扫描超时未交卷的考试并触发强制交卷。这里我分享一下个人的经验开发阶段不要过度索引。一个小考试系统数据量不过几千条加再多的索引也感受不到差异还会拖慢插入速度。索引这件事做到“能解释清楚为什么建这个索引”的程度就足够了答辩时说得头头是道比什么都强。硬背一堆索引优化理论不如指着answer_record表的唯一索引说一句“为了保证不重复交卷”更有说服力。3. 后端核心实现与关键代码3.1 基于JWT的登录与权限控制登录鉴权这块我选择用JWT 拦截器的方式没有引入Spring Security。原因很实在考试系统的权限模型不复杂角色只有三种用Spring Security的话概念一大堆配置过滤器链、密码加密器、自定义UserDetailsService光是跑通就要掉不少头发。自定义拦截器加JWT校验代码量少而且整个校验流程完全透明更适合这个项目。JWT的核心思路是用户登录成功后后端生成一个包含用户ID和角色的Token字符串返回给前端。前端把它存起来每次请求在请求头带上。后端定义一个拦截器拦截所有除登录、验证码等白名单之外的接口解析Token并验证签名然后把用户信息塞到ThreadLocal或请求上下文中。这样就实现了无状态登录。具体实现时有几个细节容易被坑。第一是Token过期时间的设置我建议2小时考试时长一般不会超过2小时但如果是长时考试前端可以在Token快过期时调用刷新接口。第二是拦截器白名单的维护除了登录和获取验证码接口一些静态资源和Swagger文档路径也需要放行。第三是角色权限的校验仅仅靠登录态还不够还必须确认该接口允许的角色集合。我自定义了一个RequireRole注解标注在Controller方法上拦截器里读取注解并比对当前用户的角色集合不匹配则返回403。放行路径和JWT工具类的配合我在下面给一个简化版的拦截器配置示例public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 如果请求的不是Controller方法直接放行 if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token) || !token.startsWith(Bearer )) { return this.writeError(response, 401, 未登录或Token缺失); } try { JwtUtil.parseToken(token.substring(7)); } catch (Exception e) { return this.writeError(response, 401, Token无效或已过期); } return true; } }在WebMvcConfig中注册拦截器时记得用excludePathPatterns把登录、注册、静态资源排除掉否则会出现登录接口本身也被要求带Token的尴尬情况。3.2 题库管理的动态SQL实现题库管理这一块典型的场景是教师按条件组合筛选题目——按科目、类型、难度、题干关键字。如果把这些筛选条件全部写在Java代码里用if判断拼字符串那将是灾难。MyBatis的动态SQL正是为这种场景设计的。在Mapper XML中我用 标签配合 标签来构造查询条件这个写法既安全又简洁。很多新手容易踩的坑是在 标签里忘了判断空字符串导致前端传了个空字符串过来查询结果反而受到干扰。正确的写法是同时判断非空和空串select idselectQuestionList resultTypecom.exam.entity.Question SELECT * FROM question where if testsubjectId ! null AND subject_id #{subjectId} /if if testtype ! null AND type #{type} /if if testdifficulty ! null AND difficulty #{difficulty} /if if testkeyword ! null and keyword ! AND content LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY create_time DESC /select另一个很实用的场景是批量插入。教师从Excel导入题目或者随机组卷时需要一次性插入几十道试卷题目关系用foreach标签可以一条SQL插入多条记录大幅减少数据库交互次数。但要注意MySQL单条SQL的max_allowed_packet限制一般批量插入一次不超过200条比较稳如果数据量更大就分批插入。我还要提一下ResultMap的映射问题。题目表里options字段是JSON数组字符串而Java实体里我定义成List 。MyBatis默认不知道如何把VARCHAR映射成List这时候就轮到TypeHandler上场了。自定义一个JsonTypeHandler在setParameter时把List序列化成JSON字符串在getResult时把字符串反序列化成List。这样实体和数据库之间的转换完全透明业务代码不需要关心序列化细节。这也是为什么热搜词里有“mybatis中typehandler的工作流程图”——在真实项目里自定义TypeHandler确实是个高频实用点。3.3 自动组卷与考试状态流转自动组卷的实现我用的是最简单直接的方式按难度比例随机抽取。打个比方教师设定这个试卷“简单题占30%中等题占40%难题占30%”那么在对应科目下分别按比例从题库中随机抽取。这种方式比复杂的遗传算法组卷容易理解得多对题库覆盖面和教师需求已经足够了。代码逻辑上是这样实现的先按科目和难度分组查询出所有符合条件的题目ID然后用随机数抽取需要的数量最后把它们插入paper_question表。有一个容易被忽略的问题抽出的题目数量不够怎么办。很多人在这个场景下不做校验考试发布后学生发现试卷只有5道题。我的做法是发布前校验一次题目数量低于设定值则不允许发布并直接提示教师“请补充XX类型的题目”。考试状态流转也需要特别设计。我的状态机是草稿→已发布→进行中→已结束。草稿状态的考试学生不可见教师可以编辑。发布后学生才能看到但这个阶段仍然可以撤销。一旦到了开始时间状态自动变成进行中此时不能再编辑试卷。等到结束时间或所有考生交卷状态变为已结束此时开始统计成绩并展示。这里最大的难点在于状态的自动流转。我这里提供一种实践上比较稳妥的做法不依赖定时器去改状态而是在Redis中存储考试状态或每次查询时动态计算状态。比如一个考试字段里有start_time和end_time前端查询考试列表时后端不需要更新数据库状态字段直接比较当前时间和起止时间现场计算是“未开始”、“进行中”还是“已结束”。这样就不需要写定时任务去扫描表也不会出现学生看到状态没刷新的问题。当然数据库里的status字段依然保留它用于记录人工操作比如教师主动撤销考试。3.4 自动阅卷与成绩统计客观题判分是最没有技术含量的部分但恰恰是这里最容易出现业务逻辑错误。单选的答案比对很简单多选则要特别注意判分规则是“全对才给分”还是“少选得一半分”我在做系统时默认采用的是“多选必须与标准答案完全一致才得分”这个规则最简单也最好解释。如果你需要“少选按比例给分”那就在判分逻辑里多写一层集合包含关系判断即可例如答案集合完全相等得满分是标准答案的真子集得一半。对于判断题答案存储的是“T”或“F”比对时直接把String equals即可。简答题这类主观题系统不能自动判分需要置为待阅卷状态教师在后台手动打分。交卷时系统先生成一个总的成绩记录客观题得算好主观题得分暂记为0等教师打完分再更新总成绩。成绩统计这块我做了三个维度的展示班级维度的平均分和及格率、按题型维度的得分率、单个学生的成绩趋势。SQL层面用到了GROUP BY加上COUNT和AVGMySQL 8.0还支持窗口函数可以算出班级排名。有时候学生的成绩趋势需要跨越多次考试那么以student_id分组按考试时间排序即可。这里唯一要提醒的是统计查询涉及多表联查一定要把索引建好不然随着考试次数增加这条SQL会越来越慢。3.5 文件上传与MinIO接入题目带图片考生头像上传这些文件存储需求在第1节我说过用MinIO解决。这里直接给一个SpringBoot集成MinIO的关键步骤实测下来非常顺。引入依赖时用io.minio下的minio客户端版本选8.5.x。在配置文件中写下endpoint、accessKey、secretKey、bucketName然后注册一个MinioClient的Bean。上传的核心逻辑三步走检查bucket是否存在不存在则创建将MultipartFile转为InputStream后调用putObject最后拼接访问URL返回给前端。public String upload(MultipartFile file) throws Exception { String objectName UUID.randomUUID().toString().replace(-, ) _ file.getOriginalFilename(); minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return endpoint / bucketName / objectName; }在部署时MinIO最好单独跑在Docker里下面第5节我会给出完整的Docker Compose编排。还有一点要提醒MinIO的访问URL必须能被浏览器直接访问否则前端无法显示图片。如果MinIO和后端不在同一台机器就要把endpoint配成对外的域名或IP不能用localhost。4. 前端工程化与考试交互4.1 Vue工程结构与请求封装前端这个工程我直接用的Vue CLI创建的标准结构Vue版本视情况用2或3。如果你对Vue还不熟悉建议从Vue 3 Vite入手因为Vue 2已经停止维护了。不过Vue 3的组合式API和Vue 2的选项式API差别不小如果你网上的参考代码多是Vue 2那就先学Vue 2也没问题系统能跑起来比版本先进更重要。请求封装是前端的根基。我通常会在src/utils/request.js里对Axios做统一封装把baseURL配置成“/api”这样在开发环境通过Vite的devServer把请求转发到后端在线上环境通过Nginx把请求转发到后端的8080端口。这样做的好处是前端代码里只需要写相对路径部署到任何环境都不需要改代码。这里需要特别说明的是很多同学在“通过Nginx怎么把前后端请求转接到一起”这个环节会卡住。原因是开发环境的前端跑在5173端口后端跑在8080端口浏览器拒绝跨域。Vite的devServer配置里加上server.proxy即可生产环境则是在Nginx配置里写location /api把请求分发到后端的Java服务。这个配置我在第5节详述先在这里埋个伏笔。请求拦截器里我统一从本地存储取出Token放到Authorization请求头里。响应拦截器里我统一处理后端返回的401状态码一旦遇到Token过期就清除本地用户信息并跳转到登录页。这个机制保证了后续所有业务代码不需要关心Token是否失效对开发者来说非常省心。4.2 动态路由与权限菜单动态路由的实现在考试系统里很值得讲一讲。传统做法是把所有路由静态写在router/index.js里然后靠查询参数判断是否显示菜单。这种做法的缺陷是学生可以在地址栏手动输入教师的页面路径虽然接口层面我们做了权限拦截但前端会闪现一下空白页或者报错页体验不好。后来我按若依框架的设计思路改成了动态路由注册。登录成功后后端返回当前用户的路由列表前端遍历这个列表用router.addRoute一个个动态添加。同时菜单栏根据同一个路由列表渲染。这样做的核心收益是学生连路由地址都不知道更不用说访问了。实现上后端返回的每个路由项包含path、name、component字符串形式的组件路径。前端这边由于Vue的import是静态分析的不能用全动态的变量加载组件所以需要维护一个本地组件映射表把字符串路径映射到具体的组件对象。例如const modules import.meta.glob(../views/**/*.vue); function loadView(viewPath) { return modules[../views/${viewPath}.vue]; }Vite的import.meta.glob支持这种动态模式匹配这是Vue 3 Vite的典型玩法。如果你用的是Vue 2 Vue CLI则需要用require.context来做类似的映射。4.3 考试页面与倒计时策略考试页面是整个系统前端体验的试金石。学生一旦进入考试所有的交互都围绕“答题”和“交卷”展开我会把注意力集中在这三个核心点第一个是试卷渲染。从后端拿到一份“试卷结构”包含大题分组和题目列表。由于题目选项是JSON数组字符串前端拿到数据后要先解析再渲染。我封装了一个QuestionItem组件根据题目类型动态渲染单选、多选、判断或简答。对于多选题需要注意选项和答案的绑定逻辑对于简答题是双向绑定一个textarea的文本值。第二个是倒计时策略。前端倒计时用setInterval每秒减1但是核心防作弊逻辑在后端。后端在登录考试接口时记录了每场考试每个考生的开始时间前端每次保存答案时后端都会校验当前服务器时间是否已经超过deadline一旦超了就拒绝保存并返回一个“考试已结束”的标记。前端收到这个标记后立即触发强制交卷。另外每次切换题目时调用一次保存接口而不是每点一个选项就请求一次可以大大减少网络请求次数也减轻后端压力。我采用的是“切题时保存上一题手动点击保存按钮定时保存”。第三个是离场防护。Vue的beforeRouteLeave路由守卫无法完全阻止浏览器关闭或刷新页面所以还需要监听beforeunload事件在页面关闭时提示确认。对于刷新操作则要配合sessionStorage来恢复状态防止学生刷新后丢失已答内容。但这些都只能防君子不能防小人真正防作弊还得靠后端校验交卷接口交卷时明确记录交卷时间和IP一旦发现异常可以人工介入。4.4 管理端页面的设计要点管理端页面相对简单主要是表格加表单的组合但题库管理是一个数据量较大的模块需要注意分页和筛选的一致性。我用的方式是前端封装一个SearchBar组件提交筛选条件表格用el-table或者Element Plus的table数据源通过分页组件切换页数时重新请求后端接口。组卷页面是另一个交互复杂的界面。我把它设计成三步走的向导式布局第一步选择科目和题型难度分布第二步系统按比例随机抽题并预览题目列表第三步确认并保存试卷。这里请一定预留一个“重新随机生成”按钮。因为教师很可能不满意随机出来的结果这个按钮会让他用户体验好很多。预览区采用抽屉组件从右侧滑出实时展示抽到的题目内容教师可以逐题查看不满意就一键重新随机。成绩管理页的核心是明细展示。教师点开一场考试后看到的是所有考生的成绩列表。点进某个考生能看到每个题型的得分汇总以及主观题的批改入口。我的实现方式是左侧表格显示成绩列表右侧抽屉展示答题明细。这样教师批改主观题时不需要跳转页面效率更高。5. 部署上线全过程实录5.1 从零初始化环境部署这件事看起来不难实际踩坑能踩到怀疑人生。我做一次完整的部署流程记录方便你照着重现。后端环境需要JDK 8或17、Maven 3.6以上、MySQL 8.0。前端需要Node.js 16以上。这些环境的安装版本需要事先确认经常出现的问题是Node版本太高导致npm install失败或者JDK版本与SpringBoot版本不兼容。我的经验是把这些工具的版本固定写在项目的README里每台机器都按同样的版本装省得反复折腾。MySQL的初始化我建议直接用命令行方式执行SQL脚本不要用Navicat可视化工具去跑带存储过程的脚本。原因是一旦脚本里包含中文注释Navicat的编码集可能出问题导致导入失败。在服务器上用mysql -u root -p exam_system.sql这种方式执行是最不容易出错的。还有一个小技巧是脚本文件用UTF-8编码保存并在SQL文件头部写上SET NAMES utf8mb4;这样中文数据进去之后不会乱码。Navicat连接MySQL 8遇到的SSL连接错误是很多新手第一个大坑。连接时如果报“SSL connection error”就在高级设置里把“Use SSL”选项关闭如果报“Authentication plugin caching_sha2_password cannot be loaded”则需要修改MySQL用户的认证插件为mysql_native_password。这个问题的根源是MySQL 8默认认证插件改了网上教程鱼龙混杂你只需要记住开发环境直接关SSL最省事生产环境再考虑加密连接。5.2 后端打包与生产配置分离SpringBoot项目的打包用Maven非常直接。在项目根目录执行mvn clean package -DskipTests就会在target目录下生成一个可执行的jar包。但这里我要特别提醒不要直接在生产环境上改application.yml里的数据库密码然后重新打包。正确做法是把配置外置打包时保留默认配置运行时通过启动参数指定外部配置文件。SpringBoot支持通过spring.config.location指定外部配置实际部署命令如下java -jar exam-system.jar --spring.config.location/opt/exam/config/application-prod.yml外置配置的另一个好处是代码打包和配置维护分离。服务器管理员改了数据库密码只需要改配置文件然后重启服务不需要找开发重新给包。这一点在公司环境中尤其重要。生产环境的JVM参数也要单独调优一下。我一般会在启动脚本里加上初始堆内存和最大堆内存设置比如-Xms256m -Xmx512m避免JVM因为动态调整堆大小而出现性能抖动。对于一台2G内存的服务器这个参数够用了。如果并发量高还要考虑开启SpringBoot的线程池配置调整Tomcat的max-threads值但这个阶段不用过度优化。这里顺带提一下Tomcat部署的问题。很多人的疑问是“前后端分离项目要不要把前端打包成war放进Tomcat”。我的答案是分情况。如果前端已经用Nginx部署后端jar包独立运行这是标配方案。如果服务器资源有限不想装Nginx也可以把前端构建后的dist目录放进Tomcat的webapps下但这样就要处理history模式路由刷新404的问题很麻烦。我自己的偏好是Nginx单独装一个成本极低收益极高。5.3 前端构建与Nginx入口分发配置前端部署的流程基本固定本地执行npm run build生成dist目录然后把dist目录里的文件上传到服务器的/opt/exam/frontend目录下。接下来配置Nginx把静态文件和接口请求整合在一起。我在Nginx配置里主要做了两件事。第一是把根路径指向前端dist目录并把所有的.php或后端接口路径请求转发给后端Java服务第二是适配Vue Router的history模式让深层路由在刷新时不会返回404。Nginx配置文件的关键片段我贴出来server { listen 80; server_name exam.example.com; # 前端静态资源 root /opt/exam/frontend; index index.html; # 接口请求转接后端 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # history模式路由刷新支持 location / { try_files $uri $uri/ /index.html; } }Nginx的proxy_pass指令配置上有一个容易误用的地方location中的路径是否带斜杠会影响转接后实际路径。如果location是/api/proxy_pass是http://127.0.0.1:8080不带路径那么请求/api/login会被转接为http://127.0.0.1:8080/api/login如果proxy_pass后面带路径比如http://127.0.0.1:8080/那么请求/api/login会被转接为http://127.0.0.1:8080/login。你在写的时候要清楚自己想保留还是去掉前缀要不然后端Controller路径就得跟着调整。接着是前端history路由刷新404的问题。Vue默认的hash模式URL会带#号不好看但刷新不会404。如果改用history模式刷新一个/papers/1这样的页面Nginx根本找不到这个文件就会返回404。此时用try_files $uri $uri/ /index.html可以让全部未知路径回到前端入口由前端路由重新接管页面渲染。这段配置是解决“线上刷新一片白”的关键不少同学开发时没问题部署后才头疼多半就是少了这一行。5.4 使用Docker Compose一键编排如果你觉得手工装MySQL、Java、Node、MinIO太麻烦或者想在全新服务器上快速复现整套环境我推荐把基础设施做成Docker Compose编排。用容器跑中间件MySQL、Redis、MinIO业务后端仍然以jar包方式跑在宿主机或也打进镜像迁移起来非常舒服。我实际用的docker-compose.yml包含三个服务MySQL 8、MinIO、Redis。MySQL和MinIO的数据卷都挂载到宿主机目录这样容器删了数据还在。Redis在这里主要是用于存储Token黑名单和考试状态缓存如果JWT无需设置黑名单也可以先不引入Redis减少部署复杂度。version: 3.8 services: mysql: image: mysql:8.0 container_name: exam-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: exam_system ports: - 3306:3306 volumes: - ./mysql/data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d minio: image: minio/minio container_name: exam-minio command: server /data --console-address :9001 environment: MINIO_ROOT_USER: admin MINIO_ROOT_PASSWORD: admin123456 ports: - 9000:9000 - 9001:9001 volumes: - ./minio/data:/data redis: image: redis:7-alpine container_name: exam-redis ports: - 6379:6379MySQL镜像初始化数据库时会自动执行docker-entrypoint-initdb.d目录下的SQL脚本所以我只需要把建表脚本放进./mysql/init里容器启动后数据库就能自动建好。这个特性很实用尤其是多台服务器环境保持一致的时候比手动执行脚本可靠得多。后端服务的Java进程我建议仍然用systemd或nohup管理不进容器。因为调试和查看日志更直接遇到OOM问题也方便用jstack等工具分析。当系统真正上了规模再容器化也不迟。至于前端静态文件连容器都不需要直接用Nginx的root指向宿主机目录即可。6. 常见问题排查与避坑实录6.1 跨域和请求拦截问题跨域问题在开发阶段出现得最多。前端跑在5173端口后端跑在8080端口前端一请求后端接口浏览器控制台立刻报错CORS。解决方式有两种后端开启跨域配置或者前端配置devServer的proxy。我个人推荐开发阶段用前端proxy生产环境用Nginx转接这样后端接口可以被彻底闭合只准通过特定入口访问安全性更好。如果后端非要开CORS也不要用CrossOrigin到处写而是统一配置CorsFilter。使用CorsFilter需要注意allowedOriginPatterns这个配置项因为老版本SpringBoot只支持allowedOrigins当你把allowCredentials设为true后allowedOrigins不能使用通配符“*”必须写具体地址。我习惯把后端服务设计成“不信任任何外部Origin”也就是说后端接口只能被同源页面访问。开发阶段靠Vite的proxy把/api转接到8080生产阶段靠Nginx转接后端根本不需要开启CORS。这样一个配置逻辑贯穿整个项目后端代码少了很多跨域相关的杂音。6.2 MySQL连接与驱动问题MySQL连接报错的排查我整理一下最典型的两个。第一个是启动SpringBoot时报“Public Key Retrieval is not allowed”。这是MySQL 8默认的caching_sha2_password插件在非SSL连接下需要用RSA公钥换取加密密码而JDBC驱动默认禁止这样做。解决方案是在数据库连接URL上加上allowPublicKeyRetrievaltrueuseSSLfalse。第二个是严重的时区问题报错会提示“The server time zone value is unrecognized”。这是因为MySQL驱动的时区与本地不一致需要把serverTimezone设置为Asia/Shanghai。完整的JDBC连接串如下我每次新建项目都是直接把这一串复制过去spring.datasource.urljdbc:mysql://localhost:3306/exam_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue另外补充一点驱动版本问题也很常见。SpringBoot 2.7默认管理的是MySQL Connector/J 8.0.x和MySQL 8匹配没有问题。但如果你的服务器MySQL是5.7使用8.0的驱动连接也完全可以。如果出现“Unknown system variable query_cache_size”说明驱动版本和MySQL不匹配通常是把5.x的驱动用在了MySQL 8上升级驱动即可解决。6.3 MyBatis缓存与TypeHandler的坑MyBatis的缓存是不少人面试时被问到的点实际开发中也偶尔会被它坑到尤其是二级缓存。这里我先说结论在线考试系统用的MyBatis我建议只开一级缓存二级缓存谨慎处理甚至先关掉。一级缓存是SqlSession级别的同一个请求内多次查询同一个Mapper方法返回同一个对象这个特性在日常开发中几乎没有副作用。二级缓存是Mapper级别的多个SqlSession共享缓存。坑点在于如果你在考试系统中开了二级缓存学生交卷后如果教师立即修改了某道题的标准答案缓存中旧的答案可能还残留着判分时使用的就是旧答案。虽然理论上MyBatis会在更新操作时自动清空相关缓存但多表关联查询的缓存失效策略并不总是覆盖到。与其在缓存失效问题上战战兢兢不如直接关掉二级缓存把压力交给MySQL。TypeHandler的坑主要是容易忘记注册。自定义的JsonTypeHandler如果只在XML的resultMap中指定而你的Mapper方法传入参数时要用到它例如插入题目时把List转成字符串你还需要在MyBatis配置文件里注册它或者在TableName等注解场景下处理。没注册的话报错会很隐晦比如“No typehandler found for property options”。这种问题排查起来比较费时间所以我的建议是XML resultMap里一定要先写上typeHandler插入语句的参数也要显式处理避免依赖全局自动注册。6.4 并发交卷与数据一致性处理在线考试系统最真实的并发场景是考试时间一到全班学生同时点击交卷瞬间几十个请求打到后端。如果交卷接口不做并发处理会出现两种异常情况一是重复提交导致成绩被覆盖或出现重复记录二是交卷时写入答题明细和计算分数不在一个事务里出现分数和答题表不一致。解决重复提交的方案我采用的是pay-as-you-go的幂等思路交卷接口进入时先根据examId和studentId去查询成绩表如果已经存在成绩记录直接返回已有结果。同时数据库层用唯一索引兜底就像answer_record表那样score表也把exam_id和student_id建唯一索引哪怕请求多来几次数据库也能保证只有一条记录插入成功。事务边界上我把交卷过程设计成一个完整的事务先更新answer_record的最终状态再计算客观题得分然后插入score记录。由于整个事务需要快速执行所以在交卷计算时只做内存比对不进行任何额外查询这样整个事务通常能在几十毫秒内完成。加上唯一索引兜底并发冲突的概率会降到很低。还有一个细节值得注意交卷时前端和后端都设置了倒计时。但你不能只依赖前端通知后端“时间到了”因为前端请求可能丢失。我建议在后端加一个定时任务每分钟扫描一次考试表找出尚未交卷但已经到了结束时间的考试逐一强制交卷。这样即使学生关掉浏览器考试成绩也不会缺失。6.5 其他容易被忽视的细节WebSocket在考试系统中可以用来做教师端的在线监考面板实时显示每位考生的登录状态和切屏次数。这个功能听起来很高级但实现起来也没那么难。SpringBoot里加一个WebSocketConfig前端创建WebSocket连接后端维护一个会话Map考生交卷或退出时广播消息。如果你想在答辩时展现亮点不防把这块做进去它比单纯的多加几个CRUD接口更有技术吸引力。文件上传的体积限制也极易踩坑。SpringBoot默认单个文件上传最大1MB如果你直接在题目里上传一张高清图片前端传到一半就报“FileSizeLimitExceededException”。需要修改spring.servlet.multipart.max-file-size和max-request-size。MinIO那边默认没有特殊限制但Nginx对外层的client_max_body_size也要同步调大否则前端请求被Nginx拦在入口处。最后是日志。上线之后排查问题日志是唯一的线索。我在项目里用Logback做了控制台加文件的双重输出并按天切割日志文件。同时在交卷接口和登录接口打印了关键参数方便出现纠纷时追溯。这条经验虽然普通但真的能帮你省下大量无用功。本系统选择SpringBootVueMyBatisMySQL这套组合就是在稳定性、资料丰富度和上手成本之间取得了很好的平衡。如果你准备做毕业设计可以以本文的架构为骨架再补充一个WebSocket在线监考或成绩导出功能技术亮点完全够用。如果你是在公司内部搭建系统也建议先按“小步快跑”的方式把核心考试流程跑通再逐步增加题库导入、试卷模板、考试报告等外围功能。我个人实际做完这套系统后的体会是前后端分离真正的分水岭并不是选了什么框架而是你能不能把架构、数据、交互、部署这四个层面的问题贯穿地想清楚。希望这篇文章能帮你把这条路走得更顺。
网站建设高端定制企业官网