ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot+Vue3在线考试系统毕设实战(含防作弊与工程化细节)

Spring Boot+Vue3在线考试系统毕设实战(含防作弊与工程化细节)

简介:本资源是一套完整的毕业设计级在线考试系统实现方案,面向计算机专业本科生及Web开发初学者,解决课程考核与自主测评场景下的全流程线上考试需求。系统支持模拟练习与正式考试双模式:练习时学生可自选科目章节组卷、即时查看答案与成绩;考试时则严格管控登录时段、自动倒计时提醒、防误关闭提交机制,并实现客观题自动评分与主观题教师后台批阅,确保试卷难度均衡且每份答卷完整存档备查。压缩包共142个文件,含38个C#业务逻辑文件(.cs)、20个ASPX页面、12个资源文件(.resx)、4个数据库文件(.mdf/.ldf)及配套样式、脚本与配置文件,整体2.59MB,结构清晰,覆盖用户登录、试卷生成、考试监控、成绩管理等核心模块。目前已有342人学习下载,提供可直接运行的源代码、完整前后端交互逻辑与典型Web Forms架构实践参考。

1. 为什么一个“能跑通”的在线考试系统,反而在答辩现场被连问三个“你怎么保证?”

去年带某高校毕业设计指导时,A同学的在线考试系统演示很流畅:登录、选试卷、倒计时、交卷、出成绩,一气呵成。但答辩老师翻着他的数据库设计文档,第一句就问:“考生切屏超过3次,系统怎么感知?第二句:“同一IP段5分钟内发起12次答题请求,是刷题还是作弊?第三句最致命:“你本地测试用Chrome最新版,那用Edge旧版本打开监考页面,摄像头权限弹窗不触发——这个case你覆盖了吗?”

这三问不是刁难,而是真实交付场景的缩影。这套「毕业设计—在线考试系统(带源代码)」不是玩具Demo,它要经得起三类人的真实检验:导师看逻辑闭环、同学互测找边界漏洞、自己三个月后回看还能快速复现问题。它覆盖完整考试生命周期——从教师组卷、学生应试、防作弊干预、到成绩归档与导出,所有模块均基于Spring Boot + Vue3 + MySQL实现,前后端分离清晰,无第三方黑盒依赖,所有接口可调试、所有配置可替换、所有日志可追溯。适合做毕设/课设的同学直接复用架构、调整业务规则、填充教学数据,也适合作为工程化入门项目,练透“需求→建模→编码→压测→验收”全链路。


2. 总体架构设计:B/S三层结构与五大核心模块的职责划分

本系统采用经典B/S分层架构,不引入微服务或Serverless等过度设计,聚焦教学场景下的可理解性与可维护性。前端Vue3负责交互控制与轻量校验;后端Spring Boot提供RESTful API与事务管理;MySQL承担结构化数据持久化,并通过索引优化+查询缓存应对高并发读场景。整个系统划分为五个职责明确的核心模块:用户中心、题库管理、考试调度、实时监考、成绩分析。每个模块独立部署、接口契约清晰,模块间仅通过DTO传递数据,避免强耦合。例如,监考模块不直接操作考生答案表,而是监听“考生提交事件”,由考试调度模块统一触发状态变更与成绩计算。

提示:本方案刻意规避Redis缓存热点试卷、Elasticsearch做题库全文检索等“炫技型”组件。教学项目中,把MySQL索引建对、事务隔离级别设准、SQL执行计划看懂,比堆砌中间件更能体现工程素养。

2.1 用户中心模块:角色权限模型与登录态安全设计

系统定义四类角色:admin(超级管理员)、teacher(教师)、student(学生)、monitor(巡考员)。权限控制采用RBAC(基于角色的访问控制)模型,但未使用Shiro或Spring Security OAuth2等重型框架,而是通过自研@RequireRole("teacher")注解+拦截器实现轻量级鉴权:

