ARTICLE DETAIL

资讯详情

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

在线考试系统开发:从状态机设计到前后端部署全解析

在线考试系统开发:从状态机设计到前后端部署全解析 简介这是一套采用 SpringBoot 与 Vue 前后端分离架构的在线考试系统项目源码面向具备一定 Java 和前端基础、希望通过完整项目提升开发能力的开发者。后端使用 SpringBoot 提供接口服务前端以 Vue 与 Element-UI 组件库实现管理后台和答题页面内置数据库脚本与项目配置导入即可运行并便于二次扩展。压缩包共 165 个文件以 72 个 Java 源文件、34 个 Vue 组件、14 个 JS 脚本为主同时包含 SQL、XML、properties 等配置类文件整体仅 2.39MB结构清晰其中 SQL 脚本预置了计算机网络试卷及测试题目另附界面截图和演示动图便于直观查看页面效果。项目还提供在线体验地址登录后台可了解试卷管理、在线答题、成绩处理等核心流程并可通过实际操作验证功能。目前已有 2825 人学习下载适合作为毕业设计、课程实训或 SpringBootVue 整合练习的参考范本。1. 这套系统最值得复用的不是前后端分离而是考试状态机见过不少在线考试项目评价是“增删改查都跑得通一考试就翻车”。问题不在代码语法而是核心边界没守住考生交卷后还能不能改答案倒计时归零但请求还在路上算不算分断网重连后试卷里的答案缓存和数据库不一致以哪个为准这几点比页面写得好看重要得多。这个标题做的事是用 SpringBoot 把考试拆成“上架题库、组卷策略、答题落库、自动判分”四个核心接口用 Vue 把页面拆成“登录、考试中、交卷结果”三个状态页面中间只靠 JSON 通信。它适合两类人一是做毕设或简历项目想把全栈链路写完整的开发者二是后端为主、想弄清楚 Vue 请求拦截和 token 处理边界的工程师。整篇文章的重点放在“考试中”这个阶段因为它才是前后端分离最容易出乱子的地方。2. 考试系统的领域模型题库、试卷、考生答案三张表的关系要先立住2.1 从表结构开始设计而不是从页面开始前后端分离项目最常见的坑是后端字段跟着前端页面走页面改了后端就改接口最后接口又臭又长。正确做法是先想清楚“一份试卷从创建到判分到底产生了哪些数据”。这里的三张核心表是 question题目、paper试卷、paper_record答题记录。很多项目还会加一张 user 表但考试系统的领域模型里它只是“考生”的载体不属于核心链路。我更习惯把题目和试卷拆开试卷本身不存题目内容只存题目 ID 列表和分值。这样组卷策略才有存在价值——一张试卷可以引用不同题库也能在后续支持随机抽题。表结构落在 MySQL 时三个关键设计是question 的 type 字段区分单选题、多选题、判断题paper 的 strategy 字段存 JSON 形式的组卷参数paper_record 的 answer 字段存 JSON 形式的答题明细。用 JSON 是为了避免把“第 3 题答案是什么”拆成多行造成判分时反复 join。2.2 建表 SQL 与 MyBatis-Plus 自动映射下面给出一份可直接落地的建表 SQL删掉了审计字段以保持核心链路清晰CREATE TABLE question ( id BIGINT AUTO_INCREMENT PRIMARY KEY, type TINYINT NOT NULL COMMENT 1单选 2多选 3判断, content TEXT NOT NULL, options JSON NOT NULL COMMENT 形如 {A:...,B:...}, answer VARCHAR(255) NOT NULL, score INT NOT NULL, difficulty TINYINT DEFAULT 1 ); CREATE TABLE paper ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, strategy JSON NOT NULL COMMENT 组卷策略如 {single:10,multi:5}, total_score INT NOT NULL, duration INT NOT NULL COMMENT 考试时长单位分钟 ); CREATE TABLE paper_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, paper_id BIGINT NOT NULL, answer JSON COMMENT 作答明细如 {1:A,2:B,C}, status TINYINT DEFAULT 0 COMMENT 0考试中 1已交卷, submit_time DATETIME NULL );选项和答案用 JSON 字段是针对两套后端的取舍如果纯用 SpringBoot MyBatis-PlusMySQL 5.7 以上对 JSON 有索引支持不必担心性能多字段 join 反而会因为答题明细的结构不好确定反复改表。后端读取时用com.alibaba.fastjson.JSONObject解析即可。需要注意spring.datasource 配置里不要开ddl-auto: update让表自动去建——自动建表遇到字段类型变更时MySQL 只会新增列而不会改列类型容易留下“线上表结构和本地不一致”的隐患。表结构变更要用 Flyway 之类的迁移工具管这个后面部署篇会说。2.3 组卷策略与 Redis 缓存分数公式题库设计完了组卷怎么落地在线考试系统常用做法是把试卷的题目列表生成一份“试卷快照”存到 Redis不再查库组卷。因为考试中所有请求都不能因为数据库压力而变慢Redis 里放paper:detail:{paperId}内容就是完整题目列表 JSON有效期等于考试时长。public PaperVO getPaperWithQuestions(Long paperId, Long userId) { String key paper:start: userId : paperId; String json redisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return JSONObject.parseObject(json, PaperVO.class); } Paper paper paperMapper.selectById(paperId); // 根据 strategy 从题目池中抽题按题号排序后组装 ListQuestion questions assembleByStrategy(paper.getStrategy()); PaperVO vo buildPaperVO(paper, questions); redisTemplate.opsForValue().set(key, JSONObject.toJSONString(vo), Duration.ofMinutes(paper.getDuration())); return vo; }只缓存“正在考试”的试卷考完即删。判分时不再依赖试卷里的题目顺序而是把考生提交的 JSON 遍历一遍用每个题号去 Redis 的paper:detail:{paperId}里查答案比对。如果 Redis 缓存被清掉了就得回表把paper_record.answer和question.answer做一次重判这是后面异常处理要讲的兜底方案。这里有一个容易忽略的参数点Redis 的 key 必须带 userId不能只以 paperId 做 key。否则两个考生同时答同一张卷子后进者会把先进入者的试卷快照顶掉导致先进入者答题过程中突然拉不到题目。3. SpringBoot 后端的核心接口登录鉴权、试卷下发、答案提交的状态机实现3.1 token 处理与登录接口的边界前后端分离的灵魂是 token考试系统的 token 要和普通管理后台区分对待——考试中的 token 生命周期必须大于考试时长。常见做法是把 token 有效期设为 2 小时而单场考试时长一般 60120 分钟这样既能容忍考生长时间答题又不至于留下巨大的安全窗口。登录接口用RestController暴露认证逻辑用 Spring Security JWT。关键点是JWT 生成时不光放 userId 和 userName还要放一个examScope标记用于后续考试接口的权限校验避免考生拿着 token 去调管理员的题库管理接口。PostMapping(/auth/login) public ResultString login(RequestBody Valid LoginReq req) { User user userService.verify(req.getUsername(), req.getPassword()); String token JwtUtil.createToken(user.getId(), user.getUsername(), student); return Result.ok(token); }参数说明verify 里用 BCrypt 做密码比对不直接比对明文JwtUtil.createToken 的第三个参数就是examScope后续在拦截器里解析出来只放行student角色的考试相关请求。这个标记并不替代 Spring Security 的角色体系只是给考试上下文做快速判断减少一次数据库查询。3.2 考试状态机作答中、自动保存、交卷三种状态的迁移规则考试是一个天然的状态机前后端分离下更需要后端做“真理来源”。很多项目把状态放在前端内存里一刷新全丢后端完全没有“考试是否有效”的判断能力。这里把状态迁移定死为三条规则作答中status0允许提交答案但必须校验当前时间是否超过 paper.duration。自动保存status0前端每 30 秒调一次保存接口后端只做“写入 Redis 暂存”落库不频繁。交卷status1一旦迁移到 status1任何写操作直接拒绝包括修改答案和重复交卷。代码上可以用一个枚举表示状态不需要引入复杂的状态机框架。关键校验在交卷接口里PostMapping(/exam/submit) public ResultVoid submit(RequestBody SubmitReq req) { PaperRecord record recordMapper .selectOne(new LambdaQueryWrapperPaperRecord() .eq(PaperRecord::getUserId, req.getUserId()) .eq(PaperRecord::getPaperId, req.getPaperId())); if (record.getStatus() 1) { throw new BizException(试卷已交卷不可重复提交); } // 时间兜底服务端时间为准而不是前端传的时长 if (System.currentTimeMillis() record.getEndTime()) { throw new BizException(考试已超时系统将自动交卷); } record.setAnswer(JSONObject.toJSONString(req.getAnswer())); record.setStatus(1); record.setSubmitTime(new Date()); recordMapper.updateById(record); return Result.ok(); }为什么后端必须存一个 endTime 而不是只靠 Redis 里的时长参数因为 Redis 可能会被清理。一旦缓存没了考生还在答题界面后端拿什么判断超没超时我在设计 record 表时加了 start_time 和 end_time 两个字段开考时写入。这样即使 Redis 挂了交卷请求也能基于数据库字段完成校验。3.3 判分接口与答案比对的三种边界情况自动判分是考试系统的收尾动作也是最容易“差不多的功能”糊弄过去的。判断单选和判断对错容易多选才会暴露问题多选是缺选不得分还是部分给分多选题的判分策略不同考试要求不一样应该做成策略可配置而不要写死在代码里。常用做法是peper 表的 scoring_strategy 字段存0 严格模式 / 1 部分给分。严格模式下答案必须完全一致部分模式下每选对一个选项得该题分值除以选项数。public int scoreQuestion(Question q, String userAnswer) { if (q.getType() 1) { return q.getAnswer().equals(userAnswer) ? q.getScore() : 0; } ListString right Arrays.asList(q.getAnswer().split(,)); ListString given Arrays.asList(userAnswer.split(,)); if (right.size() ! given.size()) { return 0; } return right.containsAll(given) ? q.getScore() : 0; }这个方法的逻辑是“先判长度再判包含”单选直接比对字符串多选先比对选项个数个数都不一致直接判 0 分避免出现 given[A,B]、right[A,B,C] 时 containsAll 误判为正确。第三种情况是判断题答案约定只存“T”或“F”业务上已经限死不会出现歧义。判分完成后把总分写入 record 表同时发一条 Spring 事件触发成绩通知。注意这里不建议用Async直接调异步方法因为异步方法和调用方在同一个类时不会走代理注解会失效。4. Vue3 前端工程登录页面、考试页面与 axios 拦截器联动4.1 项目初始化与 Vue 路由守卫前端用 Vue3 Vite Pinia组件库选择 Element Plus。初始化命令是 npm 生态最常见的一套这里按顺序给全npm create vitelatest exam-frontend -- --template vue cd exam-frontend npm install npm install axios vue-router4 pinia element-plus路由层面需要三层登录页/login、考试页/exam/:paperId、结果页/result/:recordId。考试页必须放在“登录后才能访问”的路由守卫里结果页不需要——因为结果页一般用 recordId 去查成绩记录本身是公开查询的不要再要求强制登录。前端路由守卫在router/index.js里写router.beforeEach((to, from, next) { const token localStorage.getItem(exam_token); if (to.meta.requiresAuth !token) { next(/login); } else { next(); } });这里不校验 token 是否过期判断依据是浏览器里有没有 token 字符串。token 是否有效由后端接口响应来兜底拿到 401 就跳登录这比前端解析 JWT 过期时间可靠。原因是前端解析 JWT 需要额外引入 jwt-decode 之类的库而且系统时间不准确时会导致“明明没过期却说过期了”。4.2 axios 请求拦截器token 注入与 401 跳转前后端分离项目里 token 处理做得好不好直接决定联调阶段疼不疼。拦截器要覆盖三个场景请求带 token、响应 401 自动跳登录、提交答案时防重复提交。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 { const res response.data; if (res.code 401) { localStorage.removeItem(exam_token); router.push(/login); return Promise.reject(new Error(res.msg)); } return res; }, error { if (error.response error.response.status 401) { localStorage.removeItem(exam_token); router.push(/login); } return Promise.reject(error); } );401 的拦截逻辑放在业务响应的 code 判断里同时也要放在 HTTP 错误回调里因为有的后端实现会把 401 放在 HTTP 状态码上有的放在响应体里。两处都写排错时少花时间。需要注意业务 code 判断和 HTTP 状态判断不要做成双重跳转否则会出现路由连续 push 两次的问题。4.3 考试页面实现倒计时、自动保存与本地暂存考试页是整个前端工程最需要“稳”的页面。三个核心能力倒计时、自动保存、答案暂存。倒计时的数据来源是接口返回的 endTime不是前端自己算 duration然后 new Date().getTime() 减出来的剩余毫秒。这样刷新页面后剩余时间不会重置。const state reactive({ answers: {}, endTime: null, remaining: 0 }); let timer null; function startCountdown() { timer setInterval(() { const now Date.now(); state.remaining Math.max(0, Math.floor((state.endTime - now) / 1000)); if (state.remaining 0) { clearInterval(timer); autoSubmit(); } }, 1000); }自动保存逻辑不能每次答一题就发一次请求会造成后端接口压力过大。常见做法是 30 秒节流用户答题时先把答案写进响应式 statesetInterval 每 30 秒把整个 answers 对象发给后端/exam/auto-save接口。用户连续答题过程中节流器不会与每次点击冲突。本地暂存还有一层意思是 localStorage 兜底每隔 1 分钟把 answers 序列化到 localStorage 的exam_draft_{userId}_{paperId}里。刷新页面时先读 localStorage 恢复答案再请求服务端暂存数据做合并。合并策略以服务端为准因为服务端的时间戳永远比本地可靠。5. 前后端联调难点跨域、token 过期续期与考试中途断网5.1 跨域配置后端允许与前端代理二选一前后端分离后跨域是第一道坎。生产环境推荐用 Nginx 反代解决把/api前缀的请求转发到后端服务开发环境用 Vite 的 proxy 配置解决后端完全不用开 CORS。// vite.config.js server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }这里注意 rewrite 的作用前端 axios baseURL 写/api代理转发到后端时把/api前缀去掉。如果后端接口本来就带/api前缀就不要 rewrite。前后端约定一个前缀比在代理层反复改路径要省心得多。开发环境不开 SpringBoot 的 CORS反而能逼着自己把路径规范和代理逻辑理顺。等到部署时用 Nginx 反代整个请求链路就是同源的不会出现“前端能调通、真机上不行”的跨域疑难杂症。5.2 断网重连后的状态恢复流程在线考试最怕的不是缩招是考到一半断网重连后学生发现自己做的题全丢了。这里的恢复链路要分三步走缺一步都会丢数据第一步前端在 axios 的 onLine 事件里监听window.addEventListener(online, ...)触发一次/exam/sync请求把 localStorage 的草稿数据推给后端。第二步后端接收草稿后以 Redis 中的 auto-save 数据为基准做合并合并逻辑是“按题号覆盖”——草稿里有的题号以草稿为准草稿没有的以 Redis 为准。第三步合并完成返回最新 answers 对象前端用这个对象整体替换本地 state。这套流程关键的参数是同步接口的幂等性。同一个草稿如果被网络重试推了两次后端不能存两份。设计上可以用recordId 题号做唯一索引或者 sync 接口每次全量覆盖对应 recordId 的 Redis key。我倾向于全量覆盖因为考试中的答题数据本来就是单用户单试卷不存在并发写同一份记录的合理场景。5.3 与在线考试系统匹配的参数表前端到后端有几组参数必须前后端完全一致不然联调时会浪费大量时间。这里给出完整的参数约定参数位置值域说明code响应体顶层0 成功 / 401 未认证 / 500 服务异常业务状态码exam_tokenlocalStorageJWT 字符串前端存储的 tokenAuthorization请求头Bearer xxx每次请求注入answers提交体JSON 对象题号为 key选项为 valuestatusrecord 对象0 考试中 / 1 已交卷状态机流转依据这里想强调 code 字段它必须放在响应体的最外层不能嵌在 data 里面。前端拦截器取res.code如果响应结构是{ data: { code: 0 } }拦截器就得再穿透一层而且将来扩展分页数据时结构会越改越乱。6. 前端反向代理与 Nginx 部署兼谈生产环境验证6.1 用 Nginx 把前后端揉到一个域名下生产部署最简单可靠的方式SpringBoot 打包成 jar 跑在 8080 端口Vue 构建出的 dist 目录交给 Nginx 托管。关键配置是 location /api 的反向代理规则server { listen 80; server_name exam.example.com; root /var/www/exam-frontend; 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 是 SPA 路由的核心Vue Router 使用 history 模式时刷新/exam/3这个地址Nginx 找不到对应的物理文件就回退到 index.html由前端路由接管。proxy_pass 后面的斜杠“/”和 location /api/ 的斜杠构成了一个精准的路径替换请求/api/auth/login转发到后端时变成http://127.0.0.1:8080/auth/login。这个行为等价于开发环境 Vite 的 rewrite两处保持一致前后端联调和生产行为就不会出现偏差。6.2 部署后做一轮边界验证部署完不能只点一遍“登录、做题、交卷”就收工线上考试系统的验证要覆盖三个边界场景第一刷新行为考试页面按 F5倒计时是否连续、答案是否保留。这验证了 localStorage 草稿和后端 auto-save 的合并链路。第二超时交卷把后端考试的 duration 临时改成 1 分钟考完一遍观察前端倒计时归零后是否触发自动提交后端是否接受这次提交。第三重复交卷交卷成功后手动用浏览器的 History 返回上一页再点一次交卷按钮服务端必须返回“试卷已交卷不可重复提交”而不是再存一条记录。这三个场景通过了这个考试系统的核心链路才算真正立住了。6.3 用 JMeter 做多学生同时交卷的压力验证很多在线考试系统单体功能都正常一上真实考场就慢问题往往不在接口本身而在数据库连接池和 Redis 连接数上。哪怕只有一百个学生同时交卷也会瞬间产生上百个写请求。压测时可以把线程数设在 200循环一次重点观察交卷接口的响应时间和错误率。常见的性能瓶颈有三个一是数据库连接池默认的 HikariCP 最大连接数是 10200 个并发写进来会排队二是判分循环里逐题查库没有把答案批量取出来三是 Redis 连接池不够每个答题请求都重新建立连接。压测前把 HikariCP 的 maximum-pool-size 临时调大到 50把判分接口改成select * from question where id in (...)一次性查出所有题目再跑一轮对比响应时间会有明显变化。jmeter -n -t exam_submit.jmx -l result.jtl -e -o report/这份压测命令生成 html 报告后重点看“响应时间第 90 百分位”和“错误率”两个指标。第 90 百分位超过 2 秒就要警惕错误率超过 0.1% 就要查日志。压测完了记得把连接池参数改回生产值而不是直接在压测配置上运行。本文还有配套的精品资源点击获取
返回列表