三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Spring Boot+Vue在线考试系统全栈开发:架构设计与核心功能实现

Spring Boot+Vue在线考试系统全栈开发:架构设计与核心功能实现

1. 项目概述与核心价值

最近几年,无论是高校的课程考核、企业的入职测评,还是各类资格认证,线上化的趋势越来越明显。传统的纸质考试或单机版考试软件,在组织效率、防作弊、阅卷速度和数据统计方面,已经难以满足大规模、高并发的需求。我手头这个“基于Spring Boot + Vue的在线考试系统”项目,就是在这个背景下,一个非常典型且实用的全栈开发实践。

简单来说,这个系统要解决的核心问题,就是让考试的组织、参与、评判和结果分析全部在线上完成。对于管理员,它能轻松创建试卷、管理题库、发布考试、监控过程并一键生成成绩报表;对于考生,它提供一个清晰、稳定、易用的答题界面,并能即时查看客观题成绩;对于教师或阅卷人,它则大大简化了主观题批阅和成绩复核的流程。整个系统采用前后端分离的架构,后端用Spring Boot构建RESTful API提供数据和服务,前端用Vue.js构建用户交互界面,两者通过HTTP协议进行清晰的数据交换。

选择Spring Boot + Vue这套技术栈,是经过深思熟虑的。Spring Boot以其“约定大于配置”的理念,能让我们快速搭建起一个健壮、安全、易于扩展的后端服务,它内嵌了Tomcat,简化了部署,并且拥有极其丰富的生态,像Spring Security用于权限控制、Spring Data JPA或MyBatis-Plus操作数据库、Redis做缓存和Session共享,都能无缝集成。而Vue.js作为渐进式前端框架,其响应式数据绑定和组件化开发思想,非常适合构建像考试系统这样交互复杂但逻辑清晰的单页面应用(SPA)。组件可以复用,比如一个“单选题组件”既可以用在练习模式,也可以用在正式考试中,开发效率和代码可维护性都很高。

这个项目适合有一定Java和JavaScript基础的开发者深入学习,尤其是那些想从“只会写增删改查”迈向“能独立负责一个完整业务模块”的进阶者。通过实现它,你不仅能巩固Spring Boot的控制器(Controller)、服务(Service)、数据访问层(DAO)的分层设计,理解JWT(JSON Web Token)无状态认证、接口防刷、定时任务等实战技巧,还能掌握Vue的路由管理(Vue Router)、状态管理(Vuex/Pinia)、以及如何与后端API进行优雅的交互(Axios)。接下来,我会把这个系统的设计与实现拆解开来,把每个环节的考量和实操细节讲透。

2. 系统整体架构与核心模块设计

一个在线考试系统,远不止是“前端展示题目,后端记录答案”那么简单。它需要应对高并发访问、保证数据一致性与事务安全、实现严格的权限隔离,并在用户体验和系统性能之间找到平衡。我们的整体架构遵循典型的前后端分离模式,但每一层都有其特定的职责和设计考量。

2.1 后端Spring Boot服务层设计

后端是整个系统的大脑和规则执行者。我采用经典的三层架构(Controller-Service-DAO)进行组织,但根据考试业务的特殊性,在服务层做了更细致的划分。

