news 2026/9/16 17:38:24

SpringBoot+Vue在线考试系统:高并发交卷与防作弊设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue在线考试系统:高并发交卷与防作弊设计

简介:这套在线考试系统是一份基于SpringBoot与Vue的前后端分离项目源码,适合正在学习全栈开发的程序员或需要准备毕业设计的高校学生。后端由SpringBoot提供接口服务,前端使用Vue搭配Element-UI组件库构建页面,包含用户登录、题库管理、在线答题、自动判分等典型功能模块。压缩包内共165个文件,以72个Java文件、34个Vue文件、14个JS文件为主要组成部分,另含数据库SQL初始化脚本、图片与GIF演示、MD说明文档和Maven相关配置,整体大小仅2.39MB,目录结构直观,方便局部查阅与二次开发。目前已有2800余人学习浏览,借助这份资料,读者既能获得可直接导入运行的全套工程,也能深入理解前后端数据交互、权限控制、试卷业务逻辑等实践要点,是快速上手在线考试类项目的实用参考。

1. 在线考试系统的核心课题:SpringBoot+Vue能否扛住“真考试”

在线考试系统看起来是个标准 CRUD:建立试卷、录入题目、发考试、考生作答、自动评分。但真正跑过一场考试的人都清楚,难点并不在业务列表,而在两个反直觉的点上:一是高并发写,上千人同时点交卷,数据库被插入和更新瞬间淹没;二是防作弊,考生换个标签页,后端毫无感知,考试的基本约束就没了。基于 SpringBoot+Vue 的前后端分离架构,正好可以把这两件事拆到两个域里分别处理:Vue 负责采集答题轨迹、切屏次数,SpringBoot 负责在时间边界、重复提交、成绩计算上把严关。这篇文章面向正在做毕设或企业内网无纸化考试系统的开发者,我会按鉴权链路、数据模型、并发写入、部署压测的顺序,给出可复现的方案和参数取舍。

2. 在线考试系统的数据模型设计:用SpringBoot主键策略与“引用式”题目结构支撑Vue前端渲染

2.1 场次、试卷、题目三层结构:为什么不能把题目复制进试卷

做在线考试系统,最容易犯的错是把“试卷”设计成一张大表,题目内容直接塞进一个 TEXT 字段。这样做的后果是:改一道共用题目,历史考试记录全变;批改时又要重新解析文本,几乎无法做题型统计。

我一般会拆成四张核心表:考试场次表exam_session、试卷表exam_paper、题目表exam_question、作答记录表exam_answer_record。试卷与题目之间用关联表或冗余的 paper_id 字段连接,而不是把题目快照复制进去,这种“引用式”设计才能让 Vue 前端在渲染试卷时做到“一次查询、结构清晰”。

表名核心字段说明
exam_sessionid, paper_id, start_time, end_time, status一场考试,绑定一份试卷
exam_paperid, name, total_score, duration_minutes试卷基本信息
exam_questionid, paper_id, type, stem, options_json, score题目,JSON 存选项
exam_answer_recordid, session_id, user_id, question_id, answer, score考生作答明细

这种结构下,前端 Vue 拿到的是“试卷 + 题目数组”,每个题目的options_json直接解析成单选、多选的选项列表,不需要后端再拼装 HTML。题型判断靠type字段,渲染时用 v-if 分支处理。

2.2 SpringBoot里落地四张表的DDL基座:状态字段和审计字段不能省

下面是这套结构中最常被忽略但又必须有的字段设计。主键统一用bigint自增,业务上不要依赖雪花 ID,考试系统的并发量级通常用不到分布式主键,自增主键在 MySQL 的 InnoDB 下索引更紧凑。

CREATE TABLE exam_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未开始 1-进行中 2-已结束', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_paper_id (paper_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE exam_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, type TINYINT NOT NULL COMMENT '1-单选 2-多选 3-判断 4-简答', stem TEXT NOT NULL, options_json JSON NULL COMMENT '选项JSON,判断题存true/false', answer VARCHAR(500) NULL COMMENT '客观题答案,JSON或字符串', score INT NOT NULL DEFAULT 5, sort_no INT NOT NULL DEFAULT 0, KEY idx_paper_id_sort (paper_id, sort_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意exam_session.status是必要字段,因为考试场次有“未开始/进行中/已结束”三种状态,前端页面刷新时要靠这个字段决定是显示倒计时、答题页还是成绩页;自动建表这类能力不建议引入生产库,开发环境可以用 SpringBoot 的spring.jpa.hibernate.ddl-auto=update,但正式环境必须用 Flyway 或手工执行 SQL。

2.3 为Vue组装试卷的接口约定:一次查询拿到全部结构化数据

