
简介面向高校毕业设计场景的完整资料包围绕基于Java的网络考试系统给出从设计到落地的全套方案。系统覆盖学生端在线考试与结果查看、超级管理员端的用户/角色权限/试卷/试题管理以及试题管理员老师端的试卷与试题管理等核心模块适合需要完成类似选题的高校学生或希望学习Java Web项目结构的开发者参考。压缩包共20个文件总大小约120MB包含3段MP4视频数据库安装、项目部署、功能操作演示、3份Word文档任务书、中期检查表、毕业论文、1份答辩PPT、1个SQL数据库脚本、源代码压缩包及10张项目/论文截图目录按视频、论文、数据库、源码和截图分类便于直接查阅。已有829人学习下载。除了可运行源码还配套完整的毕业论文和答辩演示材料可作为毕业设计说明书撰写、系统二次开发与答辩汇报的参考模板能有效减少从零搭建和梳理文档的时间。1. 网络考试系统到底是什么从一张期末试卷倒推系统必备的六个模块期末周老师抱着三套打印好的试卷走进教室监考两小时收上来 80 份答卷再花一整晚判卷、登分这是很多课程还在用的流程。标题里的这个基于 Java 的网络考试系统就是把出卷、组卷、在线考试、自动判分、成绩统计这条链路整体搬到 Web 上让老师不用手动排版试卷学生也不用干等纸质卷子。它最适合两种人一是要做 Java 课程设计或毕业设计的学生需要一个能讲清楚架构、能现场演示完整考试流程的 Web 工程二是学院里想快速部署一套内部考试系统、又不想上商业产品的管理员。先说一个反直觉的结论决定这套系统好不好用、能不能顺利通过答辩演示的往往不是界面有多漂亮而是题库表和交卷判分那条链路的健壮性。前端花哨但交卷丢数据演示现场就是大型翻车。所以这篇文不谈花活只讲怎么把一套能真实跑起来、能答上老师提问的 Java 网络考试系统从零搭出来。2. 技术栈选型与工程骨架SSM 不是唯一答案但最不容易翻车2.1 SSM 与 SSH 的选择为什么毕业设计模板里清一色是 SSM很多刚接触 Java Web 的人分不清 SSH 和 SSM其实区别很直白SSH 是 Struts2 Spring HibernateSSM 是 Spring SpringMVC MyBatis。从纯技术的角度两者都能做考试系统但放到毕业设计和课程设计的场景里SSM 几乎是碾压级的优势。为什么先说 Struts2 的问题。Struts2 的拦截器链是它最核心的机制但也是答辩时最容易翻车的点。老师一句“你的登录拦截是怎么实现的”就能把你带进拦截器栈的细节里如果你只是从教程里抄了配置、没真正理解拦截器的执行顺序现场很难圆回来。Hibernate 那边也类似一级缓存、二级缓存、延迟加载每一个都是黑匣子不踩几个坑很难讲清楚。普通学生做项目根本不知道该在哪个环节去触发 session.clear()也不明白为什么两条查询语句打印出来的 SQL 数量对不上。SSM 的手写 SQL 风格恰恰避开了这些深坑。MyBatis 里查询就是一条明明白白的 SQL查哪张表、返回什么字段、怎么 join全写在 mapper 里答辩时顺着 SQL 讲业务逻辑老师挑不出毛病。而且“系统实现”这一章在论文里最好写的素材就是 SQL 和 Mapper 接口贴上去就是实打实的工作量。顺便说一句Spring Boot MyBatis-Plus 确实是效率更高的近路但很多学院的验收大纲还停留在“SSM 三件套 JSP 页面”的框架里用 Spring Boot 反而会让评审老师觉得“跟大纲不符”。我不建议在这个阶段跟学院验收标准对着干SSM 是最稳的。2.2 pom.xml 最小依赖集合抄下来能编译别多引先给一份我在课程设计和毕业设计里反复用过的 pom.xml 核心依赖把它放进 SpringMVC 工程里编译和启动基本不会出问题。properties spring.version5.3.23/spring.version mybatis.version3.5.10/mybatis.version project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies !-- Spring 核心容器 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency !-- SpringMVC -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- MyBatis 核心 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency !-- MyBatis 与 Spring 整合 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency !-- MySQL 驱动注意 8.x 版本驱动类名变了 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.30/version /dependency !-- 连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.15/version /dependency !-- JSON 序列化Ajax 返回数据用 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.13.4/version /dependency !-- 以下两个依赖容器已自带必须用 provided -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency /dependencies这里有几个关键点不是放进去就完事。第一servlet-api 和 jsp-api 必须用 provided 作用域因为 Tomcat 自带了这两套库如果用了默认的 compile 作用域启动时会出现包冲突报一堆乱七八糟的 ClassCastException。第二版本别追新Spring 5.x 配 MyBatis 3.5.x 这个区间是经过大量项目验证的稳定组合Spring 6 要求 JDK 17很多课程设计的机器还是 JDK 8贸然升级只会给自己添堵。第三别把网上项目里的一整份 pom.xml 复制过来那些工程里经常混着 struts2 的依赖、老版本的 commons-* 工具包引进来启动时会冒出一堆你根本用不到的 jar出了问题都定位不到方向。2.3 工程分包按毕业设计论文目录来切不会错很多人做 Java Web 课程设计时包结构是乱的所有类堆在三个包里甚至有人直接写在默认包下这不是能不能跑的问题而是论文里的“系统设计”章节没法写。我一般会按下面这个分包方式来组织工程它同时服务于代码可读性和论文素材。com.example.exam ├── controller # Controller 层接收请求、返回视图或 JSON │ ├── LoginController.java │ ├── ExamController.java │ └── PaperController.java ├── service # Service 层业务逻辑全部在这层 │ ├── ExamService.java │ ├── PaperService.java │ └── QuestionService.java ├── mapper # Mapper 接口对应 MyBatis 的 SQL 映射 │ ├── QuestionMapper.java │ └── ExamRecordMapper.java ├── entity # 实体类与数据库表结构一一对应 │ ├── Question.java │ ├── ExamPaper.java │ └── ExamRecord.java ├── dto # 数据传输对象承接前端页面提交的复合参数 │ ├── PaperRuleDTO.java │ └── SubmitExamDTO.java └── common # 公共类统一返回结果、常量、工具 ├── Result.java └── Constants.java分包的原则很简单controller 里不做计算只做参数接收service 里写业务规则mapper 里只有 SQL 相关的东西。这里要特别提醒一个很多人会犯的错——实体类里塞业务字段。比如 ExamRecord 实体对应考试记录表这个表里只有 id、paperId、studentId、startTime、submitTime、score、status 这些字段但有人会把前端传过来的“本次考试的客观题总分”也塞进实体里最后 MyBatis 做映射时要么报错要么查出多余字段干扰判断。正确的做法是给前端交互单独建 DTO比如 PaperRuleDTO 里放组卷规则SubmitExamDTO 里放学生提交的答题卡用 DTO 和实体类隔离。这段还有一个环境上的小细节值得提前处理如果你是在 Windows 上用 zip 解压方式配置 JDK记得 JAVA_HOME 不要指向带空格的路径比如 C:\Program Files\Java\jdk1.8.0_301Tomcat 启动脚本会因为这个路径里的空格解析出错报找不到 catalina.jar这是最容易被忽略的环境变量配置坑。3. 建表先于写代码题库、试卷与考试记录的 7 张核心表怎么设计3.1 题库表一道题怎么存决定了随机组卷能不能写好考试系统的一切功能都建立在题库数据之上。题库表设计不合理后面写随机组卷时就是在垃圾数据上盖楼。我见过最差的题库表是把单选题的 A/B/C/D 四个选项分成四列存结果一遇到填空题、简答题就傻眼了——空着四个字段在论文的数据库设计章节里自己都解释不通。标准做法是把题目本身的信息和答案分开考虑。先看一份可以直接拿去建表的 DDLCREATE TABLE question ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 题目ID, course_id BIGINT NOT NULL COMMENT 所属课程ID关联 course 表, type TINYINT NOT NULL COMMENT 题目类型1单选 2多选 3判断 4填空 5简答, difficulty TINYINT NOT NULL DEFAULT 2 COMMENT 难度等级1易 2中 3难, knowledge_point VARCHAR(100) DEFAULT NULL COMMENT 所属知识点用于组卷时控制覆盖范围, stem TEXT NOT NULL COMMENT 题干, options TEXT COMMENT 选项内容JSON数组格式如[A选项,B选项,C选项,D选项]非选择题为空, answer TEXT NOT NULL COMMENT 参考答案按题型约定格式存储, score DECIMAL(5,1) NOT NULL DEFAULT 5.0 COMMENT 单题默认分值, analysis TEXT COMMENT 答案解析, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_course_type (course_id, type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题库表;几个字段要细说。options用 JSON 数组存不用拆列这是为了兼容不同题型——填空题和简答题没有选项多选的选项数不固定拆成 option_a、option_b 这种四列方案在这些题型面前就是灾难。answer字段的存储格式必须和题型绑定而且这部分要在论文里写清楚单选和判断题存一个字符A 或 T多选题存逗号分隔的字母序列填空题存竖线分隔的多个空答案比如“北京|1949”表示第一空填北京、第二空填 1949简答题存参考答案原文或评分要点。如果不做这个约定判分逻辑就只能用一堆 if-else 去猜格式后面第四章会说怎么判。knowledge_point这个字段非常容易被忽略但它是随机组卷能否合理的关键。没有这个字段组卷就只能按“难度题型”抽题出来的卷子可能十道题全考同一个知识点老师拿到卷子一眼就能看出系统不靠谱。你在建表时就该强制给每道题填上知识点它值组卷时的这一下功夫。顺带提一句如果你习惯用 MyBatis-Plus 的实体类注解去生成建表语句这个功能可以帮你快速起底一个草稿表但题目表的options和answer字段带有非常强的业务格式约定注解里表达不了最后还是要回到手写 DDL 精调。毕业设计的数据库设计章节要截图 ER 图字段注释一定要通过 COMMENT 写全省得后面补。3.2 六张关联表试卷怎么组成、成绩怎么落库question表解决“有什么题”还需要一组表把“出一道卷子、考一场试、判一次分”串起来。一套最小可用的考试系统除了题目表外还要六张表分别是sys_user、course、exam_paper、paper_question、exam_record、answer_detail。给你看几张关键表的 DDL。CREATE TABLE course ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 课程名称, teacher_id BIGINT NOT NULL COMMENT 任课教师ID关联 sys_user, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; CREATE TABLE exam_paper ( id BIGINT NOT NULL AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 所属课程, paper_name VARCHAR(200) NOT NULL COMMENT 试卷名称, total_score DECIMAL(5,1) NOT NULL DEFAULT 100.0, duration_minutes INT NOT NULL DEFAULT 120 COMMENT 考试时长分钟, rule_snapshot VARCHAR(500) COMMENT 组卷规则快照JSON字符串存当时的难度比例等, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试卷表; CREATE TABLE paper_question ( id BIGINT NOT NULL AUTO_INCREMENT, paper_id BIGINT NOT NULL COMMENT 试卷ID, question_id BIGINT NOT NULL COMMENT 题目ID, score DECIMAL(5,1) NOT NULL COMMENT 该题在此试卷中的分值, PRIMARY KEY (id), UNIQUE KEY uk_paper_question (paper_id, question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试卷题目关联表; CREATE TABLE exam_record ( id BIGINT NOT NULL AUTO_INCREMENT, paper_id BIGINT NOT NULL, student_id BIGINT NOT NULL, start_time DATETIME NOT NULL COMMENT 考试开始时间, submit_time DATETIME DEFAULT NULL COMMENT 交卷时间, objective_score DECIMAL(5,1) DEFAULT 0.0 COMMENT 客观题得分自动判分, total_score DECIMAL(5,1) DEFAULT 0.0 COMMENT 总分人工阅卷后覆盖, status TINYINT NOT NULL DEFAULT 0 COMMENT 0考试中 1已交卷 2待人工阅卷 3已出分, last_snapshot TEXT COMMENT 答题快照JSON用于掉线恢复, PRIMARY KEY (id), UNIQUE KEY uk_record (paper_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试记录表; CREATE TABLE answer_detail ( id BIGINT NOT NULL AUTO_INCREMENT, exam_record_id BIGINT NOT NULL COMMENT 关联考试记录ID, question_id BIGINT NOT NULL, student_answer TEXT COMMENT 学生提交的答案, actual_score DECIMAL(5,1) DEFAULT 0.0 COMMENT 该题实际得分, review_status TINYINT DEFAULT 0 COMMENT 0自动判分 1待人工阅卷 2已人工阅卷, PRIMARY KEY (id), KEY idx_record_id (exam_record_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答题明细表;这套表结构里最值得留意的是paper_question这张关联表。它把“题目的默认分值”和“这张卷子里的实际分值”分开了因为同一道题在不同试卷里可能分值不同——比如某道选择题在模拟卷里值 3 分在期末卷里值 5 分如果只靠question.score一个字段就没法表达这个差异。exam_record里的rule_snapshot也很微妙它存的是当时组卷的规则参数不是题目内容。老师后来说“这卷子怎么出的”你直接把快照拿出来展示这就有了说服力。3.3 建表顺序与三处约束唯一索引和外键的取舍建表顺序必须遵守“基础表先建关联表后建”的原则也就是先sys_user、course、question再exam_paper再paper_question最后才是exam_record和answer_detail。不然你建关联表时写外键会直接报错——表都不存在外键往哪指。三处约束是血泪经验换来的。第一paper_question表必须加 (paper_id, question_id) 的唯一索引防止随机组卷时同一道题在同一张卷子里出现两次。听起来不可能但抽题算法如果没做好去重这是真实发生的。第二exam_record表必须加 (paper_id, student_id) 的唯一索引这个后面第五章会专门讲并发交卷的翻车现场这里先记住它不是可选项是必须项。第三所有关联字段类型要统一用 BIGINT别sys_user.id用 BIGINT、exam_record.student_id用 INTMyBatis 做映射和 join 查询时类型不一致会让索引失效轻则慢查询重则查出来全是 0。外键这件事值得单独说。教科书写要加物理外键保证引用完整性但实际做 SSM 项目时我强烈建议在表结构里去掉物理外键、只在逻辑上做关联。原因有两个一是 MySQL 在插入answer_detail时要一次性批量写入几十条记录每条都要做外键校验数据量一上来性能肉眼可见地下降二是课程设计阶段你很可能要手动改测试数据物理外键会把一次简单的数据订正变成连环操作。逻辑外键完全够用——你在 SQL 里 join 的时候关系就清清楚楚论文里把这点写明白老师不会挑理。4. 从随机组卷到自动判分核心流程的 Java 实现与参数细节4.1 随机组卷算法按知识点覆盖和难度比例抽题随机组卷是考试系统最核心的功能也是最容易被做成“伪随机”的一块。所谓的伪随机就是 SQL 里ORDER BY RAND()然后 LIMIT 抽几道题交差了事。这种写法在小数据量时确实能跑但它控制不了知识点分布——题库里第二章的题有 50 道、第四章只有 5 道随机出来的卷子大概率全是第二章老师一看就皱眉。正路是先定规则再抽题。组卷时老师输入总分、各题型题量、难度比例、知识点范围系统按规则从题库里抽。常见做法是两轮抽题第一轮按知识点配额抽题优先保证每个知识点都有题第二轮用全局随机补足剩余的题量保证卷子总量不悬空。核心代码在 Service 层大概长这样Override public ListQuestion generatePaperQuestions(PaperRuleDTO rule) { // 组卷规则参数题型、各难度题数、知识点列表 Integer[] difficultyCounts rule.getDifficultyCounts(); // 例如 [6, 10, 4] 表示易6题、中10题、难4题 ListString knowledgePoints rule.getKnowledgePoints(); // 期望覆盖的知识点列表 // 第一轮按知识点配额抽题保证覆盖面 ListQuestion result new ArrayList(); for (String kp : knowledgePoints) { for (int i 0; i 3; i) { // 简单示例每个知识点先抽3题 ListQuestion candidates questionMapper.selectByKpAndType(kp, rule.getType()); if (candidates.isEmpty()) { continue; } Collections.shuffle(candidates); // 内存洗牌替代 ORDER BY RAND() result.add(candidates.get(0)); } } // 第二轮全局随机补足剩余题量 int remain rule.getTotalCount() - result.size(); if (remain 0) { ListQuestion all questionMapper.selectByTypeAndDifficulty( rule.getType(), rule.getDifficulty()); all.removeAll(result); // 去重已选的题不再进卷 Collections.shuffle(all); result.addAll(all.subList(0, Math.min(remain, all.size()))); } return result; }这段代码有两个关键动作。第一个是Collections.shuffle()配合内存取数用它替代ORDER BY RAND()。MySQL 的ORDER BY RAND()会对全表做随机排序再取前几条在题库只有几百条时感觉不明显题库一旦上万这个查询能把数据库拖死。先在 SQL 里按条件过滤出候选集再在 Java 内存里洗牌性能和可控性都更好。第二个是第二轮抽题前的all.removeAll(result)这就是在给组卷做去重配合第三章paper_question表上的唯一索引双保险防止同一道题出现在同一张卷子里。至于组卷参数怎么设要看老师的要求。最容易遇到的情况是老师只给了一个总数要求比如“40 道题”那你需要自己补全规则——默认可以设为单选题 20 道易 30%、中 50%、难 20%、判断题 10 道、简答题 2 道每道题分值按题型平均分摊总分凑成 100。这套默认参数在演示时丢给老师看比现场临时解释要从容得多。再提醒一句组卷时你应当把这套规则序列化成 JSON 存进exam_paper.rule_snapshot字段否则三个月后你根本想不起来当时这套卷子是怎么配的。4.2 交卷判分单判、多选、判断、填空的边界处理交卷是整个系统里事务边界最长的一段操作。学生点击“交卷”按钮前端把答题卡打包成 JSON 提交到后端后端要做的事分三层写入考试记录、批量写入答题明细、算出客观题得分。这三件事必须在一个事务里完成任何一个失败都要全部回滚否则就会出现“考试记录显示已交卷但答题明细是空的”这种尴尬现场。判分逻辑核心是对student_answer做格式解析和比对。下面是一段简化但能跑的判分代码用来说明边界怎么处理public BigDecimal scoreAnswer(Question q, String studentAnswer) { if (studentAnswer null || studentAnswer.trim().isEmpty()) { return BigDecimal.ZERO; // 白卷按 0 分处理直接返回不做后续解析 } String correct q.getAnswer().trim(); BigDecimal fullScore q.getScore(); switch (q.getType()) { case 1: // 单选 return studentAnswer.trim().equalsIgnoreCase(correct) ? fullScore : BigDecimal.ZERO; case 2: // 多选全部选对满分少选得一半错选零分 SetString stuSet new HashSet(Arrays.asList( studentAnswer.split(,))); SetString corSet new HashSet(Arrays.asList(correct.split(,))); if (stuSet.equals(corSet)) { return fullScore; } else if (corSet.containsAll(stuSet) !stuSet.isEmpty()) { return fullScore.multiply(new BigDecimal(0.5)); } else { return BigDecimal.ZERO; } case 3: // 判断约定答案存 T 或 F return studentAnswer.trim().equalsIgnoreCase(correct) ? fullScore : BigDecimal.ZERO; case 4: // 填空用竖线分隔多个空逐空 trim 后匹配 String[] stuArr studentAnswer.split(\\|); String[] corArr correct.split(\\|); if (stuArr.length ! corArr.length) { return BigDecimal.ZERO; } int hit 0; for (int i 0; i corArr.length; i) { if (stuArr[i].trim().equals(corArr[i].trim())) { hit; } } // 按正确空的占比给分例如两空题每空 50% 分值 return fullScore.multiply(BigDecimal.valueOf(hit)) .divide(BigDecimal.valueOf(corArr.length), 1, RoundingMode.HALF_UP); case 5: // 简答题不自动判分 default: return BigDecimal.ZERO; } }这里必须交代两个参数约定的来源。多选判分的“少选得一半”不是通用规则是常见的一种给分策略具体采用什么策略要在Constants里定义清楚并且答辩时能明确说出来。填空的竖线分隔符和第三章建表时的answer存储约定是配套的如果建表时的填空题答案没有用竖线统一格式这里每套题的判分逻辑就得一个 case 一个 case 地写维护成本直接上天。另外还要注意多选和填空的细节——多选判断里!stuSet.isEmpty()是挡住学生只提交了一个逗号这种空壳答案填空判分用了RoundingMode.HALF_UP防止 2.5 分这种小数在 BigDecimal 除法里报 ArithmeticException。客观题打完分之后如果试卷里没有简答题exam_record.status直接置为 3已出分如果有简答题置为 2待人工阅卷同时在answer_detail里把简答题的review_status标成 1老师端页面上就会出现一个待阅卷队列。人工阅卷完成后的总分覆盖规则是取objective_score加上老师逐一录入的简答题得分重新写回exam_record.total_score这一步要用 update 语句而不是在原来的总分上做增量避免重复评分导致分数叠加。4.3 session 与 token考试中途掉线的恢复方案考试和普通浏览不一样学生可能中途关浏览器、断网、电脑死机系统必须能恢复到“掉线前最后一刻”的状态。这里先说结论课程设计阶段用 Session 就够了不必上 Token。很多人觉得 Token 比 Session 高级但在单体架构的考试系统里Session 反而少踩坑。Token 要自己处理过期时间、服务端校验、跨域放行Session 的过期管理是容器帮你做的登录后把 studentId 丢进 session判卷时再取出来就这么简单。掉线恢复的方案我一般这样设计前端每 30 秒往后端发一次自动保存请求后端把答题卡快照 JSON 写进exam_record.last_snapshot字段。学生重新登录进入考试页面时先请求后端拿快照同时与浏览器 localStorage 里的答题卡做合并——以时间戳晚的那份为准。为什么不是只靠后端快照因为 30 秒的间隔里有 30 秒的答案可能没保存到服务器而 localStorage 里存着最后一份已答题卡两边合并才能保证最少丢失。这个方案答辩时是个很好的亮点主动说出来“我做了一个自动保存机制掉线后可以恢复”比被动回答老师的提问强得多。实现上并不复杂一个异步接口接收快照 JSON一个查询接口读回last_snapshot前端用 setInterval 定时触发。唯一要注意的是快照接口不能加登录拦截以外的任何重量级操作否则大量学生同时考试时这些心跳请求会给服务器增加不必要的压力。5. 联调阶段最容易踩的 5 个坑环境、事务、时区与并发5.1 MySQL 8 驱动类名与 serverTimezoneClassNotFoundException 的真相现象项目从老教程里复制了数据库配置启动 Tomcat 时抛ClassNotFoundException: com.mysql.jdbc.Driver或者报了 “The server time zone value” 的错误。原因MySQL 5.x 时代的驱动类名是com.mysql.jdbc.DriverMySQL 8 里改成了com.mysql.cj.jdbc.Driver。同时 MySQL 8 的 JDBC 驱动强制要求连接串里显式声明时区不写就报错。解决数据库连接配置里把驱动类名换成com.mysql.cj.jdbc.Driver连接串加完整参数jdbc:mysql://localhost:3306/exam_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8这个问题的隐蔽性在于你用的是 MySQL 8 服务端但网上 90% 的教程截图还是 5.x 的配置。直接把整行配置换掉别只改驱动类名漏掉时区参数。5.2 MyBatis 自增主键拿不到answer_detail 的外键全是 0现象考试记录插入成功但答题明细表里exam_record_id全是 0后端控制台没有任何报错前端查成绩时明细和记录对不上。原因MyBatis 默认不自动回填数据库自增主键。如果你在insert标签上既没有开useGeneratedKeys也没有指定keyProperty那插入操作之后实体的 id 字段还是默认值 0。批量插入answer_detail时用的就是这堆 0。解决在插入exam_record的 Mapper 标签里显式配置insert idinsertExamRecord useGeneratedKeystrue keyPropertyid INSERT INTO exam_record (paper_id, student_id, start_time, status) VALUES (#{paperId}, #{studentId}, #{startTime}, #{status}) /insert注意keyProperty必须指向实体类里的 id 属性名不是数据库列名。插入之后要立即读取实体 id 并传给下一段批量插入不能在事务提交之后才去取值。5.3 并发交卷出现重复记录先查后插是伪命题现象多个学生同时点交卷后台偶尔抛Duplicate entry 1-2023001 for key uk_record的异常或者考试记录表里真的出现了两条同样的记录。原因业务层的写法是“先查 exam_record 里有没有记录没有才插入”。两个并发请求同时通过了查询发现都没有记录于是同时执行插入结果就撞了。先查后插在并发下就是摆设它不是原子操作。解决第三章那张唯一索引在这时候起作用。给exam_record建 (paper_id, student_id) 唯一索引数据库层面直接拦截重复记录然后在 Service 层 catchDuplicateKeyException捕获后重新查询一次已存在的记录返回。这才是事务和唯一索引配合的正确姿势而不是靠应用层的 if 判断。5.4 改了代码第二天启动就失败僵死 Tomcat 与端口占用现象昨天晚上项目还能正常跑今天启动时控制台报Port 8080 was already in use或者你改了代码重新部署发现页面上还是旧功能。原因IDE 里点停止按钮并没有真正杀掉 Tomcat 进程后台残留一个僵死进程占着端口。更隐蔽的是如果你改的是 Java 代码但没重新编译发布Tomcat 加载的还是旧 class 文件页面表现自然还是旧的。解决Windows 下执行netstat -ano | findstr 8080找到 PID再用taskkill /F /PID pid杀掉进程。之后每次部署都养成先 clean 再 redeploy 的习惯这个动作在 IDE 里按两下就完事但能避免一半以上的“改了没生效”问题。5.5 页面显示时间差 8 小时时区问题的三种来源现象考试记录的开始时间、交卷时间在页面上比实际时间整整少 8 小时。原因时区问题可能同时出现在三个地方——JDBC 连接串里serverTimezone设成了 UTCMySQL 系统时区本身是 UTC前端拿到时间后又做了本地化转换。这三个位置只要有一个不一致页面上的时间就会错。解决统一约定一个时区方案并说到做到。连接串固定serverTimezoneAsia/ShanghaiMySQL 用08:00前端拿到后端返回的时间字符串直接展示、不做任何转换。三个环节全向北京时间看齐这个坑才算真填平。6. 交卷后的最后一公里用一个并发自测脚本验证核心链路系统功能都做完之后最该做的一件事不是截图写论文而是写一个并发自测脚本专门验证“交卷不丢数据”这条核心链路。方法很简单模拟 20 个学生同时提交答题卡每个学生交 20 道题然后核对考试记录表有没有缺记录、答题明细表的exam_record_id是否全部正确关联。代码大概长这样ExecutorService pool Executors.newFixedThreadPool(20); CountDownLatch latch new CountDownLatch(20); for (int i 0; i 20; i) { final int studentId 1000 i; pool.submit(() - { try { // 调用本机服务的交卷接口传入模拟答题卡 httpPost(http://localhost:8080/exam/submit, buildSubmitBody(studentId, A,B,C,T|北京|部分答案)); } finally { latch.countDown(); } }); } latch.await(); // 等待20个交卷请求全部完成跑完之后用一条 SQL 就能完成校验SELECT paper_id, COUNT(*) FROM exam_record GROUP BY paper_id看每个学生是不是各有一条记录再查answer_detail里有没有孤儿数据。这个动作看起来简单但它能一次性暴露连接池不足、事务失效、唯一索引缺失三类问题。最后说一个我自己当年的教训毕业设计定稿前一周演示时学生交卷后成绩记录凭空消失查了三天才发现是exam_record没建唯一索引并发请求把数据插乱了。后来所有和考试相关的表我都先建唯一索引再写业务代码。这个习惯我一直留到现在。希望帮到你。本文还有配套的精品资源点击获取