领域模型设计:这是数据库设计的蓝图,也是业务逻辑的载体。核心实体包括:

  • 用户(User):区分角色(管理员、教师、学生),通过角色关联权限。
  • 题库(Question):这是系统的基石。我设计了灵活的题目实体,包含题目类型(单选、多选、判断、填空、简答)、题干、选项(JSON格式存储)、正确答案、解析、难度系数、所属知识点分类等字段。特别是使用JSON存储选项和答案,为未来支持复杂题型(如包含图片的选项)留出了扩展空间。
  • 试卷(Paper):试卷是一个“容器”,它包含多个“试卷-题目”关联关系。试卷实体本身记录总分、考试时长、及格线、是否公开等元信息。试卷与题目的关联表(PaperQuestion)会记录本题在本试卷中的分值、顺序,有时甚至允许为同一题目在不同试卷中设置不同的分值。
  • 考试(Examination):这是核心的业务实体。它关联一份试卷(Paper)、一个考生范围(User或班级Group),并定义了考试的开始时间、结束时间、是否允许补考等规则。考试的状态(未开始、进行中、已结束、已归档)驱动着前端界面的变化和后端逻辑的判断。
  • 考试记录(ExamRecord):考生每进入一次考试,就会生成一条记录。它关联考试、考生,并记录本次考试的登录IP、设备信息、开始答题时间、交卷时间、最终得分、客观题得分、主观题得分以及考试状态(答题中、已交卷、超时强制提交等)。这是防作弊和考试过程追溯的关键。
  • 答题详情(AnswerDetail):每条考试记录下,对应每道试题的作答情况。记录考生选择的答案(或填写的文本)、本题得分、是否被标记为可疑(用于后期人工复核)等。

设计心得:在数据库设计阶段,一定要把“状态”字段考虑周全。比如ExaminationExamRecord的状态机,直接关系到业务逻辑的走向。我最初版本漏掉了“考试进行中但考生因断网导致记录异常”的状态,后来通过增加“异常中断”状态,并结合心跳检测机制才妥善处理。

2.2 前端Vue应用结构设计

前端应用采用Vue CLI创建,使用Vue Router管理路由,并采用Pinia(Vuex的替代方案,更简洁)进行全局状态管理。项目结构清晰划分:

  • src/api/:存放所有与后端交互的Axios请求封装,按模块划分文件(如exam.js,question.js)。
  • src/views/:页面级组件,对应不同的路由,如登录页、学生考试中心、教师管理后台、管理员仪表盘等。
  • src/components/:可复用的展示型组件,如QuestionCard.vue(题目卡片)、Timer.vue(考试倒计时)、PaperPreview.vue(试卷预览)。
  • src/stores/:Pinia状态仓库,例如userStore管理用户登录信息,examStore管理当前考试的状态(剩余时间、已答题目等)。
  • src/utils/:工具函数,如时间格式化、防抖节流、权限校验函数等。

路由守卫(Navigation Guards)是关键。在router/index.js中,我设置了全局前置守卫,用于:1) 检查用户是否登录(Token是否有效);2) 根据用户角色动态加载路由(实现权限菜单);3) 在进入考试页面时,校验考试是否已开始、是否已交卷、是否重复进入,防止直接通过URL绕过规则。

2.3 前后端交互与API设计规范

前后端通过RESTful API进行通信。所有API请求都集中在src/api/目录下,使用Axios实例(配置了基础URL、请求超时、请求/响应拦截器)发起。

请求拦截器:主要作用是在每次请求的Header中自动添加Authorization: Bearer ${token},实现无感认证。响应拦截器:统一处理HTTP状态码。例如,遇到401状态码,则清除本地Token并跳转到登录页;遇到403状态码,提示“权限不足”;遇到500状态码,展示友好的错误信息而非代码堆栈。

API设计遵循一些原则:

  1. 资源化GET /api/exams获取考试列表,POST /api/exams创建考试,GET /api/exams/{id}获取特定考试详情。
  2. 动作作为资源后缀:对于非CRUD操作,如“开始考试”,设计为POST /api/exams/{examId}/start;“提交答案”设计为POST /api/exam-records/{recordId}/answers
  3. 分页与过滤:列表接口必须支持分页(page,size)、排序(sortBy)和条件过滤(如?status=ongoing)。
  4. 响应格式统一:所有接口返回格式封装为{ code: number, message: string, data: any },方便前端统一处理。

3. 核心功能模块的详细实现

3.1 题库与试卷的动态管理