在线考试系统的考生端页面,加载时只调一个接口就能拿到全部信息,才是合格的做法。Vue 发起GET /api/exam/session/{sessionId}/detail,后端返回场次信息、试卷信息和题目列表三部分。

@GetMapping("/session/{sessionId}/detail") public Result<ExamSessionDetailVO> getSessionDetail(@PathVariable Long sessionId) { ExamSession session = examSessionMapper.selectById(sessionId); if (session == null || session.getStatus() == 0) { return Result.error("考试未开始"); } ExamSessionDetailVO vo = new ExamSessionDetailVO(); vo.setSessionId(session.getId()); vo.setStartTime(session.getStartTime()); vo.setEndTime(session.getEndTime()); vo.setDurationMinutes(session.getDurationMinutes()); vo.setQuestions(questionMapper.selectByPaperId(session.getPaperId())); return Result.ok(vo); }

这里重点是时间窗口的校验放在最前面,如果考试状态是“未开始”,直接拦截,不给前端任何答题数据。返回的ExamSessionDetailVO里包含durationMinutes,Vue 拿它做前端倒计时;但这只是展示用,真正判超时的逻辑必须放在后端,后面第 4 章会专门讲。

3. 前后端分离的联调核心:Vue请求拦截器与SpringBoot的Token鉴权链路

3.1 统一Token方案:为什么前后端分离场景下JWT比Session更顺手

在线考试系统如果用 Session,SpringBoot 默认依赖 Cookie,考生端和考官端如果是不同域名或者小程序 H5,Cookie 的跨域问题会扯出一堆麻烦。JWT 的核心价值是“无状态”,后端不用存会话,Vue 在登录成功后把 token 放在内存或 localStorage 里,每次请求通过 Authorization 头带过去就行。

但 JWT 也有明显的取舍:token 一旦签发,服务端无法主动让它失效。考生被强制交卷时,光删前端 token 没用,必须配合 Redis 做黑名单或业务层状态校验。我在考试系统里通常把 JWT 有效期设为 4 小时,一场考试最长 3 小时,留 1 小时代替刷新操作,考完强制注销。

存储位置优点风险
localStorage刷新不丢XSS 可窃取
sessionStorage关标签清空误关页面要重登
httpOnly Cookie防 XSS 读取跨域配置复杂

3.2 SpringBoot拦截器:解析Token、拉取用户、校验考试场次

SpringBoot 端拦截器要做两件事:解析 token 拿到 userId,再根据接口路径判断是否需要校验考试场次。注意不要把用户信息和场次信息全塞进 token,token 里只放 userId 和 sessionId,其他信息每次请求从数据库或缓存里拿,避免 token 过长。

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 ")) { response.setStatus(401); return false; } Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token.replace("Bearer ", "")) .getBody(); Long userId = Long.valueOf(claims.get("userId").toString()); Long sessionId = Long.valueOf(claims.get("sessionId").toString()); request.setAttribute("userId", userId); request.setAttribute("sessionId", sessionId); return true; } }

这个拦截器只做“身份识别”和“场次绑定”,不做“考生有没有交卷”的判断。交卷与否属于业务状态,放拦截器里会让鉴权逻辑和业务逻辑耦合。在 WebMvcConfigurer 里注册时,我会放行/api/auth/login/api/exam/session/{id}/detail这些预加载接口,但要写清楚:试卷详情接口需要校验场次,但允许在考试开始前轮询状态。

3.3 Vue侧Axios拦截器:携带Token、统一处理401与403的边界

Vue 端我在src/utils/request.js里统一创建 Axios 实例,所有组件都走这个封装,不直接裸调 axios。这样 token 注入和错误提示只需要写一份代码。

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') router.push('/login') } return Promise.reject(error) } )

要注意timeout: 15000这个值:考试系统里考生在弱网环境下交卷,如果超时设太短,会导致前端显示“交卷失败但后端已落库”,这是最严重的体验事故。我一般把“交卷”接口单独设为 30 秒超时,其他普通查询接口保持 15 秒。401 跳转登录页没问题,但如果考生已交卷、token 还在,后端拦截器返回 401 是因为 token 过期,而不是交卷标记,这时前端不能无脑清 token 跳登录。

4. 答题与防作弊实战:Vue端事件采集与SpringBoot并发落库的取舍

4.1 交卷并发的三档选择:从数据库行锁到Redis分布式锁

在线考试系统最容易压垮数据库的瞬间就是统一交卷。1000 人同时提交,如果每份试卷 50 道题,就是 5 万条 insert 和 1000 条 update 同时落在同一张表上,MySQL 默认的数据库连接池瞬间被打满。

方案落地方式适合规模
逻辑删除+普通insert不做并发控制50人以下
唯一约束+幂等键数据库层面去重几百人
Redis setnx锁锁考生ID,防重复提交千人以上

