ARTICLE DETAIL

资讯详情

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

基于SpringBoot+Vue的前后端分离知识竞赛系统设计与实现

基于SpringBoot+Vue的前后端分离知识竞赛系统设计与实现 做信息知识竞赛系统的初衷其实很简单部门每年都要组织一场信息知识竞赛往年都是现场PPT出题、手举牌抢答组织起来累不说统计得分还容易出错。后来干脆自己动手用SpringBoot Vue MyBatis MySQL这套前后端分离组合从零搭了一个线上竞赛平台把题库维护、在线考试、自动评分、成绩排行全流程串了起来。这篇文章就把整套项目的设计思路、核心数据模型、关键代码、部署流程和一些实战中踩过的坑完整记录下来适合正在做毕业设计、企业内部竞赛平台或者想系统学习前后端分离项目的人直接参考。1. 项目从0到1的决策为什么是前后端分离为什么选这套组合1.1 这个系统到底要解决什么问题我在动手之前先梳理了一遍需求。信息知识竞赛的使用场景非常明确管理员维护一批题目创建一场竞赛设置好考试时间和题量选手在浏览器里登录后进入答题页在限定时间内完成作答交卷后系统自动判分展示个人成绩和总排行榜。整个流程里涉及三种角色管理员、参赛选手、系统本身。管理员最在意的是题库和赛事配置是否灵活选手在意的是答题流程是否顺畅、界面是否友好而我作为开发者在意的则是这套逻辑能不能用最少的代码稳定跑起来。这个需求听起来简单但实际拆开以后会发现有几个很容易被忽略的细节比赛计时怎么处理才不会被前端篡改不同选手拿到的试卷顺序是不是一样的多选题漏选要不要得分交卷时网络断了怎么办这些问题如果不在一开始就通过表结构和接口设计规避掉后面写代码时会反复返工。所以我没有急着写Controller和页面而是先花了两天时间把数据模型和核心接口的契约定清楚。1.2 技术选型的取舍备选方案有两个传统单体服务端渲染项目用JSP或者Thymeleaf一套搞定另一个就是前后端分离也就是现在要说的这套SpringBoot Vue MyBatis MySQL组合。我最终选了后者理由有三个。第一开发协作层面前后端可以完全并行。题库管理、考试流程这些后端接口定了以后我这边专心写接口前端页面同步开发不用挤在一个工程里互相等。第二部署层面前后端分离之后静态资源由Nginx统一托管后端只负责API后续要扩展小程序端、App端时后端接口可以直接复用不用重写一套。第三也是比较现实的一点前后端分离是目前团队里大家最熟悉的一套模式后续如果要把系统交给别人维护上手成本低。技术栈具体版本上SpringBoot我选的是2.7.x版本搭配JDK 1.8。为什么不是SpringBoot 3因为当时项目里要用的很多依赖在SpringBoot 3下的兼容性还不够稳定生产环境求稳2.7.x成熟得多。MyBatis负责持久层主要是看重它写复杂SQL的灵活性像题库分页、多条件筛选、随机抽题这些场景用XML里的动态SQL可以写得很直观不会像JPA那样被自动生成的查询束缚住。前端Vue用的2.6版本配合Element UI组件库Vue 2生态在当时的成熟度最高坑也少。MySQL用的8.0虽然5.7也完全够用但既然是新项目就直接上8.0后续数据库迁移也省心。这套组合对应的关键词很简单前后端分离、SpringBoot、Vue、MyBatis、MySQL。整篇文章我会按照一个完整项目的推进顺序来讲从数据表设计到接口实现再到前端页面和最终部署每个环节都给出可以直接参考的细节。2. 数据库设计一张张表把竞赛流程拆清楚2.1 五个核心表与字段设计做竞赛系统第一步不是写代码而是把业务流程翻译成数据模型。我梳理了一遍完整链路管理员维护题库、创建竞赛、选手在规定时间内答题、系统自动评分、展示成绩和排名。围绕这条链路我设计了五个核心表。第一张是用户表user。字段包括id、username、password、real_name、role、department、create_time。password存的是BCrypt加密后的密文role用1表示管理员、2表示普通选手department用来记录选手所属部门后期做部门维度的成绩统计时很有用。第二张是题库表question这是整个系统的核心。字段包括id、type、category_id、content、option_a、option_b、option_c、option_d、answer、analysis、difficulty、create_time。type用1表示单选题、2表示多选题、3表示判断题。判断题没有选项content里直接放题目文字answer存1或0。analysis是答案解析选手交卷之后可以查看。difficulty用来标记题目难度分1、2、3三档。第三张是竞赛表contest。字段包括id、title、description、start_time、end_time、duration、question_count、total_score、status、create_time。start_time和end_time控制竞赛的有效时间窗口duration是单次答题时长以分钟为单位question_count是这场竞赛一共要考多少题total_score是总分。status用0、1、2表示未开始、进行中、已结束。第四张是考试记录表exam_record。字段包括id、user_id、contest_id、start_time、submit_time、score、status。status用0表示答题中1表示已交卷。这张表是整个竞赛流程的主线一个选手参加一场竞赛会产生一条记录。第五张是答题明细表exam_answer。字段包括id、record_id、question_id、user_answer、is_correct、score。交卷时把所有题目的作答情况都写入这张表方便后续做统计分析。2.2 表关系与建表SQLuser和exam_record是一对多关系一个用户可以有多次参赛记录contest和exam_record也是一对多contest和question之间是多对多通过一张中间表contest_question关联用于固定或者动态生成一场竞赛的题目集合。exam_record和exam_answer是一对多每条考试记录下挂若干条答题明细。下面是建表的核心SQL片段我截取关键的三张表CREATE TABLE 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, role tinyint(4) NOT NULL DEFAULT 2 COMMENT 1管理员 2选手, department varchar(100) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE question ( id bigint(20) NOT NULL AUTO_INCREMENT, type tinyint(4) NOT NULL COMMENT 1单选 2多选 3判断, category_id bigint(20) DEFAULT NULL, content varchar(500) NOT NULL, option_a varchar(200) DEFAULT NULL, option_b varchar(200) DEFAULT NULL, option_c varchar(200) DEFAULT NULL, option_d varchar(200) DEFAULT NULL, answer varchar(10) NOT NULL, analysis varchar(500) DEFAULT NULL, difficulty tinyint(4) DEFAULT 1, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE exam_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, contest_id bigint(20) NOT NULL, start_time datetime DEFAULT NULL, submit_time datetime DEFAULT NULL, score int(11) DEFAULT 0, status tinyint(4) DEFAULT 0 COMMENT 0答题中 1已交卷, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意所有表都统一用utf8mb4字符集目的是支持表情符号。虽然竞赛题目里不太会出现emoji但管理员的题目描述里偶尔会有特殊符号utf8mb4比utf8更稳妥避免插入时出现Incorrect string value报错。2.3 关于数据库设计的几个经验把answer字段设计成varchar(10)而不是单个字符是为了兼容多选题。多选题的正确答案可能是A,C,D用逗号拼接存储。后面评分的时候再用split拆开做集合比对。这种设计在数据量不大、面向内部竞赛的场景下完全够用没必要刻意做范式化拆表反而会增加查询复杂度。再有一个经验是不要在设计表的时候把所有字段都加上。业务没跑通之前很多字段是拍脑袋想出来的后面会不断调整。我第一版就把竞赛表设计得过于复杂加了及格线、奖励积分、证书模板等十几个字段后来发现实际用不上又回头清理。先从最小可用模型开始迭代扩展这才是务实的做法。3. 后端接口层SpringBoot MyBatis如何支撑业务3.1 登录认证与用户上下文系统虽然不对外开放注册但管理端和选手端需要区分权限。我用了比较轻量级的方案JWT做登录令牌加一个Spring拦截器做权限校验。用户登录成功后后端生成一个带userId和role信息的token返回给前端前端每次请求在请求头里带上Authorization: Bearer xxx拦截器解析token后把用户信息放到ThreadLocal里后续业务代码直接取。Component public class AuthInterceptor 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); Long userId JwtUtil.getUserId(token); Integer role JwtUtil.getRole(token); UserContext.set(userId, role); return true; } response.setStatus(401); return false; } }登录接口本身比较简单根据username查用户用BCrypt的matches方法比对密码比对成功生成token返回。需要注意注册接口一定要控制权限不能让人随意注册成管理员。我这里是初始化一个默认管理员账号选手账号由管理员在后台批量导入或者手动创建。3.2 题库管理和竞赛管理接口题库管理的核心是分页查询和多条件筛选。选手端和管理端都会用到这个接口管理端多了新增、修改、删除的能力。分页查询我直接用MyBatis的PageHelper插件写起来非常省事Override public PageInfoQuestion page(int pageNum, int pageSize, Integer type, Long categoryId) { PageHelper.startPage(pageNum, pageSize); ListQuestion list questionMapper.selectByCondition(type, categoryId); return new PageInfo(list); }对应的MyBatis XML里用动态SQL处理条件拼接select idselectByCondition resultTypecom.example.entity.Question SELECT * FROM question where if testtype ! null AND type #{type} /if if testcategoryId ! null AND category_id #{categoryId} /if /where ORDER BY create_time DESC /select竞赛管理接口的逻辑重点在创建竞赛。创建时除了插入contest表还要处理赛题关联。这里我采用的方式是创建竞赛时不固定赛题集合而是让选手点击开始竞赛时从题库里临时随机抽题这样每个人拿到的试卷都不一样从根本上避免了前后桌答案雷同的问题。这个设计我在第五部分会详细讲。3.3 MyBatis动态SQL在复杂查询中的应用信息知识竞赛系统的查询场景其实不少成绩排名要按分数排序、部门维度要聚合统计、题库要按难度筛选。这些用MyBatis的foreach和if标签都能优雅解决。比如批量插入答题明细一次交卷可能有几十道题如果循环单条插入性能差而且事务不好控制。我用foreach一次性批量插入insert idbatchInsert parameterTypelist INSERT INTO exam_answer (record_id, question_id, user_answer, is_correct, score) VALUES foreach collectionlist itemitem separator, (#{item.recordId}, #{item.questionId}, #{item.userAnswer}, #{item.isCorrect}, #{item.score}) /foreach /insert还有动态更新。交卷的时候要更新exam_record的submit_time、score、status三个字段但如果是管理员手动修正某人的成绩可能只更新score一个字段。用MyBatis的set标签配合if可以做到传入哪些字段就更新哪些字段避免把不需要动的字段覆盖掉。4. 前端实现Vue 2 Element UI搭建管理与答题两端4.1 项目初始化与路由设计前端工程我用Vue CLI 3脚手架创建沿用Vue 2.6的语法写业务代码。Element UI做后台管理界面非常顺手表格、表单、弹窗这些组件都是现成的省去大量造轮子的时间。路由设计上我分成两块面向选手的应用页面和面向管理员的后台页面。应用页面包括登录页、竞赛列表页、答题页、个人成绩页后台页面包括用户管理、题库管理、竞赛管理、数据统计。两边用不同的布局组件包裹通过路由的meta字段标记是否需要管理员权限配合前置路由守卫做跳转拦截。const router new VueRouter({ mode: history, routes: [ { path: /login, component: Login }, { path: /, component: Layout, meta: { requiresAuth: true }, children: [ { path: , component: ContestList }, { path: exam/:id, component: ExamPage, meta: { requiresAuth: true } }, { path: result, component: ResultPage } ]}, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, admin: true }, children: [ { path: user, component: UserManage }, { path: question, component: QuestionManage }, { path: contest, component: ContestManage } ]} ] })4.2 答题页的交互细节答题页是整个前端最复杂的部分。我的实现方案是进入竞赛后先请求后端拿到当前选手在这次竞赛中的试卷数据包括题目列表、每题类型、选项内容和剩余时间保存在页面data里。用户切换题目时通过数组下标切换显示对应的题目内容已经作答的题目在侧边栏用不同颜色标记方便用户快速定位未答题目。选项交互上单选题用el-radio-group多选题用el-checkbox-group判断题只有两个按钮。用户每选择一次就更新本地一个answerMap对象键是questionId值是用户答案。题目切换之间不需要实时和后端通信只有交卷时才把所有答案一次性提交这样既省流量体验也流畅。这里有一个容易忽略的小细节多选题的答案顺序。用户在界面上勾选的顺序可能是C、A、D但存储时应该按选项字母排序后再存否则评分阶段两个语义相同的集合会因顺序不同被判错。我在前端组装答案时对多选答案做了排序处理后端再配合集合比较两者都稳定。4.3 倒计时与交卷流程倒计时我用setInterval每秒递减存到页面的remainTime变量里然后把时间显示成HH:MM:SS的格式。需要注意两个问题一是定时器必须在组件销毁前清除否则页面跳转后定时器还在跑会引发内存泄漏二是防止用户刷新页面导致倒计时重置。我的方案是进入考试时后端记录start_time前端在localStorage里存一个expireTime刷新时读取这个时间计算剩余秒数超过就自动交卷。交卷按钮点击后前端先做一次本地校验提示用户还有哪些题没做确认后向后端发起提交。后端收到提交请求后会再次校验当前时间是否超过截止时间超时就拒绝提交并自动按超时处理。这个双重校验的机制是必须的因为浏览器端时间可以被篡改不能只信任前端。5. 竞赛系统最核心的三个流程抽题、计时、评分5.1 随机抽题保证每个人拿到不同顺序的试卷信息知识竞赛有一个很典型的场景选手之间会挨着坐如果大家拿到的是同一份固定顺序的试卷很容易出现互相瞄答案的情况。所以我对组卷逻辑做了两层随机第一层是题目从题库中随机抽取第二层是每个人拿到的题目顺序不同。实现方式很简单创建竞赛时不预先固定赛题而是让选手点击开始竞赛时临时从题库中随机抽题生成一份属于这个人的试卷记录。题库中符合条件分类、难度的题目用一条SQL随机取N道SELECT * FROM question WHERE type IN (1,2,3) ORDER BY RAND() LIMIT #{questionCount}需要注意的是数据量大的时候ORDER BY RAND()会全表扫描性能问题明显。但信息知识竞赛系统的题库一般也就几百到几千道题这个量级完全扛得住。如果题库真的很大可以先用SELECT id FROM question WHERE ...取出符合条件的id列表在Java层面随机打乱后取前N个再用IN查询回表性能会更稳。5.2 倒计时与自动交卷前后端双重校验在线竞赛最怕的其实是意外。选手作答到一半页面崩了、断网了、误关了浏览器如果没有兜底机制成绩就丢失了。我在后端做了一套兜底逻辑记录每个选手的开始时间和竞赛截止时间。前端每次进入考试状态时先调用一个校验接口后端把剩余秒数返回。常规情况下前端倒计时归零后触发自动交卷如果前端异常没有触发后端也会在交卷接口里校验时间超过截止时间的直接按已提交处理把当前已作答内容提交进去并正常评分。另外exam_record表里status字段保留着答题中的状态选手再次进入时如果后端发现该记录status仍为0会恢复已有的答题数据让选手继续作答而不是重新开始。这个恢复机制在实际使用中非常关键遇到过几次选手浏览器崩溃后重新登录直接回到原题继续答体验比重新考一次好得多。5.3 自动评分单选/多选/判断的处理逻辑评分逻辑按题目类型分别处理。单选题最简单直接把用户答案和数据库answer字段比对一致就算对。判断题也一样只是选项变成了“正确/错误”。多选题稍微麻烦因为存在多选、漏选、错选三种情况。我的规则是全部选对得满分漏选得一半分只要选项中有一个错的就得零分这也是很多知识竞赛通用的计分方式。private int scoreQuestion(Question q, String userAnswer) { if (userAnswer null || userAnswer.isEmpty()) { return 0; } if (q.getType() 2) { String[] correctArr q.getAnswer().split(,); String[] userArr userAnswer.split(,); SetString correctSet new HashSet(Arrays.asList(correctArr)); SetString userSet new HashSet(Arrays.asList(userArr)); if (correctSet.equals(userSet)) { return 2; } if (correctSet.containsAll(userSet)) { return 1; } return 0; } return q.getAnswer().equals(userAnswer) ? 2 : 0; }评分在交卷接口里一次性完成先把所有答案遍历算分再批量写入exam_answer然后累加总分更新exam_record。事务要确保一致性我这里加了Transactional注解避免出现写了答案但没更新成绩的中间状态。6. 完整部署教程从本地开发到服务器上线6.1 后端构建与启动后端项目打包用Maven。在本地环境执行mvn clean package -DskipTests生成jar包然后将jar包上传到服务器。服务器上需要提前装好JDK 1.8和MySQL 8.0。MySQL初始化时只需要把建表SQL执行一遍再插入默认管理员账号即可。application.yml里的关键配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/knowledge_contest?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true启动命令使用nohup让进程在后台运行日志输出到文件方便排查问题nohup java -jar knowledge-contest-server.jar --spring.profiles.activeprod server.log 21 6.2 前端构建与Nginx配置前端打包很简单在工程根目录执行npm install安装依赖然后执行npm run build生成dist目录。把dist目录上传到服务器后通过Nginx托管静态文件同时配置接口反向代理。server { listen 80; server_name your-domain.com; root /opt/knowledge-contest/dist; 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; } location / { try_files $uri $uri/ /index.html; } }这个配置里最关键的是try_files那一行它解决了Vue Router使用history模式时刷新页面404的问题。因为前端路由跳转不会真正向服务器发起请求但用户手动刷新某个URL时Nginx会去找对应的真实文件找不到就404。try_files把所有请求兜底到index.html由前端路由接管路径解析。6.3 部署时容易忽略的细节部署过程中有几个细节容易被忽略。第一个是MySQL的时区问题如果JDBC URL里不配置serverTimezone高版本MySQL驱动会直接启动报错。第二个是防火墙服务器安全组要放行80端口和8080端口否则外网访问不了。第三个是跨域问题我在后端加了一个全局CORS配置开发阶段用前后端分离模式时前端devServer代理到8080生产环境则走Nginx反向代理所以跨域问题在生产环境已经被反向代理规避了但开发阶段一定要配置好devServer的proxy。还有一个容易被忽略的点是前端请求的BaseURL。开发环境要指向devServer代理路径生产环境要指向/api这两套环境变量如果没分开部署后会出现接口404。我通过环境变量VUE_APP_BASE_URL来区分构建时传入不同的值。7. 我踩过的那些坑按排查过程复现7.1 MySQL 8.0时区报错第一次在本机跑后端服务启动时直接报错The server time zone value is unrecognized。这个报错的原因是MySQL 8.0的时区默认值不是标准的GMT格式驱动无法解析。解决方案是在JDBC URL里显式加上serverTimezoneAsia/Shanghai或者给MySQL设置全局时区。我当时顺手在MySQL里执行了set global time_zone 08:00;但又担心重启后失效最后还是改JDBC URL一劳永逸。7.2 跨域问题排查前端开发时通过axios请求http://localhost:8080/api浏览器控制台报No Access-Control-Allow-Origin header。这个问题的原因很直接前端开发服务器运行在8081端口后端接口在8080端口不同源就属于跨域。解决办法有两个一个是在后端加CORS配置一个是前端的webpack devServer配置proxy。我开发时用的是devServer代理把/api代理到localhost:8080这样浏览器看到的请求是同源的就没有跨域问题了。// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }7.3 Vue刷新页面404这个问题前面提到了部署后发现用户点击刷新按钮页面就白屏F12看到请求返回404。原因就是Vue Router的history模式和Nginx静态文件服务不匹配。把Nginx配置里的location /增加try_files后完美解决。如果实在不想改Nginx配置也可以把路由模式改成hash模式URL里会多一个#刷新不会出问题但美观度差点。7.4 MyBatis查询结果字段为null有一段时间发现题库列表里categoryId字段一直是null但数据库里明明有值。排查了半天发现是实体类里categoryId字段用的驼峰命名而MyBatis默认没有开启驼峰转下划线映射。解决办法是在mybatis配置里开启map-underscore-to-camel-case: true或者在XML的resultMap里手动映射列名。我选了前者一步到位后面所有驼峰字段都不用手动映射了。7.5 关于会话超时的一个补充还有一个不太起眼但很影响体验的问题token过期后前端还在答题页等用户提交时才收到401响应然后数据全部丢失。我的处理方式是前端axios封装拦截器遇到401时先弹提示然后把用户重定向到登录页。答题页的状态在进入时已经通过接口恢复用户重新登录后可以继续作答。这在答题类系统中很重要不能因为一次登录态失效就让用户所有的答题记录都清空。到这里基本上把整个信息知识赛系统的前后端分离实现过程、核心代码、部署方法和实际踩坑都讲完了。我个人的体会
返回列表