☰
SpringBoot毕业审核系统实战:从数据建模到规则引擎的完整落地
2026/10/7 10:45:43 网站建设 项目流程

简介:这份资源是面向高校计算机相关专业毕业生与Java初学者的毕业设计完整源码包,主题为基于SpringBoot的学生毕业审核系统,可用于课程设计参考、毕设选题落地或SpringBoot入门实战。压缩包共81个文件,以74个Java源码文件为核心,配套properties配置文件、pom.xml依赖描述、mvnw与mvnw.cmd构建脚本及jar包等,整体约110KB,结构精简、便于导入IDE后直接运行调试。系统围绕用户管理、学生信息管理、成绩管理、毕业审核流程与系统日志等模块展开,涵盖SpringBoot自动配置、RESTful接口设计、Spring Security权限控制与统一异常处理等关键技术点,并采用MySQL与JPA完成数据持久化。目前已有58人学习下载,适合希望快速理解SpringBoot项目分层结构、掌握前后端交互与权限控制实现思路的读者参考借鉴。

1. 毕业审核系统到底卡在哪:从“能跑就行”到“敢给教务用”

每年五六月,教务老师最怕的不是排课,是毕业审核。一个专业几百号人,培养方案里必修、选修、实践、学分、绩点、挂科重修,条件叠在一起,用 Excel 筛一遍要一整天,筛完还不敢保证没错。学生那边更焦虑,不知道自己差几个学分、哪门课替代没认。学生毕业审核系统要解决的就是这件事:把培养方案变成可执行的规则,把成绩单变成可判定的数据,让审核从“人眼比对”变成“系统跑批”。基于 SpringBoot 做这套系统,是计算机毕业设计里少有的“业务真实、技术栈主流、工作量可控”的选题。它适合想拿一个能讲清楚业务闭环的毕设的同学,也适合教务信息化方向想快速搭原型的开发者。下面我按自己做过的一版思路,把选型、建模、规则引擎、踩坑和验证讲透。

2. 需求拆解与数据建模:审核规则怎么落成表

2.1 先分清三类角色和四条主流程

毕业审核系统看着简单,真动手最容易翻车的地方是角色和状态没理清。我一般先画清楚三类角色:学生只看自己的审核结果和缺项明细;教务老师按专业/年级发起审核批次、处理人工认定;管理员维护培养方案、课程库和规则模板。四条主流程是:培养方案录入 → 学生成绩导入 → 审核规则执行 → 结果认定与导出。

这里有个反直觉的点:审核不是“一次算完就结束”。真实场景里,学生大四上还有课在修,教务要跑“预审”和“终审”两轮,中间还有重修成绩补录、课程替代认定。所以状态机必须设计成可重入的,不能算完就锁死。常见做法是给每个审核批次一个batch_status,给每个学生一条audit_record,记录pre_audit、final_audit、manual_adjust三个阶段的结果快照。这样教务改了一条认定,只需要重跑单个学生,不用全量重算。

数据建模的核心是“培养方案版本化”。同一专业不同年级的培养方案不一样,甚至同一年级不同方向也不同。所以方案表要有major_id、grade、direction、version四个维度,学生通过class_id关联到具体方案版本。这一步没做好,后面规则全乱。

2.2 核心表结构与字段说明

下面是我实际用过的核心表设计,MySQL 8.0,字段做了精简但保留了关键约束。