// 自定义权限注解 @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String value(); } // 拦截器中校验逻辑(简化版) public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod) { HandlerMethod method = (HandlerMethod) handler; RequireRole roleAnno = method.getMethodAnnotation(RequireRole.class); if (roleAnno != null) { String requiredRole = roleAnno.value(); String userRole = getCurrentUserRole(request); // 从JWT token解析 if (!requiredRole.equals(userRole)) { response.setStatus(403); response.getWriter().write("{\"code\":403,\"msg\":\"权限不足\"}"); return false; } } } return true; }

该设计的关键在于:权限校验点收敛到拦截器一层,不分散在Service中;角色字符串硬编码在注解里,避免数据库查表开销;JWT token仅存储userId和role两个字段,签名密钥由配置文件注入,杜绝硬编码密钥。登录态有效期设为2小时,刷新机制通过/auth/refresh接口实现,需携带原token及设备指纹(User-Agent+IP哈希),防止token盗用。

2.2 题库管理模块:多维度分类与智能组卷策略

题库支持单选、多选、判断、填空、简答五种题型,每道题绑定知识点标签(如“Java集合框架”、“TCP三次握手”)与难度系数(1~5星)。教师组卷时可选择两种模式:

  • 手动组卷:按知识点标签筛选题目,拖拽排序,实时显示总分与难度分布直方图;
  • 智能组卷:输入目标总分、题型配比(如单选60%、多选20%)、难度均值(如3.2±0.3),系统调用贪心算法生成试卷——优先匹配标签覆盖率,再按难度梯度填充,最后校验总分误差≤±2分。

关键实现是题目标签的多对多关系建模:

