
在线考试系统这个题目在计算机毕设里属于经典中的经典几乎每年都有一批人做但真正能讲清楚、做完整、答辩能圆场的并不多。很多同学走到最后发现功能看着都做了但一深问就露怯比如Redis怎么用的多角色权限怎么控制的试卷怎么组出来的。这篇文章我以毕设项目基于Spring Boot Vue的在线考试全流程管理平台为例从需求拆解到数据库设计从核心算法到权限模型再到前后端联调时的典型坑把整条链路完整过一遍。内容既面向正在做毕设的学生也适用于想快速搭一套考试系统做二次开发的朋友你会看到所有功能的取舍思路、代码层面的关键实现以及那些文档里通常不会写的实战细节。1. 项目定位与整体思路拆解1.1 为什么做在线考试系统在线考试这个场景其实一直处于人人都在用但用得都很痛苦的状态。学校学期末要组织统考培训机构要搞阶段测评企业要安排入职考核甚至是知识竞赛、员工认证本质上都是一套出题、组卷、发布、答题、判分、统计的流程。传统的线下考试方式人力成本高纸质试卷从出题、印刷、分发到回收、批改、登分环环都是时间黑洞。而在线考试系统要解决的就是三件事让考试不受场地限制、让组卷出分自动化、让成绩数据可追溯。选这个题目做毕设还有一个很实际的原因它覆盖的技术栈足够全栈。后端要处理接口设计、权限控制、事务、缓存、定时任务前端要处理路由权限、状态管理、富文本、倒计时、数据可视化数据库要设计出至少七八张有关联的表。这一套组合下来放在简历上或在答辩时讲技术亮点材料都非常充分。1.2 Spring Boot Vue 组合的本质逻辑为什么选Spring Boot配Vue而不是传统的JSP、或者Spring Boot直接渲染模板核心原因是这套组合代表了当前企业级Web开发的主流分工方式后端只负责提供RESTful API前端通过Ajax异步获取数据并渲染页面。前后端分离之后后端同学不需要操心页面渲染逻辑前端同学也不需要去碰Java代码两端可以并行开发联调通过接口文档来进行。Spring Boot在这个项目里的价值是开箱即用。内置的Tomcat、自动配置的MyBatis数据源、起步依赖Starter机制让开发者不用去纠结SSM时代那一堆XML配置。对于毕设项目来说这意味着可以把精力集中在业务代码上而不是环境搭建上。Vue这边选择Vue 2还是Vue 3我的建议是如果你对前端不算太熟Vue 2 Element UI的生态最稳网上资料最多几乎所有你遇到的坑都已经有人踩过并写了解法如果你时间充裕、想在前端环节体现一些新技术点可以上Vue 3 Element Plus Vite但要做好遇到插件兼容性问题的心理准备。1.3 MVP需求边界不做什么比做什么更重要很多人在启动项目时最大的问题是想要的太多题库管理、随机组卷、在线考试、自动判分、成绩分析、人脸识别防作弊……结果做到一半做不动了。毕设项目的正确打开方式是先把主流程跑通再往里面加亮点。我建议的核心功能边界如下图所示基础角色管理员后台管理、教师出题与阅卷、考生在线答题核心模块题库管理、试卷管理手动组卷 随机组卷、在线考试、自动/手动判分、成绩管理加分组件Redis防重复提交、断点续考、成绩导出Excel、简单的成绩统计图表人脸识别、视频监控这类功能对毕设来说风险太高第三方SDK的接入成本不可控不建议作为必做项。同理在线考试系统如果是万人同时在线的并发场景也不是毕设需要考虑的问题这部分可以在论文里作为未来展望来写反而显得考虑得全面。2. 核心模块设计与数据库建模2.1 多角色权限模型基于角色的访问控制多角色协同是这个题目里最关键的词。管理员、教师、考生三种角色如果只用前端判断路由显隐来实现权限区分那是假权限——因为接口其实是暴露的会点浏览器开发者工具的人都能直接调接口。所以必须用后端接口级权限控制。我这里用的是Spring Security JWT但如果你们学校对Spring Security不太熟悉也可以退而求其次用拦截器HandlerInterceptor配合自定义注解来实现。权限模型的表结构一般遵循RBACRole-Based Access Control标准涉及五张表user用户表存用户名、加密后的密码、昵称、头像role角色表存角色编码ADMIN/TEACHER/STUDENTmenu也叫permission权限表存可访问的接口或菜单路径user_role用户-角色关联表一个用户可以有多个角色role_menu角色-权限关联表一个角色关联多条权限用这种方式做的好处是以后想加一个监督员角色只需要往role_menu表里插数据不需要改代码。权限控制的核心思想就一句话请求进入Controller之前先解析JWT里的用户ID和角色集合再和目标接口要求的权限码做匹配匹配失败直接返回403。2.2 题库与试卷表设计一对多还是多对多题库表结构相对简单question_bank题库是一张主表里面的question_type字段区分单选题、多选题、判断题、简答题。答案字段的策略是单选题和判断题用单字符或单字符串存答案多选题用逗号分隔的字符串如A,C,D简答题直接把参考答案以文本形式存储判分时人工或关键词比对。这里有一个重要的设计决策题目表与试卷表是多对多关系。因为一道题可以被多张试卷引用一张试卷又包含多道题。如果直接在题目表里加paper_id字段那这道题就属于某一张试卷了下次再想把它放到别的试卷里就只能复制一份导致数据冗余和判分逻辑混乱。正确做法是中间表paper_question里面至少包含paper_id、question_id、question_score该题在此试卷中的分值、question_order题号顺序这几个字段。试卷表exam_paper本身要存的主要是试卷名称、总分、考试时长分钟、及格分数线、创建人ID。还有一个字段经常被忽略但很重要——is_random用来标记这张试卷是固定卷还是随机卷。随机卷的逻辑是教师设定好从哪个题库抽几道什么类型的题考生交卷时或者获取试卷时再动态去抽题这样每个考生拿到的题目顺序和内容都能不同。2.3 考试记录与成绩表的设计思路考试记录是整个系统里业务逻辑最复杂的部分它不只存一个几分的结果。我建议拆成两张表exam_record考试记录表存一次考试的基本信息用户ID、试卷ID、开始时间、交卷时间、总分、状态进行中/已交卷/已判分answer_record答题明细表存考生对每一道题的回答所属考试记录ID、题目ID、考生答案、得分、是否正确。为什么要拆两张表因为查看答题详情是刚需。考生交卷后不仅想看总分还要看每一道题自己选了什么、正确答案是什么、得分是多少。如果只有一张考试记录表根本存不下这些明细数据。此外校方经常要做某道题的正确率统计这时候只要对answer_record表按题目ID做聚合查询就能得出结果不需要去解析JSON文档。数据库主键建议直接用自增ID或者雪花算法ID。如果你在MyBatis-Plus里用了ASSIGN_ID策略要注意前端接收Long类型ID时的精度丢失问题——这个问题我在后面常见问题部分会专门说因为几乎每个做前后端分离的都会撞上。2.4 缓存与状态Redis在考试场景中的具体位置Redis不是必须组件但如果你想让项目有技术亮点Redis几乎是性价比最高的一个。在线考试系统中Redis能干三件事第一记录考试剩余时间。考生开始考试后把exam_record_id作为key剩余秒数作为value存到Redis设好过期时间。好处是如果考生中途断网重连或者换了一台设备服务端还知道他还剩多少时间而不是在Session里拿一份已经丢失的数据。第二防重复提交。考生交卷时同一个exam_record_id在短时间内多次调用交卷接口可以用Redis的SETNX命令实现一个简单的分布式锁防止重复计分。第三缓存热点数据。比如首页轮播图、课程公告或者是对简单题答案的快速匹配缓存。Redis和数据库之间的数据一致性不需要过度设计因为考试场景是读多写少且实时性要求不高的场景。你用Cache Aside模式就足够了先读缓存缓存没有则查数据库再回填更新时先更新数据库再删缓存。3. 核心业务流程与关键代码实现3.1 自动组卷算法不只靠随机数自动组卷是这类系统最容易被问到的核心算法。很多同学实现随机组卷就是ORDER BY RAND()一把梭这确实能用但存在两个问题一是当数据量大时性能会急剧下降二是组卷策略太粗糙——实际需求往往是单选题10道每题2分多选题5道每题3分判断题10道每题1分从题库中按难度均匀抽取。更合理的做法是按策略分步组卷。我先定义一个组卷策略对象包含题目类型、数量、难度分布。然后一步步查题库public ListQuestion generatePaper(PaperStrategy strategy) { ListQuestion questions new ArrayList(); // 按题目类型分组处理避免一次性加载全部题目 for (QuestionType type : QuestionType.values()) { int count strategy.getCountByType(type); ListQuestion pool questionMapper.selectByType(type); // 按难度分层简单题占30%中等题占50%困难题占20% MapInteger, ListQuestion byLevel pool.stream() .collect(Collectors.groupingBy(Question::getDifficulty)); for (Map.EntryInteger, ListQuestion entry : byLevel.entrySet()) { int target strategy.getDifficultyCount(type, entry.getKey()); Collections.shuffle(entry.getValue()); questions.addAll(entry.getValue().subList(0, Math.min(target, entry.getValue().size()))); } } return questions; }这种做法的核心思想是按比例 随机先分组再洗牌既保证了知识面和难度分布又体现了随机性。Collections.shuffle()用的是Fisher-Yates洗牌算法时间复杂度是O(n)性能天然没毛病。顺带一提如果面试或答辩被问到排序冒泡排序Java实现几乎是默认题但如果你想表现得比一般学生强一点可以说清楚为什么随机场景不用冒泡而用洗牌算法——前者是排序、后者是打乱解决的问题本质完全不同。3.2 在线考试流程从开始到交卷的完整链路一次完整的在线考试流程是考生点击开始考试→ 后端创建exam_record记录并写入进行中状态 → 后端根据试卷策略动态生成题目如果是随机卷 → 考生在页面逐题作答 → 前端持续做本地暂存→ 倒计时结束或主动点击交卷 → 后端接收答案列表批量写入answer_record表 → 调用判分逻辑 → 更新考试记录的总分和状态。这里有三个需要特别留意的状态处理考试中断考生意外关闭浏览器或断网他的exam_record还处于进行中状态但倒计时已经不在页面上了。我的方案是前端每次进入考试页先调用一个/exam/in-progress接口如果发现当前用户存在未完成的考试记录就弹出提示检测到未完成的考试是否继续后端同步检查Redis里剩余时间是否有效没过期则继续作答过期则强制交卷。手动交卷的二次确认交卷是不可逆操作所以前端至少做两层确认——点击交卷按钮后弹确认框确认框里还要显示你还有X道未作答提醒考生。后端再次验证JWT身份并校验该考试记录是否属于当前用户防止恶意传别人的ID。判分的一个小技巧单选题自动判分是完全确定的答案字符串相等但简答题不能这么做。简答题的参考实现是先把考生的答案和参考答案都做分词或关键词提取计算重合度给一个机器建议分再由教师在阅卷页面人工确认或修改。在毕设层面做到关键词匹配给出建议分 人工可改就已经很有诚意了。3.3 我的答案暂存方案localStorage还是Redis关于作答暂存网上很多方案是前端定时把答案打包往后端存但这对毕设来说过于复杂。我最终采用的是localStorage为主后端Redis兜底的方案。前端的逻辑很简单考生每切换一道题把当前页的答案对象写到localStorage里的一个key以exam_{recordId}命名后端每60秒同步一次。切换到下一题的时候读取本地暂存的答案填充。这样即使断网考生本地的做题痕迹也不会丢重连之后后台自动把本地数据推给后端。这个方案有一个很实际的体验优势考生在快速答题时不会因为一次网络抖动就丢失前面所有答案。很多在线考试系统体验差坏就坏在答案全存在内存里一刷新全部清空。说实话考试场景最让人崩溃的不是系统崩溃而是自己的答案莫名其妙没了。3.4 前端核心页面考试页的组件化设计前端页面里最核心的是考试页。考试页的布局一般是左侧固定题目导航区显示题号已答的题号高亮中间是题干和选项右侧或顶部是倒计时和交卷按钮。这个页面我用Vue组件拆成了三个子组件QuestionCard.vue题目卡片、QuestionNavigator.vue题号导航、ExamTimer.vue倒计时。组件之间的通信要注意父子组件传值可以用props和emit但题目列表和答题记录的共享状态在多级组件间传递会很麻烦。我的建议是引入Vuex或者Pinia把当前试卷的题目列表和答题记录Map作为全局状态管理。这样在QuestionCard里选择答案时直接commit一个setAnswer的mutation题号导航组件订阅答题记录Map就能实时刷新高亮状态不需要一层层向上抛事件逻辑清晰很多。路由权限方面前端用Vue Router的beforeEach导航守卫进入每个路由前从localStorage读取JWT通过store里的用户角色判断该用户是否可以进入该路由不行就重定向到登录页。这里要强调路由守卫只负责体验不负责安全真正拦截非法访问的必须是后端的接口权限校验。3.5 Web端PDF打印导出成绩单打印也是实际使用中的常见需求。Vue端实现PDF打印有两种路径一种是用html2canvas把DOM节点截图生成图片再嵌入到jspdf生成PDF另一种是直接用浏览器的window.print()打印整个页面配合CSS的media print样式。我建议成绩单这种简单场景直接用print方案把成绩页设成专门的打印路由打印时隐藏掉导航栏和按钮只保留表格区域。如果硬要用html2canvas要注意跨域图片会导致canvas被污染的问题另外截图生成的PDF是位图文字放大后会模糊。3.6 Web服务器开发与部署的环境差异开发环境的Web服务器就是Spring Boot内置的TomcatVue的开发服务器则是Vite或者Webpack DevServer默认端口分别是5173和8080注意冲突。联调时前端需要配置代理Vite里通过server.proxy把/api前缀的请求转发给后端接口地址避免出现跨域问题。部署环境就简单多了后端打包成jar运行前端先执行npm run build生成dist静态文件然后把这个静态文件目录挂载到Nginx下Nginx再把/api请求反向代理到后端的Tomcat。这一套下来对外就只要暴露一个80端口跨域也不存在了。这个部署模式我强烈建议掌握因为它不仅适用于考试系统几乎任何前后端分离的项目都是同一个套路。4. 常见问题与排查技巧实录4.1 前端Long类型精度丢失问题做前后端分离的项目几乎所有人的第一个大坑都是Java后端返回一个雪花ID前端拿到的值最后几位全变成了0。原因很简单雪花ID是19位数字超出了JavaScript的Number安全整数范围2^53 - 1。解决办法是在实体或VO的ID字段上使用JsonSerialize(using ToStringSerializer.class)或者将配置Jackson的toString序列化策略设为按字符串输出。后端全局处理更省心Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }这个坑不是考试系统独有但在操作exam_record_id这种字段时尤其致命——如果你在前端把ID传回去继续做交卷、查详情操作精度丢失后就会查不到记录或者更糟查到了别人的记录。4.2 考试时间一致性前端倒计时与后端截止时间考试时间的一致性是个隐蔽的坑。如果前端拿new Date()算结束时间用户把电脑时间改早几分钟倒计时就能偷时间。解决思路很简单以后端时间为准。考生开始考试时后端返回一个服务端的截止时间戳startTime duration前端只根据当前时刻和这个截止时间戳来计算剩余秒数。交卷接口同样要校验服务端时间是否已超时超时则取消提交逻辑按强制交卷处理。另一个细节是倒计时结束时自动交卷。前端倒计时归零时用setTimeout触发自动交卷请求但如果此时网络不稳定请求失败就会很尴尬。我的处理是时间归零后前端把所有答案打包存到本地立即发起交卷请求若失败则提示网络异常答案已保存请重试交卷同时后端兜底——考生下次调用进行中考试接口时发现超时会触发强制交卷。这样兜底逻辑就闭环了。4.3 Spring Security JWT在使用中的几个注意点Spring Security JWT是常见的权限组合但配置模板非常多稍不留神就会出错。最常见的坑是放行了登录接口但放行规则写错导致所有接口都放行或者反之把登录接口也拦截了。你需要理解这样一段核心配置的含义http.authorizeRequests() .antMatchers(/api/auth/**, /captcha, /static/**).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .antMatchers(/api/teacher/**).hasAnyRole(ADMIN, TEACHER) .anyRequest().authenticated();这里antMatchers优先级是从上到下的越具体的规则越要放在前面。另一个要点是前后端分离项目通常没有Session所以要在配置里关闭csrf并设置sessionCreationPolicy(SessionCreationPolicy.STATELESS)否则Spring Security默认会用Session来存储登录状态接口认证对不上号。JWT的过滤器链上要确保OncePerRequestFilter先于UsernamePasswordAuthenticationFilter执行把解析出来的用户信息放进SecurityContext里。这个环节你如果自己动手配一次比背十遍JWT源码都管用。4.4 高并发下的重复交卷问题重复交卷导致同一考生被记录两次成绩或者事务并发下成绩覆盖异常这是考试系统里逻辑上最严重的故障。好在解决思路很清晰利用数据库的唯一约束或Redis的分布式锁做幂等。我在交卷接口上做的处理是先用Redis执行SET exam:submit:{recordId} 1 NX EX 30拿到锁之后再执行判分和更新操作执行完毕删除锁。如果NX失败说明已有交卷请求在处理直接返回正在提交中请勿重复操作。数据库层面还可以给exam_record表增加一个submit_status字段通过UPDATE ... WHERE status ONGING的条件更新来保证并发安全。4.5 前端环境配置与Vue安装常见报错“vue安装及环境配置”类的热搜词很多说明大家在前端环境上确实容易出状况。如果是毕设项目环境问题越早解决越好。基本流程是先装Node.js建议LTS版本然后用npm install -g vue/cli安装脚手架或者用npm create vitelatest创建Vite项目。踩过的坑包括Node版本过高导致node-sass编译失败这种问题直接换成sassDart Sass就能解决npm install安装了某个依赖后环境崩溃直接node_modules目录和package-lock.json一起删掉再重新安装这招屡试不爽。还有一个很奇怪的报错failed to load tsconfig vue/tsconfig/tsconfig.web.json: tsconfig not found这通常出现在用Vite 5创建Vue 3 TypeScript项目时引用路径配错或者依赖没装全。解决方法也很直接先确认package.json里有vue/tsconfig这个devDependency没有就装一下有的话检查tsconfig.app.json里的references路径是否写对了。总之前端环境的排错思路不能靠背而是要能看懂报错指向的是配置路径还是依赖缺失。5. 工具选型与效率提升建议5.1 数据库设计器与逆向生成MyBatis-Plus代码生成器在动手写业务代码之前数据库表结构最好先设计完。我用的是Navicat直接建库建表然后用MyBatis-Plus的代码生成器AutoGenerator一次性生成实体类、Mapper接口、Service层以及Mapper XML。搜索热词里有个mybatisplus根据java实体类生成创建表的sql语句说明很多人在做反方向的事。MyBatis-Plus确实能根据实体类自动生成建表SQL但这更适合在已有代码的情况下快速同步数据库。更好的做法是在开发早期先设计表用SQL定义清楚字段和约束再建实体因为表结构决定了业务边界实体注解永远表达不了主外键关系、唯一索引和联合索引的完整性。5.2 接口文档与Mock数据前后端并行不等待前后端分离的团队里前端经常被后端卡脖子——后端接口还没写好前端就没法联调。解决思路是用SwaggerSpringDoc自动生成接口文档后端把接口方法定义好、返回结构定好前端照着文档先Mock数据开发。不想用Swagger的同学也可以在Controller层先返回假的JSON数据硬编码一个Result对象等逻辑实现后再替换。核心原则是接口契约先行字段名和嵌套结构一旦确定两边尽量都不要改改动成本会随着项目推进会成倍放大。5.3 版本管理Git不只是用来交代码毕设项目一定要用Git做版本管理这不仅是代码安全的需要更是研发习惯的体现。我通常的建议是本地建一个dev分支和main分支main始终保证是可运行状态功能没做完不要往main上合并。每次实现一个模块再提交一次提交信息写清楚完成了什么而不只是update。这样到答辩前如果老师想看你的开发过程你可以直接把Git提交记录展示出来比口头说我做了很多工作有说服力得多。6. 写在最后的一些心里话在线考试系统这个项目我前前后后带过几个同学完整做过自己也重构过两版最大的感受是它不是一个学会了再动手的项目而是一个边做边学的项目。一开始不要想着一口气把所有表设计得尽善尽美也不要指望照着网上的开源项目敲一遍代码就能懂。更建议的做法是先画出最核心的开始考试—答题—交卷—判分—看成绩这条链路然后每一天都在这个链路上加一个环节比如今天解决答案暂存明天解决Redis防重复提交后天解决Excel导出。每解决一个具体问题你对整个系统的理解都会上一个台阶。如果在做的过程中卡住了调试时记着从日志入手后端看打印的SQL语句和异常堆栈前端看Network面板的状态码和响应体。大部分所谓系统有Bug说白了不过是你还没有把日志打全。这个项目做完你不仅会收获一个能通过答辩的系统还会顺手掌握一套进阶Web开发的基本方法和排查问题的思路这对后面的工作和学习比那一个优秀毕设的评定有用得多。