我在实际项目中常用组合:数据库唯一索引做底线,Service 层再用 Redis 锁做大门。唯一索引建在exam_answer_record表的session_id + user_id + question_id上,这样同一个人对同一道题物理上只能有一条记录;交卷接口再套一层 Redis 锁,锁的 key 是submit:lock:{sessionId}:{userId},超时时间 5 秒。

4.2 SpringBoot交卷服务代码:幂等键、状态机与成绩落库

交卷接口是整场考试的“临门一脚”,代码必须保证三件事:同一考生重复请求不产生多重分数、考试已结束后不能继续交卷、客观题成绩即时计算。

@PostMapping("/submit") public Result<?> submitExam(@RequestBody SubmitRequest req) { Long sessionId = req.getSessionId(); Long userId = getCurrentUserId(); String lockKey = "submit:lock:" + sessionId + ":" + userId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (!locked) { return Result.error("交卷处理中,请勿重复点击"); } try { ExamSession session = examSessionMapper.selectById(sessionId); if (session.getStatus() != 1) { return Result.error("考试已结束,无法交卷"); } List<AnswerItem> items = req.getAnswers(); for (AnswerItem item : items) { ExamAnswerRecord record = new ExamAnswerRecord(); record.setSessionId(sessionId); record.setUserId(userId); record.setQuestionId(item.getQuestionId()); record.setAnswer(item.getAnswer()); // 计算客观题得分,存快照 record.setScore(calcObjectiveScore(item.getQuestionId(), item.getAnswer())); examAnswerRecordMapper.insert(record); } examSessionMapper.updateSubmitStatus(sessionId, userId); return Result.ok("交卷成功"); } finally { redisTemplate.delete(lockKey); } }

这段代码里最容易忽略的是 Redis 锁的释放时机。finally块删除锁在大多数场景没问题,但如果 Redis 主节点宕机,setIfAbsent返回成功但主从同步失败,会导致锁根本没生效。真要较真就要用 Redisson 的看门狗机制,但考试系统里 5 秒超时锁足够兜底,因为交卷接口的数据量不大,正常都在几百毫秒内完成。成绩计算用calcObjectiveScore只处理单选、多选和判断,简答题统一在全部交卷后由人工评阅,这样成绩计算只读题目表中的标准答案,不需要额外查试卷快照。

4.3 Vue监听页面切换、断网与心跳上报的完整实现

防作弊不能全指望后端拦截,前端采集行为数据更直接。Vue 的visibilitychange事件能在用户切走标签页时立刻触发,我把切屏时间和次数记录在内存里,每 30 秒一次心跳上报给后端。

mounted() { document.addEventListener('visibilitychange', this.handleVisibilityChange) this.heartbeatTimer = setInterval(this.sendHeartbeat, 30000) }, beforeUnmount() { document.removeEventListener('visibilitychange', this.handleVisibilityChange) clearInterval(this.heartbeatTimer) }, methods: { handleVisibilityChange() { if (document.visibilityState === 'hidden') { this.switchCount++ this.lastSwitchTime = Date.now() } }, sendHeartbeat() { const payload = { sessionId: this.sessionId, switchCount: this.switchCount, lastSwitchTime: this.lastSwitchTime, onLine: navigator.onLine } this.$http.post('/exam/heartbeat', payload) } }

心跳接口在 SpringBoot 侧只做一件事:更新exam_behavior_log表。这表不用承担成绩计算,所以即使一次心跳丢失也不要紧,但必须防止刷频,后端用@RateLimiter注解限制同一考生每秒最多一次心跳上报。注意心跳上报是低频接口,不要和交卷接口共用一张表,避免行锁竞争影响交卷性能。

4.4 时间边界:前端倒计时与SpringBoot服务端时间校验

Vue 的倒计时用setInterval每 1 秒更新剩余时间,到 0 时自动触发交卷函数。但浏览器被切到后台后,setInterval会被 Chrome 降频甚至暂停,考生切回来时才发现时间已经过了。所以“考试是否超时”必须由服务端判断。

private void checkExamTimeout(ExamSession session) { long now = System.currentTimeMillis(); long endTime = session.getEndTime().getTime(); if (now > endTime) { throw new ExamException("考试已结束,系统已自动交卷"); } long submitBeforeEnd = endTime - now; if (submitBeforeEnd > session.getDurationMinutes() * 60 * 1000) { throw new ExamException("非法的时间戳请求,已记录异常行为"); } }

这个校验放在交卷接口的第二行,比状态字段更可靠。因为exam_session.status可以由管理员手动改,但end_time是硬约束。前端倒计时到 0 触发的自动交卷,最终能否成功,取决于后端now > endTime这行判断,这是考试公平性的底线。

5. Nginx部署与模拟压测:让SpringBoot+Vue在线考试系统在真实环境下不翻车

5.1 构建Vue产物并配置Nginx的history路由回退

Vue 项目打包后只有一个dist目录,如果后端接口和前端静态资源都在同一台服务器上,用 Nginx 一个 server 块解决最省事。先执行npm run build生成 dist,然后配置 Nginx 把静态资源直接指过去。

server { listen 80; server_name exam.example.com; root /opt/exam-frontend/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; } }

try_files $uri $uri/ /index.html这一行是 Vue Router history 模式的关键,否则刷新/exam/1/detail页面直接 404。location /api/反代到 SpringBoot 默认的 8080 端口即可,不用给 SpringBoot 配 CORS,因为请求经过 Nginx 后是同源的。注意部署时间要避开考试进行中,否则 Nginx reload 的瞬间所有长连接会被断开。

5.2 用ab做一次性压测,确认交卷接口的吞吐量是否达标

考试系统上线前,我会先做一轮最核心的交卷接口压测。Apache ab 是 Linux 自带工具,不需要额外安装,压测命令和结果解读如下:

ab -n 2000 -c 200 -p submit.json -T application/json http://localhost:8080/api/exam/submit

参数含义:-n 2000表示总共发 2000 个请求,-c 200表示 200 个并发连接,submit.json里放一份 10 道题的答案 JSON。关注Requests per secondTime per request两个指标。如果 QPS 低于 50,先看数据库连接池设置,SpringBoot 的spring.datasource.hikari.maximum-pool-size默认只有 10,考试场景要调到 100 以上才能支撑几百人同时交卷。

实际压测时不要把 200 个并发全部打向数据库。我在代码里已经做了 Redis 锁,锁竞争本身会限流,所以压测重点看“锁之外的耗时”是否失控。如果Time per request的平均值超过 1000ms,优先排查是不是exam_session表的selectById走了全表扫描,给paper_id加上索引后重测。

5.3 用本地时钟开关验证“到点自动收卷”的边界行为

线上环境不能随便改服务器时间,验证超时逻辑可以用一个测试开关。SpringBoot 里写一个ExamClockService,正常时间走System.currentTimeMillis(),测试环境中通过注解或配置项切换成“伪当前时间”。

@Component public class ExamClockService { @Value("${exam.mock-time:0}") private long mockTime; public long now() { return mockTime > 0 ? mockTime : System.currentTimeMillis(); } }

在 application.yml 中设置exam.mock-time: 1710000000000,再调一次交卷接口,能看到接口返回“考试已结束”。确认逻辑无误后,把 mock-time 修改为 0 或直接删除配置项。这个开关还有一个额外用处:前端开发时,把伪时间设置成考试开始前 1 分钟,可以直接在浏览器里调试“考试即将开始”的按钮禁用态,不需要真的等一场考试跑完。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 17:35:31

工业级智能客服平台核心技术解析与应用实践

1. 项目背景与核心价值工业级智能客服平台正在经历从"简单问答机器人"向"全流程服务中枢"的转型。传统客服系统面临三大痛点&#xff1a;人工成本居高不下&#xff08;占企业运营成本15%-35%&#xff09;、非标准问题处理效率低&#xff08;平均响应时间超…

作者头像 李华
网站建设 2026/9/16 17:34:30

Vue.js家教网前端源码解析:组件化与数据流实践

简介&#xff1a;面向Vue.js学习者和前端开发者的家教网前端设计源码&#xff0c;基于Vue.js与JavaScript技术栈构建&#xff0c;覆盖课程浏览、教师展示、个人中心等典型家教业务页面&#xff0c;适合快速搭建家教平台界面&#xff0c;也可作为组件化开发的练习项目。压缩包共…

作者头像 李华
网站建设 2026/9/16 17:33:58

GitHub 日榜实战指南:从趋势筛选到本地运行的开源项目实操

GitHub 日榜这个页面&#xff0c;我几乎每天早上开工前都会花十分钟扫一遍。别小看这段时间&#xff0c;它能让我在评审技术方案时说出"这个方向最近已经有几个仓库在做了"&#xff0c;也能在周末找点值得研究的源码来读。今天&#xff08;2026-09-02&#xff09;的日…

作者头像 李华
网站建设 2026/9/16 17:32:52

OpenClaw接入阿里云百炼API:本地与云服务器2分钟部署AI管家中枢

2026年再聊AI&#xff0c;大家早就不满足于在网页里问问题了。我自己的需求很简单&#xff1a;手机、电脑、服务器上随时能有个喊得动的AI管家&#xff0c;不要被任何一家厂商的App界面绑死。折腾了一圈开源方案&#xff0c;最后常住在OpenClaw上——社区都喜欢叫它“龙虾”。它…

作者头像 李华