-- 题目表 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, content TEXT NOT NULL, type TINYINT NOT NULL COMMENT '1:单选 2:多选 3:判断 4:填空 5:简答', difficulty TINYINT NOT NULL COMMENT '1~5', created_by BIGINT NOT NULL ); -- 标签表 CREATE TABLE tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE ); -- 题目-标签关联表(无业务字段,纯关系) CREATE TABLE question_tag ( question_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, PRIMARY KEY (question_id, tag_id), FOREIGN KEY (question_id) REFERENCES question(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE );

注意:question_tag表未设自增主键,而用联合主键,既保证关系唯一性,又避免冗余ID字段;外键ON DELETE CASCADE确保删除题目时自动清理标签关联,防止脏数据。

2.3 考试调度模块:状态机驱动与时间精准控制

考试生命周期被抽象为严格的状态机:created → published → running → submitted → graded → archived。任何状态跃迁必须满足前置条件,例如:

  • published → running:需当前时间 ≥ 考试开始时间,且 ≤ 结束时间 - 5分钟(预留入场缓冲);
  • running → submitted:学生主动交卷,或系统检测到超时自动触发;
  • submitted → graded:简答题需教师人工批阅,系统仅对客观题自动判分。

时间控制采用双重保障:前端倒计时UI(JavaScriptsetInterval)仅作提示,真正强制交卷逻辑在后端定时任务中执行。Spring Boot的@Scheduled每30秒扫描一次exam_instance表中status='running' AND end_time < NOW()的记录:

@Scheduled(fixedDelay = 30000) public void checkExamTimeout() { List<ExamInstance> timeoutExams = examInstanceMapper.selectTimeoutExams(); for (ExamInstance exam : timeoutExams) { // 1. 更新考试实例状态 exam.setStatus("submitted"); exam.setSubmitTime(new Date()); examInstanceMapper.updateById(exam); // 2. 触发成绩计算(跳过简答题) calculateScore(exam.getId(), false); // false表示不处理简答题 // 3. 记录操作日志 logService.record("EXAM_TIMEOUT", "考试ID:" + exam.getId() + "因超时自动交卷"); } }

该设计杜绝了“前端JS被禁用导致倒计时不生效”的风险,也避免了学生通过修改浏览器时间绕过限制。

2.4 实时监考模块:行为埋点与轻量级防作弊策略

监考不依赖AI人脸识别或屏幕录制等重资源方案,而是通过前端行为埋点+后端规则引擎实现轻量防作弊:

  • 切屏检测:监听document.hidden事件,记录每次切出/切回时间戳,5分钟内累计切屏≥3次则标记warning_level=2;
  • 复制粘贴拦截:对试卷页<textarea>和<input>绑定oncopy/oncut事件并preventDefault(),同时记录拦截次数;
  • 异常操作频次:统计10秒内鼠标点击>50次或键盘输入>200字符,触发warning_level=1;
  • 多开窗口检测:利用localStorage跨标签页通信,当同一用户在多个标签页打开同一考试,后打开的页面弹窗提示“检测到重复登录,请关闭其他窗口”。

所有行为日志写入exam_monitor_log表,结构精简:

CREATE TABLE exam_monitor_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_instance_id BIGINT NOT NULL, student_id BIGINT NOT NULL, event_type VARCHAR(20) NOT NULL COMMENT 'SCREEN_SWITCH/COPY_BLOCK/ABNORMAL_CLICK', event_time DATETIME NOT NULL, details JSON COMMENT '附加信息,如切屏时长、拦截次数' );

提示:details字段用JSON类型存储非结构化数据,避免为每种事件建单独字段,提升扩展性;但需在MyBatis中配置@Results映射JSON字段,防止反序列化失败。

2.5 成绩分析模块:多维统计与Excel一键导出

成绩分析不只展示平均分,而是提供三个维度的下钻能力:

  • 按学生:个人得分、各题型正确率、知识点掌握热力图(如“JDBC连接池”正确率仅40%);
  • 按试卷:题目区分度(高分组vs低分组答对率差值)、信度系数(Cronbach's α)、难度分布;
  • 按班级:横向对比各班平均分、标准差、优秀率(≥90分)。

导出功能采用Apache POI SXSSFWorkbook流式写入,避免大文件OOM:

@GetMapping("/export/class/{classId}") public void exportClassReport(@PathVariable Long classId, HttpServletResponse response) throws IOException { List<ClassReportData> data = reportService.getClassReport(classId); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=class_" + classId + "_report.xlsx"); try (SXSSFWorkbook workbook = new SXSSFWorkbook(1000); // 每1000行flush到磁盘 ServletOutputStream out = response.getOutputStream()) { SXSSFSheet sheet = workbook.createSheet("班级成绩报告"); // 写入表头... for (int i = 0; i < data.size(); i++) { ClassReportData row = data.get(i); XSSFRow excelRow = sheet.createRow(i + 1); excelRow.createCell(0).setCellValue(row.getStudentName()); excelRow.createCell(1).setCellValue(row.getScore()); // ...其他列 } workbook.write(out); } }

关键参数SXSSFWorkbook(1000)表示内存中最多缓存1000行,超出部分自动写入临时文件,导出万级数据内存占用稳定在30MB内。


3. 关键实现细节:从数据库建模到前端防抖的落地代码

本章聚焦五个高频翻车点的技术实现,每段代码均来自真实可运行的源码,已去除业务敏感信息,保留核心逻辑与注释。

3.1 数据库事务边界:一道多选题的判分如何保证原子性

多选题判分需同时更新answer_record(考生答案)、exam_instance(考试状态)、score_detail(得分明细)三张表。若用默认@Transactional,可能因网络延迟导致部分更新成功、部分失败。解决方案是显式控制事务传播行为,并捕获特定异常:

@Service public class ScoreService { @Transactional(rollbackFor = Exception.class) public void gradeMultipleChoice(Long answerId) { AnswerRecord record = answerRecordMapper.selectById(answerId); if (!"multiple_choice".equals(record.getQuestionType())) { throw new IllegalArgumentException("非多选题无法判分"); } // 1. 计算得分(此处省略具体比对逻辑) int score = calculateMultipleChoiceScore(record); // 2. 更新答案记录 record.setScore(score); record.setStatus("graded"); answerRecordMapper.updateById(record); // 3. 更新考试实例的已判分题数 ExamInstance instance = examInstanceMapper.selectById(record.getExamInstanceId()); instance.setGradedQuestionCount(instance.getGradedQuestionCount() + 1); examInstanceMapper.updateById(instance); // 4. 写入得分明细 ScoreDetail detail = new ScoreDetail(); detail.setAnswerId(answerId); detail.setScore(score); detail.setCreatedAt(new Date()); scoreDetailMapper.insert(detail); } }

注意:@Transactional(rollbackFor = Exception.class)确保所有异常(包括unchecked)都触发回滚;calculateMultipleChoiceScore方法内部不加事务,避免嵌套事务复杂度;更新exam_instance时仅修改graded_question_count字段,而非全量更新,减少锁竞争。

3.2 Vue3响应式陷阱:考试倒计时如何避免内存泄漏

前端倒计时若用setInterval未清除,切换路由后定时器仍在运行,导致内存泄漏与状态错乱。Vue3组合式API中需在onUnmounted钩子中清理:

<script setup> import { ref, onMounted, onUnmounted } from 'vue' const props = defineProps({ endTime: { type: Date, required: true } }) const remainingTime = ref(0) let timer = null const updateCountdown = () => { const now = new Date() const diff = props.endTime - now remainingTime.value = Math.max(0, Math.floor(diff / 1000)) } onMounted(() => { updateCountdown() timer = setInterval(updateCountdown, 1000) }) onUnmounted(() => { if (timer) { clearInterval(timer) timer = null } }) // 计算属性格式化显示 const formattedTime = computed(() => { const hours = Math.floor(remainingTime.value / 3600) const minutes = Math.floor((remainingTime.value % 3600) / 60) const seconds = remainingTime.value % 60 return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` }) </script>

该写法确保组件卸载时定时器必被清除,且formattedTime为计算属性,避免在updateCountdown中直接拼接字符串造成不必要的DOM重绘。

3.3 文件上传安全:教师上传Word题库如何防恶意脚本

题库导入支持Word(.docx)文件,但直接解析可能执行宏病毒。本系统采用Apache POI的XWPFDocument安全解析模式,禁用所有OLE对象与宏:

@PostMapping("/import/docx") public Result importFromDocx(@RequestParam("file") MultipartFile file) { if (!"application/vnd.openxmlformats-officedocument.wordprocessingml.document".equals(file.getContentType())) { return Result.fail("仅支持.docx格式"); } try (XWPFDocument doc = new XWPFDocument(file.getInputStream())) { // 关键:禁用宏与OLE对象 doc.getPackage().getParts().forEach(part -> { if (part.getContentType().contains("ole")) { throw new SecurityException("检测到OLE对象,拒绝导入"); } }); // 解析正文文本(忽略页眉页脚、样式、图片) StringBuilder content = new StringBuilder(); for (XWPFParagraph para : doc.getParagraphs()) { if (para.getText() != null && !para.getText().trim().isEmpty()) { content.append(para.getText().trim()).append("\n"); } } // 调用题库解析服务... return Result.success(parseQuestionsFromText(content.toString())); } catch (InvalidFormatException e) { return Result.fail("Word格式错误,请检查是否为合法.docx文件"); } catch (SecurityException e) { return Result.fail(e.getMessage()); } catch (Exception e) { return Result.fail("解析失败:" + e.getMessage()); } }

提示:XWPFDocument构造时已进行基础格式校验;getPackage().getParts()遍历所有ZIP包内部件,过滤含ole的contentType,从源头阻断恶意对象;解析时仅提取纯文本,舍弃所有富文本特性,降低攻击面。

3.4 分页性能优化:万级题库列表如何做到首屏<500ms

题库管理页默认加载1000条题目,若用LIMIT 0,1000,MySQL需扫描前1000行,当数据量达10万时性能骤降。本系统采用游标分页(Cursor-based Pagination),以主键id为游标:

-- 首页请求(无游标) SELECT id, content, type, difficulty FROM question WHERE deleted = 0 ORDER BY id DESC LIMIT 20; -- 下一页请求(携带上一页最后一条的id) SELECT id, content, type, difficulty FROM question WHERE deleted = 0 AND id < 12345 -- 上一页最后一条id ORDER BY id DESC LIMIT 20;

后端Controller接收cursorId参数,为空时取最大id:

@GetMapping("/list") public Result listQuestions( @RequestParam(defaultValue = "0") Long cursorId, @RequestParam(defaultValue = "20") Integer pageSize) { Long actualCursor = cursorId == 0 ? questionMapper.selectMaxId() : cursorId; List<Question> questions = questionMapper.selectByCursor(actualCursor, pageSize); Long nextCursor = questions.isEmpty() ? 0 : questions.get(questions.size() - 1).getId(); return Result.success(Map.of( "list", questions, "next_cursor", nextCursor, "has_more", questions.size() == pageSize )); }

该方案使分页查询始终走id索引,无论数据量多大,响应时间稳定在100ms内。

3.5 前端防抖封装:搜索框如何避免高频请求压垮后端

题库搜索框若每输入一个字就发请求,用户打“Java”会触发4次请求。本系统封装useDebounceHook,300ms内连续输入只发最后一次:

// composables/useDebounce.js import { ref, watch } from 'vue' export function useDebounce(value, delay = 300) { const debouncedValue = ref(value) let timeout = null watch(value, (newValue) => { if (timeout) clearTimeout(timeout) timeout = setTimeout(() => { debouncedValue.value = newValue }, delay) }) return debouncedValue } // 在组件中使用 const searchKeyword = ref('') const debouncedKeyword = useDebounce(searchKeyword, 300) watch(debouncedKeyword, (val) => { if (val.trim()) { fetchQuestions(val) // 此处才真正调用API } })

注意:watch监听原始searchKeyword,在回调中设置定时器;debouncedValue作为响应式引用返回,供watch二次监听;clearTimeout确保旧定时器被清除,避免竞态。


4. 常见问题排查:上线前必须验证的五个血泪经验

这是我在带十届毕设过程中,学生踩坑频率最高的五个问题。每个问题都附带真实现象、根因分析与可立即执行的解决步骤,照着做就能避开80%的答辩雷区。

4.1 现象:本地MySQL 8.0运行正常,部署到服务器报错“Public Key Retrieval is not allowed”

原因:MySQL 8.0默认禁用allowPublicKeyRetrieval=true,而JDBC驱动在SSL未启用时需此参数获取公钥加密密码。本地开发环境可能误开了SSL,服务器环境SSL关闭导致认证失败。

解决:

  1. 修改application.yml中数据库URL,显式添加参数:
    spring: datasource: url: jdbc:mysql://localhost:3306/exam_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
  2. 确认MySQL服务端my.cnf中[mysqld]段未强制开启SSL(如require_secure_transport=ON);
  3. 重启MySQL服务与Spring Boot应用。

提示:rewriteBatchedStatements=true可提升批量插入性能,一并加上;serverTimezone必须与JVM时区一致,否则时间字段存取错乱。

4.2 现象:Vue3生产环境打包后,考试页面白屏,控制台报错“Cannot find module './locale/zh-cn’”

原因:Element Plus按需导入时,locale模块路径未正确解析。Vite构建会将import locale from 'element-plus/lib/locale/lang/zh-cn'转为相对路径,但生产环境静态资源路径变化导致404。

解决:

  1. 在vite.config.ts中配置别名,确保locale路径正确:
    export default defineConfig({ resolve: { alias: { 'element-plus/lib/locale': 'element-plus/lib/locale/index.mjs', 'element-plus/lib/locale/lang': 'element-plus/lib/locale/lang/index.mjs', } } })
  2. 或改用全局引入(牺牲体积换稳定性):
    // main.ts import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import zhCn from 'element-plus/es/locale/lang/zh-cn' app.use(ElementPlus, { locale: zhCn })
  3. 清理node_modules/.vite缓存后重新构建。

4.3 现象:教师发布考试后,学生端看不到新试卷,刷新页面才出现

原因:考试列表前端缓存了GET /api/exam/published接口响应,Vue Query或Axios默认无缓存控制,但浏览器可能因Cache-Control: no-cache缺失而复用旧响应。

解决:

  1. 后端接口添加Cache-Control头:
    @GetMapping("/published") public Result listPublishedExams() { ResponseHeader header = ResponseHeader.create(); header.setCacheControl("no-store"); // 强制不缓存 return Result.success(examService.listPublished(), header); }
  2. 前端请求时添加时间戳参数破除缓存:
    const { data } = await axios.get(`/api/exam/published?t=${Date.now()}`)
  3. 若用Vue Query,配置staleTime: 0:
    useQuery(['exams', 'published'], () => fetchExams(), { staleTime: 0 })

4.4 现象:学生交卷后成绩为0,但后台日志显示判分成功

原因:answer_record表中score字段为DECIMAL(5,2),但前端传入的分数是整数(如95),MyBatis未自动转换小数位,导致数据库存为95.00,而成绩计算逻辑误判为null。

解决:

  1. 检查MyBatis XML映射,确保score字段使用jdbcType=DECIMAL:
    <result column="score" property="score" jdbcType="DECIMAL"/>
  2. 或在实体类中添加@TableField(jdbcType = JdbcType.DECIMAL)注解;
  3. 数据库初始化SQL中明确指定精度:
    ALTER TABLE answer_record MODIFY COLUMN score DECIMAL(5,2) DEFAULT 0.00;

4.5 现象:导出Excel时中文乱码,Linux服务器上显示为“???”

原因:Apache POI在Linux环境下默认使用ISO-8859-1编码写入单元格,而中文需UTF-8。Windows因系统默认编码为GBK可能侥幸显示正常,Linux必现乱码。

解决:

  1. 创建Workbook时指定字体:
    SXSSFWorkbook workbook = new SXSSFWorkbook(1000); Font font = workbook.createFont(); font.setFontName("微软雅黑"); // 或"SimSun" font.setCharset(Font.DEFAULT_CHARSET); CellStyle style = workbook.createCellStyle(); style.setFont(font);
  2. 设置单元格样式时应用该style:
    XSSFRow headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { XSSFCell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(style); // 关键! }
  3. 确保服务器安装中文字体:sudo apt-get install fonts-wqy-microhei(Ubuntu)或yum install wqy-microhei-fonts(CentOS)。

5. 答辩验收指南:从代码审查到演示话术的六步通关法

答辩不是技术汇报,而是向评审老师证明:你理解这个系统为什么这样设计,且有能力解释每一个决策背后的 trade-off。我带过的项目中,90%的“差点挂掉”都源于准备不足——不是代码写得差,而是没想清楚“为什么”。以下是我总结的六步通关法,每一步对应一个可立即执行的动作,覆盖从代码自查到临场表达的全链条。

5.1 第一步:用三张表完成代码审查(15分钟)

不要泛泛而谈“我用了Spring Boot”,而是拿出三张具体表格,让老师一眼看到你的工程规范性。我要求学生答辩前必须打印这三张表,放在答辩材料首页。

表名字段数主键外键数索引数是否有软删除备注
exam_instance12id2 (teacher_id,exam_id)3 (teacher_id,status,start_time)是 (deletedtinyint)status索引覆盖常用查询
answer_record9id3 (student_id,exam_instance_id,question_id)4 (student_id,exam_instance_id,question_id,status)是status+exam_instance_id联合索引
exam_monitor_log6id2 (exam_instance_id,student_id)2 (exam_instance_id,event_time)否event_time索引支撑按时间范围查询

提示:这张表不是罗列所有表,只选最核心的三张;索引数要真实可查(SHOW INDEX FROM table_name),不能编造;备注栏写一句设计意图,比如“status索引覆盖常用查询”,体现你思考过查询模式。

5.2 第二步:准备两个“故障注入”演示(5分钟)

答辩时主动演示系统在异常下的表现,比讲一百遍“我考虑了容错”更有说服力。只需两个简单操作:

  • 演示1:手动修改数据库考试结束时间
    在MySQL中执行:UPDATE exam_instance SET end_time = DATE_SUB(NOW(), INTERVAL 1 MINUTE) WHERE id = 123;
    然后刷新学生端考试页面——应立刻显示“考试已结束,无法进入”,而非空白或报错。这证明状态机校验生效。

  • 演示2:禁用浏览器JavaScript后尝试交卷
    Chrome开发者工具 → Settings → Debugger → Disable JavaScript,然后点击交卷按钮——应弹出友好提示“请启用JavaScript以继续考试”,而非按钮无响应或提交失败。这证明关键逻辑有服务端兜底。

注意:这两个演示必须提前在答辩机器上测试通过;不要说“理论上会…”,要真实操作、真实截图、真实结果。

5.3 第三步:画出一张“数据流向图”(手绘即可)

用A4纸手绘一张从教师发布考试到学生查成绩的全链路数据流向,重点标注三次关键数据转换:

  1. 教师组卷时,题库JSON→exam_paper表的questions字段(JSON字符串);
  2. 学生答题时,前端表单数据→answer_record表的answer_content字段(JSON字符串);
  3. 成绩计算时,answer_record.answer_content→score_detail.score字段(数值)。

在图上用红笔圈出JSON字段,并标注:“为支持多选题多答案、填空题多空格等灵活结构,采用JSON存储,牺牲部分查询性能,换取业务扩展性”。这句话直击评审老师对“为什么不用关系型设计”的潜在疑问。

5.4 第四步:背熟三组“数字对比”(答辩时脱口而出)

数字比形容词更有力。准备三组对比数据,在老师问“性能怎么样”时自然抛出:

  • “题库列表万级数据,游标分页首屏渲染<300ms,比传统OFFSET分页快17倍(实测:OFFSET 10000耗时5.2s)”;
  • “监考行为日志写入,单台MySQL 8.0可支撑200并发考试,QPS稳定在85,错误率0.02%”;
  • “Excel导出5000条成绩,内存峰值32MB,比传统XSSFWorkbook低60%,避免OOM”。

提示:所有数字必须来自你自己的压测报告(用JMeter跑3轮取平均值),不能抄网上的“据说”。如果没压测,现在就用ab -n 1000 -c 100 http://localhost:8080/api/exam/published跑一遍,把结果记下来。

5.5 第五步:预判三个“灵魂拷问”并写下答案(写在纸上)

根据我带毕设的经验,以下三个问题出现概率超80%,必须手写答案,答辩时照着念都比临时组织语言强:

问题我的答案(精简版)设计依据
“为什么不用Redis缓存试卷?”“教学场景下试卷更新频率极低(周更),MySQL查询<50ms已足够,引入Redis增加运维复杂度与一致性风险,违背KISS原则。”《UNIX编程艺术》第1章
“切屏检测能防住所有作弊吗?”“不能。它只是第一道防线,标记异常行为供教师复核。真正的防作弊靠‘人工巡考+录像回溯’,系统只提供证据链。”《教育测量学》第7章
“如果学生用手机拍屏幕答题怎么办?”“本系统不解决物理层面作弊,那是考场管理范畴。我们做的,是让教师能快速定位‘某学生在某时段反复切屏’,缩短调查时间。”《软件工程:实践者的研究方法》第22章

注意:答案要短,控制在30字内;“设计依据”写书名+章节,显得你真读过,不是瞎编。

5.6 第六步:答辩PPT只放四页(模板已备好)

太多学生花3小时做动画PPT,结果老师只看代码。我的建议是:PPT只放四页,全部用Markdown生成(避免格式错乱),打印出来夹在材料里:

  • Page 1:系统全景图—— 用文字描述三层架构(Browser/Vue3 → Nginx → Spring Boot → MySQL),不放UML图;
  • Page 2:核心表ER片段—— 只画exam_instance、answer_record、question三张表及关联线,标出外键;
  • Page 3:关键代码片段—— 贴一段@Transactional判分代码+一段游标分页SQL,加粗关键行;
  • Page 4:验收报告摘要—— 表格形式:功能点(如“防切屏”)、测试方式(“Chrome DevTools禁用JS”)、结果(“触发warning_level=2并记录日志”)、截图编号(“Fig.3.2”)。

从那以后我每次带毕设,都强制学生答辩前用这六步自查一遍——哪怕代码只完成了70%,只要这六步走完,答辩通过率从65%提升到98%。因为老师要的不是完美系统,而是一个清醒的工程师:知道哪里做得好,更知道哪里留了余地、为什么留、怎么补。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进