-- 培养方案主表:一个专业+年级+方向对应一个版本 CREATE TABLE train_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, major_id BIGINT NOT NULL COMMENT '专业ID', grade VARCHAR(10) NOT NULL COMMENT '年级,如2021', direction VARCHAR(50) DEFAULT '通用' COMMENT '方向', version INT NOT NULL DEFAULT 1 COMMENT '版本号', total_credit DECIMAL(5,1) NOT NULL COMMENT '毕业总学分要求', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1启用 2停用', UNIQUE KEY uk_plan (major_id, grade, direction, version) ) COMMENT '培养方案'; -- 方案课程要求:规则的最小单元 CREATE TABLE plan_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_id BIGINT NOT NULL, course_id BIGINT NOT NULL, course_type TINYINT NOT NULL COMMENT '1必修 2限选 3任选 4实践', module_code VARCHAR(30) NOT NULL COMMENT '模块,如通识/专业核心/实践', min_credit DECIMAL(4,1) NOT NULL COMMENT '该课程最低学分', group_code VARCHAR(30) DEFAULT NULL COMMENT '课程组,同组修满即可替代', KEY idx_plan (plan_id) ) COMMENT '方案课程要求'; -- 学生审核结果快照 CREATE TABLE audit_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, student_id BIGINT NOT NULL, stage TINYINT NOT NULL COMMENT '1预审 2终审 3人工调整', passed TINYINT NOT NULL DEFAULT 0 COMMENT '0未通过 1通过', missing_json JSON DEFAULT NULL COMMENT '缺项明细快照', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stu_batch (student_id, batch_id) ) COMMENT '审核结果';

plan_course里的group_code是课程替代的关键。比如“大学英语(一)到(四)”和“高级英语”同属一个组,学生修了高级英语可以顶掉其中一门。审核时按组聚合学分,而不是逐门死磕。missing_json存快照而不是实时算,是为了让教务看到“当时为什么没通过”,避免成绩补录后历史记录对不上,这个设计在答辩时很加分。

2.3 规则怎么表达:别一上来就上规则引擎

很多同学一听“审核规则”就想上 Drools,我的血泪经验是:毕设阶段别碰。Drools 学习成本高,规则文件调试麻烦,答辩老师一问“为什么用规则引擎”你答不好反而扣分。真实教务规则 90% 是“某模块学分 ≥ X 且 必修课全部通过”,用配置表 + 代码判定完全够用。

我一般把规则拆成三类:学分总量判定、模块学分判定、课程组判定。每类写一个Checker实现类,用策略模式串起来。这样新增规则只加一个类,符合开闭原则,答辩讲设计模式也有话说。规则参数全部从plan_course和train_plan读,不硬编码,换专业不用改代码。

3. SpringBoot 工程落地:从建表到跑通一次审核

3.1 项目分层与依赖选型

工程结构按经典三层走,但审核模块单独抽一个audit包,避免和基础 CRUD 混在一起。依赖上,SpringBoot 3.x 配 MyBatis-Plus 是我最顺手的组合,比 JPA 写复杂查询省心。注意热搜里常提“springboot版本太高”,确实有坑:SpringBoot 3.x 要求 JDK 17,且javax.*全部换成jakarta.*,如果你抄的是 2.x 的代码,导入包名会全线报错。毕设求稳的话,JDK 17 + SpringBoot 3.2 是当前主流,别用 2.7 了,答辩时显得旧。

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

MyBatis-Plus 的LambdaQueryWrapper在按模块聚合学分时很好用,不用手写 XML。validation用来校验培养方案录入,比如总学分不能小于各模块之和,这个校验放在@Valid里,比在 Service 里写 if 优雅。

3.2 审核核心逻辑:一次跑批的完整代码

审核入口我设计成按批次触发,支持全量和单学生两种模式。核心逻辑是:加载学生方案 → 拉取有效成绩 → 逐规则判定 → 写快照。

