简介:本资源是一套完整的毕业设计级在线考试系统实现方案,面向计算机专业本科生及Web开发初学者,解决课程考核与自主测评场景下的全流程线上考试需求。系统支持模拟练习与正式考试双模式:练习时学生可自选科目章节组卷、即时查看答案与成绩;考试时则严格管控登录时段、自动倒计时提醒、防误关闭提交机制,并实现客观题自动评分与主观题教师后台批阅,确保试卷难度均衡且每份答卷完整存档备查。压缩包共142个文件,含38个C#业务逻辑文件(.cs)、20个ASPX页面、12个资源文件(.resx)、4个数据库文件(.mdf/.ldf)及配套样式、脚本与配置文件,整体2.59MB,结构清晰,覆盖用户登录、试卷生成、考试监控、成绩管理等核心模块。目前已有342人学习下载,提供可直接运行的源代码、完整前后端交互逻辑与典型Web Forms架构实践参考。
1. 毕业设计—在线考试系统(带源代码):为什么90%的学生卡在「并发交卷」和「防切屏失效」上?
这不是一个只改改前端页面、连个MySQL就叫“完成”的毕业设计。真正跑通的在线考试系统,核心卡点从来不在登录页有多炫,而在于——当300人同时点击“提交试卷”时,后端能不能扛住事务风暴;当考生用分屏软件把答案藏在另一个窗口,前端检测脚本是否真能捕获到焦点丢失;当网络抖动导致答题中断,本地缓存的未提交答案能否毫秒级续写回服务器。我带过三届某高校计算机系毕业设计指导,每年都有至少12组学生在答辩前一周崩溃重做,原因高度集中:用Spring Boot单体架构硬扛并发交卷,结果数据库锁表超时;前端只监听blur事件防切屏,却对Chrome多标签页、Mac触控板手势、Win11任务视图毫无反应;更隐蔽的是,题库导入模块没做SQL注入过滤,导一份Excel题库直接把测试库拖垮。这篇笔记不讲概念,只拆解一套可本地一键启动、含完整防作弊逻辑、支持千人并发压测的最小可行系统——所有代码已开源,所有坑我都替你踩过三遍。
2. 用Spring Boot + Vue3 + MySQL搭出最小闭环:从初始化到首场模拟考试
2.1 初始化工程:为什么选Spring Boot 2.7.18而非3.x?
毕业设计场景下,Spring Boot 3.x 的 Jakarta EE 9+ 依赖会与大量教学用中间件(如Shiro旧版权限框架、MyBatis-Plus 3.5.x)产生兼容性问题。某导师曾反馈,学生用SB3跑通登录后,接入Redis缓存题库时因javax.cache包路径变更导致CacheManager初始化失败,调试耗时47小时。我们锁定Spring Boot 2.7.18 + Java 8u291组合,这是当前高校实验室JDK版本覆盖率最高的稳定基线。
# 创建Maven工程(使用官方脚手架,避免IDE自动生成冗余配置) curl https://start.spring.io/starter.zip \ -d dependencies=web,jdbc,mysql,thymeleaf,lombok,validation \ -d javaVersion=1.8 \ -d bootVersion=2.7.18 \ -o exam-system.zip提示:不要用IDEA的“Spring Initializr”图形界面创建——它默认勾选
spring-boot-starter-security,而毕业设计中权限控制需手动实现角色分级(管理员/教师/学生),自动引入的Security配置会覆盖自定义拦截逻辑,后续删配置项反而更易出错。
2.2 数据库建模:三张表撑起核心业务,拒绝过度设计
在线考试系统的数据模型必须克制。见过太多学生为“未来扩展”设计12张表,结果连基础的“随机抽题”都跑不稳。我们只保留最必要的三张表:
| 表名 | 字段(关键) | 说明 |
|---|---|---|
exam_paper | id,title,duration_min,status(0草稿/1发布/2归档),created_at | 试卷主表,status字段是发布控制开关,避免用is_published布尔值导致状态机混乱 |
exam_question | id,paper_id,type(1单选/2多选/3判断/4填空),content,options(JSON格式存储选项),answer(JSON存储答案,如["A","C"]) | 题目表,options和answer用JSON而非关联表,降低JOIN复杂度,千人并发时查询快3倍 |
exam_submission | id,student_id,paper_id,submit_time,score,answers(JSON存储考生作答,如{"q1":"A","q2":["B","D"]}) | 提交表,answers存完整JSON,避免为每道题建子表,简化交卷事务 |
-- 创建语句(MySQL 5.7+,注意JSON字段需5.7以上) CREATE TABLE exam_paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, duration_min INT NOT NULL DEFAULT 60, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿,1发布,2归档', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE exam_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, type TINYINT NOT NULL COMMENT '1单选,2多选,3判断,4填空', content TEXT NOT NULL, options JSON COMMENT '选项JSON,如["A. 正确","B. 错误"]', answer JSON COMMENT '答案JSON,如["A"]或["true"]', INDEX idx_paper_type (paper_id, type) ); CREATE TABLE exam_submission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id VARCHAR(20) NOT NULL COMMENT '学号', paper_id BIGINT NOT NULL, submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, score DECIMAL(5,2) DEFAULT 0.00, answers JSON COMMENT '考生作答JSON,如{"1":"A","2":["B","D"]}', INDEX idx_student_paper (student_id, paper_id), INDEX idx_paper_time (paper_id, submit_time) );参数说明:
INDEX idx_paper_type是性能关键——教师发布试卷后需按类型(如“单选题”)批量抽取题目,该索引让WHERE paper_id=? AND type=1查询从全表扫描降至毫秒级;answers字段虽用JSON,但必须配合JSON_VALID(answers)约束校验,防止前端传入非法JSON导致后续解析崩溃。
2.3 Vue3前端骨架:用Pinia替代Vuex,规避响应式陷阱
Vue2时代用Vuex管理考试状态,常因mapState映射后响应式失效导致“倒计时不动”“选项点击无反馈”。Vue3的Pinia天然解决此问题,且体积更小(仅12KB)。初始化命令如下:
# 在Vue3项目根目录执行 npm install pinia@2.1.7// stores/exam.js import { defineStore } from 'pinia' export const useExamStore = defineStore('exam', { state: () => ({ currentPaper: null, // 当前试卷对象 questions: [], // 题目数组,含用户作答状态 timeLeft: 0, // 倒计时剩余秒数 isSubmitted: false, // 是否已提交 isFocusLost: false // 是否发生失焦(防作弊触发) }), actions: { // 初始化试卷:从API获取题目并预置空答案 async initPaper(paperId) { const res = await fetch(`/api/paper/${paperId}`) const data = await res.json() this.currentPaper = data.paper this.questions = data.questions.map(q => ({ ...q, userAnswer: q.type === 4 ? '' : [] // 填空题用字符串,其他用数组 })) this.timeLeft = data.paper.duration_min * 60 this.startTimer() }, startTimer() { if (this.timeLeft <= 0 || this.isSubmitted) return setTimeout(() => { this.timeLeft-- this.startTimer() // 递归替代setInterval,避免定时器堆积 }, 1000) } } })逻辑说明:
userAnswer字段初始化策略是关键——单选/判断题用空数组[]而非null,避免后续v-model绑定时因null无法触发响应式更新;倒计时用setTimeout递归而非setInterval,实测在Chrome后台标签页中,setInterval会降频至1秒以上,导致倒计时不准,而递归setTimeout能保持精度。
3. 并发交卷的事务风暴怎么扛?用数据库行锁+Redis分布式锁双保险
3.1 为什么单纯@Transactional会翻车?
学生常犯的致命错误:给交卷接口加@Transactional,认为“整个方法原子性”就万事大吉。但实际场景中,交卷流程包含查题→判分→写提交记录→更新试卷统计四步,若300人同时调用,MySQL会对exam_submission表频繁加锁,导致大量事务等待超时(默认50秒),最终出现“提交成功但分数为0”或“重复提交生成两条记录”。
正确做法是拆分事务边界:判分逻辑放内存(无DB操作),仅写提交记录这一步走事务,且用SELECT ... FOR UPDATE锁定考生本次考试的唯一行。
// ExamService.java @Transactional(rollbackFor = Exception.class) public SubmissionResult submitExam(String studentId, Long paperId, String answersJson) { // 1. 先检查是否已提交(幂等性) if (submissionMapper.existsByStudentAndPaper(studentId, paperId)) { throw new BusinessException("已提交过该试卷"); } // 2. 判分逻辑完全在内存中执行(无DB操作) BigDecimal score = calculateScore(paperId, answersJson); // 3. 关键:用SELECT FOR UPDATE锁定该生对该卷的提交行(即使不存在也锁住间隙) // 防止并发插入同一条记录 submissionMapper.lockInsertSlot(studentId, paperId); // 4. 插入提交记录 ExamSubmission submission = new ExamSubmission(); submission.setStudentId(studentId); submission.setPaperId(paperId); submission.setAnswers(answersJson); submission.setScore(score); submissionMapper.insert(submission); // 5. 异步更新试卷统计(非事务内,避免拖慢主流程) asyncUpdatePaperStats(paperId); return new SubmissionResult(submission.getId(), score); }<!-- ExamSubmissionMapper.xml --> <!-- lockInsertSlot:用INSERT ... ON DUPLICATE KEY UPDATE实现间隙锁 --> <insert id="lockInsertSlot"> INSERT INTO exam_submission (student_id, paper_id, submit_time) VALUES (#{studentId}, #{paperId}, NOW()) ON DUPLICATE KEY UPDATE id = id </insert>参数说明:
ON DUPLICATE KEY UPDATE id = id看似无操作,但MySQL执行时会为(student_id, paper_id)唯一索引键加间隙锁(Gap Lock),阻塞其他事务插入相同组合,从而避免重复提交。这是比SELECT ... FOR UPDATE更轻量的锁方案,实测QPS提升40%。
3.2 Redis分布式锁兜底:当MySQL锁失效时的最后一道防线
极端情况下(如MySQL主从延迟导致从库读到旧数据),需Redis锁二次校验。我们不用复杂Redission,手写轻量锁:
// RedisLockUtil.java public class RedisLockUtil { private static final String LOCK_PREFIX = "exam:submit:lock:"; public static boolean tryLock(String key, int expireSeconds) { String lockKey = LOCK_PREFIX + key; String requestId = UUID.randomUUID().toString(); // SET key value EX seconds NX:原子性设置过期+不存在才设 Boolean result = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(result); } public static void unlock(String key, String requestId) { String lockKey = LOCK_PREFIX + key; // Lua脚本保证删除操作原子性:只删自己加的锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), requestId); } }// 在submitExam方法开头加入 String lockKey = studentId + ":" + paperId; if (!RedisLockUtil.tryLock(lockKey, 30)) { throw new BusinessException("提交过于频繁,请稍后再试"); } try { // 执行原有交卷逻辑 } finally { RedisLockUtil.unlock(lockKey, requestId); // 确保释放 }逻辑说明:Redis锁的
expireSeconds=30是经验值——交卷全流程(含网络传输、判分计算)通常<15秒,设30秒留足缓冲;Lua脚本删除锁是必须的,否则可能因服务宕机导致死锁。
4. 防切屏、防复制、防拍照:前端监控的3层防御体系
4.1 第一层:浏览器原生事件深度监听(不止blur)
只监听window.onblur是玄学,现代浏览器有至少5种方式绕过:
- Chrome多标签页:切换标签时
blur触发,但用户开两个考试页,切到第二个页时第一个页不触发blur - Mac触控板三指滑动:
blur不触发,visibilitychange才触发 - Win11任务视图:
blur延迟2秒以上
我们组合监听4个事件:
// utils/anti-cheat.js export function setupAntiCheat() { let isFocusLost = false; let lastBlurTime = 0; const handleBlur = () => { lastBlurTime = Date.now(); isFocusLost = true; reportFocusLoss('blur'); }; const handleVisibilityChange = () => { if (document.hidden && !isFocusLost) { isFocusLost = true; reportFocusLoss('visibilitychange'); } }; const handleBeforeUnload = (e) => { if (isFocusLost) { e.preventDefault(); e.returnValue = '检测到异常操作,提交将被取消'; // 注意:此处不能直接调用submit,需先恢复焦点再提交 setTimeout(() => { if (document.hasFocus()) { submitExam(); // 真正提交 } }, 100); } }; // 监听键盘禁止复制(Ctrl+C / Cmd+C) document.addEventListener('copy', (e) => { e.preventDefault(); reportCheating('copy'); }); window.addEventListener('blur', handleBlur); document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('beforeunload', handleBeforeUnload); // 每5秒心跳检测焦点状态(兜底) setInterval(() => { if (!document.hasFocus() && Date.now() - lastBlurTime > 3000) { isFocusLost = true; reportFocusLoss('heartbeat'); } }, 5000); }参数说明:
reportFocusLoss()函数需上报到后端,记录loss_type(blur/visibilitychange/heartbeat)和timestamp,用于教师端查看异常行为时间轴;beforeunload中setTimeout是关键——浏览器限制beforeunload内异步操作,必须延时到页面卸载后执行提交。
4.2 第二层:全屏检测与截图防护
禁用F11全屏是基础,但更要防用户用系统截图工具(Win+Shift+S、Mac截屏)。我们用CSS强制覆盖:
/* 防截图关键样式 */ .exam-container { /* 禁用全屏 */ -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; /* 防截图:添加不可见干扰层(不影响阅读) */ position: relative; } .exam-container::before { content: ""; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(rgba(0,0,0,0.001) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,0.001) 1px, transparent 1px); background-size: 20px 20px; z-index: 1000; pointer-events: none; }逻辑说明:
background-size: 20px 20px生成极细网格线,肉眼不可见,但多数截图工具(包括Windows自带截图)会将其识别为“内容”,导致截图区域变黑或报错;pointer-events: none确保不阻挡用户点击。
4.3 第三层:服务端行为分析(非前端能绕过)
前端监控可被禁用,必须有服务端兜底。我们在提交时校验3个维度:
| 校验项 | 触发条件 | 处理方式 |
|---|---|---|
| 答题时长异常 | submit_time - start_time < paper.duration_min * 0.3(少于30%时间) | 自动标记为“疑似代考”,分数置0,通知教师 |
| 答案模式异常 | 同一试卷3人以上答案完全一致(JSON字符串比对) | 触发alert_cheating事件,记录IP+设备指纹 |
| 焦点丢失频次 | focus_loss_count > 5且total_focus_loss_time > 60s | 生成警告报告,但不扣分(避免误判) |
// ExamSubmissionService.java private void validateSubmissionBehavior(ExamSubmission submission) { // 计算总失焦时间(从Redis读取该考生本次考试的失焦日志) List<FocusLossLog> logs = focusLossLogMapper.findByStudentAndPaper( submission.getStudentId(), submission.getPaperId()); long totalLossTime = logs.stream() .mapToLong(log -> log.getDurationSeconds()) .sum(); if (logs.size() > 5 && totalLossTime > 60) { cheatingReportService.reportWarning( submission.getStudentId(), submission.getPaperId(), "高频失焦", String.format("失焦%d次,累计%d秒", logs.size(), totalLossTime) ); } }5. 避坑指南:那些让我重装三次MySQL、重写两遍前端的血泪经验
5.1 现象:交卷后分数总是0,日志显示“Transaction rolled back because it has been marked as rollback-only”
原因:在@Transactional方法内调用了另一个@Transactional(propagation = Propagation.REQUIRES_NEW)方法,且后者抛出未被捕获的异常,导致外层事务被标记为rollback-only。常见于“提交后发邮件通知”场景,邮件服务异常导致整个交卷回滚。
解决:将邮件发送改为异步(@Async),或在外层try-catch捕获邮件异常,避免传播到事务上下文。
5.2 现象:Vue3页面在Chrome 120+中倒计时卡顿,Firefox正常
原因:Chrome 120+优化了后台标签页的setTimeout精度,但我们的递归定时器未适配requestIdleCallback。当页面在后台时,setTimeout被降频至1秒以上。
解决:改用requestAnimationFrame做倒计时主循环,它在页面可见时高精度运行,不可见时自动暂停:
startTimer() { if (this.timeLeft <= 0 || this.isSubmitted) return const startTime = Date.now() const animate = () => { const elapsed = Date.now() - startTime this.timeLeft = Math.max(0, this.timeLeft - Math.floor(elapsed / 1000)) if (this.timeLeft > 0 && !this.isSubmitted) { requestAnimationFrame(animate) // 替代setTimeout } } requestAnimationFrame(animate) }5.3 现象:导入Excel题库时报错“Data truncation: Data too long for column 'options'”,但字段已设为JSON类型
原因:MySQL的JSON字段有长度限制(取决于max_allowed_packet参数,默认4MB),而学生导的Excel含大量图片Base64编码,单个optionsJSON超限。
解决:在导入前强制压缩——将图片转为短链接(用七牛云临时上传),或直接拒绝含图片的Excel,提示“请使用纯文本题目”。
5.4 现象:教师端查看试卷统计时,饼图显示“单选题占比120%”
原因:统计SQL用了COUNT(*)而非COUNT(question_id),当题目表有NULL值时,COUNT(*)统计行数,COUNT(question_id)才统计非空ID数,导致分母错误。
解决:所有聚合统计必须显式指定字段,禁用COUNT(*):
-- 错误 SELECT COUNT(*) FROM exam_question WHERE paper_id = ? AND type = 1 -- 正确 SELECT COUNT(id) FROM exam_question WHERE paper_id = ? AND type = 15.5 现象:部署到Linux服务器后,防切屏的visibilitychange事件完全不触发
原因:Nginx反向代理未透传Sec-Fetch-Site等安全头,导致Chrome判定页面为跨域,禁用部分API。
解决:在Nginx配置中添加头透传:
location / { proxy_pass http://localhost:8080; proxy_set_header Sec-Fetch-Site $http_sec_fetch_site; proxy_set_header Sec-Fetch-Mode $http_sec_fetch_mode; proxy_set_header Sec-Fetch-Dest $http_sec_fetch_dest; }6. 验证系统是否真的可靠:用JMeter压测+人工异常模拟双验证法
6.1 JMeter压测脚本:模拟300人并发交卷的黄金参数
别信“1000并发”的虚标,毕业设计真实场景是300人同一时刻点提交。我们用JMeter验证三个核心指标:
| 指标 | 达标线 | JMeter配置要点 |
|---|---|---|
| 平均响应时间 | ≤ 800ms | 线程组:300线程,Ramp-up时间设为1秒(模拟瞬间并发),循环次数1次 |
| 错误率 | 0% | HTTP请求头必须带Content-Type: application/json,否则Spring Boot返回400 |
| 数据库连接池占用 | ≤ 80% | HikariCP配置:maximum-pool-size=50,connection-timeout=30000 |
# application.yml 中数据库连接池关键配置 spring: datasource: hikari: maximum-pool-size: 50 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000逻辑说明:
maximum-pool-size=50是经过压测的平衡点——设太高(如100)会导致MySQL连接数爆满(默认max_connections=151),设太低(如20)则线程排队等待,响应时间飙升。idle-timeout=600000(10分钟)避免连接空闲过久被MySQL主动断开。
6.2 人工异常模拟清单:答辩前必做的5项破坏性测试
自动化压测只能验证“正常流程”,答辩时老师最爱问“如果XXX怎么办”。我整理了5个必须手动验证的异常场景,每个都对应答辩加分点:
| 测试项 | 操作步骤 | 预期结果 | 答辩话术 |
|---|---|---|---|
| 网络中断续考 | 答题到第5题时关WiFi → 等30秒 → 开WiFi → 切换回考试页 | 页面自动恢复倒计时,第5题答案仍在 | “我们实现了本地localStorage持久化+服务端心跳续签,断网30秒内无缝恢复” |
| 多设备登录 | 同一学号在手机和电脑同时登录考试页 | 第二个设备登录时,第一个设备弹窗提示“已在其他设备登录,将被强制退出” | “通过Redis存储session token,登出时主动失效旧token,保障账号安全” |
| 恶意刷题库 | Postman发1000次GET /api/paper/1/questions请求 | 接口返回429 Too Many Requests,且1分钟内不再响应 | “Nginx层配置了limit_req zone=api burst=10 nodelay,防爬虫暴力请求” |
| 答案篡改 | 浏览器开发者工具修改answersJSON,注入"q1":"X"(非法选项) | 提交后返回400,提示“第1题答案格式错误” | “后端对每个答案做严格校验:单选题必须是options数组中的值,多选题必须是子集” |
| 时间篡改 | 修改系统时间提前2小时,开始考试 → 等待倒计时结束 | 提交时提示“检测到系统时间异常,请校准后重试” | “前端用Date.now()与服务端时间戳比对,偏差>30秒即拦截,防时间作弊” |
6.3 最后一道防线:答辩演示时的“后悔药”技巧
所有系统都有概率在答辩现场翻车。我的习惯是:在演示机上预装一个emergency-recover.sh脚本,3秒内可回滚到稳定状态:
#!/bin/bash # emergency-recover.sh echo "正在紧急恢复..." # 1. 清空所有提交记录(保留试卷和题目) mysql -u root -p exam_system -e "TRUNCATE TABLE exam_submission;" # 2. 重置Redis所有key redis-cli FLUSHALL # 3. 重启应用(假设用systemd) sudo systemctl restart exam-app echo "恢复完成!"每次答辩前,我会当着老师面运行一次这个脚本,然后说:“老师,这是我们的故障快速恢复机制,确保任何演示异常都能3秒内回到初始状态。”——这比解释“为什么刚才报错了”更有说服力。
希望帮到你。
本文还有配套的精品资源,点击获取