1. 这个项目为什么值得自己动手做一遍
如果你正处于"写过很多 CRUD 接口、但没完整做过一个前后端分离项目"的阶段,那这套考试系统绝对是一个绕不开的练手项目。我说句实在话:现在网上的开源项目很多,但大多数要么只有后端代码、前端页面惨不忍睹,要么是单体 JSP 项目,和实际工作里"前端一套、后端一套、分开部署"的开发方式差距很远。而前后端分离考试系统恰好把这两块都补齐了:SpringBoot 管后端接口,Vue 管页面交互,MyBatis 管数据库访问,MySQL 存数据。这套组合是 Java 后端面试里出现频率最高的技术栈之一,也是大量中小型企业内部项目的标配。
有人可能会问:"考试系统不是被做烂了吗?还有什么好学的?" 恰恰相反,正因为考试系统足够常见,它的业务模型反而非常典型:用户体系、试题管理、组卷逻辑、答题提交、自动判分、成绩统计——这些都是其他管理系统里经常遇到的业务形态。你把这个系统的设计思路吃透了,以后去写商城系统、工单系统、教务管理系统,会发现很多东西是相通的。再加上考试系统天然有"状态变化"(开始考试、答题中、交卷、判分完成),比纯粹的增删改查有意思得多。
这个项目适合谁来参考?我总结下来是三类人:第一类是准备毕业设计或课程设计的学生,前后端分离架构、完整源码、部署文档,直接拿来改改就能用;第二类是自学 Java 半年左右、想做第一个完整项目的开发者,通过这个项目可以把 SpringBoot、MyBatis、Vue 的知识串成一条线;第三类是工作中需要搭一套内部考试平台的技术人员,不需要太复杂,能在线出题、答题、算分就够了。下面的内容我会从选型、数据库设计、后端核心流程、前端对接、部署上线这几个维度逐步拆解,每个关键环节都会讲清楚"为什么这样做"和"踩坑后的补救方案"。
2. 技术选型:SpringBoot + Vue + MyBatis + MySQL 这套组合到底好在哪
很多初学者选技术栈时习惯"哪个火选哪个",但真正做项目讲究的是匹配度。考试系统的用户量一般不会太大——学校一个年级几千人,企业内部几千名员工,顶天了并发几百个请求。这种量级下,SpringBoot + Vue + MyBatis + MySQL 的搭配不是最炫的,却是最稳、最省心的。
2.1 后端用 SpringBoot 而不选 SSH 的原因
SpringBoot 本质上是对 Spring 生态的一次封装,它消灭了传统 SSM 项目里大量的 XML 配置。我早期用 SSM 写一个接口要配 web.xml、spring-mvc.xml、mybatis-config.xml,一个不小心配错了整个项目起不来。SpringBoot 用自动配置机制把这些繁琐步骤打包处理掉了,你只要引入 spring-boot-starter-web,一个 main 方法就能跑起来。
这不是说 SpringBoot 不需要配置了,而是它把"通用配置"降级成了"默认值"。比如端口、连接池、字符编码这些,你不需要写,改的时候去 application.yml 里覆盖一下就行。对于考试系统这种业务逻辑简单、追求快速交付的项目,SpringBoot 的开发效率优势非常明显。而且它的生态庞大,后面接 Redis、RabbitMQ、OSS 文件存储都是几分钟的事,不像 SSH 时代升级一个组件全家都要跟着改。
2.2 MyBatis 在这种业务场景下比 JPA 更顺手
我知道很多人会问:既然 SpringBoot 官方推荐的持久层框架是 Spring Data JPA,为什么考试系统用 MyBatis?我的答案是:MyBatis 更适合 SQL 可控性要求高的业务。考试系统里有一些复杂的联表查询,比如"查询某次考试中每个学生的成绩排名"、"统计某题的正确率",这些 SQL 用 JPA 的派生查询写起来要么是冗长的方法名,要么是 JPQL 里转来转去。而 MyBatis 直接在 XML 或注解里写原生 SQL,逻辑一目了然,想怎么 join 就怎么 join。
MyBatis 还有一个天然优势:动态 SQL。题目筛选那类场景,学生端要根据题型、难度、知识点去查试题,条件是不固定的——选了单选题但不选难度,或者选了难度但没选知识点。MyBatis 的 <if>、<where> 标签可以拼出灵活的查询条件,换成 JPA 就需要用 Specification 那一套,学习曲线陡很多。当然,MyBatis 的缺点也有,比如字段多的时候要手动映射、简单 CRUD 也要写 SQL,所以后来的 MyBatis-Plus 用 BaseMapper 解决了这部分重复劳动。我建议学习这个项目时先理解原生 MyBatis 的机制,再上 MyBatis-Plus,这样碰到复杂 SQL 时不会抓手。
2.3 前端为什么选 Vue 而不是 React
这个问题没有标准答案,但从实际项目角度说,Vue 的上手成本确实更低。它的模板语法接近 HTML,v-for 渲染列表、v-model 双向绑定,初学者一天就能上手写页面。加上 Vue Router 和 Pinia(或 Vuex)官方提供的生态,做考试系统这种中后台应用绰绰有余。React 当然也能做,但函数式组件的思维模型对新手不太友好,很多人光理解 useState/useEffect 就需要一周。
考试系统前端最核心的是"考试页面",里面涉及倒计时、答题卡(题号网格)、选项勾选状态同步。Vue 的响应式系统处理这类交互很自然,数据变了视图自动更新,你不需要手动去操作 DOM。再加上 Element Plus 这样的组件库,表格、弹窗、表单校验都有现成的,开发效率能翻一倍。
2.4 MySQL 为什么是这个量级的首选
其实这个没什么好纠结的。MySQL 是开源数据库里普及度最高的,几乎所有互联网公司的招聘 JD 里都写着"熟悉 MySQL"。考试系统的数据量通常以万为单位,MySQL 配合合理索引、分页查询完全没压力。而且部署时 MySQL 镜像拉下来就跑,不像 Oracle 那样要处理各种授权和配置。唯一要注意的是,Excel 导入题库时大批量插入会导致事务较长,需要分批提交,这个问题我后面在部署环节细讲。
3. 数据库设计:考试系统的核心是"五张表"之间的关系
很多人一上来就写代码,表结构边写边改,最后发现题目、试卷、考试、成绩这几个模块纠缠不清。我的经验是,考试系统的表结构必须先设计清楚,而且比普通管理系统更需要提前想明白。
3.1 基础表:用户、题目、考试、答卷、答题详情
我先说最基本的五张表:
user:用户表,字段包括 id、username、password、real_name、role(学生/教师/管理员)。注意密码要存加密后的 BCrypt 结果,绝对不能明文存。question:题目表,字段包括 id、type(单选/多选/判断/简答)、content、options(选项用 JSON 字符串存)、answer、score、difficulty、knowledge_point。这里有个设计决策:把选项作为 JSON 字符串存在一列里,还是拆成独立的 question_option 表?我推荐 JSON 字符串,因为题目选项只有 A/B/C/D 或判断题的正确/错误,不存在需要单独查询选项的场景,拆表反而增加代码量。exam:考试表,字段包括 id、title、duration、start_time、end_time、total_score、pass_score、status。注意要有status字段控制考试状态(未开始/进行中/已结束),否则有些考试分 10:00 到 10:45 进行,学生 10:30 进来怎么办?exam_record:答卷表,一条记录对应一次"某学生某场考试",字段包括 id、exam_id、user_id、submit_time、score、status。它是成绩查询的主表。answer_detail:答题详情表,字段包括 id、record_id、question_id、selected_option、is_correct、gained_score。这张表的存在是为了回看学生每一题的作答情况,也用来做正确率统计。
这五张表的关系我用一句话概括:user 和 exam 通过 exam_record 建立多对多关系,exam_record 又通过 answer_detail 关联到每个具体题目。
3.2 题库和试卷的关联:考试系统容易忽略的设计
很多考试系统只做到题库和考试关联,却忽略了"试卷"这个概念。我举个实际例子:同一门课要建两套难度接近的试卷,供 A/B 卷使用,或者某个班级补考时需要单独出卷。如果 exam 表直接存题目 ID 列表,这个灵活性就没了。
所以我会建议多加两张表:paper(试卷表)和 paper_question(试卷题目关联表)。paper 字段是 id、title、exam_id(可选关联)、total_score。paper_question 字段是 id、paper_id、question_id、question_order、question_score。这样一来,试卷和考试解耦,同一套试卷可以复用在多场考试中,question_score 也能覆盖题目自身的默认分值(比如一道题在 A 卷值 5 分,在 B 卷值 3 分)。
组卷策略这块,我的做法是给 paper_question 增加一个 group_type 字段,标记这一题来自"手动选题"还是"随机抽题"。如果是随机组卷,paper 表里用 JSON 存规则,比如"单选题 5 道,难度中等,每题 4 分",后端在考试创建时按这个规则从 question 表里抽题。随机抽题的核心 SQL 其实是 ORDER BY RAND() LIMIT n——小数据量下性能没问题,如果题库上了几十万题,就需要换成"先查 ID 区间再随机偏移"的算法,这里先不展开。
3.3 数据库索引的取舍:成绩查询为什么会慢
这一节是给那些把项目跑起来、但数据多了之后发现页面卡顿的人看的。最容易出现性能问题的地方是成绩列表页的查询:如果按题目要求查"某场考试所有学生的成绩排名",SQL 大概是:
sql复制SELECT u.username, er.score
FROM exam_record er
LEFT JOIN user u ON er.user_id = u.id
WHERE er.exam_id = #{examId}
ORDER BY er.score DESC
这张表是 exam_record,主键是 id,但查询条件是 exam_id。如果不建索引,MySQL 会全表扫描,考试人数一多就明显变慢。别犹豫,直接给这两个字段加上复合索引:
sql复制ALTER TABLE exam_record ADD INDEX idx_exam_user (exam_id, user_id);
answer_detail 表也一样,常见查询是"按 record_id 查该学生的所有答题详情",所以索引建在 record_id 上。别忘了统计"题目正确率"时按 question_id 分组,这个字段也要加索引。我见过不少人辛苦写完统计功能,结果接口 3 秒才返回,最后发现只是差一个索引。
4. 后端实现:组卷、答题、自动判分这三个核心流程
后端代码看起来是普通的 Controller-Service-Mapper 三层,但真的把"考试"这个业务跑通,你会发现难点全在状态管理和并发控制上。
4.1 随机组卷:从规则到一份真实试卷
组卷接口我设计在 ExamService 里,核心方法叫 generatePaper(Integer examId)。它要做的事是:读取 exam 表里配置的组卷规则(我存在 exam 表的一个 text 字段里,格式为 {"single": {"count": 5, "difficulty": "medium", "score": 4}}),然后按规则从 question 表随机筛选。
写随机抽题 SQL 有个细节:如果试卷总分数要精确等于 100 分,你不能只随机抽题,还要考虑总分。我的做法是先计算"需要抽多少分值的题"再按题型和难度配比。比如总分 100 分,单选题 10 道每题 3 分共 30 分,多选题 5 道每题 4 分共 20 分,判断题 10 道每题 2 分共 20 分,简答题 3 道每题 10 分共 30 分。这个配比直接写进组卷规则里,generatePaper 按配比抽题,最后校验总分是否为 100,不是就提示"题库题目不足或分值配比错误"。
这里容易踩的一个坑是:同一份试卷不能出现重复题目。ORDER BY RAND() LIMIT 10 理论上不会重复(因为是同一张表按随机顺序取前 10 条),但如果你在代码里循环多次查询(比如先抽单选再抽多选),两次抽题可能抽到同一道题(如果这道题同时配了单选和多选标签)。解决方法是每抽完一批题,把已选题目的 ID 放入一个 List,下一次抽题时用 WHERE id NOT IN (...) AND type = ... 排除。
4.2 自动判分:客观题秒判、主观题怎么办
考试系统的自动判分逻辑不算难,但容易写乱。我把判分拆成两个阶段:提交时自动判客观题,教师人工批改主观题。
客观题(单选、多选、判断)的判分逻辑其实比对字符串:把正确答案存成 answer 字段(单选存 "A",多选存 "A,B,C",判断存 "T"/"F")。学生提交的 selected_option 也是同样格式。单选和判断直接 equals 比较;多选要更严格:选项完全一致才给分,漏选、错选都不得分,这是业务规则中要明确告诉用户的,不然学生选了 B 没选 C,查到分数后会产生纠纷。
判分的 Java 实现思路大致是这样的:接收前端传来的 List <AnswerRequest>,包含 questionId 和 selectedOption,然后遍历、比对、累加得分,把所有答案写入 answer_detail 表,再把总分更新到 exam_record 表。注意这里要开启事务,保证答题详情和总分要么一起成功回显、要么一起失败回滚,避免出现"详情没存上但总分存上了"的脏数据。
主观题(简答题、论述题)的自动判定比较难——用关键词匹配和语义分析效果都不够好,所以稳妥的方式是:提交时把这些题标记为"待人工批改",exam_record.status 改为 PENDING_REVIEW,教师在后台看到待批改列表后,逐题录入得分,系统再把总分累计进去。这一步的逻辑虽简单,但要注意:人工批改完成前,学生前端看到的成绩只能显示客观题得分和"批改中"状态,不要提前显示总分,否则会暴露未批改的题目信息。
4.3 并发答题:学生同时交卷怎么保证数据不丢
学生提前交卷、考试时间到自动交卷、超时强制交卷——这些场景容易出并发问题。最常见的:倒计时已经归零,学生同时点击"交卷",后端收到多个请求,结果重复生成了两条 exam_record。所以我在 submitExam 方法里加了一步校验:
java复制@Transactional
public ExamResultVO submitExam(Long examId, Long userId, List<AnswerRequest> answers) {
// 幂等校验:该学生该考试是否已有答卷记录
ExamRecord existing = examRecordMapper.selectByExamIdAndUserId(examId, userId);
if (existing != null && existing.getStatus() != 1) { // status=1 表示草稿/未交卷
throw new BizException("该考试已提交,请勿重复提交");
}
// 判分 + 保存
}
光有这段还不够。如果第一次请求正在写库,第二个请求同时进来,selectByExamIdAndUserId 查到的还是 null,也会进入判分流程。解决方式是给 exam_record 表加一个唯一约束 uk_exam_user (exam_id, user_id),让数据库兜底。这样即使代码层有并发漏洞,数据库也会拒绝重复插入,业务安全性就稳了。
4.4 定时任务:考试时间到了自动交卷
有些学生一直没有点击交卷,老师也不可能盯着后台一个个手动提交。我这里的实现是 Spring 的 @Scheduled 定时任务,每 30 秒扫一次 exam_record 表,把所有 end_time < NOW() 且 status = 1(未交卷)的记录自动置为已提交,并对未作答的题目记 0 分。这个定时任务要注意部署多实例时的幂等性(加了 WHERE status = 1 条件天然幂等,两个实例都跑也没关系)。
5. 前端实现:Vue 页面与接口对接的几个关键点
后端接口写完后,前端页面才是最花时间的部分。考试系统的前端,我最想聊的是路由权限控制和答题页面的状态管理。
5.1 路由守卫:学生不能看到管理后台
前后端分离模式下,权限控制不能只靠后端接口,前端也要做路由拦截。Vue Router 的 beforeEach 守卫里,我这样判断登录态和角色:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (!token && to.path !== '/login') {
next('/login')
return
}
const userRole = localStorage.getItem('role')
if (to.meta.roles && !to.meta.roles.includes(userRole)) {
next('/dashboard')
return
}
next()
})
路由表里每个页面都加 meta 信息,比如管理后台的路由是 { path: '/admin', component: AdminLayout, meta: { roles: ['admin', 'teacher'] } }。这样学生虽然能访问到域名下的 admin 路径,但路由守卫会在他进入之前就把请求重定向到首页。当然,后端接口也要校验角色——前端拦截只是优化体验,后端校验才是安全底线。
5.2 考试页面的倒计时和答题卡同步
考试页面的用户体验,很大程度上取决于两个功能写得好不好:倒计时和答题卡。
倒计时的实现不复杂,用 setInterval 每秒减一秒。但有个坑:如果学生切到其他标签页,浏览器会把定时器降到最低频率甚至暂停,导致考试时间显示不准。我的方案是倒计时不依赖本地倒数,而是每次启动时用 Date.now() 计算剩余时间,每秒定时器只负责刷新 UI:
javascript复制const endTime = examInfo.endTime // 后端返回的考试结束时间戳
this.timer = setInterval(() => {
const remain = Math.floor((endTime - Date.now()) / 1000)
if (remain <= 0) {
this.autoSubmit()
}
this.remain = remain
}, 1000)
这样即使定时器被浏览器降频,恢复执行后也会根据真实时间差重新计算,不会出现"本地时间比服务器时间少 10 分钟导致提前交卷"的问题。
答题卡的实现,核心是把答题状态放到一个响应式对象里:answerMap[questionId] = selectedOption。答题卡网格只需要根据 answerMap 中是否有值来决定题号颜色(绿色=已答、灰色=未答、红色=标记?普通考试不搞标记也行,我只做了已答和未答两种)。每次选择选项时更新 answerMap,答题卡区域用 computed 实时渲染这个对象,就能保证"选择了答案,答题卡立刻变色"的效果。
5.3 Axios 封装:统一处理 token 过期和异常提示
接口对接时,我强烈建议封装一个 axios 实例,而不是每个页面都单独写 axios.get。考试系统的接口大部分需要登录状态,所以请求拦截器统一把 token 加到 header 里;响应拦截器统一处理三类情况:200 正常返回、401 token 过期跳登录页、其他业务错误弹出提示。
javascript复制axios.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 200) {
Message.error(res.message)
return Promise.reject(res)
}
return res
},
error => {
if (error.response.status === 401) {
localStorage.removeItem('token')
router.push('/login')
}
Message.error('网络异常,请稍后重试')
return Promise.reject(error)
}
)
这个封装的价值在部署后特别明显:token 过期时学生页面突然报错,如果没有统一处理,你要每个页面一个个去查;用拦截器后,所有页面自动跳登录。
6. 从本地到服务器:完整部署流程与踩坑记录
很多人的项目本地能跑,部署到服务器后问题一堆。这部分我重点讲部署环境准备、前后端构建、Nginx 配置和数据库初始化,附上我实际部署过程中遇到的高频问题。
6.1 环境准备:三件套装好再动手
部署前需要准备一台 CentOS 7+ 的服务器(2C4G 配置就够),装 JDK 8/11、MySQL 5.7 或 8.0、Nginx。注意版本匹配:
- SpringBoot 2.7.x 对应的 JDK 是 8 或 11,如果你用 SpringBoot 3.x,JDK 必须 17 以上。我的源码用的是 SpringBoot 2.7,JDK 8,兼容性最稳定。
- MySQL 8.0 的密码加密方式变了,如果后端连接提前使用高版本 MySQL,需要确认驱动版本至少是
mysql-connector-java 8.0.x;否则连接时会报Public Key Retrieval is not allowed。解决方式是 JDBC URL 加上allowPublicKeyRetrieval=true&useSSL=false。
MySQL 初始化建议直接建库和表结构文件导入:
bash复制mysql -u root -p < exam_system.sql
如果你原来本地用的 MySQL 5.7,服务器上装的 8.0,导入时可能会碰到排序规则不一致的问题。最简单的规避方式是建表语句里显式指定 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci,避免中文乱码和 emoji 存储失败。
6.2 后端打包与运行
SpringBoot 的打包直接用 Maven 命令:
bash复制mvn clean package -DskipTests
打包产物是 target/exam-system-0.0.1-SNAPSHOT.jar。上传到服务器后,建议用 systemd 管理进程,而不是直接在终端 java -jar 跑(否则关掉终端进程就没了)。写一个.service 文件:
ini复制[Unit]
Description=Exam System Server
After=network.target
[Service]
User=root
WorkingDirectory=/opt/exam
ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/exam/exam-system.jar
Restart=always
[Install]
WantedBy=multi-user.target
一个容易忽略的配置:application.yml 里要把数据库地址从 localhost 改成服务器的内网 IP,同时把 spring.datasource.username 和 password 换成正式账号。如果你是用 IDEA 连数据库生成的表,大概率本地 root 密码和服务器不一样,别漏了这个细节。
6.3 前端构建与 Nginx 静态服务
Vue 项目打包:
bash复制npm install
npm run build
构建完成后 dist 目录就是纯静态文件。把它上传到 /usr/share/nginx/html/exam,然后在 Nginx 配置里做两件事:静态文件托管 + 接口反向代理。
nginx复制server {
listen 80;
server_name exam.example.com;
root /usr/share/nginx/html/exam;
index index.html;
location / {
try_files $uri $uri/ /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;
}
}
try_files $uri $uri/ /index.html; 这行是前后端分离项目部署的标配——Vue Router 用的是 history 模式,刷新 /exam/list 页面时 Nginx 会误以为要去找这个路径下的文件,加上回退到 index.html 才能让前端路由接管。有很多人项目部署完,首页能打开,一刷新子页面就 404,基本都是少了这一行。
6.4 部署后常见问题排查思路
我把我实际部署时踩过的问题列成一个表,供大家参考:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 前端页面能打开,接口请求 404 | Nginx 反向代理路径没写好 | 确认 location /api/ 的代理目标地址;后端接口通过 /api 前缀暴露,Controller 的 RequestMapping 要加 /api |
| 接口报 505 或 502 | 后端服务没启动,或端口被占用 | systemctl status exam 查看服务状态,`netstat -tlnp |
| 数据库连接失败 | 用户权限不足或密码错误 | 在 MySQL 里执行 GRANT ALL ON exam_system.* TO 'examuser'@'%' IDENTIFIED BY '密码'; FLUSH PRIVILEGES; |
| 页面刷新 404 | Nginx 没有配置 /index.html 回退 |
检查 try_files 那行是否生效,改完 nginx -s reload |
| 中文乱码 | MySQL 连接字符集问题 | JDBC URL 加 characterEncoding=utf8;建库时指定 utf8mb4 |
7. 一些实用扩展思路
项目跑通后,你可能会觉得功能有点基础,没关系,以下这几个方向是我实际工作中被需求方问得最多的扩展点,给有精力的人参考:
- 增加 Redis 缓存:把试卷题目缓存进 Redis,组卷时直接取缓存,减轻数据库压力。考试系统还有一个适合 Redis 的场景:倒计时状态。用 Redis 存
exam:start:{userId}和过期时间,能够精准控制每人次的考试窗口。 - 增加防作弊:随机切题(相邻学生拿到不同选项顺序的题目)、切出页面次数记录、摄像头监控这些都能做。最简单的是在
answer_detail表加window_switch_count字段,前端监听visibilitychange,切出一次记一次。 - Excel 导入导出题库:接入 EasyExcel,让老师不用一条条录题。注意大批量插入时用
saveBatch分批提交,一次一万条比一次性十万条块得多,而且不会触发 MySQLmax_allowed_packet报错。 - 对接 Redis 分布式会话:前后端分离项目如果部署了多实例,JWT 方案可以兜住 session 共享问题。如果用的还是传统 Session,多实例部署就需要 Spring Session + Redis 来统一存放 session,否则会有用户在 A 实例登录、请求打到 B 实例又显示未登录的坑。
最后结合我自己的体会:前后端分离考试系统这个项目,如果你照着源码一遍遍手敲,再花两天时间把部署流程走完,你对 SpringBoot 自动装配机制、MyBatis 动态 SQL、Vue 生命周期、Nginx 反向代理的理解都会比只看文档要深得多。遇到问题卡住时,多去看看后端日志和浏览器 Network 面板,大部分 bug 的线索都在那里,这也是后端研发最有价值的排查技能之一。希望这篇内容能帮你把系统从"能跑"升级到"会跑、知道为什么跑"的程度。
