十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot + Vue前后端分离在线考试系统设计与实战

Spring Boot + Vue前后端分离在线考试系统设计与实战 1. 项目概述这两年在线教育、远程办公的需求越来越旺盛考试系统作为教学闭环里最关键的一环几乎是每个教育类项目绕不开的模块。我自己在接手这个前后端分离的Spring Boot在线远程考试系统之前也踩过不少坑——比如单体架构下前端页面和后端逻辑耦合太深改个样式都能把接口搞挂再比如传统的JSP渲染方式稍微复杂一点的交互逻辑就得靠jQuery堆代码维护成本高得吓人。所以当我决定重新做一版考试系统时直接锁定了Spring Boot Vue MyBatis MySQL这套前后端分离的经典组合。这套系统覆盖的场景其实很典型教师创建试卷、管理题库、查看学生成绩学生参加考试、交卷后立即看到客观题得分管理员维护系统基础数据。整个项目从数据库设计到前后端联调再到最后打包部署完整走完一个企业级项目的全流程。对于正在学Spring Boot和Vue的开发者来说这个项目是一份非常合适的中等难度练手素材——它没有复杂到让人劝退的微服务架构但也不是那种只有一个CRUD的玩具项目涉及到的随机组卷、批量批阅、Token鉴权、跨域处理、Nginx部署等都是实战中高频使用的技术点。我写这篇博文的目的很直接把我在开发这套系统过程中做的技术选型、踩过的坑、以及最终的实现方案完整记录下来。无论你是准备拿它做毕业设计还是想通过实战项目巩固Spring Boot和Vue的技能按照这篇文章的思路走一遍基本能把前后端分离开发的整个套路摸清楚。注意这套方案没有使用过于花哨的技术栈——没有Redis集群、没有RabbitMQ、也没有微服务全家桶一切只为了解决实际业务问题而存在这正是它适合上手的原因。2. 整体架构设计与技术选型2.1 为什么选前后端分离架构早期做Java Web项目大家习惯了Spring MVC JSP那套服务端渲染模式。页面上的每一块内容几乎都要通过Controller传递ModelAndView来渲染前端工程师改一个按钮样式后端就得重新打包部署一次非常痛苦。前后端分离之后前端只负责页面展示和交互后端只负责提供JSON格式的接口数据两边通过HTTP协议通信互不干扰。具体到在线考试这个场景前后端分离带来的好处是实打实的。考试页面有倒计时、实时状态切换、题目切换动画这些交互需求用Vue的响应式机制和组件化开发来处理非常顺手而后端专心处理题目读取、答案提交、自动评分这些事务逻辑不用操心页面渲染的性能损耗。另外如果后续需要拓展移动端或者小程序端后端接口可以完全复用只需要新写一套前端页面就够了——这是单体JSP项目做不到的灵活性。这套系统的前后端分工是这样的端技术栈职责前端Vue 2 Vue Router Vuex Axios Element UI页面渲染、路由控制、用户交互、Token存储后端Spring Boot 2.x MyBatis MySQL业务逻辑、数据持久化、鉴权认证、事务管理部署层Nginx Maven Node.js前端静态资源托管、反向代理、项目构建在实际开发中我建议你也严格按照这个边界来组织代码前后端不越界联调阶段的互相扯皮会少很多。2.2 后端Spring Boot四层架构的业务意义Spring Boot项目的代码组织方式业内最主流的就是四层架构Controller层、Service层、Mapper层、Entity层。很多初学者不理解为什么非要多此一举拆这么多层直接在一个类里写完不香吗我用一个考试提交的场景来解释你就明白了。当学生点击交卷按钮请求会先到Controller层。Controller只做三件事接收参数、校验参数格式、调用Service。接下来Service层开始处理核心业务逻辑——遍历学生的答题记录、判断题目的对错、累加得分、处理主观题标记这中间可能涉及到多次数据库操作。Service层把这些操作放在一个事务里保证要么全部成功要么全部回滚。而真正执行SQL语句的是Mapper层它只需要按照Service层的要求去查询和更新数据。Entity层则是数据的载体对应数据库中的一张张表。这样拆分的核心价值在于解耦和复用。还是以交卷为例如果以后要开发一个批量导入考试成绩的功能不需要改动任何数据库操作代码只需要在Service层新增一个方法复用Mapper层已有的接口即可。如果数据库从MySQL换成PostgreSQL只需要修改Mapper层的SQL方言上层代码完全不用动。我在该项目中遵循的目录规范如下com.exam.system ├── controller // 接收请求、返回响应 ├── service // 业务逻辑层接口实现类 ├── mapper // MyBatis的Mapper接口 ├── entity // 数据库实体类 ├── common // 通用工具类、常量、异常处理 ├── config // 配置类跨域、拦截器、WebMvc └── util // JWT工具、密码加密工具等2.3 核心需求解析与模块划分在线远程考试系统本质上可以拆成三个核心角色和一条考试主流程。三个角色就是管理员、教师、学生它们对应着不同的权限和功能入口。这条主流程则是教师录入题目和创建试卷 → 学生选择试卷并在线作答 → 系统自动批改客观题 → 教师批改主观题 → 学生查看成绩。整个系统开发的核心难度都集中在这条主流程上。我的模块划分是这样的用户管理模块登录注册、JWT签发与校验、角色权限控制管理员/教师/学生题库管理模块单选题、多选题、判断题、简答题的增删改查支持按科目检索试卷管理模块创建试卷、从题库手动选题、按规则随机组卷、试卷列表在线考试模块学生参加考试、答题计时、交卷评分、考试记录成绩管理模块成绩列表、统计图表、教师批改主观题在这五个核心模块之外还需要注意一个容易被忽略的设计点考试状态机。试卷有未开始、进行中、已完成三种状态学生的每场考试也有未参加、考试中、已交卷三种状态这些状态转换必须写清楚规则否则很容易出现学生重复交卷、或者教师在考试中途修改试卷导致考试数据错乱这类低级Bug。我在设计时把所有状态流转收敛到Service层统一处理后面联调时果然省了不少心。3. 数据库设计与建模3.1 核心表结构与字段规划数据库设计是整个考试系统最不该图省事的部分。表设计不合理后面写代码的时候处处受制改表结构又涉及一堆关联代码变动代价极高。我在设计这个系统时总共规划了六张核心表用户表、科目表、题目表、试卷表、试卷题目关联表、考试记录表。用户表sys_user是最基础的表它需要支撑登录认证和角色区分CREATE TABLE sys_user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, role tinyint(4) NOT NULL DEFAULT 3 COMMENT 角色1管理员 2教师 3学生, email varchar(100) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1正常 0禁用, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;题目表exam_question和试卷表exam_paper我放在一起说因为它们之间是一对多的关系必须通过中间表来关联。题目表的字段要考虑题型区分所以我用question_type字段来标识单选题1、多选题2、判断题3、简答题4。选项部分用JSON字符串存储而不是单独建一张选项表——这是我在项目里做的一个务实取舍对于题目选项这种固定结构的数据JSON存储远比关联表简单高效。3.2 题目与试卷的关联设计试卷和题目的关系是经典的多对多关系一份试卷包含多道题目一道题目可以出现在多份试卷中。因此需要一张中间表exam_paper_question来维护这种关系同时这张表还要记录每道题在试卷中的分值因为同一道题在不同试卷中可能分值不同。CREATE TABLE exam_paper_question ( id int(11) NOT NULL AUTO_INCREMENT, paper_id int(11) NOT NULL COMMENT 试卷ID, question_id int(11) NOT NULL COMMENT 题目ID, score int(11) NOT NULL DEFAULT 5 COMMENT 本题分值, sort_order int(11) DEFAULT 0 COMMENT 题目在试卷中的顺序, PRIMARY KEY (id), KEY idx_paper_id (paper_id), KEY idx_question_id (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;sort_order字段非常关键它决定了题目在试卷中的展示顺序。我最初设计时忽略了它导致每次查询试卷时题目顺序都是乱的学生答题体验极差。后来加上排序字段每次组卷时按照创建时的顺序自动生成sort_order问题就解决了。考试记录表exam_record用于存储学生的答题情况和得分结果。它需要记录考试基本信息哪个学生、哪张试卷、何时开始、何时交卷同时用单独的表或字段来存储每道题的作答详情。我在项目里采用的是JSON字段存储答题明细的方式把每道题的答案、是否得分、得分值打包成一个JSON数组放进detail字段。这样做的优点是查询成绩列表时不需要做多表join一次就能查出完整数据缺点是如果后续要做逐题统计分析JSON解析会比较麻烦。对于当前版本的需求来说这个方案性价比是最高的。3.3 数据一致性与索引优化考试系统有一个高并发场景需要注意大量学生同时交卷时如果数据库连接池不够用或者SQL执行效率低很容易出现连接超时。我在设计阶段就从两个角度做了优化。首先是索引设计。所有外键关联字段以及频繁出现在WHERE条件中的字段我都加上了索引。比如exam_record表上的user_id、paper_idexam_paper_question表上的paper_id、question_id。这就好比一本书必须有目录否则查找内容需要一页一页翻。其次是事务边界控制。交卷评分操作涉及到多张表的更新更新考试记录、更新成绩表、修改用户考试状态这些操作必须放在同一个事务里。Spring Boot中使用Transactional注解实现声明式事务但我必须提醒你一个在MyBatis场景下容易忽略的问题事务默认只在抛出RuntimeException时回滚如果代码中手动捕获了异常而没有重新抛出事务会失效造成数据不一致。注意在使用Transactional的方法内部一定不要在catch块里把异常吞掉。正确做法是记录日志后重新抛出或者使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。4. 后端Spring Boot核心实现4.1 JWT鉴权与Token处理机制前后端分离的架构下Session机制已经不适用了——因为前端可能是浏览器、可能是App、也可能是小程序后端服务无法依赖Cookie维持登录状态。业界最成熟的做法是使用JWTJSON Web Token做无状态认证。用户登录成功之后后端签发一个包含用户ID和角色信息的Token返回给前端前端在后续每次请求中把这个Token放在请求头里后端拦截器验签通过就放行。我在项目中封装了JWT工具类核心逻辑是生成Token和解析TokenComponent public class JwtUtils { // 签名密钥实际开发中应放到配置文件中用环境变量注入 private String secret your-secret-key; // Token过期时间我这里设置为24小时 private long expire 86400000; public String generateToken(Long userId, String username, Integer role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }Token有了还得有拦截器来统一校验。我写了一个LoginInterceptor实现HandlerInterceptor接口在preHandle方法中从请求头里取出Token调用JwtUtils解析解析成功就把用户信息放到ThreadLocal中方便后续业务代码取用解析失败则直接返回401状态码和错误信息。这里有一个前后端联调时的经典问题跨域CORS。前端运行在localhost:8080后端运行在localhost:9090两者端口不同浏览器会拦截跨域请求。解决方案是在后端配置跨域过滤器允许指定的前端来源访问。同时要注意跨域请求会先发送一个OPTIONS预检请求拦截器必须对OPTIONS请求直接放行否则会出现前端明明配置了Token却还是报401的奇怪问题。4.2 基于拦截器的角色权限控制考试系统有三个角色不同角色能访问的接口必须做隔离。教师不能调学生的交卷接口学生不能调教师的组卷接口管理员应该能管理所有基础数据。如果这些权限判断散落在各个Controller里代码会非常冗余且容易遗漏。我的做法是定义一个RequireRole注解标注在Controller方法上然后在拦截器中通过反射读取注解信息做权限校验Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { int[] value(); }使用示例RequireRole({1, 2}) // 管理员和教师可以访问 PostMapping(/paper/create) public Result createPaper(RequestBody PaperDTO paperDTO) { return paperService.createPaper(paperDTO); }这样做的好处是权限规则和业务代码完全解耦新增接口时只要看一眼接口需要哪些角色访问标注一下注解就行。拦截器统一处理Token解析、角色比对、异常返回Controller层代码清爽很多。4.3 在线考试核心接口交卷与自动评分考试系统最核心的接口就是交卷评分。这个接口的请求体包含了学生的所有答题信息包括每道题的题目ID和学生的答案后端需要完成的任务是解析答题数据、对照正确答案判断对错、累加分数、保存考试记录。对于客观题单选、多选、判断系统自动评分主观题简答题需要先标记为待批改状态由教师后续阅卷。我在Service层的实现思路如下Override Transactional(rollbackFor Exception.class) public ExamResultVO submitExam(ExamSubmitDTO submitDTO) { // 1. 校验考试状态防止重复交卷 ExamRecord record examRecordMapper.selectByUserIdAndPaperId( submitDTO.getUserId(), submitDTO.getPaperId()); if (record ! null record.getStatus() 1) { throw new BusinessException(该试卷已提交请勿重复操作); } // 2. 查询试卷及其所有题目 ListPaperQuestionVO questions paperQuestionMapper.selectQuestionsByPaperId( submitDTO.getPaperId()); // 3. 遍历答题数据逐题判分 int totalScore 0; int objectiveScore 0; int subjectiveScore 0; ListAnswerDetailVO details new ArrayList(); for (QuestionAnswerVO answer : submitDTO.getAnswers()) { AnswerDetailVO detail new AnswerDetailVO(); detail.setQuestionId(answer.getQuestionId()); detail.setUserAnswer(answer.getAnswer()); PaperQuestionVO question findQuestion(questions, answer.getQuestionId()); // 客观题自动判分 if (question.getQuestionType() 3) { boolean correct checkAnswer(question, answer.getAnswer()); detail.setCorrect(correct); detail.setScore(correct ? question.getScore() : 0); if (correct) { totalScore question.getScore(); objectiveScore question.getScore(); } } else { // 主观题跳过等待教师批改 detail.setCorrect(null); detail.setScore(0); detail.setStatus(0); } details.add(detail); } // 4. 更新考试记录 record.setAnswerDetail(JSON.toJSONString(details)); record.setObjectiveScore(objectiveScore); record.setTotalScore(totalScore); record.setStatus(1); record.setSubmitTime(new Date()); examRecordMapper.updateById(record); return new ExamResultVO(record.getId(), objectiveScore, totalScore); }这道评分逻辑的关键在于客观题答案的比对方式。单选和判断题直接比较学生答案字符串和正确选项字符串是否相等多选题情况稍微复杂因为学生的答案顺序可能和正确答案顺序不一致需要先拆分成数组再排序比较。千万别用简单的字符串相等去判断多选题比如正确答案是A,B学生选了B,A字符串不相等但其实应该判对。这个问题我在自测阶段就踩过后来统一封装了一个checkAnswer方法处理不同类型的比对逻辑。4.4 MyBatis动态SQL与缓存配置MyBatis作为持久层框架在这个项目里最重要的价值就是动态SQL。以题库管理为例题目列表需要支持多种组合条件的筛选——按题型、按科目、按难度、按关键字模糊查询如果这些条件都要写独立的SQL那代码量会非常惊人。MyBatis的 标签配合 标签可以根据传入参数动态拼SQL一个查询方法就能覆盖所有组合场景select idselectQuestionList resultTypecom.exam.system.entity.Question SELECT * FROM exam_question where if testquestionType ! null and questionType ! 0 AND question_type #{questionType} /if if testsubjectId ! null AND subject_id #{subjectId} /if if testkeyword ! null and keyword ! AND (question_content LIKE CONCAT(%, #{keyword}, %) OR analysis LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY create_time DESC /select关于MyBatis的缓存很多初学者会有一点困惑。MyBatis默认开启了一级缓存SqlSession级别同一个SqlSession中执行相同的SQL会直接命中缓存。但在Spring Boot集成环境下每次Mapper方法调用都会新建和关闭SqlSession一级缓存实际上是失效的所以不要指望MyBatis的缓存能帮你扛住高并发查询。这个项目的数据实时性要求高成绩和考试状态随时在变化所以我直接关闭了二级缓存所有查询都走数据库保证数据一致性。如果你的项目后续遇到较大的查询压力优先考虑数据库层面的索引优化和SQL调优而不是盲目依赖MyBatis缓存。5. 前端Vue实现与关键交互5.1 前端项目结构与路由设计前端部分我用了Vue 2 Element UI这套组合。Vue 2虽然已经不是最新版本但生态系统最成熟遇到问题搜索答案也最容易对学习者和毕业设计来说足够用了。如果是从零开始搭建项目直接用Vue CLI创建一个项目骨架然后安装Element UI、Axios、Vue Router、Vuex这几个必备依赖。前端项目的目录结构src ├── api // 封装所有后端接口请求 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // Vuex状态管理 ├── utils // 工具函数request封装、token处理 ├── views // 页面组件 │ ├── admin // 管理员页面 │ ├── teacher // 教师页面 │ ├── student // 学生页面 │ └── login // 登录页 └── App.vue路由设计上我采用了动态路由的思路定义好所有页面组件但在路由守卫中根据用户的角色动态放行。比如学生访问教师管理页面的路由时直接重定向到401页面。这样做的优点是通过前端路由器快速感知权限问题减少无效的HTTP请求。5.2 Axios请求封装与Token刷新前后端分离项目中Axios请求封装是每个前端项目都必须做的基础设施建设。它要解决的核心问题有三个统一处理请求头、统一处理错误状态码、统一处理Token过期。// utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) // 请求拦截器自动携带Token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }, error { return Promise.reject(error) }) // 响应拦截器统一处理错误 service.interceptors.response.use(response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response.status 401) { // Token过期或无效清除本地信息并跳转登录页 localStorage.removeItem(token) localStorage.removeItem(userInfo) router.push(/login) Message.error(登录状态已过期请重新登录) } else { Message.error(网络请求异常请稍后重试) } return Promise.reject(error) })Token过期处理是一个容易被忽略的细节。如果只是简单地弹出登录过期再让用户手动跳转登录页用户体验会很差。我这边处理的是检测到401状态码时清除本地存储的Token和用户信息自动跳转到登录页。用户重新登录后之前访问的路径信息可以存在sessionStorage中登录成功后再跳回原路径。5.3 考试页面的计时与自动交卷考试页面是整个前端开发中最有挑战性的部分。它需要在页面上实时显示倒计时、允许学生切换题目、记录每道题的作答状态、最后提醒交卷确认。我使用了Vue的定时器机制来实现倒计时功能同时还要防止学生刷新页面重置计时。具体方案是学生点击开始考试时后端返回考试截止时间从题库和考试设置中计算出来前端用这个截止时间作为计时依据而不是简单地用60:00这样的倒计时。这样即使学生意外刷新页面重新加载后倒计时也能够正确恢复。自动交卷的边界情况也需要注意。当倒计时归零时前端自动收集当前所有作答数据提交后端。但这里有一个技术难点如果定时器回调函数里提交的数据是异步获取的可能会和用户最后几秒的操作产生竞态。我的做法是倒计时剩余10秒时锁定所有选项按钮禁止学生继续修改答案然后用一次性定时器完成自动交卷。提示不要依赖前端的setInterval做精确的倒计时因为浏览器在标签页切到后台时定时器会被节流导致倒计时变慢。最稳妥的做法是每次更新时间戳与截止时间做差值计算。5.4 答题交互与题目切换答题页面的交互设计我采用了Element UI的步骤条加卡片设计左侧显示题目列表带答题状态标识右侧展示当前题目的题干和选项。学生点击右侧的上一题/下一题按钮或者点击左侧的题目序号都可以切换题目。有一个细节体验值得注意单选题和多选题的交互方式略有不同。单选使用el-radio-group切换选项后自动记录答案多选使用el-checkbox-group允许多选。判断题本质上也是单选只是选项固定为正确和错误。在实现上我统一抽象成一个answerMap对象key是题目IDvalue是学生答案的数组这样不管什么题型都能用同一个数据结构来管理。在项目实测中学生反馈最多的一个问题是多选题选到一半不小心点到别的题目回来发现选项被清空了。我排查后发现是因为answerMap中的值被意外重新赋值。解决办法是在监听选项变化时使用Vue.set方法更新嵌套对象确保响应式数据能正确跟踪变化。6. 核心难点深度剖析6.1 随机组卷算法的设计与实现随机组卷是考试系统的一个核心难点。教师创建试卷时通常不指定具体题目而是设定规则——比如单选题10道、多选题5道、判断题5道从题库中随机抽取。这就要求后端在组卷时根据每种题型的题目池大小和需要抽取的数量生成随机数来确定题目ID。我的实现逻辑如下public PaperVO generatePaper(PaperRuleDTO rule) { // 1. 根据科目和题型查询题目池 ListQuestion singlePool questionMapper.selectByTypeAndSubject(1, rule.getSubjectId()); ListQuestion multiPool questionMapper.selectByTypeAndSubject(2, rule.getSubjectId()); // 2. 校验题目池是否充足 if (singlePool.size() rule.getSingleCount()) { throw new BusinessException(单选题数量不足当前仅有 singlePool.size() 道); } if (multiPool.size() rule.getMultiCount()) { throw new BusinessException(多选题数量不足当前仅有 multiPool.size() 道); } // 3. 使用Collections.shuffle打乱题目列表取前N道 Collections.shuffle(singlePool); ListQuestion selectedSingle singlePool.subList(0, rule.getSingleCount()); Collections.shuffle(multiPool); ListQuestion selectedMulti multiPool.subList(0, rule.getMultiCount()); // 4. 合并题目列表生成试卷 return buildPaper(selectedSingle, selectedMulti, rule); }这里有一个需要特别注意的坑题目池校验必须放在查询之后、抽取之前。我最初版本把校验逻辑放在组卷方法外面结果教师在不检查题库数量的情况下直接创建试卷发现生成的试卷缺题少选项学生答题界面直接报错。后来把校验纳入组卷逻辑内部遇到题目不足时明确提示教师补充题库问题彻底解决。另外随机组卷可能出现同一份试卷中题目重复的概率虽然很低但并非为零。我增加了一个去重判断将已选中的题目ID放入Set集合每次从池中选中新题时先判断是否已存在。6.2 考试防作弊与异常处理在线考试系统无法完全阻止学生作弊但可以从技术层面增加作弊成本。我在这个项目里做了三件事限制考试时间后端在创建考试记录时记录开始时间交卷时校验实际用时是否超过试卷规定的考试时长。如果是异常提交比如通过接口伪造交卷直接拒绝。作答时间间隔检测记录每道题的开始作答时间和提交时间如果某道题瞬间完成且答案完全正确可能是切屏查题或复制粘贴标记为异常提交。禁止重复登录同一个账号在同一时间只允许在一个地方登录。后端的Token缓存机制在第二次登录时会促使前一个Token失效这样至少能防止多人共用一个账号。这些方案并不完美但足够拦截绝大多数低级作弊行为。做这个模块时我最大的体会是不要试图在后端做所有的事情比如严格地记录鼠标轨迹、键盘输入这类行为数据工程成本非常高而收益有限。合理的方案是后端做主流程的校验和控制前端配合做请求频率限制把防守重心放在账号不能同时异地在线和不能无限次修改答案这两个关键点上。6.3 并发场景下的数据库操作在线考试的并发场景和秒杀系统有相似之处大量学生同时交卷数据库瞬时写入压力骤增。但考试系统的并发量和秒杀不是一个量级所以不需要引入消息队列或分布式锁只需要在数据库层面做好两件事。第一件事是使用数据库锁防止重复交卷。我的考试记录表中在user_id paper_id上建立了唯一索引。这样即使前端因为网络重试导致两个交卷请求几乎同时到达后端数据库的唯一索引也会拒绝第二个插入请求从根源上杜绝重复交卷产生的脏数据。注意在高并发场景下先查后写的检查方式存在竞态风险。比如两个请求同时进来都查到没有交卷记录就会各自插入一条记录。解决办法就是数据库唯一索引兜底这比加分布式锁的成本低得多可靠性也高得多。第二件事是合理设计事务隔离级别。交卷评分的操作是读多写少使用MySQL默认的REPEATABLE READ事务隔离级别就够了。但要注意如果在一个事务里先查询试卷信息、再更新考试记录需要避免幻读问题。实际上因为我们的业务是插入更新混合REPEATABLE READ下表现是正常的不需要刻意降级为READ COMMITTED。6.4 Spring Boot Actuator的安全配置在开发阶段Spring Boot Actuator能提供丰富的监控信息包括健康检查、Bean列表、环境变量等对调试非常有用。但在生产环境如果不加限制这些端点的暴露就变成了安全漏洞——攻击者可以通过/env接口读取数据库密码通过/heapdump接口下载堆内存信息Java应用泄露的敏感数据足以让整个系统沦陷。我在这套系统中做了双重防护。第一将Actuator端口独立出来和生产服务端口区分开外部访问不到监控端口第二设置management.endpoints.web.exposure.include只包含health和info这两个最安全的端点其余全部关闭。# 只暴露健康检查和基础信息 management.endpoints.web.exposure.includehealth,info management.endpoint.health.show-detailsnever management.endpoint.health.probes.enabledtrue这个配置看起来简单但很容易被忽略。如果你在这个项目中使用了Spring Boot Actuator一定要在生产环境做这个收敛。我之前排查过一个线上项目发现服务器CPU飙升一查是攻击者在持续扫描并调用Actuator的heapdump接口好在那次项目的数据敏感性不高不然损失会很大。7. 部署上线与常见问题排查7.1 后端打包与MySQL环境配置后端项目打包使用Maven。在pom.xml中配置了Spring Boot的Maven插件后执行mvn clean package就能生成可直接运行的jar包。但打包前有一个重要步骤——检查application.yml中的环境配置确保数据源地址、密码、文件上传路径等全部改成了生产环境的实际值。MySQL的安装与配置是很多新手第一次部署时卡壳的地方。我这里用的MySQL 8.0版本提醒几个要点MySQL 8.0默认使用caching_sha2_password认证插件而某些旧版本的JDBC驱动不支持这种认证方式会报Unable to load authentication plugin错误。解决办法是使用mysql-connector-java 8.0以上版本或者在创建用户时指定mysql_native_password插件。数据库的时区配置要显式加上serverTimezoneAsia/Shanghai否则Spring Boot连接数据库时会报时区错误。同理JVM启动参数也需要加上-Duser.timezoneAsia/Shanghai。创建数据库时务必使用utf8mb4字符集不要用utf8因为utf8在MySQL中并不能完整支持所有emoji和生僻字。7.2 Nginx反向代理与前端部署前端项目构建后生成的是纯静态文件部署方式非常灵活可以用Nginx、Tomcat、甚至直接扔到对象存储上。我的方案是使用Nginx托管前端静态资源同时通过反向代理将/api前缀的请求转发给后端服务。server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /opt/exam-frontend/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:9090/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files指令在这里非常重要它是Vue Router使用history模式时的必备配置。因为前端路由是客户端渲染用户访问/paper/1这类路径时Nginx在磁盘上找不到对应的静态文件如果没有try_files回退到index.html就会返回404。有的同学为了省事把路由改成hash模式绕开这个问题但从用户体验和SEO角度history模式是更好的选择。7.3 常见问题速查与排查思路我整理了这套系统从开发到部署过程中最容易遇到的典型问题做成了速查表任何一个问题卡住你超过半小时都可以先来这里找找思路。问题现象可能原因解决方案前端请求接口报跨域错误后端未配置CORS或配置不当检查跨域配置类特别注意OPTIONS预检请求是否放行登录成功后请求其他接口仍返回401Token未正确传递或过期检查Axios请求拦截器确认请求头是Authorization字段且名称与后端一致交卷提示请勿重复提交考试记录状态未正确更新单独查看考试记录表数据确认status字段和update_time是否写入页面中文乱码数据库字符集不是utf8mb4修改数据库和表的字符集并重启后端服务部署后CSS样式错乱路由模式与静态资源路径不匹配检查Vue项目vue.config.js中的publicPath配置建议设置为./7.4 前后端联调的调试技巧前后端联调阶段我建议把Chrome DevTools用透。Network面板可以看到每一个请求的完整链路包括请求头、请求体、响应体、耗时几乎所有联调问题都能在这里找到线索。有一个高频问题后端返回的数据结构前端取不到某个字段拿到的是undefined。这种情况十个有八个是字段名不匹配。Java实体类的命名习惯是驼峰式如createTime而数据库字段名可能是下划线式如create_time。如果MyBatis没有开启驼峰映射配置查询出来的结果中create_time这个字段就映射不到createTime属性上。解决办法是在application.yml中开启mybatis: configuration: map-underscore-to-camel-case: true还有一个排查问题的经验当接口报错时不要只看前端控制台的错误提示更要看后端日志。我在项目中使用logback输出日志每个Controller的入口都打印了请求参数和返回结果Service层的关键操作也打了INFO级别日志。排查问题时我通常会先查后端日志确认请求是否到达、参数是否正确、异常发生在哪一层定位速度比瞎猜快得多。8. 项目扩展方向与个人实践心得整套系统从数据库设计到部署上线目前已经能跑通完整的教师组卷 → 学生考试 → 系统判分 → 学生查看成绩链路。但如果要我继续迭代这个项目我会优先考虑三个扩展方向。一个是成绩分析模块。现在系统中积累了大量的答题数据完全可以计算每道题的得分率、每个知识点的薄弱程度、班级整体成绩分布。这些数据对教学改进非常有价值实现思路也不复杂——查询考试记录中的答题明细JSON按题目ID聚合统计即可。另一个是在线监考功能。前文说的防作弊方案目前比较基础如果要应对更严格的考试场景可以考虑接入摄像头定时抓拍、屏幕录制、切屏检测上报等功能。Vue前端有现成的getUserMedia接口可以调取摄像头后端只需要新增一个上传接口来接收抓拍图片。最后是性能优化。目前系统部署在单台服务器上当并发量达到上千时性能瓶颈首先会出现在数据库连接池和SQL查询上。可以考虑引入HikariCP连接池的参数调优、把高频查询比如成绩排行做Redis缓存、甚至是读写分离。但这些优化方向需要在系统真正承接大流量之后再做过早优化反而会增加复杂度。做完整套系统我自己最深的体会是写业务代码之前先花足够时间把表和接口设计好。我第一版做考试成绩模块时直接给用户表加了几个成绩字段结果一个用户多门课成绩的需求一出来表设计就得推翻重做。后来规规矩矩从角色和需求出发梳理实体关系才把数据结构稳定下来。另外也想给正在学这套技术栈的同学一个建议不要只跟着视频敲代码试着自己改需求。比如学完随机组卷想想如果现在允许教师手动调整试卷中某道题的分值代码要怎么改学完交卷评分想想如果考试中途教师撤回了试卷学生的考试记录应该怎么处理。拿真实的需求变化去驱动自己对代码结构的思考成长速度会快很多。这套系统的源码和部署教程都在我整理的资源包里按步骤从环境搭建到项目跑起来大概率半天之内就能看到登录页剩下的时间拿来调试和二次开发会是你收获最大的部分。
返回列表