简介:面向高校毕业设计场景的完整资料包,围绕基于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 + Hibernate,SSM 是 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.version>5.3.23</spring.version> <mybatis.version>3.5.10</mybatis.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- Spring 核心容器 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <!-- SpringMVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 核心 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <!-- MyBatis 与 Spring 整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <!-- MySQL 驱动,注意 8.x 版本驱动类名变了 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <!-- 连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.15</version> </dependency> <!-- JSON 序列化,Ajax 返回数据用 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.13.4</version> </dependency> <!-- 以下两个依赖容器已自带,必须用 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</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_301,Tomcat 启动脚本会因为这个路径里的空格解析出错,报找不到 catalina.jar,这是最容易被忽略的环境变量配置坑。
3. 建表先于写代码:题库、试卷与考试记录的 7 张核心表怎么设计
3.1 题库表:一道题怎么存,决定了随机组卷能不能写好
考试系统的一切功能都建立在题库数据之上。题库表设计不合理,后面写随机组卷时就是在垃圾数据上盖楼。我见过最差的题库表是把单选题的 A/B/C/D 四个选项分成四列存,结果一遇到填空题、简答题就傻眼了——空着四个字段,在论文的数据库设计章节里自己都解释不通。
标准做法是把题目本身的信息和答案分开考虑。先看一份可以直接拿去建表的 DDL:
CREATE 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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用 INT,MyBatis 做映射和 join 查询时类型不一致会让索引失效,轻则慢查询,重则查出来全是 0。
外键这件事值得单独说。教科书写要加物理外键保证引用完整性,但实际做 SSM 项目时,我强烈建议在表结构里去掉物理外键、只在逻辑上做关联。原因有两个:一是 MySQL 在插入answer_detail时要一次性批量写入几十条记录,每条都要做外键校验,数据量一上来性能肉眼可见地下降;二是课程设计阶段你很可能要手动改测试数据,物理外键会把一次简单的数据订正变成连环操作。逻辑外键完全够用——你在 SQL 里 join 的时候关系就清清楚楚,论文里把这点写明白,老师不会挑理。
4. 从随机组卷到自动判分:核心流程的 Java 实现与参数细节
4.1 随机组卷算法:按知识点覆盖和难度比例抽题
随机组卷是考试系统最核心的功能,也是最容易被做成“伪随机”的一块。所谓的伪随机,就是 SQL 里ORDER BY RAND()然后 LIMIT 抽几道题,交差了事。这种写法在小数据量时确实能跑,但它控制不了知识点分布——题库里第二章的题有 50 道、第四章只有 5 道,随机出来的卷子大概率全是第二章,老师一看就皱眉。
正路是先定规则再抽题。组卷时老师输入总分、各题型题量、难度比例、知识点范围,系统按规则从题库里抽。常见做法是两轮抽题:第一轮按知识点配额抽题,优先保证每个知识点都有题;第二轮用全局随机补足剩余的题量,保证卷子总量不悬空。核心代码在 Service 层,大概长这样:
@Override public List<Question> generatePaperQuestions(PaperRuleDTO rule) { // 组卷规则参数:题型、各难度题数、知识点列表 Integer[] difficultyCounts = rule.getDifficultyCounts(); // 例如 [6, 10, 4] 表示易6题、中10题、难4题 List<String> knowledgePoints = rule.getKnowledgePoints(); // 期望覆盖的知识点列表 // 第一轮:按知识点配额抽题,保证覆盖面 List<Question> result = new ArrayList<>(); for (String kp : knowledgePoints) { for (int i = 0; i < 3; i++) { // 简单示例:每个知识点先抽3题 List<Question> 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) { List<Question> 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: // 多选:全部选对满分,少选得一半,错选零分 Set<String> stuSet = new HashSet<>(Arrays.asList( studentAnswer.split(","))); Set<String> 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 驱动类名与 serverTimezone:ClassNotFoundException 的真相
现象:项目从老教程里复制了数据库配置,启动 Tomcat 时抛ClassNotFoundException: com.mysql.jdbc.Driver,或者报了 “The server time zone value” 的错误。
原因:MySQL 5.x 时代的驱动类名是com.mysql.jdbc.Driver,MySQL 8 里改成了com.mysql.cj.jdbc.Driver。同时 MySQL 8 的 JDBC 驱动强制要求连接串里显式声明时区,不写就报错。
解决:数据库连接配置里把驱动类名换成com.mysql.cj.jdbc.Driver,连接串加完整参数:
jdbc:mysql://localhost:3306/exam_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8这个问题的隐蔽性在于:你用的是 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 id="insertExamRecord" useGeneratedKeys="true" keyProperty="id"> 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设成了 UTC,MySQL 系统时区本身是 UTC,前端拿到时间后又做了本地化转换。这三个位置只要有一个不一致,页面上的时间就会错。
解决:统一约定一个时区方案并说到做到。连接串固定serverTimezone=Asia/Shanghai,MySQL 用+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没建唯一索引,并发请求把数据插乱了。后来所有和考试相关的表,我都先建唯一索引再写业务代码。这个习惯我一直留到现在。希望帮到你。
本文还有配套的精品资源,点击获取