题库管理是基础,核心在于“灵活”和“批量”。除了基本的增删改查,我实现了两个实用功能:

  • Excel批量导入:提供模板下载,用户按格式填写题目信息后上传。后端使用Apache POI或EasyExcel解析Excel,逐行校验数据格式(如选项数量、答案格式),然后批量插入数据库。这里必须用事务管理,保证全部成功或全部失败。
  • 智能组卷:这是亮点功能。教师可以选择“手动组卷”或“自动组卷”。自动组卷时,需要设定参数:试卷总分、各题型数量、各难度题目比例、知识点分布。后端算法(一个简单的加权随机选择算法)会根据这些条件,从题库中筛选符合条件的题目,并计算分值,最终生成一份试卷草稿。算法会避免重复选取最近几次考试中用过的题目(需在Question实体上增加lastUsedTime字段)。

试卷发布后,其内容(即关联的题目)应被“冻结”。这意味着,即使管理员后来修改了题库中的原题,已发布试卷中的题目内容应保持不变。我的实现方式是:在创建考试时,将题目快照(包括题干、选项等完整信息)以JSON格式存入PaperQuestion表或一个单独的QuestionSnapshot表。这样保证了考试过程的公平性和可追溯性。

3.2 考试过程的实时性与防作弊策略

考试模块是系统的核心,稳定、公平、防作弊是重中之重。

考试时序控制

  1. 进入考试:考生点击“开始考试”,前端调用POST /api/exams/{id}/start。后端校验:考试是否在有效期内、考生是否有资格、是否已有未完成的记录(防止多开)。校验通过后,创建一条ExamRecord,状态为“进行中”,并向后端和前端返回考试剩余时间(根据考试结束时间计算)。
  2. 心跳与倒计时同步:前端启动一个定时器(例如每30秒),向后台发送心跳请求POST /api/exam-records/{recordId}/heartbeat。这个请求有两个作用:一是告诉服务器“考生还在线”,防止因网络波动导致会话超时被误判为离开;二是从服务器获取最新的剩余时间,校正前端可能因浏览器休眠或本地时钟不准产生的误差。
  3. 自动保存答案:为避免考生因意外丢失作答,需要自动保存。我为每个QuestionCard组件绑定输入监听,使用防抖函数(例如延迟1.5秒)后,将答案提交到POST /api/exam-records/{recordId}/answers/{questionId}接口。注意,这里保存的是答题详情,并非最终提交。后端只做更新操作。
  4. 交卷处理:交卷分为“主动交卷”和“超时交卷”。主动交卷由前端触发;超时交卷由后端定时任务扫描实现。交卷时,后端需要:a) 立即计算客观题(单选、多选、判断)得分;b) 将主观题(填空、简答)交由系统或等待教师批阅;c) 更新ExamRecord状态为“已批阅”或“待批阅”,并计算总分。

防作弊基础策略

  • 防止多标签/多窗口:在考试页面加载时,通过beforeunload事件监听页面关闭或刷新,给出警告。更严格的做法是,利用BroadcastChannelAPI或共享Worker,检测同一浏览器内是否打开了多个考试标签页。
  • 防止切屏:监听浏览器的visibilitychange事件。当页面切换到后台(如切屏到其他应用)时,记录切屏次数和时间。超过设定阈值(如3次或累计超过30秒),可以强制交卷或标记为作弊嫌疑。注意:此功能需谨慎使用,并应在考试开始前明确告知考生,因为浏览器兼容性和用户误操作(如弹出系统通知)可能触发误判。
  • 题目乱序与选项乱序:在向考生下发题目时,后端对题目列表和每个题目的选项列表进行随机乱序。每个考生拿到的顺序都不同,增加抄袭难度。乱序的种子可以基于用户ID+考试ID生成,保证同一考生每次进入顺序一致(防止刷新页面换顺序)。

3.3 实时通信与协同批阅

对于主观题批阅,如果支持多名教师协同批阅同一场考试,就需要一个机制来防止重复批改。我采用了一种基于数据库乐观锁的简单方案。

