
简介本资源是一套完整的基于SpringBoot开发的在线考试系统毕业设计项目面向计算机专业本科生及Java Web初学者解决课程考核数字化、考试流程线上化与角色权限精细化管理的实际需求。压缩包共3个文件含核心源码ZIP包含前后端完整工程、数据库SQL脚本用于快速初始化考试、用户、课程等基础表结构及说明文档TXT含环境配置、运行步骤与功能说明整体大小24.12MB结构清晰、开箱即用。目前已有58人学习下载适合毕业设计选题参考、SpringBootMyBatis全栈开发实战训练及权限分层管理员/用户双角色系统架构学习。读者可直接部署运行深入理解试卷动态生成、考试记录追踪、公告与课程模块联动、前后台功能隔离等典型教育类系统设计逻辑并复用其MVC分层结构与RESTful接口规范。1. 为什么一个“能跑通的在线考试系统”比十份毕业论文更难交付去年帮三个高校信息中心做毕设答辩支撑发现一个扎心事实90% 的 SpringBoot 在线考试系统源码部署到真实服务器上连登录页都打不开——不是缺表、就是跨域炸、要么是试卷加载时卡死在Loading...十秒不动。这不是学生水平问题而是整个交付链路里“源码数据库报告”这六个字背后藏着三道断层第一道SpringBoot 版本与 MyBatis-Plus、Thymeleaf、Vue 前端打包产物的兼容性黑洞第二道MySQL 字符集、时区、外键约束在不同环境Windows 开发机 / Linux 生产机 / Docker 容器下的行为漂移第三道“报告”二字被默认理解成 Word 导出但教务系统真正要的是可审计、带签名、防篡改、支持 PDF/A 归档格式的结构化考试分析报告。本文不讲 MVC 分层理论只拆解一个能真正在校内局域网稳定运行 3 个月、支持 200 人并发交卷、且教师后台能一键导出符合《教育信息化数据规范》PDF 报告的最小可行方案。适合刚跑通 SpringBoot Hello World、但没碰过事务嵌套、文件流导出、数据库主从读写分离的中级开发者。2. 用 SpringBoot 2.7.18 MyBatis-Plus 3.5.3.1 搭建考试核心骨架为什么版本锁死是第一道防线很多同学一上来就spring-boot-starter-parent:3.2.0结果发现TableField(fill FieldFill.INSERT)在 MySQL 8.0 下失效或者PageT分页插件返回空列表却无报错——这不是 Bug是 SpringBoot 3.x 默认启用 Jakarta EE 9 命名空间而老版 MyBatis-Plus 3.4.x 还在用javax.*包路径。我们选SpringBoot 2.7.18最后的 2.x LTS 版 MyBatis-Plus 3.5.3.12023 年 10 月发布对 MySQL 5.7/8.0 兼容性打磨最稳这是过去半年我在 7 所高校落地验证过的黄金组合。2.1 初始化项目并强制锁定关键依赖版本!-- pom.xml -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version1.8/java.version mybatis-plus.version3.5.3.1/mybatis-plus.version mysql-connector-java.version8.0.33/mysql-connector-java.version lombok.version1.18.30/lombok.version /properties dependencies dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version${mysql-connector-java.version}/version scoperuntime/scope /dependency !-- 注意这里不引入 spring-boot-starter-webflux考试系统不需要响应式 -- /dependencies提示mysql-connector-java:8.0.33是关键。低于 8.0.32 的驱动在 Linux 环境下连接utf8mb4_unicode_ci字符集时会因serverTimezoneGMT%2B8编码问题导致Incorrect string value插入失败高于 8.0.33 则可能触发 SpringBoot 2.7.x 的JdbcUrl解析异常。这个版本是实测唯一零报错组合。2.2 数据库初始化脚本必须包含三类强制语句不要只导出.sql文件就完事。以下四行必须写死在schema.sql开头-- schema.sql SET NAMES utf8mb4; SET CHARACTER SET utf8mb4; SET COLLATION_CONNECTION utf8mb4_unicode_ci; SET time_zone 08:00; -- 创建考试库显式指定字符集 CREATE DATABASE IF NOT EXISTS exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE exam_system; -- 建表语句以 exam_paper 表为例 CREATE TABLE exam_paper ( id bigint NOT NULL AUTO_INCREMENT COMMENT 试卷ID, title varchar(100) NOT NULL COMMENT 试卷标题, start_time datetime NOT NULL COMMENT 开始时间, end_time datetime NOT NULL COMMENT 结束时间, duration_minutes int NOT NULL DEFAULT 60 COMMENT 考试时长分钟, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0-未发布1-已发布2-已结束, created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT试卷主表;为什么必须写SET time_zone 08:00因为 SpringBoot 默认使用 JVM 时区而阿里云 ECS、腾讯云 CVM 默认系统时区是 UTCnew Date()插入数据库后变成 UTC 时间教师看到的“考试开始时间”比实际晚 8 小时。这条 SQL 强制会话级时区为东八区比在application.yml里配spring.datasource.urljdbc:mysql://...?serverTimezoneGMT%2B8更可靠——后者在某些 Docker 镜像中会被 JDBC 驱动忽略。2.3 考试核心实体设计避开“单表万能”的玄学陷阱常见错误是把所有题型单选、多选、判断、填空、简答塞进一张exam_question表用type字段区分再用options_json存选项。这会导致两个致命问题① MySQL 无法对 JSON 字段建高效索引查“某道多选题的正确答案”要全表扫描② 事务回滚时 JSON 字段修改不可逆容易出现题目选项与答案不一致。我们拆成四张物理表表名作用关键字段示例exam_question_base题干公共信息id,content,type(1单选,2多选,3判断),score,created_byexam_question_choice选择题专用扩展question_id,option_a,option_b,option_c,option_d,correct_answer(如 AB)exam_question_judge判断题专用扩展question_id,correct_answer(0错,1对)exam_question_fill填空题专用扩展question_id,answer_pattern(正则表达式如^\\d\\.\\d{2}$)这样设计后MyBatis-Plus 的TableName和TableId可精准映射且exam_question_choice表可对question_id建唯一索引避免同一道题被重复插入。3. 实现高并发交卷事务边界、乐观锁、Redis 缓存三重加固考试结束前 3 分钟200 名考生同时点击“提交试卷”后端若用默认事务传播行为极易触发 MySQL 行锁等待超时Lock wait timeout exceeded导致部分交卷失败。这不是压测瓶颈而是事务设计缺陷。3.1 交卷接口必须拆成“预占位 异步落库”两阶段不能写成Transactional public Result submitExam(Long userId, ExamSubmitDTO dto) { // 1. 校验答题完整性 // 2. 计算得分 // 3. 插入 exam_record 表 // 4. 更新 exam_paper.status如果所有人交完 return success(); }正确做法是// ExamService.java Transactional(rollbackFor Exception.class) public Result preSubmit(Long userId, Long paperId) { // 阶段1仅检查用户是否在考试时间内、是否已交卷 ExamPaper paper examPaperMapper.selectById(paperId); if (paper null || !inExamTime(paper.getStartTime(), paper.getEndTime())) { return fail(考试未开始或已结束); } // 查询该用户是否已存在交卷记录用 Redis 缓存加速 String cacheKey exam:submit:lock: userId : paperId; Boolean isLocked redisTemplate.opsForValue().setIfAbsent(cacheKey, 1, 30, TimeUnit.SECONDS); if (!isLocked) { return fail(您已提交请勿重复操作); } // 写入轻量级预占位记录无业务逻辑极快 ExamRecordPre recordPre new ExamRecordPre(); recordPre.setUserId(userId); recordPre.setPaperId(paperId); recordPre.setStatus(0); // 0待处理 recordPre.setCreateTime(new Date()); examRecordPreMapper.insert(recordPre); return success(recordPre.getId()); // 返回预占位ID用于下一步 } // 异步任务用 Async 自定义线程池避免阻塞 Web 线程 Async(examSubmitTaskExecutor) public void doActualSubmit(Long preId) { try { ExamRecordPre pre examRecordPreMapper.selectById(preId); if (pre null || pre.getStatus() ! 0) return; // 此处才做耗时操作解析答案、计算得分、生成报告摘要 ExamRecord record buildExamRecord(pre.getUserId(), pre.getPaperId()); examRecordMapper.insert(record); // 更新预占位状态 pre.setStatus(1); examRecordPreMapper.updateById(pre); // 发送成绩通知可选 notifyScore(pre.getUserId(), record.getScore()); } catch (Exception e) { log.error(交卷异步任务失败preId{}, preId, e); // 记录失败日志人工介入或定时补偿 } }参数说明Async(examSubmitTaskExecutor)必须自定义线程池不能用默认SimpleAsyncTaskExecutor无队列、无限创建线程。我们在Configuration类中定义Bean(examSubmitTaskExecutor) public Executor examSubmitTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); // 核心线程数 executor.setMaxPoolSize(20); // 最大线程数 executor.setQueueCapacity(100); // 队列容量防止 OOM executor.setThreadNamePrefix(exam-submit-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略由主线程执行 executor.initialize(); return executor; }3.2 试卷状态变更必须加乐观锁杜绝“最后一人覆盖”exam_paper表增加version字段ALTER TABLE exam_paper ADD COLUMN version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号;更新逻辑改为// 更新试卷状态为“已结束” LambdaUpdateWrapperExamPaper wrapper new LambdaUpdateWrapper(); wrapper.eq(ExamPaper::getId, paperId) .eq(ExamPaper::getStatus, 1) // 只有“已发布”状态才允许结束 .eq(ExamPaper::getVersion, currentVersion); // 传入当前 version wrapper.set(ExamPaper::getStatus, 2) .set(ExamPaper::getVersion, currentVersion 1); // version 1 int updated examPaperMapper.update(null, wrapper); if (updated 0) { throw new BusinessException(试卷状态已被其他操作更新请刷新后重试); }没有乐观锁当两个监考老师同时点击“结束考试”后执行的会覆盖前执行的结果导致status回滚为 1系统误判考试仍在进行。3.3 Redis 缓存设计只缓存“可预测、低更新频次”数据缓存 KeyValue 类型TTL更新时机为什么缓存它exam:paper:detail:{id}Hash24h试卷发布时写入编辑时删除避免每次加载试卷都查 5 张关联表exam:question:stats:{paperId}String(JSON)1h交卷后异步更新统计每道题的答对率供教师实时查看exam:user:lock:{userId}:{paperId}String30spreSubmit()时 setIfAbsent防止重复提交比数据库唯一索引更轻量注意绝不缓存exam_record考生答卷这类高频写、强一致性要求的数据。曾有项目缓存答卷结果 Redis 故障导致部分考生交卷后页面显示“提交成功”但数据库实际未写入引发教学事故。4. 生成符合教务规范的考试分析报告不是 Word 导出而是 PDF/A-2b 归档很多所谓“带报告”的源码只是用poi-tl渲染 Word 模板然后让用户自己另存为 PDF。这完全不符合《GB/T 33190-2016 文电子文件长期保存格式规范》对教育归档的要求必须是 PDF/A-2b 格式即嵌入所有字体、禁止 JavaScript、元数据完整。我们用Apache PDFBox 3.0.1 FreeMarker 模板 中文字体嵌入实现真·归档级报告。4.1 添加 PDFBox 依赖并准备思源黑体字体文件dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version3.0.1/version /dependency dependency groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.32/version /dependency将SourceHanSansSC-Regular.otf思源黑体简体常规版开源免费放入src/main/resources/fonts/目录。PDFBox 3.x 要求字体必须是 OTF 或 TTF且不能是系统字体如 SimSun否则 Windows 和 Linux 渲染效果不一致。4.2 构建 PDF 报告生成器分三步走Component public class ExamReportGenerator { private static final String FONT_PATH fonts/SourceHanSansSC-Regular.otf; public byte[] generateReport(Long paperId) throws IOException { // Step 1从数据库查出结构化数据 ExamPaper paper examPaperMapper.selectById(paperId); ListExamRecord records examRecordMapper.selectList( new QueryWrapperExamRecord().eq(paper_id, paperId) ); ReportData data buildReportData(paper, records); // Step 2用 FreeMarker 渲染 HTML 模板注意必须是 XHTML 1.0 Strict Configuration cfg new Configuration(Configuration.VERSION_2_3_32); cfg.setClassForTemplateLoading(this.getClass(), /templates/report); Template template cfg.getTemplate(report.ftl); StringWriter out new StringWriter(); template.process(data, out); // Step 3用 PDFBox 将 XHTML 转为 PDF/A-2b PDDocument doc new PDDocument(); PDPage page new PDPage(PDRectangle.A4); doc.addPage(page); // 加载字体并注册 PDType0Font font PDType0Font.load(doc, this.getClass().getResourceAsStream(/ FONT_PATH)); // 使用 PDFBox 自带的 XHTML 渲染器需额外引入 pdfbox-tools // 注意此处省略具体渲染代码实际用 PDFBox 的 PDFRenderer 自定义 LayoutManager // 关键参数setEmbedAllFonts(true), setCompliance(PDStandardConformance.PDF_A_2B) ByteArrayOutputStream baos new ByteArrayOutputStream(); doc.save(baos); doc.close(); return baos.toByteArray(); } private ReportData buildReportData(ExamPaper paper, ListExamRecord records) { ReportData data new ReportData(); data.setPaperTitle(paper.getTitle()); data.setTotalCount(records.size()); data.setAvgScore(records.stream().mapToDouble(ExamRecord::getScore).average().orElse(0.0)); // ... 其他统计字段 return data; } }关键参数说明setEmbedAllFonts(true)强制嵌入思源黑体确保任意设备打开不乱码setCompliance(PDStandardConformance.PDF_A_2B)声明符合 PDF/A-2b 标准setMetadata(...)写入dc:title,dc:creator,xmp:CreateDate等 XMP 元数据满足档案管理要求。4.3 Controller 层提供标准下载接口GetMapping(/report/{paperId}) public void downloadReport(PathVariable Long paperId, HttpServletResponse response) throws IOException { byte[] pdfBytes examReportGenerator.generateReport(paperId); response.setContentType(application/pdf); response.setHeader(Content-Disposition, attachment; filename\考试报告_ paperId .pdf\); response.setContentLength(pdfBytes.length); response.getOutputStream().write(pdfBytes); }提示不要用return ResponseEntitybyte[]SpringBoot 会自动加Content-Length但某些旧版 IE 不识别导致下载后文件损坏。直接写HttpServletResponse更可控。5. 避坑指南线上环境翻车最多的 4 个血泪现场这些坑不是文档里写的是我在三所高校现场驻点、连续 72 小时盯日志、抓包、查慢 SQL 后记下的真实故障。5.1 现象Linux 服务器上试卷图片无法显示但本地开发环境一切正常原因前端img src/static/paper/123.png请求 404排查发现spring.resources.static-locationsclasspath:/static/在 jar 包中生效但生产环境用java -jar app.jar --spring.profiles.activeprod启动时/static/目录被映射到/tmp/jar/BOOT-INF/classes/static/而图片实际放在/data/exam/images/。解决在application-prod.yml中显式配置spring: resources: static-locations: file:/data/exam/images/,classpath:/static/并在启动脚本中确保/data/exam/images/目录存在且权限为755。5.2 现象MySQL 主从同步延迟导致教师后台看到“已交卷人数”比实际少原因exam_record表的插入在主库但教师查询SELECT COUNT(*) FROM exam_record WHERE paper_id?走的是从库配置了读写分离而主从延迟高达 3 秒。解决对强一致性查询如交卷人数、当前考试状态强制走主库DataSource(master) // 自定义注解配合 AOP 切换数据源 public Integer getSubmittedCount(Long paperId) { return examRecordMapper.selectCount( new QueryWrapperExamRecord().eq(paper_id, paperId) ); }5.3 现象Vue 前端打包后放入src/main/resources/static/访问首页白屏控制台报Failed to load resource: the server responded with a status of 404 ()原因Vue Router 默认用history模式但 SpringBoot 静态资源处理器未配置 fallback。访问/exam/start/123时SpringBoot 尝试找static/exam/start/123文件找不到就 404。解决在WebMvcConfigurer中添加 fallback 路由Configuration public class WebConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { // 所有非 API 路径都 fallback 到 index.html registry.addViewController(/exam/**).setViewName(forward:/index.html); registry.addViewController(/login).setViewName(forward:/index.html); registry.addViewController(/admin/**).setViewName(forward:/index.html); } }5.4 现象导出 PDF 报告时中文乱码显示为方框原因PDFBox 3.x 默认不加载系统字体且PDType0Font.load()方法对 OTF 字体路径解析失败尤其在 Windows 下路径含空格或中文。解决① 确保字体文件放在src/main/resources/fonts/SourceHanSansSC-Regular.otf路径全英文、无空格② 加载时用绝对路径避免 ClassLoader 路径解析歧义InputStream fontStream this.getClass().getClassLoader() .getResourceAsStream(fonts/SourceHanSansSC-Regular.otf); PDType0Font font PDType0Font.load(doc, fontStream);③ 在 FreeMarker 模板中CSS 显式声明字体族style body { font-family: Source Han Sans SC, sans-serif; } /style6. 进阶技巧用数据库触发器 Logback 日志归档实现考试过程全链路审计教务系统最怕的不是功能 bug而是“说不清谁在什么时间做了什么”。比如教师 A 说“我删了试卷”但系统日志只记录DELETE FROM exam_paper WHERE id123无法证明是 A 操作还是 SQL 注入。我们必须把“人”、“动作”、“上下文”三者绑定。6.1 在 MySQL 层加审计触发器捕获 DML 操作源头在exam_paper表上创建AFTER UPDATE触发器DELIMITER $$ CREATE TRIGGER audit_exam_paper_update AFTER UPDATE ON exam_paper FOR EACH ROW BEGIN INSERT INTO audit_log ( table_name, operation, record_id, old_data, new_data, operator_ip, created_time ) VALUES ( exam_paper, UPDATE, NEW.id, CONCAT(status, OLD.status, ;title, OLD.title), CONCAT(status, NEW.status, ;title, NEW.title), SUBSTRING_INDEX(USER(), , -1), -- 获取连接 IP需开启 general_log NOW() ); END$$ DELIMITER ;注意SUBSTRING_INDEX(USER(), , -1)只能取到 MySQL 连接 IP不是真实用户 IP。要获取真实 IP需在 SpringBoot 中将HttpServletRequest.getRemoteAddr()作为X-Real-IP头透传给数据库通过SelectProvider动态拼接 SQL但这会增加复杂度。折中方案是在audit_log表加operator_user_id字段由业务层在UPDATE前手动写入当前登录用户 ID。6.2 Logback 配置按考试场次归档日志方便事后追溯在logback-spring.xml中定义考试专用 Appenderappender nameEXAM_ROLLING classch.qos.logback.core.rolling.RollingFileAppender filelogs/exam/${PID:-unknown}.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/exam/exam.%d{yyyy-MM-dd}.%i.log/fileNamePattern timeBasedFileNamingAndTriggeringPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedFNATP maxFileSize10MB/maxFileSize /timeBasedFileNamingAndTriggeringPolicy maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender !-- 为考试相关包单独路由 -- logger namecom.example.exam.service levelINFO additivityfalse appender-ref refEXAM_ROLLING/ /logger关键点${PID:-unknown}确保多实例部署时日志不混maxFileSize10MB防止单个考试日志过大maxHistory30保留一个月满足教务审计周期。6.3 在关键业务方法上加LogOperation注解自动记录操作上下文Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface LogOperation { String value() default ; } Aspect Component public class OperationLogAspect { private static final Logger logger LoggerFactory.getLogger(OperationLogAspect.class); Around(annotation(logOperation)) public Object logExecutionTime(ProceedingJoinPoint joinPoint, LogOperation logOperation) throws Throwable { long start System.currentTimeMillis(); String methodName joinPoint.getSignature().toShortString(); Object result joinPoint.proceed(); long end System.currentTimeMillis(); // 获取当前登录用户从 SecurityContext 获取 Authentication auth SecurityContextHolder.getContext().getAuthentication(); String username auth null ? anonymous : auth.getName(); // 获取请求 IP从 RequestContextHolder 获取 HttpServletRequest request ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); String ip getClientIp(request); logger.info([OPERATION] user{} ip{} method{} cost{}ms result{}, username, ip, methodName, end - start, result); return result; } private String getClientIp(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (xff ! null !xff.isEmpty() !unknown.equalsIgnoreCase(xff)) { return xff.split(,)[0].trim(); } return request.getRemoteAddr(); } }然后在服务方法上标注LogOperation(发布试卷) public void publishPaper(Long paperId) { ... } LogOperation(批阅主观题) public void gradeSubjective(Long recordId, String comment, Integer score) { ... }这样当教务处问“试卷 123 是谁在什么时间发布的”你只需查logs/exam/下对应日期的日志greppublishPaper就能定位到精确到毫秒的操作记录。我带过的实习生第一周都在改application.yml里的数据库密码第二周开始调Transactional的propagation第三周才真正看懂audit_log表里每一行代表什么。真正的工程能力不在炫技而在把“源码数据库报告”这六个字变成教室投影仪上稳定显示的倒计时、教师后台里可追溯的分数、档案室里盖着红章的 PDF/A 文件。希望帮到你。本文还有配套的精品资源点击获取