@Service public class AuditService { @Resource private PlanCourseMapper planCourseMapper; @Resource private ScoreMapper scoreMapper; @Resource private AuditRecordMapper auditRecordMapper; /** * 审核单个学生 * @param studentId 学生ID * @param planId 培养方案ID * @param batchId 审核批次 * @param stage 1预审 2终审 */ public AuditResult auditOne(Long studentId, Long planId, Long batchId, int stage) { // 1. 拉取该方案所有课程要求 List<PlanCourse> requirements = planCourseMapper.selectByPlanId(planId); // 2. 拉取学生已通过的成绩(只取及格且有效的) List<Score> scores = scoreMapper.selectPassedByStudent(studentId); // 3. 构建已修课程学分映射:courseId -> credit Map<Long, BigDecimal> earnedMap = scores.stream() .collect(Collectors.toMap(Score::getCourseId, Score::getCredit, BigDecimal::add)); List<String> missing = new ArrayList<>(); BigDecimal totalEarned = BigDecimal.ZERO; // 4. 按模块聚合判定 Map<String, List<PlanCourse>> byModule = requirements.stream() .collect(Collectors.groupingBy(PlanCourse::getModuleCode)); for (Map.Entry<String, List<PlanCourse>> entry : byModule.entrySet()) { String module = entry.getKey(); BigDecimal moduleEarned = BigDecimal.ZERO; for (PlanCourse pc : entry.getValue()) { BigDecimal got = earnedMap.getOrDefault(pc.getCourseId(), BigDecimal.ZERO); if (got.compareTo(pc.getMinCredit()) >= 0) { moduleEarned = moduleEarned.add(got); } else if (pc.getCourseType() == 1) { // 必修课未修满,直接记缺项 missing.add("必修课未通过:" + pc.getCourseId()); } } // 模块学分要求从方案配置读,这里简化用课程要求之和 BigDecimal moduleNeed = entry.getValue().stream() .map(PlanCourse::getMinCredit).reduce(BigDecimal.ZERO, BigDecimal::add); if (moduleEarned.compareTo(moduleNeed) < 0) { missing.add("模块[" + module + "]学分不足,差 " + moduleNeed.subtract(moduleEarned)); } totalEarned = totalEarned.add(moduleEarned); } // 5. 写结果快照 AuditResult result = new AuditResult(); result.setPassed(missing.isEmpty()); result.setMissing(missing); result.setTotalEarned(totalEarned); saveRecord(studentId, batchId, stage, result); return result; } }

这段代码的关键在earnedMap的构建:用Collectors.toMap的第三个参数BigDecimal::add处理同一课程多次修读(重修)的情况,取累加而不是覆盖。missing列表直接存中文描述,前端展示和导出 Excel 都能直接用,省一层翻译。参数stage决定快照归属哪个阶段,预审和终审各存一份,教务对比两次结果就能看出学生补修进展。

3.3 成绩导入与课程替代的接口设计

成绩导入是脏数据重灾区。教务给的 Excel 里课程名可能和课程库对不上,常见做法是先做名称模糊匹配,匹配不上的进“待认定”列表,由教务人工绑定course_id。我一般用 HanLP 或简单的编辑距离做匹配,热搜里提到的hanlp分词在springboot就是这个场景,但毕设用编辑距离足够,别过度设计。

课程替代走独立接口,教务选定“被替代课程”和“替代课程”,写一条course_substitute记录。审核时先查替代关系,把替代课程的学分映射到被替代课程上,再走正常判定。这个接口要加权限校验,只有教务角色能调,用 Spring Security 的@PreAuthorize一行搞定。

4. 避坑与排查:审核系统最容易翻车的五个点

4.1 学分精度丢失导致“差 0.1 分不通过”

现象:学生明明修够了,系统判定差 0.1 学分。原因:double做学分累加有浮点误差,0.1 + 0.2 不等于 0.3。解决:所有学分字段用DECIMAL(5,1),Java 侧统一用BigDecimal,比较用compareTo不用equals。这个坑我在第一次跑批时踩得结结实实,教务拿着名单来找我,场面很尴尬。

4.2 重修成绩覆盖还是累加搞反

现象:学生重修同一门课,学分被算了两遍,总学分虚高。原因:toMap没传合并函数,或者传了但逻辑写成累加。解决:同一课程多次修读只取最高分对应的学分,用BinaryOperator.maxBy按成绩排序取最高,而不是BigDecimal::add。上面代码里我为了演示累加写了add,实际生产要改成取最高分,这点务必注意。

4.3 培养方案版本切换后历史审核结果错乱

现象:教务更新了培养方案,之前审核通过的学生重新查变成不通过。原因:审核时实时关联了最新方案,没存方案版本快照。解决:audit_record里加plan_id和plan_version字段,审核结果永远关联当时的方案版本,方案更新不影响历史记录。

4.4 批量审核接口超时

现象:一个专业 500 人,全量审核跑 3 分钟,前端请求超时。原因:循环里逐条查数据库,N+1 问题。解决:先批量查出所有学生的成绩和方案要求,在内存里做匹配,或者用 MyBatis 的foreach批量查。500 人规模优化后能压到 5 秒内。再大就上异步任务,返回taskId让前端轮询。

4.5 前端打包放进 SpringBoot 后刷新 404

现象:Vue 打包后丢进static目录,首页能开,刷新子路由 404。原因:History 路由模式下,SpringBoot 没配 fallback。解决:加一个WebMvcConfigurer,把所有非 API 路径转发到index.html。热搜里vue打包放进springboot中说的就是这个,配置如下。

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/**") .addResourceLocations("classpath:/static/"); } @Override public void addViewControllers(ViewControllerRegistry registry) { // 非 /api 开头的路径全部回退到 index.html registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); } }

5. 让审核结果可解释:导出明细与二次认定

5.1 审核明细导出:教务要的是能贴进通知的表格

审核跑完只是开始,教务真正要的是一份能发给学生的明细表。我一般用 EasyExcel 导出,字段包括学号、姓名、总学分、已修学分、缺项说明。缺项说明直接取missing_json里的列表,用分号拼接。导出时按专业分 sheet,一个 sheet 一个专业,教务不用再手动拆。

public void exportAudit(Long batchId, HttpServletResponse response) { List<AuditExportVO> list = auditRecordMapper.selectExportByBatch(batchId); // 按专业分组,每个专业一个 sheet Map<String, List<AuditExportVO>> byMajor = list.stream() .collect(Collectors.groupingBy(AuditExportVO::getMajorName)); try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) { int sheetNo = 0; for (Map.Entry<String, List<AuditExportVO>> e : byMajor.entrySet()) { WriteSheet sheet = EasyExcel.writerSheet(sheetNo++, e.getKey()) .head(AuditExportVO.class).build(); writer.write(e.getValue(), sheet); } } catch (IOException ex) { throw new RuntimeException("导出失败", ex); } }

groupingBy按专业名分组,sheetNo自增保证 sheet 顺序。注意response要提前设置Content-Type和文件名编码,否则中文文件名会乱码,这个坑在 Windows 服务器上尤其常见。

5.2 二次认定:人工调整怎么不破坏历史

教务人工认定后,不能直接改原审核记录,要新增一条stage=3的记录,并在missing_json里标注“人工认定通过”。这样学生查历史能看到“系统判定未通过,教务认定通过”,责任清晰。认定接口要记录操作人和时间,方便追溯。我一般还会加一个“认定原因”必填项,答辩时老师问“怎么保证认定不滥用”,这就是答案。

5.3 验证方法:怎么证明你的审核是对的

毕设最怕答辩老师问“你怎么知道算得对”。我的做法是准备一组边界用例:刚好修满学分的、差 0.5 学分的、有课程替代的、有重修取高分的、必修课挂科的。每个用例手工算一遍预期结果,写成单元测试。跑通这五个用例,基本能覆盖 90% 的判定逻辑。测试用 JUnit 5 +@SpringBootTest,断言直接比对missing列表内容。

@Test void testAudit_justEnoughCredit_shouldPass() { AuditResult result = auditService.auditOne(1001L, 1L, 1L, 1); assertTrue(result.isPassed()); assertTrue(result.getMissing().isEmpty()); }

这组测试用例本身就是答辩材料,比空口说“我测过了”有说服力得多。我自己的习惯是:每加一条审核规则,先写测试用例再写实现,跑不通过不提交。这个习惯让我的审核模块在联调阶段几乎没出过逻辑错误,教务改需求时也敢改,因为测试会兜底。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询