AnswerDetail表中,增加reviewerIdreviewStatus字段。当教师A开始批阅某道题时,执行一个更新操作:UPDATE answer_detail SET reviewer_id = #{teacherId}, review_status = 'reviewing' WHERE id = #{answerId} AND reviewer_id IS NULL。这个SQL利用了reviewer_id为NULL作为“未被领取”的判断条件。如果更新影响的行数为1,说明领取成功;如果为0,说明已被其他教师抢先领取。前端通过短轮询(每10秒)或WebSocket,从后端获取最新的批阅任务列表和状态,动态更新任务池的显示。

实操心得:WebSocket(如用Spring Boot的WebSocket STOMP或Netty)确实能提供更实时的体验,但对于批阅场景,短轮询的简单性往往更可控。关键在于批阅任务的粒度要细(按题分配),并且要有“任务释放”机制,即教师离开批阅页面或长时间无操作后,系统应自动将reviewerId置空,防止任务被长期占用。

4. 关键技术点与深度优化

4.1 基于JWT与Spring Security的无状态认证授权

系统采用JWT作为认证令牌,避免了服务端存储Session带来的扩展性问题。

  1. 登录与签发:用户登录成功,后端使用密钥(如HMAC SHA256)生成一个JWT,包含用户ID、角色、过期时间等声明(Claim),返回给前端。
  2. 前端存储:前端将JWT存储在localStoragesessionStorage中(考虑到考试系统安全性,可使用sessionStorage,关闭浏览器即失效)。
  3. 请求携带:Axios拦截器自动将JWT放入Authorization请求头。
  4. 后端校验:我编写了一个JWT认证过滤器(JwtAuthenticationFilter),继承自Spring Security的OncePerRequestFilter。它在UsernamePasswordAuthenticationFilter之前执行,从请求头中提取JWT,进行验签和解析,如果有效,则根据其中的用户信息,构造一个Authentication对象并放入SecurityContextHolder,这样后续的控制器就能通过@AuthenticationPrincipal注解获取当前用户。
  5. 权限控制:在方法级别使用@PreAuthorize(“hasRole(‘TEACHER’)”)@PreAuthorize(“hasAuthority(‘exam:create’)”)进行细粒度控制。这些权限数据可以在用户登录时,从数据库查询并放入JWT的自定义声明中,也可以每次请求时从数据库/缓存加载(后者更灵活,但开销稍大)。

安全加固

  • Token刷新:JWT过期时间不宜过长(如设为2小时)。同时提供/api/auth/refresh接口,用旧的但未过期的JWT换取一个新的JWT,实现“无感刷新”。
  • 黑名单:虽然JWT无状态,但为了支持“登出即失效”,可以维护一个短期的Token黑名单(存于Redis,过期时间与JWT本身一致)。登出时,将剩余有效期的Token加入黑名单。在认证过滤器中,除了校验JWT有效性,还需查询该Token是否在黑名单中。

4.2 高并发下的性能与一致性保障

考试开始和结束的瞬间,可能面临瞬时高并发请求。

  • 缓存应用:使用Redis作为缓存层。
    • 热点数据:如考试基本信息、题目快照(如果未冻结到表)。在考试开始时,将整场考试的数据结构(如Map<questionId, QuestionSnapshot>)缓存到Redis,键为exam:snapshot:{examId}。考生答题时,直接从缓存读取题目,减轻数据库压力。
    • 答题状态:考生每道题的答案可以先暂存到Redis(结构如Hash,键为exam:record:{recordId}:answers),并设置过期时间略长于考试时长。定时或交卷时,再将Redis中的数据批量持久化到MySQL。这既能应对频繁的自动保存请求,又能避免对数据库造成高频的写压力。
  • 异步处理:对于非实时强一致性的操作,采用异步。
    • 日志记录:考生的操作日志(如切屏事件、答案修改)可以发送到消息队列(如RabbitMQ/Kafka),由消费者异步写入数据库或日志文件。
    • 成绩计算与统计:交卷后,客观题评分可以同步完成,但复杂的成绩分析(如班级平均分、分数段分布、知识点掌握情况报表)可以发到消息队列,由后台任务异步生成,并通过WebSocket或轮询通知前端生成完成。
  • 数据库优化
    • 索引:在ExamRecord表的exam_iduser_id上建立复合索引,加速查询某个考试的所有记录或某个用户的所有记录。在AnswerDetail表的exam_record_idquestion_id上建立索引,加速按记录和题目查询。
    • 分库分表:如果数据量极大(例如百万级考试记录),可以考虑按年份或考试ID哈希对ExamRecordAnswerDetail进行水平分表。

