做信息知识竞赛系统的初衷其实很简单,部门每年都要组织一场信息知识竞赛,往年都是现场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 表关系与建表SQL
user和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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意,所有表都统一用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 PageInfo<Question> page(int pageNum, int pageSize, Integer type, Long categoryId) { PageHelper.startPage(pageNum, pageSize); List<Question> list = questionMapper.selectByCondition(type, categoryId); return new PageInfo<>(list); }对应的MyBatis XML里用动态SQL处理条件拼接:
<select id="selectByCondition" resultType="com.example.entity.Question"> SELECT * FROM question <where> <if test="type != null"> AND type = #{type} </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY create_time DESC </select>竞赛管理接口的逻辑重点在创建竞赛。创建时除了插入contest表,还要处理赛题关联。这里我采用的方式是:创建竞赛时不固定赛题集合,而是让选手点击开始竞赛时从题库里临时随机抽题,这样每个人拿到的试卷都不一样,从根本上避免了前后桌答案雷同的问题。这个设计我在第五部分会详细讲。
3.3 MyBatis动态SQL在复杂查询中的应用
信息知识竞赛系统的查询场景其实不少:成绩排名要按分数排序、部门维度要聚合统计、题库要按难度筛选。这些用MyBatis的foreach和if标签都能优雅解决。比如批量插入答题明细,一次交卷可能有几十道题,如果循环单条插入,性能差而且事务不好控制。我用foreach一次性批量插入:
<insert id="batchInsert" parameterType="list"> INSERT INTO exam_answer (record_id, question_id, user_answer, is_correct, score) VALUES <foreach collection="list" item="item" 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(","); Set<String> correctSet = new HashSet<>(Arrays.asList(correctArr)); Set<String> 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?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false 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.active=prod > server.log 2>&1 &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里显式加上serverTimezone=Asia/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时先弹提示,然后把用户重定向到登录页。答题页的状态在进入时已经通过接口恢复,用户重新登录后可以继续作答。这在答题类系统中很重要,不能因为一次登录态失效就让用户所有的答题记录都清空。
到这里基本上把整个信息知识赛系统的前后端分离实现过程、核心代码、部署方法和实际踩坑都讲完了。我个人的体会