ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue+MyBatis在线考试系统:从数据库设计到部署避坑

SpringBoot+Vue+MyBatis在线考试系统:从数据库设计到部署避坑 1. 从一个真实需求说起为什么还要自己做在线考试系统先交代一下背景。很多同学私信问我市面上的在线考试平台那么多免费开源的项目也不少为什么还要从零写一套考试系统我的答案很直接为了搞懂一套完整业务系统的所有环节。在线考试系统是一个特别典型的“麻雀虽小、五脏俱全”的Web全栈项目。它不像图书管理、员工管理那种纯CRUD系统只用增删改查就能撑起来。考试系统涉及题库管理、试卷生成、在线答题、倒计时、自动阅卷、成绩统计这一整条业务链路每一环都有真实的状态流转和业务规则。如果你能独立把这样一个系统做完SpringBoot、Vue、MyBatis、MySQL这几个主流技术栈的配合逻辑、常见坑点、部署方案基本就能摸透七八成了。我这次写出来的项目技术栈就是最经典的组合SpringBoot 2.7 Vue 2 MyBatis MySQL 8.0。前端用Vue全家桶Vue Router、Vuex、Axios、Element UI后端用SpringBoot整合MyBatis数据库用MySQL部署环节包括前端打包后放入Nginx/静态服务器、后端打jar包运行还额外整理了Tomcat部署前后的注意事项。文章很长我把项目的设计思路、核心代码、部署过程、以及我踩过的坑全部拆开讲。已经熟练的可以直接跳到第5章看部署刚接触前后端分离的同学建议从头往后顺着读这套流程正好对应一个完整项目的生命周期。2. 项目整体设计与技术选型思路2.1 为什么非要用前后端分离先回答一个很多人纠结的问题JavaWebJSP/Servlet也能做在线考试系统为什么非要用前后端分离我的看法是JSP那套方案已经不适合现在的项目协作方式了。前后端分离的核心优势不在技术本身而在工程组织前端和后端可以并行开发。后端定义好接口文档后专心写接口前端按接口文档渲染页面互不阻塞。考试系统的试卷答题页、倒计时、成绩图表这部分交互很重适合前端独立维护。部署解耦。前端是纯静态资源扔到Nginx后可以独立升级不用重启后端服务。考试高峰期前端资源走CDN也能方便一些。开发体验更好。Vue的热更新Hot Reload、浏览器Vue DevTools调试比JSP写完重启Tomcat再刷新浏览器要舒服太多。当然代价也很明确跨域问题、鉴权方案复杂度增加、部署链路变长。这些坑我后面都会讲到。2.2 技术选型的具体考量选型不是越新越好而是越稳越好。我最终定下的方案是这样的技术层次选型选择理由后端框架SpringBoot 2.7.x主流版本稳定资料多兼容JDK8ORM层MyBatis手写SQL可控性强方便处理复杂的联表查询和动态SQL数据库MySQL 8.0主流关系型数据库8.0性能更好窗口函数等特性便利前端框架Vue 2.7 Element UIVue2生态成熟Element UI组件适合快速搭后台系统前端状态管理Vuex管理用户登录态、考试中的答题数据前端路由Vue Router实现页面跳转、路由守卫做登录校验HTTP通信Axios统一处理请求、响应拦截器、携带Token等缓存/登录态JWT Redis可选无状态鉴权前后端分离的标准做法这里重点说一下为什么用MyBatis而不是MyBatis-Plus或Spring Data JPA。MyBatis-Plus确实开发效率更高但很多面试题恰恰爱在MyBatis上出文章动态SQL、缓存机制、TypeHandler而且手写SQL对于考试系统这种有复杂查询比如随机抽题、联表统计分数的场景可控性更强。我自己在项目里把MyBatis的动态SQL用得比较多后面会给例子。2.3 功能模块划分在线考试系统的功能我拆成四个核心模块加两个辅助模块模块核心功能用户认证模块登录、注册、JWT鉴权、路由守卫题库管理模块题目的增删改查、分类管理、试题导入考试业务模块试卷生成、考试发布、在线答题、自动交卷成绩统计模块自动阅卷、成绩查询、简单统计图表系统管理模块用户管理、角色权限辅助工具模块批量导入题库、答题记录导出等先说题库管理。考试系统的数据基础就是题目我的题目表设计包含了题干、选项JSON格式、答案、题型、所属科目分类、难度、创建时间这些字段。题型我分了单选题、多选题、判断题三种这是因为它们能实现纯前端自动阅卷。主观题简答题、论述题涉及人工评阅逻辑会复杂很多如果你想扩展可以加一个教师端批改页面。考试业务模块的核心是组卷和答题。组卷策略有几种固定试卷每场考试同一套题、随机抽题从题库按规则抽取、按难度比例抽题。我实现的是最常用的随机抽题固定卷两种模式混合管理员创建考试时选择组卷策略。答题过程中前端记录倒计时、未答题目数量交卷时把答题数据一次性提交给后端。成绩统计模块后端根据试卷答案和用户提交的答案进行比对算出总分和正确率。这块实现不复杂核心是选择题答案比对时要注意多选题判分的边界——是全对才得分还是漏选部分得分我实现的全对才得分这种规则可以在试卷表里加一个字段配置。3. 数据库设计考试系统的地基3.1 表结构总览这是整个项目的重中之重。数据库设计如果出了问题后面写代码的时候就到处别扭。我设计了5张核心表-- 用户表 CREATE TABLE sys_user ( id bigint(20) 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 varchar(20) NOT NULL DEFAULT STUDENT COMMENT 角色ADMIN教师/管理员 STUDENT学生, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户信息表;用户表这里有一个容易被忽略的设计密码字段要能容纳BCrypt加密之后的长度。BCrypt加密的字符串长度通常是60位如果一开始用varchar(50)后面加密结果存不进去就尴尬了。我用的varchar(100)保险一些预留了空间。-- 题库表 CREATE TABLE question ( id bigint(20) NOT NULL AUTO_INCREMENT, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, type tinyint(4) NOT NULL COMMENT 题型1单选 2多选 3判断, content text COMMENT 题干, options varchar(500) DEFAULT NULL COMMENT 选项JSON[{key:A,text:xxx},...], answer varchar(50) NOT NULL COMMENT 正确答案A / A,B / 对错, analysis text COMMENT 试题解析, difficulty tinyint(4) DEFAULT 1 COMMENT 难度1简单 2中等 3困难, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_type (type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题库表;题库表我用了options字段存JSON字符串的方式而不是单独建一个选项表。这是因为单选题、多选题的选项数量不固定可能是A-D四选一也可能是A-F六个选项用JSON存储更灵活。查询的时候把JSON解析出来即可在MyBatis里面可以用JSONObject.parseArray()处理也可以直接用JsonTypeHandler来处理。后面我会详细讲TypeHandler的用法。-- 考试表 CREATE TABLE exam ( id bigint(20) NOT NULL AUTO_INCREMENT, exam_name varchar(100) NOT NULL COMMENT 考试名称, exam_duration int(11) NOT NULL COMMENT 考试时长分钟, total_score int(11) NOT NULL DEFAULT 100 COMMENT 总分, pass_score int(11) NOT NULL DEFAULT 60 COMMENT 及格分, category_id bigint(20) DEFAULT NULL COMMENT 考试科目分类, question_ids varchar(1000) DEFAULT NULL COMMENT 固定试卷题目ID集合, random_question_num int(11) DEFAULT NULL COMMENT 随机抽题数量, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0未发布 1已发布 2已结束, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试表;这里有个设计细节要说明。试卷方案我其实有两种实现路径一种是单独建exam_paper表保存这次考试的具体试卷快照包括考完存的题目和答案另一种是在exam表里直接存question_ids。我为了控制代码复杂度采用了后者。固定试卷保存了该考试的所有题目ID集合用逗号分隔随机抽题是按照条件从题库中实时抽取。但这个方案有一个明显的缺点如果老师修改了题库中的题目历史考试记录会受影响。如果是要做正式的正式考试项目建议建exam_paper和exam_paper_question两张表考试发布的时候就把题目快照存下来保证历史的完整性。我这个项目的定位是教学和演示所以选择了更简洁的方案。-- 考生答题记录表 CREATE TABLE exam_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 考生ID, exam_id bigint(20) NOT NULL COMMENT 考试ID, score int(11) DEFAULT NULL COMMENT 得分, answers_json text COMMENT 答题详情JSON题目ID用户答案, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0考试中 1已交卷, start_time datetime DEFAULT NULL COMMENT 开始考试时间, submit_time datetime DEFAULT NULL COMMENT 交卷时间, PRIMARY KEY (id), UNIQUE KEY uk_user_exam (user_id, exam_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试记录表;这里保存答题详情时我同样用了JSON字段。每一道题的用户答案、正确答案、是否答对、得分都存在一个JSON数组里[ { questionId: 1, type: 1, userAnswer: A, correctAnswer: B, isCorrect: false }, { questionId: 2, type: 2, userAnswer: A,C, correctAnswer: A,B,C, isCorrect: false } ]这样设计的最大好处是阅卷时只需要遍历解析这个JSON数组就能算出总分和每道题的得分情况不需要额外的明细表。坏处是如果要统计某道题的正确率需要遍历所有考生的record记录数据量大了之后性能会变差。对于中小规模系统这个方案完全够用。数据量上了万之后可以考虑拆exam_record_item明细表。-- 分类表用于管理科目、题库分类 CREATE TABLE category ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 分类名称如Java基础、MySQL、数据结构, sort_order int(11) DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT分类表;3.2 为什么在MySQL里用JSON字段而不是分表有人会在后端把answers_json解析成List再手动循环插入到明细表里。我的经验和结论是小题量的在线考试系统别急着过度设计。在线考试系统通常就是班级规模的使用几十到几百人一场考试最多就几百条考卷记录。用JSON存二维数据可以显著减少表数量让应用层代码更简洁——读一张表就能拿到完整考试详情不需要JOIN。如果要统计某道题的错误率可以用MySQL 8.0的JSON函数JSON_EXTRACT()结合JSON_TABLE()来查询。代价是数据一致性需要应用层来保证不能直接通过SQL做跨记录的字段聚合。对于应用级开发这个取舍是值得的。真的要扩展也是在出现性能瓶颈之后再拆表不用一开始就搞一套复杂的企业级设计。4. 后端核心实现SpringBoot MyBatis 的实战细节4.1 项目初始化与依赖配置后端我用的IDE是IntelliJ IDEA项目构建工具用Maven。创建SpringBoot项目时选Spring Web依赖就够了MyBatis和MySQL的依赖手动加到pom.xml里dependencies !-- SpringBoot Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis 启动器 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.0/version /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- JWT -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency !-- Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies特别注意MySQL驱动包名在MySQL 8.0之后发生了变化从mysql-connector-java改成了com.mysql:mysql-connector-j这个是在某个版本之后的改动如果你的项目里还在用旧的依赖坐标可能拉取不到正确的驱动。配置文件application.yml如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/exam_system?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4 username: root password: yourpassword mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.exam.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里的map-underscore-to-camel-case配置非常关键数据库的user_id字段可以自动映射到JavaBean的userId属性否则你写resultMap要写一堆映射。还有log-impl配置开发阶段建议开启StdOutImpl这样MyBatis执行的每条SQL和参数值都会打印在控制台排查问题极方便。这个配置对应到搜索趋势里的“mybatis配置打印”话题面试问起来也能答得出来。再来说说时区问题。serverTimezoneAsia/Shanghai是必须的。MySQL 8.0默认时区是UTC如果你不指定Jackson序列化时间的时候会出现时间差了8小时的问题。我在这上面栽过跟头提醒各位先补上这个参数。4.2 JWT登录鉴权的完整实现前后端分离场景下Session机制不好使因为前端和后端可能不同域名CORS跨域下Cookie处理麻烦所以我用的方案是JWTJSON Web Token。登录成功后后端返回一个Token字符串前端存在localStorage里之后每次请求在HTTP头里带上Authorization: Bearer token。JWT的组成是Header.Payload.Signature三部分注意它只是签名防篡改并不是加密。所以不要把密码等敏感信息直接放进Token的Payload里。我保存的是用户ID和用户名、角色这些身份信息。登录接口具体实现如下Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public String login(String username, String password) { User user userMapper.findByUsername(username); if (user null) { throw new BusinessException(用户名不存在); } // BCrypt密码校验 if (!BCrypt.checkpw(password, user.getPassword())) { throw new BusinessException(密码错误); } // 生成JWT String token Jwts.builder() .setSubject(user.getUsername()) .claim(userId, user.getId()) .claim(role, user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60 * 8)) // 8小时过期 .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); return token; } }密码加密我用的Spring Security中的BCrypt方法。注意这里我只引入了spring-security-crypto这一个工具包并没有依赖完整的Spring Security框架。如果你直接把spring-boot-starter-security加进来SpringBoot会默认开启全站拦截干扰登录接口和静态资源。那样你还要额外写SecurityConfig去放行所有请求绕一圈回来还得配置密码加密器得不偿失。所以我只用工具包dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency注册的时候用BCrypt.hashpw(password, BCrypt.gensalt())存进数据库。老项目常见的坑是明文密码或MD5加密这些都是不安全的方案。BCrypt的算法本身自带盐值就算密码相同加密结果也不同能有效应对彩虹表攻击。4.3 前端路由守卫如何配合JWT前端拿到Token后存到localStorage里然后在Vue Router的beforeEach导航守卫里做全局校验router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login || to.path /register) { next() } else { if (!token) { // 未登录强制跳转到登录页 next(/login?redirect to.path) } else { next() } } })前端Axios再配合一个响应拦截器后端返回401状态码时说明Token过期或无效自动清除本地Token并跳转登录页。这样就形成了一条完整的登录鉴权闭环。4.4 在线考试最核心的接口随机组卷和自动交卷随机组卷是考试系统的关键业务。管理员创建考试时如果选择随机抽题后端接口从题库中按指定分类随机抽取指定数量的题目!-- 随机抽题SQL -- select idselectRandomQuestions resultTypecom.example.exam.entity.Question SELECT * FROM question WHERE category_id #{categoryId} AND type #{type} ORDER BY RAND() LIMIT #{limit} /select注意ORDER BY RAND()在数据量大几万条以上时性能不好因为它会在每行都生成随机数再排序。但在题库规模几千甚至上万条的在线考试系统里查询时间是百毫秒级别完全可以接受。如果你要扩展到更大规模的数据可以优化为“主键随机取模”或预生成随机序列的方案。自动交卷的流程是前端考试倒计时归零或者用户点击交卷按钮时把该考生本次考试的所有答题信息一次性提交到后端。后端接口做的核心动作是保存答题记录 计算成绩Override Transactional(rollbackFor Exception.class) public Integer submitExam(ExamSubmitDTO submitDTO) { // 1. 获取考试信息 Exam exam examMapper.selectById(submitDTO.getExamId()); // 2. 获取试卷所有题目固定卷/随机卷 ListQuestion questions getExamQuestions(exam); // 3. 校验考试是否已存在记录防止重复交卷 ExamRecord record recordMapper. selectByUserIdAndExamId(submitDTO.getUserId(), submitDTO.getExamId()); if (record ! null record.getStatus() 1) { throw new BusinessException(不能重复交卷); } // 4. 解析用户提交的答案逐题判分 int score 0; ListQuestionAnswerResult answerResults new ArrayList(); for (AnswerItem item : submitDTO.getAnswers()) { Question question findQuestion(questions, item.getQuestionId()); QuestionAnswerResult result new QuestionAnswerResult(); result.setQuestionId(question.getId()); result.setUserAnswer(item.getUserAnswer()); result.setCorrectAnswer(question.getAnswer()); boolean isCorrect checkAnswer(question, item.getUserAnswer()); result.setCorrect(isCorrect); if (isCorrect) { // 该题分值 总分 / 题目数量可扩展为按难度赋分 int perScore exam.getTotalScore() / questions.size(); score perScore; } answerResults.add(result); } // 5. 保存记录 record.setAnswersJson(JSON.toJSONString(answerResults)); record.setScore(score); record.setStatus(1); record.setSubmitTime(new Date()); recordMapper.updateById(record); return score; }这里有两个关键点Transactional注解保证整个交卷过程的事务性——如果保存中间出了异常不会残留半个记录。checkAnswer这个方法根据题型不同来处理。单选题直接字符串比较判断题比较“对”“错”多选题的正确答案存的是“A,B,C”这样的字符串用户答案也是按“A,C”这种格式提交比较前要把字符串拆成集合排序再比较。这个方法是我在实际编码中反复调试才确定的。很多初学者会去比较字符串是否完全相等但多选题用户可能提交成“C,A”顺序和正确答案“A,C”不完全一致就会误判。解决方法是提交答案前前端按选项顺序排好序再提交或者后端split后先sort再比较。我这里采用后端排序后比较更稳妥。private boolean checkMultiAnswer(String correctAnswer, String userAnswer) { if (userAnswer null || userAnswer.isEmpty()) { return false; } String[] correctArr correctAnswer.split(,); String[] userArr userAnswer.split(,); Arrays.sort(correctArr); Arrays.sort(userArr); return Arrays.equals(correctArr, userArr); }4.5 自动阅卷的扩展玩法上面实现的自动阅卷是全对才得分。实际考试场景里多选通常会采用“漏选得一半分”的规则。这个改动也不复杂把checkAnswer返回boolean改成返回得分即可先判全对得满分否则判断是否漏选用户答案集合是正确答案的子集且不为空得一半分。规则可以配置在exam表里增加一个字段multi_score_rule。这种可扩展性在我们设计数据库时就应该考虑到。4.6 说说MyBatis缓存和其他容易翻车的地方这是热搜词里出现“mybatis缓存”“mybatis二级缓存”比较频繁的原因之一也是面试必问。MyBatis的缓存机制分两级一级缓存本地缓存默认开启作用域是SqlSession即同一个SqlSession内执行相同的SQL第二次直接走缓存。这也就是为什么有人说“MyBatis事务内重复查询不会打DB”。二级缓存全局缓存默认关闭需要手动在Mapper XML中配置cache/作用域是namespace。但要注意多表联查的SQL不建议开启二级缓存因为一张表的数据更新会导致其他关联该表的namespace缓存失效策略复杂化反而容易产生脏读。我建议在线考试系统中题库查询、考场信息这类读多写少的数据可以配置二级缓存而答题记录、成绩统计这样的写多读少数据不要开。如果你在配置里发现数据更新了页面还是旧数据先检查是不是二级缓存没清除flushCache属性的作用要搞清楚。另外MyBatis在Spring Boot中的configuration定制也值得一提。我们可以在启动类里加入配置也可以直接放到application.yml。除了上面提到的驼峰映射外你还可以自定义TypeHandler。比如数据库options字段是JSON字符串实体属性是ListOptionItem这时可以写一个JsonTypeHandlerMappedJdbcTypes(JdbcType.VARCHAR) MappedTypes(List.class) public class JsonListTypeHandler extends BaseTypeHandlerListObject { Override public void setNonNullParameter(PreparedStatement ps, int i, ListObject parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, JSON.toJSONString(parameter)); } Override public ListObject getNullableResult(ResultSet rs, String columnName) throws SQLException { String json rs.getString(columnName); return JSON.parseArray(json); } // 省略两个 getNullableResult 重载 }使用方式很简单在实体字段上标注TableField(typeHandler JsonListTypeHandler.class) private ListOptionItem options;这样既能享受到JSON存的灵活应用层又不需要手动来回转换。热搜里的“mybatis中typehandler的工作流程图”就是在问这个机制我理解它本质上就是JDBC的ResultSet到Java对象之间的双向桥接。5. 前端Vue实现要点5.1 Vue项目初始化与目录结构前端我用Vue CLI工具生成项目vue create exam-frontend。Vue CLI在国内环境下如果下载慢可以用cnpm镜像npm config set registry https://registry.npmmirror.com提高依赖安装速度。这里插一句NPX的镜像配置和缓存问题经常让人卡在第一步装依赖装了一个小时还没成功多半就是镜像源的问题。项目结构我按模块化思路组织src/ api/ // 接口请求模块 exam.js question.js user.js assets/ // 静态资源 components/ // 公共组件 Timer.vue // 考试倒计时组件 QuestionCard.vue// 题目卡片组件 router/index.js // 路由配置 store/ // Vuex状态管理 views/ Login.vue // 登录页 Dashboard.vue // 管理员首页 ExamList.vue // 考试列表 ExamDetail.vue // 考试详情开始考试入口 ExamTaking.vue // 在线答题页核心页面 ExamResult.vue // 考试成绩页5.2 在线考试答题页的实现思路在线答题页ExamTaking.vue是整个前端最复杂的页面。它要处理倒计时、答题进度、题目切换、未答题目提示、交卷确认这五件事。倒计时我用的是setInterval定时器和剩余秒数。为了避免切页面或浏览器休眠造成的倒计时不准每次tick都会重新用Date.now()计算已经过的时间减去开始时间戳来更新剩余秒数。只做简单的remaining--在浏览器切换到后台再切回来时大概率会失真。data() { return { duration: 0, // 考试总时长秒 remainSeconds: 0, // 剩余秒数 timer: null, answers: {}, // 用户答案key是题目IDvalue是答案 currentIndex: 0, // 当前题号从0开始 questionList: [] }; }, mounted() { this.remainSeconds this.examDuration * 60; this.startTimer(); }, methods: { startTimer() { const endTime Date.now() this.remainSeconds * 1000; this.timer setInterval(() { const diff Math.floor((endTime - Date.now()) / 1000); if (diff 0) { clearInterval(this.timer); this.autoSubmit(); // 时间到自动交卷 } else { this.remainSeconds diff; } }, 1000); } }题目切换时我用一个索引currentIndex控制显示哪一道题所有答案存在answers对象里key是题目IDvalue是用户选择。交卷时遍历questionList把答题数据组装成一个数组const submitData this.questionList.map(q ({ questionId: q.id, userAnswer: this.answers[q.id] || })); await ExamApi.submitExam({ examId: this.examId, answers: submitData });前端这里有个细节值得注意用户在考试中途刷新页面。因为考试状态存在内存里刷新就全部丢失了。我的处理方案是开始考试时就向后端请求开始考试后端记录start_time和考试状态并返回一个考试记录ID。刷新页面时通过查询考试记录恢复答题中间状态。真正生产级的项目还需要把答题数据同步存到草稿表里这属于更高级的容灾设计了。5.3 路由守卫和权限控制前面说了一些再补充完整。系统中管理员和学生角色是不同的。管理员能看到题库管理和创建考试菜单学生只能看到我的考试、开始考试、成绩查询。前端用Vue Router的meta字段标记角色信息{ path: /admin/user, component: UserManage, meta: { role: ADMIN } }, { path: /exam/start/:examId, component: ExamTaking, meta: { role: STUDENT } }路由守卫中除了判断登录态还要判断角色权限if (to.meta.role to.meta.role ! currentUser.role) { next(/403) // 无权限页面 }后端也必须做同样的权限校验不能只靠前端拦截。我在后端的拦截器里从JWT中解析出角色信息如果是管理员接口访问且角色不匹配就直接返回403双保险。6. 部署全流程从本地环境到服务器运行6.1 环境准备后端环境要求软件版本建议说明JDK1.8或11建议用8兼容性最好Maven3.6.x以上构建和依赖管理MySQL8.05.7也可SQL写法兼容Node.js14.x或16.xVue2 Vue CLI需要前端环境注意点Node.js版本不要太高。Vue 2.7在Node 18以上的版本也可以工作但Vue CLI4以下的版本可能会因为OpenSSL问题报错error:0308010C:digital envelope routines::unsupported。如果遇到这个报错可以设置环境变量export NODE_OPTIONS--openssl-legacy-provider或者直接用Node 16稳妥。6.2 后端打包与启动后端打包前检查一下application.yml中的数据库连接信息是否改成了服务器地址。然后执行mvn clean package -DskipTests打包完成后target目录下会生成一个exam-system-0.0.1-SNAPSHOT.jar。启动方式推荐两种。第一种是直接运行jarjava -jar exam-system-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod想指定数据库地址也可以java -jar exam-system-0.0.1-SNAPSHOT.jar --spring.datasource.urljdbc:mysql://xxx.xxx.xxx.xxx:3306/exam_system...第二种生产环境我推荐用nohup后台运行方便断开SSH后服务不挂nohup java -jar exam-system-0.0.1-SNAPSHOT.jar exam.log 21 查看日志tail -f exam.log如果你想用Tomcat的war包方式部署也无问题。注意需要把SpringBoot打包方式改成war在pom.xml中packagingwar/packaging并且重写初始化类SpringBootApplication public class ExamApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(ExamApplication.class); } }打包用mvn clean package生成war放进Tomcat的webapps目录。但提醒一句war包部署时内嵌的Tomcat和外置Tomcat底版本冲突可能导致兼容问题SpringBoot版本和Tomcat版本如果能匹配好问题不大。我个人在前后端分离的项目中推荐直接用jarboot模式简单省事。6.3 前端打包与Nginx部署前端部署的步骤是先打包成静态文件然后用Nginx托管。在项目根目录执行npm install npm run build打包完成后dist目录里是静态资源。我习惯把dist下的文件直接拷贝到Nginx的web目录例如/usr/share/nginx/html/exam。Nginx配置中需要处理两个问题。一是前端路由history模式。Vue Router用了history模式去除URL中的#号刷新页面时如果访问的是子路由Nginx默认会返回404必须配置try_files重写location /exam { alias /usr/share/nginx/html/exam; try_files $uri $uri/ /exam/index.html; }二是跨域反向代理。把前端请求的/api转发到后端服务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; }注意proxy_pass后面这个斜杠很关键。如果写成http://127.0.0.1:8080/api/会有两段api拼接的坑。我详细的写法是server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/exam; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; # 将/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; } }前端请求地址统一用/api前缀生产环境就不存在跨域问题了。如果前端页面直接使用8080端口访问后端则必须开启后端的CORS配置。两种方案各有取舍用Nginx代理更干净配置一次就解决跨域CORS方案适合开发环境方便调试。我之前因为Nginx配置没改好前端打包上线后所有接口都404排查了很久最终发现就是proxy_pass的路径问题。特此记录一下。6.4 MySQL数据库部署MySQL可以多种方式安装。生产服务器上用RPM安装包或者官方仓库安装都是常见选择。安装完成后创建数据库并导入建表SQLmysql -u root -p exam_system.sql导入后检查一下字符集。在这里要特别提醒一个MySQL 8.0的认证插件问题MySQL 8.0默认认证插件是caching_sha2_password而老版本的驱动或客户端可能不支持。如果后端连接时报Authentication plugin caching_sha2_password cannot be loaded可以用下面的方式把用户改为mysql_native_password或者换新版驱动。新版驱动已经适配好了所以我在前面特意推荐使用com.mysql:mysql-connector-j注意版本别用错。6.5 SSL连接错误问题连接字符串中如果漏了useSSLfalse启动时会看到警告或异常。开发环境不要用一个自签名证书来运行直接关闭SSL即可。生产环境如果要上HTTPS建议在Nginx层统一配置证书后端保持不变。还有一种常见的MySQL连接错误是Public Key Retrieval is not allowed出现这个也容易解决JDBC URL中加allowPublicKeyRetrievaltrue。这些全部记录在后面的排查清单里。7. 常见问题排查与避坑技巧这一章是最容易踩坑的部分我把实操中遇到的问题逐一记录下来适合直接收藏当作速查表症状原因解决办法前端访问接口报跨域错误前后端域不同未配置CORSNginx反向代理或后端写CorsFilterMyBatis只查出部分字段其余为null数据库字段带下划线实体属性是驼峰未开启映射map-underscore-to-camel-case: true数据库时间差8小时数据库时区未指定或连接串缺serverTimezone连接串加serverTimezoneAsia/ShanghaiVue项目启动报OpenSSL错误Node版本太高Vue CLI4及以下不兼容用Node 16或NODE_OPTIONS--openssl-legacy-provider打包后刷新404Vue Router history模式未配置try_filesNginx配置try_files $uri $uri/ /index.htmlMySQL 8.0连接报Authentication plugin错误认证插件与驱动不兼容换新版驱动或改用户的认证插件启动时端口被占用8080被其他程序占用改端口或查杀进程netstat -ano | findstr 8080npm install慢到崩溃默认下载源不稳定npm config set registry https://registry.npmmirror.com后端启动提示找不到Mapper XML文件mapper-locations路径配置不对确认classpath:mapper/*.xml与文件实际位置一致SpringBoot版本太高导致部分API变动新版本对javax命名空间有改动教学项目统一用2.7.x具体版本可锁定在2.7.18第一个跨域问题再多说几句。开发环境下前端跑在http://localhost:8081后端跑在http://localhost:8080两者端口不同浏览器就会拦截跨域请求。开发模式最省事的方案是在Vue CLI的vue.config.js里配置代理module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样前端请求/api/user/login时开发服务器会把它代理到后端http://localhost:8080/user/login浏览器里完全看不到跨域。需要注意的是生产环境必须使用Nginx代理或者后端配置真正的CORS。顺便补充一个很隐蔽的坑Vue前端里如果Element UI按需引入有时构建后的项目路由切换会出现样式错乱或者图标不显示。凡是搜索热词里带“vue image能显示pdf吗”等奇奇怪怪的问题本质上都是资源路径配置引起的。排查思路先检查vue.config.js里publicPath是不是/如果部署在子目录就要改成./或具体前缀再检查Nginx里静态资源location和root路径是否匹配。这种配置问题一旦定位不准能折腾好几个小时。8. 项目扩展方向这些点你还能继续加东西写完基础版本之后这个项目还有几个能够继续扩展成高含金量的方向导入题库Excel批量解析。目前的题库是后台手工录入几百道以上的题目管理效率极低。做导入功能时可以用EasyExcel批量解析并做重复校验和数据格式检查。这是一个让人觉得专业度提升很大的功能。考试防作弊机制的雏形。纯前端的做题系统其实很脆弱学生开两个浏览器查答案几乎没有办法完全杜绝。基本方案是随机抽题让每个人题目顺序不一样高级方案可以增加摄像头监考WebRTC视频流答题过程中随机抓拍。这部分在做项目展示时很加分。考试监控大屏。管理员希望看到多少人正在考试、多少人已交卷、实时平均分、题目正确率等数据。做一个轮询接口大屏页面用ECharts展示统计数据。这个功能做出来之后再放作品集里明显会比简单CRUD有竞争力。去除考试中的本地存储风险。前面提到的刷新页面丢答题数据问题真正的生产版本要设计成开始考试就把草稿存库每10秒自动同步一次答题数据到后端草稿表恢复考试时从草稿恢复。这样无论浏览器崩溃还是断网考生都不会丢进度。Redis接入分布式缓存。如果要用Redis做验证码、Token黑名单、热点考试数据的缓存可以引入Spring Boot整合Redis。这套内容配合考试系统再写一篇文章又是一片很好的内容。如果把这些扩展项都完成这个项目已经从一个教学示例接近真实商用项目的完整度了。你完全可以把它作为毕业设计、实习项目经历甚至商业外包项目的底子。最后分享一点我的实际体会。做这类全栈项目最忌讳的是拿到代码就开始跑跑通就觉得完事了。技术飞跃发生在你在某个功能上真正遇到卡点、再把它解决掉的那个瞬间。比如随机组卷SQL性能如何优化、JSON字段和关系表怎么权衡、JWT过期了前端如何无缝刷新——我上面写的每一个“坑”都是过程中扎实学到的东西。希望这篇拆解能帮你把这个项目的价值完全吃干榨净。
返回列表