前后端分离在线考试系统实战:SpringBoot+Vue3从数据库设计到自动判分

📅 发布时间:2026/9/9 9:08:49
前后端分离在线考试系统实战:SpringBoot+Vue3从数据库设计到自动判分 做在线考试系统这个项目之前我在网上翻了不少源码去参考发现大量项目要么是老的JSPServlet那一套要么就是SpringBoot单体加个简单模板页面真正能体现前后端分离、技术栈足够新的并不多。所以我决定自己完整写一套后端SpringBoot2前端Vue3持久层MyBatis-Plus数据库MySQL8.0。整个项目从需求分析、数据库建模、后端接口、前端页面到最后的部署和排错我用一期文章把关键内容全部拆开讲透。这篇文章适合正在做课程设计或毕业设计的学生也适合公司内部想快速搭一套轻量考试系统的开发者。1. 考试系统的需求拆解先定义清楚做什么再动代码很多初学者拿到这种项目第一反应是开个工程直接敲代码但做完整项目最忌讳的就是连需求都没理清就开始写。在线考试系统表面上就是出一套卷子让学生做然后判分但真做起来里面涉及的角色、流程和细节比想象中多不少。我一开始就做了个简单的角色和业务梳理后面写代码才没有返工。1.1 两个核心角色和三件核心事这个系统里有两种关键角色管理员教师和考生学生。管理员负责维护题库、组合试卷、发布考试、查看成绩考生负责登录系统、参加考试、查看自己的成绩和答题情况。围绕这两个角色系统最核心的业务闭环是三件事第一题库管理。管理员要能维护大量的客观题包括单选题、多选题、判断题、填空题并且要给题目打上知识点和难度标签这样后面随机组卷才有依据。没有题库管理的考试系统就是一个空壳。第二组卷与考试管理。管理员可以选择手动选题也可以设定规则让系统自动组卷。考试要设置开始时间、结束时间、考试时长、总分并且要保证组出来的试卷总分和设定的一致。第三在线考试与自动阅卷。考生进入考试后有倒计时、答题卡、题目切换交卷后系统自动判分客观题当场出成绩考生可以回看错题。这三件事之间是有依赖关系的题库是基础组卷是桥梁考试和阅卷是出口。我的做法是先做题库再做试卷最后做考试流程每一步都验证通过再进入下一步。1.2 功能模块的收敛与划分我最初的设想里还加了班级管理、课程管理、成绩导出、试卷导出但评估之后砍掉了一部分只保留了能形成完整闭环的核心模块。因为功能越多表结构越复杂前后端工作量成倍增长。最终我确定了六个模块模块核心功能关键技术点用户认证登录、注册、角色区分JWT令牌、BCrypt加密题库管理题目的增删改查、按题型/难度/知识点筛选分页查询、条件构造器试卷管理手动组卷、随机组卷、试卷上下架随机算法、试卷快照在线考试答题、倒计时、答题卡、交卷状态管理、时间控制自动阅卷客观题自动判分、主观题标注逐题比对、成绩回写成绩统计成绩列表、正确率、个人详情聚合查询、排行榜这个划分没有做太细但足够支撑起完整的业务流程。你要是想做扩展在这个基础上加班级、课程、通知公告这些模块都比较容易表和数据关联都是能平滑接上去的。1.3 提前想清楚系统边界项目做到一半的时候我意识到边界问题不提前定好后面会被需求拖死。这里说的边界主要是三点。主观题判分的边界。一句话简答题、论述题这类主观题用程序做智能评判在课程设计这个级别基本不现实。我当时采取了折中方案如果试卷全是客观题就完全自动判分如果含主观题主观题部分标记为待人工批改管理员在后台手动打分。这样既保证了流程完整又把难度控制在合理范围内。防作弊的边界。像人脸识别、切屏监控、摄像头监考这些功能对单机部署的课程设计项目来说负担太重。我的做法是考试开始后全屏展示离开页面时记录一次切出日志仅做记录不做强制阻断。把这个作为增强功能写进文档里既体现实测过又不会因为过度承诺给自己挖坑。并发规模的边界。这套系统设计目标是支撑一个班级几十人同时在线考试不是高并发产品。所以不需要引入消息队列、Redis缓存这些重型组件。Redis可以用在临时答案缓存上但不是必需品MySQL完全够用。边界想清楚了后面的系统设计和代码实现就快很多。接下来就是技术选型这部分我踩过不少坑下面分开说。2. 技术选型的真实权衡为什么最终定了这套组合技术选型不是越新越好也不是越老越稳。我在标题里写的SpringBoot2、Vue3、MyBatis-Plus、MySQL8.0这四个核心组件每一项都是经过实际测试和版本兼容性确认之后才定下来的。下面拆开讲讲我做选择时的逻辑。2.1 SpringBoot2而不是SpringBoot3兼容性优先SpringBoot3很早就发布了但它带来了一个比较麻烦的变化Java EE规范从javax迁移到了jakarta命名空间。这意味着如果你用了老版本的MyBatis、Shiro或者其他依赖库SpringBoot3下面很容易出现ClassNotFound之类的兼容性问题。对于在线考试系统这种以课程设计、毕业设计为主要使用场景的项目稳定压倒一切。我选的是SpringBoot 2.7.x版本这是2.x系列的最后一个大版本功能完善第三方库生态成熟。配合JDK8或者JDK11都没有问题部署时也不会被JDK版本卡住。代码里体现最明显的就是导入包路径。SpringBoot2项目里用的还是javax.servlet到了SpringBoot3就强制要求改成jakarta.servlet。很多网上找的参考代码都是基于SpringBoot2写的沿用2.x版本可以少踩一大半的兼容性坑。2.2 Vue3加Vite、Element Plus、Pinia后台管理的现代组合前端我选了Vue3配合Vite构建工具、Element Plus组件库和Pinia状态管理。这四个东西在Vue3生态里是当前比较标准的组合。Vue3相比Vue2最大的变化是Composition API。在线考试系统里有一个场景特别能体现它的优势考生答题界面。答题卡状态、当前题目、倒计时、暂存答案这几个状态是强关联的。用Vue2的Options API写逻辑会散落在data、methods、watch里用Vue3的setup语法可以按功能维度组织代码。我当时把倒计时、答题卡、答案存储抽成了三个组合式函数代码可读性提升很明显。Vite替代Webpack以后开发服务器启动速度提升非常明显。Vue2时代启动工程要等好几秒Vite基本上秒开。Element Plus是完全够用的表格、表单、弹窗、消息提示这些后台管理需要的东西都有现成组件和Vue3的配合是原生级别的。状态管理我选了Pinia。Vuex官方对Vue3的支持虽然也可以用但Pinia更轻、更符合组合式API风格而且TypeScript支持更好。考试过程中考生答案会频繁变化用Pinia管理答案状态配合本地缓存刷新页面不会丢答案这个体验比纯Vuex方案好不少。2.3 MyBatis-Plus单表操作为主的场景用起来很顺手在线考试系统的大部分SQL都是单表CRUD题目表查列表、试卷表插一条、答题明细表批量插入。这种场景用MyBatis-Plus太合适了。MyBatis-Plus最大的价值是BaseMapper。你定义好实体类继承BaseMapper之后insert、deleteById、selectById、selectList这些基础方法全都有了不需要写一行SQL。再加上LambdaQueryWrapper条件构造器代码非常接近自然语言ListQuestion list questionMapper.selectList( new LambdaQueryWrapperQuestion() .eq(Question::getType, MULTI) .eq(Question::getDifficulty, 3) .orderByAsc(Question::getId) );这段代码的含义是查多选题、难度3、按ID升序可读性非常强而且Lambda方式不会出现字段字符串拼错的问题编译期间就能发现。MyBatis-Plus的分页插件也很省事。题库管理页面肯定要分页配置一个分页拦截器然后写Page参数就可以PageQuestion page new Page(current, size); questionMapper.selectPage(page, wrapper);如果你把项目规模做大一点涉及多表关联查询MyBatis-Plus就会比较吃力。我的做法是单表操作全部用MyBatis-Plus多表关联的复杂查询仍然写XML。比如统计各题型的正确率这种涉及三张表join的查询就老实写SQL既不绕弯也更容易优化。2.4 MySQL8.0默认字符集和窗口函数带来的实在好处MySQL8.0相比5.7有一个很实际的改进默认字符集是utf8mb4。MySQL 5.7默认的utf8实际上不是真正的四字节UTF-8遇到emoji和生僻字会直接报错。导致的结果是你插入正确✅这种内容时就会遇到Incorrect string value错误每次都要手动改表结构或者数据库配置文件。MySQL8.0省掉了这一步。MySQL8.0的窗口函数也是5.7没有的。我在做成绩排行榜的时候需要根据总分给考生排名用窗口函数写起来非常简单SELECT user_id, total_score, ROW_NUMBER() OVER (ORDER BY total_score DESC) AS rank_no FROM exam_record WHERE paper_id #{paperId};在MySQL5.7里写排名需要用户变量配合实现代码又长又容易出错。MySQL8.0的窗口函数一步到位。再说说连接MySQL的依赖版本和配置。项目连接MySQL8.0用的是mysql-connector-java的8.x驱动驱动类名是com.mysql.cj.jdbc.DriverURL里要指定时区参数比如serverTimezoneAsia/Shanghai。不指定时区的话驱动会默认使用UTC时间和本地时间相差8小时你会看到时间数据全部错位这个下面讲坑的时候还会详细说。技术栈确定以后下一步就是数据库设计。考试系统的表结构看起来不复杂但如果不提前设计好后面写业务逻辑的时候会非常难受。3. 数据库设计六张核心表撑起整个考试业务在线考试系统的核心表我最终设计成六张用户表、题库表、试卷表、试卷题目关联表、考试记录表、答题明细表。这个划分是我做了两版调整后确定的第一版把试卷和题目直接存在一张表中导致试卷复用和题目换版本非常麻烦后来才拆开。下面说说每张表的关键字段和设计思路。3.1 用户表与角色设计用户表不需要太复杂在线考试系统没有独立的角色表用role字段区分就行CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码(BCrypt加密), real_name VARCHAR(50) COMMENT 姓名, role VARCHAR(20) NOT NULL DEFAULT STUDENT COMMENT 角色: ADMIN/TEACHER/STUDENT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );密码字段用BCrypt加密这是Spring Security里常用的加密方式不会明文存储。角色用简单字符串admin管理员可以对所有数据进行管理student只能参加考试和查看自己的成绩。不加Spring Security框架只用一个JWT拦截器做权限控制整个登录认证流程要轻很多。3.2 题库表题型、难度、知识点是关键筛选维度CREATE TABLE tb_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type VARCHAR(20) NOT NULL COMMENT 题型: SINGLE/MULTI/JUDGE/BLANK, content TEXT NOT NULL COMMENT 题干, options TEXT COMMENT 选项JSON数组,如[A:xxx,B:xxx], answer VARCHAR(255) NOT NULL COMMENT 正确答案, analysis TEXT COMMENT 解析, difficulty INT DEFAULT 3 COMMENT 难度1-5, knowledge_point VARCHAR(50) COMMENT 知识点, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这道表格设计有几个要注意的地方。选项字段我用了TEXT存JSON字符串比如[A:乔布斯,B:库克,C:马斯克,D:比尔盖茨]。这样做的好处是表结构非常稳定不用为不同题型建不同表缺点是查询时不能对选项内容做条件筛选但这也不是考试系统的核心需求完全可以接受。知识点的价值在随机组卷时体现。管理员要从题库中抽取10道Java基础题、5道MySQL题如果没有知识点字段就只能全库随机出出来的试卷方向感很差。我的做法是在前端做一个按知识点筛选的手动选题页面后端接口同时支持知识点参数这样管理员既能手动选系统也能自动按规则抽题。3.3 试卷表与试卷题目关联表必须存题目快照试卷主表比较简单试卷名称、总分、考试时长、开始时间、结束时间、状态。但关键在关联表。CREATE TABLE tb_paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_name VARCHAR(100) NOT NULL, total_score INT NOT NULL DEFAULT 100, duration INT NOT NULL COMMENT 考试时长(分钟), start_time DATETIME, end_time DATETIME, status INT DEFAULT 0 COMMENT 0草稿 1已发布 2已结束, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tb_paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, score INT NOT NULL COMMENT 该题分值, sort_order INT DEFAULT 0 COMMENT 题号顺序 );关联表里除了paper_id和question_id还必须存一个score字段表示这道题在这份试卷里占多少分。为什么不能直接去题目表里查分值因为同一道题在不同的试卷里分值可能不同而且题目表里的answer和analysis如果后来被修改了历史试卷的判分和解析就全乱了。所以要存题目快照。我在表里设计了单独的快照字段在组卷完成时把题目内容、选项、正确答案、解析全部拷到关联表里后面即使原题目被修改已经生成的试卷也不会受影响。这是做考试系统最容易忽略又特别重要的一个细节。3.4 考试记录表与答题明细表成绩和学生作答内容分离CREATE TABLE tb_exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, user_id BIGINT NOT NULL, total_score INT NOT NULL COMMENT 总分, correct_count INT DEFAULT 0, answer_count INT DEFAULT 0, duration INT DEFAULT 0 COMMENT 实际用时(秒), status INT DEFAULT 0 COMMENT 0进行中 1已提交, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, submit_time DATETIME, UNIQUE KEY uk_paper_user (paper_id, user_id) ); CREATE TABLE tb_answer_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL, question_id BIGINT NOT NULL, paper_question_id BIGINT NOT NULL, user_answer VARCHAR(500) COMMENT 考生作答内容, correct_flag TINYINT DEFAULT 0 COMMENT 是否答对, score INT DEFAULT 0 COMMENT 本题得分 );考试记录表是考生和试卷的关系表一条记录代表一次考试。我在表上加了唯一索引(paper_id, user_id)防止同一考生对同一份试卷重复创建考试。这里有个实际场景要处理考生如果第一次没交卷就退出页面再次进入考试时不能新建一条记录而是应该查询并恢复进行中的那条。答题明细表保存考生的每道题作答内容和判分结果。考生提交后系统逐题判分把每道题的得分和正确标志写进明细表。这样成绩回看页面可以直接查明细表的数据不用重新判分。后来我看网上有些项目把学生答案存成一个大JSON字段塞在考试记录表里这种做法当时省事但后面做逐题对错分析知识点掌握率统计就难了因为每次都要解析JSON再汇总。正确做法就是把每个小题单独拆记录明细数据分开存统计查询的灵活度完全不一样。建表完毕就到了最核心的业务链路随机组卷、在线答题、自动判分。这三段是整个项目的灵魂我重点讲实现思路和关键代码。4. 随机组卷到自动判分核心业务链路的实现4.1 随机组卷按题型、难度、知识点比例出题随机组卷不是简单地从题库随机捞题目而是要凑出一张总分、题型分布、难度分布都符合要求的试卷。我的实现思路是按题型分组抽题。假设管理员想要一份总分100分的试卷组卷规则是这样单选题10道每题3分共30分多选题5道每题4分共20分判断题10道每题2分共20分填空题5道每题6分共30分那么后端接口接收到的参数就是一组抽题规则。核心代码大致如下public void generateQuestions(PaperRuleDTO rule) { ListLong resultQuestionIds new ArrayList(); for (RuleItem item : rule.getItems()) { // 查询满足条件的题目ID ListQuestion candidates questionMapper.selectList( new LambdaQueryWrapperQuestion() .eq(Question::getType, item.getType()) .eq(item.getDifficulty() ! null, Question::getDifficulty, item.getDifficulty()) .eq(StringUtils.hasText(item.getKnowledgePoint()), Question::getKnowledgePoint, item.getKnowledgePoint()) ); // 打乱顺序 Collections.shuffle(candidates); // 取前N题 ListQuestion selected candidates.subList(0, Math.min(item.getCount(), candidates.size())); // 计算总分并校验 int totalScore rule.getItems().stream() .mapToInt(i - i.getScore() * i.getCount()).sum(); if (totalScore ! rule.getTotalScore()) { throw new BusinessException(抽题规则的总分不等于设定总分); } resultQuestionIds.addAll(selected.stream().map(Question::getId).toList()); } }这里有几个容易踩的坑。第一个是题库数量不够的问题题目数量少于需要抽取的数量时subList会越界所以我用了Math.min兜底更稳妥的做法是提前检查并抛出题库数量不足的提示。第二个是总分校验很多随机组卷的实现不做这个校验最后出来一份总分不是100分的试卷考试分数体系就乱套了。第三个是在随机组卷时要考虑性能如果题库量很大每次都查全库再shuffle是不推荐的可以先用SELECT id FROM tb_question WHERE ...查出ID列表再shuffle取前N个ID最后查询ID in (N个ID)的完整数据这样效率高很多。抽题规则定义好之后后端就把选中的题目ID、分值、排序写入tb_paper_question表同时把题目内容快照也存进去。这一步是在一个事务里完成的避免试卷创建到一半时服务异常导致半张残卷入库。4.2 在线答题倒计时、答案暂存与答题卡在线答题页面看起来是一个简单的界面左边题目区右边答题卡和倒计时。但实际上要处理的状态和异常场景非常多。我设计的前端状态管理包含这几个核心变量const examState reactive({ recordId: null, // 考试记录ID paperId: null, // 试卷ID currentQuestion: 0, // 当前题号索引 answers: {}, // 题目ID - 用户答案Map结构 timeLeft: 0, // 剩余秒数 submitted: false // 是否已交卷 })答案存储是我重点考虑的。如果考生每点一个选项就请求后端保存网络稍慢就会产生卡顿体验很差。我的方案是前端把答案实时更新到Pinia仓库同时写一份到localStorage每10秒做一次批量暂存调用后端接口。这个方案的思路是本地优先远端兜底。即使考生突然刷新页面或者浏览器崩溃从localStorage恢复答案是最快的localStorage丢失了也能从后端数据库的暂存接口恢复。为了支持暂存接口我在原来的tb_answer_detail表里加了一个status字段0表示暂存未提交1表示已正式交卷。正式交卷时批量把状态更新为1并且触发判分逻辑。倒计时要特别注意精度问题。很多人把截止时间存成前端变量用setInterval每秒减一这样一个刷新页面就前功尽弃了。正确的做法是后端返回当前服务器时间和考试截止时间前端用这两个时间差值做倒计时这样即使本地时间不准、刷新页面倒计时也不会错乱。function startCountdown(deadline, serverTime) { const offset Date.parse(deadline) - Date.parse(serverTime) const timer setInterval(() { const remainSeconds Math.floor((offset - (Date.now() - startTime)) / 1000) if (remainSeconds 0) { clearInterval(timer) submitAutomatically() // 时间到自动交卷 } examState.timeLeft remainSeconds }, 1000) }考试结束的自动交卷是后端实现的不能只依赖前端。我在后端接口中加了时间校验如果当前时间已经超过考试结束时间交卷接口不让提交直接按当前暂存答案强制判分。这个后端兜底的思路考试系统里非常重要。答题卡组件就是遍历试卷题目列表根据题号显示答题状态。答过的题目显示绿色没答的显示灰色当前题目高亮显示。点击答题卡可以跳转到对应题目。这个组件用Element Plus的Button组件循环渲染就能实现不需要额外封装。4.3 交卷与判分后端判分保证结果可靠判分逻辑放在后端而不是前端。前端唯一要做的事情是把所有答案明细传给后端后端在一个事务里完成写入答题明细、逐题判分、计算总分、更新考试记录状态。判分核心代码大致如下Transactional public SubmitResult submitExam(SubmitDTO dto) { // 1. 校验时间防止超时提交 ExamRecord record examRecordMapper.selectById(dto.getRecordId()); if (isOverdue(record.getPaperId())) { throw new BusinessException(考试已结束系统将自动交卷); } // 2. 获取试卷的题目快照列表 ListPaperQuestion pqList paperQuestionMapper.selectByPaperId(record.getPaperId()); int totalScore 0; int correctCount 0; for (PaperQuestion pq : pqList) { String correctAnswer pq.getAnswerSnap(); // 快照中的正确答案 String userAnswer dto.getAnswerMap().get(pq.getQuestionId()); boolean correct judgeCorrect(pq.getType(), correctAnswer, userAnswer); // 3. 写入答题明细 AnswerDetail detail new AnswerDetail(); detail.setRecordId(record.getId()); detail.setQuestionId(pq.getQuestionId()); detail.setUserAnswer(userAnswer); detail.setCorrectFlag(correct ? 1 : 0); // 多选题部分给分逻辑 int score correct ? pq.getScore() : (partialScore(pq, userAnswer)); detail.setScore(score); answerDetailMapper.insert(detail); totalScore score; if (correct) correctCount; } // 4. 更新考试记录 record.setTotalScore(totalScore); record.setCorrectCount(correctCount); record.setStatus(1); record.setSubmitTime(new Date()); examRecordMapper.updateById(record); return new SubmitResult(totalScore, correctCount); }判分逻辑按题型区分处理。单选题和判断题直接比对答案字符串一致就对。多选题的情况要复杂一点我提供了两种判分模式全对才得分或者多选按漏选部分给分。我在后台试卷设置里加了一个配置项默认选全对才得分如果老师想宽松一点可以改成部分给分模式。填空题我采用严格匹配答案多个时用竖线分隔用户回答匹配任意一个就算对。这里特别强调的是判分用的正确答案必须是试卷题目关联表里的快照字段而不是题库表。如果学生在考试过程中管理员修改了题库中某道题的正确答案快照保证了正在进行的这场考试判分结果不受影响。5. 权限控制、接口规范与前后端联调的细节5.1 JWT加拦截器轻量级登录认证方案在线考试系统不需要引入Spring Security全重框架我用JWT加Spring MVC拦截器就实现了权限控制。流程是这样的用户登录成功后后端生成一个包含用户ID和角色的JWT令牌返回给前端前端每次请求在请求头里带上Authorization: Bearer token后端拦截器解析令牌并校验有效期然后把当前登录用户信息放到ThreadLocal里业务代码直接使用。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims Jwts.parser().setSigningKey(secretKey) .parseClaimsJws(token).getBody(); UserContext.set(claims); return true; } catch (Exception e) { // token过期或非法 } } response.setStatus(401); return false; } }在SpringBoot 2.7里配拦截器比较简单实现WebMvcConfigurer接口重写addInterceptors方法然后放行登录、注册接口其他接口全部拦截。然后再加一个角色判断的注解比如RequireRole(ADMIN)放在管理端的接口上拦截器里读到注解就校验当前用户的角色不匹配返回403。这套方案的核心是无状态服务端不存session用户信息全在JWT令牌里对前后端分离非常友好。放到考试场景里还有个好处考生交卷之后即使用户刷新页面当前身份也不会丢失除非登录状态过期。5.2 Vue3路由守卫与Axios请求封装前端权限控制主要靠路由守卫。我在Vue Router里配置了meta信息标记哪些路由需要登录、哪些路由需要管理员角色const routes [ { path: /login, component: LoginView }, { path: /exam/list, component: ExamListView, meta: { requiresAuth: true } }, { path: /admin/question, component: QuestionManageView, meta: { requiresAuth: true, role: ADMIN } } ] router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.role getRole() ! to.meta.role) { next(/403) } else { next() } })Axios请求封装主要是两个拦截器。请求拦截器统一加token响应拦截器统一处理后端返回的结果。这个项目的接口返回格式是统一的{ code, message, data }结构响应拦截器里判断code为200才把data直接返回给业务代码401就跳转登录页其他错误用Element Plus的message提示。axios.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(网络请求异常) return Promise.reject(error) } )Axios封装做得好前后端联调期间可以省下大量排查问题的时间。因为错误信息统一从后端message字段带出来前端每个页面的catch分支都只需要调用同一个消息提示页面代码干净很多。5.3 跨域和统一前缀开发环境最容易卡的环节前后端分离开发时前端跑在5173端口后端跑在8080端口浏览器默认会拦截跨域请求。我在后端做了一次全局CORS配置允许的路径、请求头、方法都放开Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }不过开发阶段更方便的做法是在Vite里配置代理把所有/api开头的请求转发到后端8080端口这样浏览器看到的请求是同源的根本不会触发跨域// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })两个方案我用的是Vite代理为主。生产部署时才是把前端打包到dist目录然后扔给Nginx托管再由Nginx把API请求反向代理到SpringBoot服务。这个部署方案比较标准文档里也写清楚了配置。6. 从本地跑通到部署上线环境清单与踩过的坑6.1 本机开发环境清单我开发这套项目时的环境如下仅供参考JDK1.8Boot 2.7.x可以完美支持Maven3.6.3以上MySQL8.0推荐用8.0.26以上版本Node.js16.20以上npm会带后端IDEIntelliJ IDEA前端IDEVS Code数据库初始化脚本我放在了项目的sql目录下包含建库语句、建表语句、初始化的管理员账号和一批示例题目数据。第一次跑的时候执行这个脚本启动后端、启动前端就能看到一个带数据的完整系统。6.2 我实际踩过的七个坑做这个项目的时候碰到的问题不少我挑几个影响比较大的写下来这些坑对照网上搜到的教程常常是没人讲的。问题原因解决方案数据库时间差8小时MySQL驱动默认使用UTC时区URL加serverTimezoneAsia/Shanghai前端请求报403跨域携带了自定义Header但CORS未放行allowedHeaders(*)配置放开返回给前端的Long型ID精度丢失JavaScript Number精度有限后端Long字段加JsonSerialize(using ToStringSerializer.class)转String刷新页面答案全丢答案只存在前端内存中localStorage缓存加后端暂存接口随机组卷总分对不上抽题规则没有校验总分组卷前先计算总分并校验同一考生重复考试无限创建记录没加唯一索引和查询判断exam_record加(paper_id, user_id)唯一索引前端页面组件样式错乱Element Plus按需引入没配置完整改为全量引入或使用完整的unplugin配置这里重点说两个容易被忽视的问题。一个是Long型ID的精度丢失数据库主键用的自增BIGINT到了JSON序列化之后传给前端超过JavaScript安全整数范围2的53次方减1约9007199254740991之后精度就会丢。虽然主键ID不在这个范围但一旦后续插入的数据ID超过这个数就会出现明明操作的是这条数据后端却查出来另一条的诡异问题。解决办法就是给返回的ID字段统一加ToStringSerializer。另一个是答案暂存的时机。我做了一个定时自动暂存每10秒把当前答案发送到后端这个接口特意做成幂等的也就是重复发送相同数据不会产生重复记录。这里幂等是靠status字段实现的服务端判断如果存在同一recordId和questionId的记录就更新而不是插入新纪录。6.3 项目目录结构与二次开发建议最后给出这个项目的最终目录结构方便你拿到源码后快速定位代码位置exam-system ├── backend // SpringBoot后端 │ ├── src/main/java/com/exam │ │ ├── controller // 接口层 │ │ ├── service // 业务层 │ │ ├── mapper // MyBatis-Plus Mapper接口 │ │ ├── entity // 数据库实体 │ │ ├── dto // 请求/响应对象 │ │ ├── config // 拦截器、CORS配置 │ │ └── common // 统一返回结果、异常处理 │ └── src/main/resources │ ├── application.yml │ └── mapper // XML文件 ├── frontend // Vue3前端 │ ├── src │ │ ├── api // 接口请求定义 │ │ ├── views // 页面视图 │ │ ├── components // 公共组件 │ │ ├── stores // Pinia状态 │ │ └── router // 路由 │ └── vite.config.js └── sql // 建库建表脚本和示例数据如果你拿到了源码要改成自己的项目我建议先看两个地方一个是backend/src/main/resources/application.yml把数据库账号密码改成自己的另一个是frontend/src/api目录下的接口路径把所有请求地址改成你后端的实际部署地址。然后从普通用户登录、参加考试、交卷看成绩这条链路跑一遍再进管理员后台看题库管理、随机组卷、成绩统计。项目里带的那份文档把表结构说明、接口说明、部署步骤都写清楚了跟着文档过一遍就差不多掌握了。最后再分享一个我做这类项目的经验拿到一套完整源码不要急着改功能先把主流程跑通再用断点调试把关键接口的调用链跟一遍。比如随机组卷从点击按钮到试卷落库中间经过了哪个Controller、哪个Service、哪些SQL跟一遍之后你对整个项目的理解会完全不一样。后面要加功能、修Bug效率都不会太低。