Spring Boot+Vue在线考试系统开发实践:从组卷判分到部署上线

发布时间:2026/9/14 8:32:17
Spring Boot+Vue在线考试系统开发实践:从组卷判分到部署上线 简介这是一套基于 Spring Boot 与 Vue.js 构建的前后端分离在线考试系统完整源码适合高校学生用于毕业设计、课程设计或项目实训。系统覆盖出题组卷、线上考试、题库练习、反作弊、阅卷批改与答题分析的全流程并提供考试与练习两种做题模式其鲜明特色是针对答题情况自动生成错题知识点树状清单和正确率统计帮助学生及时查漏补缺提升学习效果。压缩包内共194个文件、大小7.34MB主要包含 Java 与 Class 源码、Vue 页面、XML 映射、SQL 数据库脚本、CSS 样式及日志文件等目录结构清晰便于导入开发工具后直接运行与二次开发。当前已有350人学习下载适合具备一定 Java 或前端基础、希望获取完整可运行项目并深入理解在线考试业务逻辑的开发者。1. 在线考试系统为什么值得自己从零做一套在线考试系统的开发里最容易被忽视的不是登录、题库管理这些常规模块而是交卷那一刻的业务一致性。两个考生在同一秒提交答卷后端没做事务和幂等保护成绩就可能丢一条或写错一条前端在本地算好分数直接传给后端存储更是连防篡改都谈不上。这些问题在功能演示里看不出来放在真实考场环境里就是事故。这篇文章围绕 springboot vue 这套前后端分离方案把在线考试系统的数据模型、权限控制、随机组卷、交卷判分、防作弊到上线部署的完整链路讲清楚。适合要落地内部考试平台的开发团队也适合正在做基于 springboot 的 Java 毕设的同学。我会把参数怎么调、坑在哪里写细而不是只堆 CRUD 代码。2. springboot后端在线考试系统的数据模型与权限控制在线考试系统的后端用 Spring Boot 提供 REST API 是当前最常规的选型。相比传统的 JSP 服务端渲染REST API 的最大好处是前端可以独立部署后续要加移动端或小程序端时后端接口不用重写。权限这套建议直接用 Spring Security JWT 做无状态认证考试这种高并发场景下无状态意味着网关层、负载均衡层不用处理 Session 同步。2.1 五张核心表搞定考试领域建模我一般会按“考试场次、题目、组卷快照、答卷、答题明细”五张表来建模型。不要在题库表里直接改答案也不要让前端一次性查出所有题目核心是区分「题库里的题」和「这场考试里的题」。表名核心字段职责examid, title, start_time, end_time, duration, total_score, status考试场次信息questionid, type, content, options_json, answer_hash, score, knowledge_point题库答案只存摘要exam_questionid, exam_id, question_id, sequence, score, answer_hash组卷结果快照考生重进考试卷子不变exam_recordid, exam_id, user_id, start_time, submit_time, score, status一次作答的答卷记录answer_detailid, record_id, question_id, user_answer, correct每题作答明细方便对单题判分复议注意question表里不存明文答案存answer_hash防止题库导出后答案直接泄露。判分时把考生答案做同样的哈希再比对选择题、判断题都能这样处理。exam_question表要冗余这次考试的score和answer_hash快照否则组卷之后管理员改了题库原题历史答卷重算分数会发生漂移。2.2 JWT Spring Security 的双角色权限控制在线考试系统至少有两类角色管理员和考生。管理员管题库、管场次、看成绩考生只能进自己已报名的场次答题。用 JWT 把用户 ID 和角色放进去后端每个接口只认 token不再查 Session。Spring Boot 版本坑先说一下网上大量老教程让你继承WebSecurityConfigurerAdapter但 Spring Boot 2.7 之后这个类被标记废弃Spring Security 5.7 起官方推荐直接声明SecurityFilterChainBean。如果你创建 springboot 项目时选了比较新的版本照老教程写会直接编译报错。Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .antMatchers(/api/exam/**).authenticated() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }这段配置的含义是登录接口放行/api/admin/**只允许管理员角色考生接口要求必须登录。注意 Spring Security 6 里antMatchers改名为requestMatchers如果你的 springboot 版本是 3.x要写成requestMatchers(/api/admin/**).hasRole(ADMIN)。JWT 过滤器是权限控制的关键我一般这样写Component public class JwtAuthFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { try { Claims claims JwtUtil.parseToken(header.substring(7)); Long userId Long.valueOf(claims.get(uid).toString()); String role claims.get(role).toString(); ListSimpleGrantedAuthority authorities Collections.singletonList(new SimpleGrantedAuthority(ROLE_ role)); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken(userId, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } catch (Exception e) { SecurityContextHolder.clearContext(); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } } chain.doFilter(request, response); } }这段代码做的事情是从请求头取出 token解析出 uid 和 role构造 Spring Security 的认证对象放入上下文。后面的接口直接通过SecurityContextHolder拿当前用户不用每个 Controller 都重复解析 token。token 过期和 token 伪造在异常处理里都返回 401如果你想给前端区分“请重新登录”和“登录状态异常”可以在异常类型上做细分。2.3 用 MyBatis-Plus 写分页查询的三个注意点后台管理页面的考试列表、学生成绩列表用 MyBatis-Plus 的IPage最省事。先注册分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页查询示例IPageExamRecordVO page new Page(current, size); LambdaQueryWrapperExamRecord wrapper new LambdaQueryWrapperExamRecord() .eq(ExamRecord::getExamId, examId) .orderByDesc(ExamRecord::getScore); IPageExamRecordVO result examRecordMapper.selectExamRecordPage(page, wrapper);三个注意点。第一分页插件不注册PaginationInnerInterceptor时Page对象传进去不会生效SQL 里没有 LIMIT数据量一大页面直接卡死。第二如果ExamRecord做了逻辑删除自定义 SQL 里要手动拼deleted 0条件MyBatis-Plus 的自动逻辑删除只对内置方法生效。第三排序字段如果是前端传进来的不要直接拼到orderByDesc里要做白名单校验否则就是 SQL 注入点。3. vue前端考生端和管理端的页面组织前端用 Vue 3 Vite 搭单页应用路由用 vue-router 4状态管理看复杂度决定考试系统一般用不上 Pinia 也够。页面组织上分成考生端和管理端两套布局考生端是进入考试、答题页、成绩页管理端是题库列表、组卷配置、成绩导出。路由设计要解决两个问题不同角色能访问的页面隔离以及考试过程中的刷新和返回控制。3.1 vue-router 按角色拆路由的两种写法在线考试系统的路由规模不大我更推荐静态路由加 meta 角色标记而不是上来就做动态路由。路由守卫里判断角色简单直接刷新页面也不用重新注册路由。router.beforeEach((to, from, next) { const token localStorage.getItem(exam_token) const role localStorage.getItem(exam_role) if (!token to.path ! /login) { next(/login) return } if (token to.path /login) { next(/) return } if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) return } next() })to.meta.roles在路由表里这样声明{ path: /admin/exam/list, component: () import(../views/admin/ExamList.vue), meta: { roles: [ADMIN] } }token 存在 localStorage 里路由守卫拦的是页面跳转接口层面的校验后端还是要做不能只靠前端守卫。像若依 vue 这类开源框架会把路由表存在后端登录后通过addRoute动态注册在线考试系统页面数量有限没必要照搬权限集中在一个守卫里反而更好排查。3.2 axios 拦截器统一处理 Token 和 403axios 拦截器是前后端联调一定要先写的部分否则每个接口都要手动带 token重复代码多且容易漏。请求拦截器负责加认证头响应拦截器负责统一处理 401 和 403import axios from axios import router from ./router const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(exam_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(exam_token) localStorage.removeItem(exam_role) router.push(/login) } else if (error.response?.status 403) { ElMessage.error(没有权限执行该操作) } return Promise.reject(error) } )超时时间 15 秒是个比较稳的取值在线考试系统的接口大多是列表查询和判分提交判分接口如果在大试卷量下超过 15 秒用户体验会很难受但调太高又会在网络异常时长时间等待。baseURL 写成/api配合后端网关或者 Nginx 反向代理开发环境用 Vite proxy 指到本机 8080 即可。3.3 倒计时组件与答题卡组件的联动考试页最常见的两个问题倒计时在切后台后不准以及刷新后已答题目丢失。倒计时不能用前端本地累减的方式要用服务器下发的时间戳做差值计算import { ref, onUnmounted } from vue export function useCountdown(endTime) { const remain ref(0) let timer null const tick () { const diff Math.floor((endTime - Date.now()) / 1000) remain.value Math.max(diff, 0) if (diff 0 timer) clearInterval(timer) } tick() timer setInterval(tick, 500) onUnmounted(() clearInterval(timer)) return { remain } }endTime是进入考试时后端返回的考试截止时间戳前端每次 tick 只用当前时间减截止时间。用 500ms 的间隔是避免 setInterval 被浏览器节流后错过整秒更新500ms 的跳动在日常使用里也察觉不到。答题卡组件根据题号高亮当前题、标识已答题、标识未答题和倒计时组件之间不需要通信各自绑定同一份答题数据源就行。答题数据源我会写一个自动同步到 localStorage 的逻辑每次选项变化就把整个答案对象序列化存储。考试中途网络断开或者误刷新重新进入页面时先读 localStorage 恢复到未提交状态花费不大但考场体验差异明显。注意交卷成功之后必须清理 localStorage否则下次考试会把上一场的残留答案带出来。4. 在线考试系统的核心随机组卷与交卷判分随机组卷和交卷判分是考试系统和普通后台管理最大的区别。组卷要做到“同一场次不同考生看到不同题目或不同顺序”判分要做到“无论前端传什么分数以后端重新计算为准”。这两个模块直接决定系统能不能用。4.1 三种组卷策略和 SQL 写法组卷策略按规模从简单到复杂有三种做法。第一种是纯随机全题库ORDER BY RAND()抽题适合题库量在一万以内的小系统第二种是按知识点配额随机先指定每个知识点抽几道题适合有明确考纲的考试第三种是按难度系数分配适用于题目打了难度标签的题库。我一般至少做第二种它贴合实际考试需求且实现成本只比纯随机多一步。-- 从三个知识点里各按配额抽题LIMIT 数量由程序计算 SELECT q.id, q.content FROM question q WHERE q.type single AND q.knowledge_point #{point} AND q.deleted 0 ORDER BY RAND() LIMIT #{quota}这段 SQL 的缺点是ORDER BY RAND()在题库数据量大时会让数据库对全表结果做排序十万级以上明显变慢。我的做法是先查每个知识点符合条件的id列表在 Java 程序里用随机抽样取指定数量的 id再用WHERE id IN (...)查回完整题目。抽样逻辑放进 Service 层单元测试也好写。组卷不只抽题还要把一个重要问题处理掉考生刷新页面不能换一套卷子。组卷完成后把所有题目写入exam_question表记录题目顺序、分值和答案快照。考生再次进入考试时只从exam_question读取不再重新调用组卷逻辑。4.2 交卷时分数怎么才算准交卷接口是整个系统最需要严谨处理的接口。前端在交卷时把答案数组传过来后端重新读取题目、重新判分前端计算的分数只能作为展示后端返回值才是最终成绩。事务要包住整段判分逻辑防止成绩写到一半进程崩溃导致明细和总分不一致。Transactional public ExamResult submit(Long examId, Long userId, ListAnswerItem answers) { ExamRecord record examRecordMapper.selectOne( new LambdaQueryWrapperExamRecord() .eq(ExamRecord::getExamId, examId) .eq(ExamRecord::getUserId, userId) .last(FOR UPDATE)); if (record null || record.getStatus().equals(1)) { throw new BusinessException(答卷不存在或已交卷); } int score 0; for (AnswerItem answer : answers) { Question question questionMapper.selectById(answer.getQuestionId()); boolean correct MessageDigest.isEqual( question.getAnswerHash().getBytes(StandardCharsets.UTF_8), answer.getAnswer().trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); if (correct) { score question.getScore(); } answerDetailMapper.insert(new AnswerDetail(record.getId(), question.getId(), answer.getAnswer(), correct)); } record.setScore(score); record.setStatus(1); record.setSubmitTime(new Date()); examRecordMapper.updateById(record); return new ExamResult(score); }.last(FOR UPDATE)是行级锁两个请求同时交卷时第二个事务会等第一个事务提交后才读到最新的status发现已交卷就抛出业务异常。这比在代码层加synchronized要可靠因为分布式部署下 synchronized 锁不住多个实例的并发。答题明细在循环里逐条 insert 会放大事务时间性能敏感时改成批量插入把判分过程拆成多段提交。代码里对答案做了.trim().toLowerCase()规范化避免考生多打了个空格被判错。注意简答题不能用answer_hash判分这类题目应该走“先保存答案、后人工阅卷”的流程交卷接口只保证答案落库。4.3 防作弊切屏监控和乱序选项的落地做法在线考试系统的防作弊通常是三层配合前后端切屏监控、题目乱序、后台人工复核。切屏监控用浏览器的visibilitychange事件和blur事件切出页面或者浏览器失去焦点就计数一次把次数上报到后端。let switchCount 0 let countUploaded false document.addEventListener(visibilitychange, () { if (document.hidden) { switchCount 1 if (switchCount 3 !countUploaded) { // 上报后端后端记录该考生切屏事件 axios.post(/api/exam/record-violation, { count: switchCount }) countUploaded true } } })切屏次数到达阈值后系统只是记录事件并提醒考生不要自动交卷。浏览器在系统弹窗、插件冲突时也会触发失焦自动交卷会引发大量误伤人工复核更合理。乱序主要是 Fisher-Yates 洗牌。注意不要用sort(() Math.random() - 0.5)这个写法不是均匀随机重复次数多了能观察到明显的分布偏差function shuffle(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)) ;[arr[i], arr[j]] [arr[j], arr[i]] } return arr }题目顺序洗牌后选项的 key 也要一起洗。考生提交的答案要保存成“题目 ID 正确选项的 key”这种语义化结构不要保存“我选了第几个选项”。如果答案里存的是位置序号题目乱序之后后端按题目 ID 查正确答案时会对应不上。5. 部署、压测与收尾springboot项目上线前的验证清单5.1 前端打包后布局异常和刷新 404 的排查vue 项目打包后最常见的两个问题一是刷新二级路由变成 404二是 CSS 样式全部错乱。404 基本可以断定是部署环境没有把路由回退到 index.htmlnginx 配置里加try_filesserver { listen 80; server_name exam.example.com; root /opt/exam/dist; 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; } }布局异常先看 Vite 的base配置。部署在域名根路径就保持默认/部署在子路径就设base: /exam/并且页面里的静态资源别写绝对路径。检查顺序是先看浏览器 Network 里 CSS/JS 是否 200再看加载出的资源 URL 前缀和部署路径是否一致。5.2 用 JMeter 对交卷接口做一次低配压测交卷接口是写库最重的接口强烈建议上线前用 JMeter 压一次。在线考试系统和普通网站不同峰值集中在考试结束前 1 分钟考生同时点击交卷这才是系统真正的压力点。线程数Ramp-Up秒循环次数预期目标100510错误率低于 1%TP99 小于 800ms观察结果时不要只看平均响应时间要看 TP99 和错误率。交卷接口如果 TP99 超过 1 秒说明数据库连接池或判分事务需要优化优先检查是不是循环单条 insert 明细拖慢了事务。压测前先压登录接口获取 token再用 CSV 数据文件把不同 token 分给不同线程避免 100 个线程共用同一个账号造成数据覆盖。5.3 生产环境关掉 heapdump 和统一服务器时间Spring Boot Actuator 如果开启了所有端点heapdump端点会暴露 JVM 堆转储文件攻击者下载后可以直接从内存里提取数据库密码、Redis 密码和 JWT 密钥。生产环境只暴露健康检查端点management: endpoints: web: exposure: include: health,info另外要检查服务器时间和考生客户端时间的一致性。在线考试系统的开始时间、剩余时间、交卷截止时间全部以后端服务器时间为准前端展示的倒计时只是把服务器时间戳做本地差值计算。如果服务器时间慢了 1 分钟考生实际提前交卷快了 1 分钟考生还没答完就被强制提交。考试开始时把服务器时间显示在考生端页面客户端时间偏差超过 30 秒就禁止进入考试这一项值得在系统上线前就写进检查清单里。本文还有配套的精品资源点击获取