4.3 前端体验优化与异常处理

  • 离线答题支持(进阶):考虑到网络不稳定的极端情况,可以使用浏览器的localStorageIndexedDB在本地保存答案草稿。前端定期尝试向后台同步,网络恢复后自动补传。这需要设计一套本地与远程数据的冲突解决机制(通常以服务器数据为准,或标记冲突由用户决定)。
  • 加载优化:对于一场包含大量题目的考试,不要一次性加载所有题目数据。可以采用分页加载或虚拟滚动。例如,先加载前20题,当用户滚动到接近底部时,再通过Intersection Observer API触发加载下一批。
  • 全局异常捕获:使用Vue的全局错误处理器Vue.config.errorHandler和Axios的响应拦截器,捕获所有未处理的错误。对于网络超时、断网等情况,在前端展示友好的提示(如“网络不稳定,正在尝试重连...”),而不是白屏或控制台报错。

5. 部署与运维实践

5.1 前后端分离部署

  • 前端部署:使用npm run build生成静态文件(dist目录),将其部署到Nginx或Apache等Web服务器上。Nginx配置需要将所有非静态文件的请求(即API请求)反向代理到后端服务,并配置try_files指令支持Vue Router的history模式。
server { listen 80; server_name exam.yourdomain.com; root /path/to/vue/dist; index index.html; location / { try_files $uri $uri/ /index.html; # 支持history模式 } location /api/ { proxy_pass http://backend-server:8080; # 代理到Spring Boot后端 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }
  • 后端部署:Spring Boot应用打包成可执行的JAR文件(java -jar exam-system.jar)。生产环境推荐使用Docker容器化部署,将应用、依赖和环境封装在一起,保证一致性。使用Docker Compose可以方便地编排后端应用、MySQL、Redis等服务。

5.2 数据库备份与监控

  • 备份:定期对MySQL数据库进行全量备份和增量备份。可以使用mysqldump命令结合cron定时任务,也可以使用云数据库的自动备份功能。备份文件应传输到异地存储。
  • 监控:使用Spring Boot Actuator暴露应用的健康、指标等信息,并通过Prometheus采集,用Grafana进行可视化监控。关键指标包括:应用服务的HTTP请求量、延迟、错误率;JVM内存和GC情况;数据库连接池使用率;Redis缓存命中率。设置告警规则,当服务异常或资源使用率过高时及时通知。

5.3 安全加固 Checklist

上线前,务必进行安全检查:

  1. 依赖安全扫描:使用OWASP Dependency-Check或GitHub的Dependabot扫描项目依赖,修复已知漏洞。
  2. API安全:确保所有敏感操作(如创建考试、修改成绩)的接口都经过了权限校验(@PreAuthorize)。对用户输入进行严格的校验和清理,防止SQL注入和XSS攻击。
  3. HTTPS:务必为生产环境域名配置SSL证书,启用HTTPS,保护数据传输安全。
  4. 密码安全:用户密码必须加盐哈希存储(使用BCryptPasswordEncoder)。
  5. 日志审计:记录关键操作日志(谁、在何时、做了什么),便于事后追溯。

6. 开发中常见问题与排查实录

在实际开发中,我遇到并解决了一些典型问题,这里记录下来供你参考。

问题现象可能原因排查步骤与解决方案
前端页面刷新后,登录状态丢失,跳回登录页。1. Token未持久化存储。
2. Token存储在localStorage,但路由守卫校验逻辑有误。
3. 后端Token校验失败(过期或无效)。
1. 检查登录成功后的Token存储逻辑,确认使用了localStorage.setItem
2. 在路由守卫的全局前置钩子中,打印当前Token和校验结果。检查校验逻辑是否在Token存在时,还去请求了用户信息接口,而该接口可能因Token过期返回401,导致被拦截器跳转。
3. 查看浏览器开发者工具的Network面板,看刷新后发出的第一个API请求是否携带了Token,以及后端返回的HTTP状态码。
考试倒计时结束后,页面没有自动交卷。1. 前端倒计时定时器被浏览器休眠或切屏暂停。
2. 向后端发送“超时交卷”请求失败。
3. 后端定时扫描任务未正常执行或执行出错。
1. 强化前端倒计时:使用Web Worker运行计时逻辑,或依赖后端心跳返回的服务器时间。
2. 在前端倒计时结束时,增加重试机制发送交卷请求,并做好网络异常的提示。
3. 检查后端定时任务(如使用@Scheduled注解)的日志,确认其是否按时触发,以及执行交卷逻辑时是否有异常抛出(如数据库死锁)。
多人同时组卷时,出现了题目重复被选中的情况。自动组卷的算法在并发环境下未加锁,导致多个请求在同一题库池中选取了同一道题。1.数据库悲观锁:在组卷事务开始时,SELECT ... FOR UPDATE锁定相关的题目记录集,性能损耗大,不推荐。
2.应用层分布式锁:使用Redis实现分布式锁。在组卷逻辑开始前,尝试获取一个以paper:generate:{criteriaHash}为键的锁,获取成功后再执行选题逻辑,完成后释放锁。这是更推荐的方案。
3.业务设计规避:将组卷任务队列化,同一时间只处理一个组卷请求,或者接受极小概率的重复,在最后一步进行去重校验。
教师批阅主观题时,看到一道题已被批阅,但分数未被统计。批阅和分数更新可能不是原子操作。教师提交分数后,更新AnswerDetail表成功,但更新ExamRecord总分的操作失败或未执行。1.事务管理:确保“更新本题得分”和“重新计算并更新考试记录总分”这两个操作在同一个数据库事务中。在Spring服务方法上添加@Transactional注解。
2.最终一致性补偿:如果事务拆分,可以增加一个后台补偿任务,定期扫描AnswerDetail表中review_status为‘已批阅’但is_calculated标记为false的记录,重新计算并更新总分。
使用JWT后,无法实现“强制下线”功能。JWT一旦签发,在有效期内始终有效,服务端无法主动使其失效。1.短期Token + 刷新机制:将Access Token有效期设短(如15分钟),同时提供Refresh Token(有效期较长,存于数据库或Redis)。强制下线时,将用户的Refresh Token禁用或删除。当Access Token过期后,用户无法通过Refresh Token获取新的,从而实现下线。
2.Token黑名单:如上文所述,将强制下线时未过期的Token ID加入黑名单(Redis),每次认证时检查。

最后一点个人体会:开发这样一个系统,最大的挑战往往不是某个具体的技术点,而是对复杂业务状态的管理和对异常情况的周全考虑。在编码之前,花足够的时间去设计清晰的状态流转图(例如考试从创建到归档的所有状态)、定义好各个实体之间的关系、想清楚边界情况(如网络中断、浏览器崩溃、时间同步),这些设计上的投入会在后期开发和调试中带来十倍的回报。另外,前后端开发者的沟通至关重要,一定要共同定义好每一处API的接口契约(请求/响应格式、状态码、错误信息),并用Swagger或Knife4j这样的工具生成在线文档,能极大减少联调时的摩擦。这个项目做下来,你会对如何构建一个完整、健壮的企业级Web应用有一个非常扎实的理解。

← 返回列表