☰
SpringBoot毕业设计管理系统:状态机与MinIO文件存储实战
2026/10/28 16:40:52 网站建设 项目流程

简介:这是一套面向高校计算机相关专业毕业设计场景的完整管理系统源码,基于Spring Boot与Java后端技术栈开发,适合正在准备毕业设计的学生、需要课程设计参考的开发者以及希望了解前后端协作流程的初学者。系统按角色划分为管理员、教师、学生三大模块:管理员可完成登录、个人信息维护、用户添加、模板设置、数据配置、选题情况查看与远程控制;教师端支持个人信息管理、密码修改、题目发布、学生选择、任务书下发与论文下载;学生端则涵盖个人信息、选题、任务书下载与论文上传等环节,基本覆盖毕业设计全流程管理需求。压缩包共763个文件,约10.35MB,包含55个Java源文件、55个class编译文件、119个xml配置、78个js与62个html页面,以及png、gif等静态资源,另附sql脚本与docx论文文档,便于直接部署与二次开发。目前已有3330人学习下载,可作为毕业设计选题与实现的实用参考。

1. 毕业设计管理系统:从选题到答辩,一套 SpringBoot 项目怎么把流程管起来

每年到了毕业季,教务老师手里那堆 Excel 就开始失控:选题靠邮件来回确认,开题报告用微信传,中期检查表打印出来签字再扫描,答辩分组靠人工排。学生也痛苦,改了三版的论文到底哪版是终稿,导师批注散落在各个聊天记录里。毕业设计管理系统要解决的就是这件事——把选题、开题、中期、查重、答辩、成绩归档这条链路搬到线上,让状态可追踪、材料可沉淀、角色权限清晰。用 SpringBoot 做这件事,核心原因不是它流行,而是这套业务天然适合分层架构:学生、导师、教务三类角色对应三套接口,流程节点对应状态机,文件上传对应对象存储,通知对应消息队列。如果你正在做这个方向的毕业设计,或者单位要搭一套轻量级教务流程系统,下面这套从建表到部署的路径可以直接抄。

2. 选题与角色建模:先把三张核心表和状态机定下来

2.1 为什么毕设系统的难点不在增删改查,而在状态流转

很多同学拿到这个题目,第一反应是画 ER 图,把学生表、教师表、课题表建出来,然后写 Controller 做 CRUD。写完发现教务老师问了一句「学生选了课题但导师还没确认,这个状态怎么算」,整个系统就卡住了。毕业设计管理系统的本质是一个带审批流的业务系统,不是简单的信息录入系统。选题有「待选、已选待确认、已确认、被驳回」四个状态,开题有「未提交、待审、通过、退回修改」,中期检查同理,答辩还有「待分组、已分组、已答辩、已评分」。这些状态之间的迁移条件、谁能触发迁移、迁移后通知谁,才是这个系统真正的复杂度所在。

我一般会建议先用一张project_topic表把课题和状态绑在一起,再用一张topic_selection表记录学生和课题的关联关系及确认状态。不要把状态字段塞在学生表里,因为一个学生可能先选了一个课题被驳回,再选另一个,历史记录要留痕。状态机可以用枚举加一个status字段实现,也可以用 Spring StateMachine,但对于毕设这种规模,枚举加 Service 层校验足够了,引入状态机框架反而增加调试成本。

2.2 三张核心表的字段设计与索引策略

下面是我在实际项目里用的建表语句,MySQL 8.0,字符集 utf8mb4。注意topic_selection表上建了联合唯一索引,防止同一学生对同一课题重复提交。

-- 课题表:导师发布,教务审核 CREATE TABLE `project_topic` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL COMMENT '课题名称', `description` TEXT COMMENT '课题描述', `teacher_id` BIGINT NOT NULL COMMENT '发布导师ID', `max_students` INT DEFAULT 1 COMMENT '可带学生数', `selected_count` INT DEFAULT 0 COMMENT '已选人数', `status` TINYINT DEFAULT 0 COMMENT '0待审 1已发布 2已满 3已关闭', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_teacher_status` (`teacher_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选题记录表:学生选课与导师确认 CREATE TABLE `topic_selection` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `topic_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0待确认 1已确认 2已驳回 3已退选', `apply_reason` VARCHAR(500) COMMENT '申请理由', `confirm_time` DATETIME COMMENT '导师确认时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_topic_student` (`topic_id`, `student_id`), KEY `idx_student_status` (`student_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 流程节点表:开题/中期/答辩统一抽象 CREATE TABLE `process_node` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `student_id` BIGINT NOT NULL, `node_type` VARCHAR(20) NOT NULL COMMENT 'OPENING/MIDTERM/DEFENSE', `status` TINYINT DEFAULT 0 COMMENT '0未提交 1待审 2通过 3退回', `file_url` VARCHAR(500) COMMENT '材料存储路径', `score` DECIMAL(5,2) COMMENT '评分', `comment` VARCHAR(1000) COMMENT '评语', `submit_time` DATETIME, `review_time` DATETIME, PRIMARY KEY (`id`), KEY `idx_student_node` (`student_id`, `node_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

project_topic上的idx_teacher_status索引是为了导师端「我发布的课题列表」按状态筛选;topic_selection的联合唯一索引是硬约束,业务层也要做一次校验,因为并发下唯一索引报错信息对用户不友好;process_node用node_type把开题、中期、答辩统一抽象,避免建三张结构一样的表,后期加「预答辩」节点只需加一个枚举值。

2.3 用 SpringBoot 实现选题确认的状态流转

状态流转写在 Service 层,Controller 只做参数校验和权限判断。下面这段代码是导师确认选题的核心逻辑,用了乐观锁防止两个导师同时确认导致人数超限。

@Service public class TopicSelectionService { @Autowired private ProjectTopicMapper topicMapper; @Autowired private TopicSelectionMapper selectionMapper; @Transactional(rollbackFor = Exception.class) public void confirmSelection(Long selectionId, Long teacherId) { TopicSelection selection = selectionMapper.selectById(selectionId); if (selection == null || selection.getStatus() != 0) { throw new BizException("该申请不存在或已处理"); } ProjectTopic topic = topicMapper.selectById(selection.getTopicId()); if (!topic.getTeacherId().equals(teacherId)) { throw new BizException("无权操作该课题"); } // 乐观锁更新已选人数,selected_count < max_students 才允许 int updated = topicMapper.increaseSelectedCount(topic.getId()); if (updated == 0) { throw new BizException("该课题名额已满"); } selection.setStatus(1); selection.setConfirmTime(new Date()); selectionMapper.updateById(selection); } }

increaseSelectedCount对应的 SQL 是UPDATE project_topic SET selected_count = selected_count + 1 WHERE id = #{id} AND selected_count < max_students,把并发控制交给数据库行锁,比在 Java 层加 synchronized 可靠。@Transactional保证人数更新和状态更新要么都成功要么都回滚。参数teacherId从当前登录用户上下文取,不要从前端传,否则越权确认是常见漏洞。

3. 文件上传与材料归档:开题报告、中期检查表怎么存才不丢

3.1 本地存储还是对象存储,毕设场景怎么选

毕业设计管理系统里文件上传是高频操作:开题报告、中期检查表、论文初稿、查重报告、答辩 PPT。很多教程直接教用MultipartFile存到服务器本地磁盘,开发阶段没问题,部署到宝塔面板的 Docker 容器里就翻车——容器重启,挂载没配好,文件全丢。我的血泪经验是:如果只是校内小规模使用,本地存储加定时备份可以接受;但凡要上生产,直接上对象存储,阿里云 OSS 或 MinIO 自建都行。MinIO 的优势是可以 Docker 部署在校内服务器,数据不出校园网,符合教务数据管理要求。

选型判断标准很简单:文件总量超过 10GB、或者有多个应用实例、或者需要做文件访问鉴权,就上对象存储。毕设系统通常三者都占。下面以 MinIO 为例,讲 SpringBoot 怎么整合。

3.2 SpringBoot 整合 MinIO 的最小配置与上传接口

先在pom.xml引入依赖,版本用 8.5.x 系列,注意 MinIO 客户端版本和服务端版本要匹配,版本差太多会出现S3 API兼容问题。

<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency>

application.yml里配置连接信息,endpoint不要带桶名,桶名在代码里指定。

minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: graduation-design

配置类初始化MinioClient,并检查桶是否存在,不存在则创建。

@Configuration public class MinioConfig { @Value("${minio.endpoint}") private String endpoint; @Value("${minio.access-key}") private String accessKey; @Value("${minio.secret-key}") private String secretKey; @Value("${minio.bucket}") private String bucket; @Bean public MinioClient minioClient() throws Exception { MinioClient client = MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); boolean exists = client.bucketExists( BucketExistsArgs.builder().bucket(bucket).build()); if (!exists) { client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); } return client; } }

上传接口用MultipartFile接收,生成带日期前缀的对象名,避免同名覆盖。

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file, @RequestParam("nodeType") String nodeType) throws Exception { String originalName = file.getOriginalFilename(); String suffix = originalName.substring(originalName.lastIndexOf(".")); // 对象名:节点类型/日期/uuid.后缀 String objectName = nodeType + "/" + LocalDate.now() + "/" + UUID.randomUUID().toString().replace("-", "") + suffix; minioClient.putObject(PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return Result.ok(objectName); }

stream方法的第三个参数-1表示分片大小由客户端自动决定,文件小于 5MB 时走单次上传,大于 5MB 自动分片。contentType从上传文件带过来,不设置的话浏览器访问会变成下载而不是预览。对象名用 UUID 而不是原始文件名,是为了防止中文文件名和特殊字符导致的 URL 编码问题,原始文件名存在数据库字段里用于展示。

3.3 文件下载鉴权与临时链接生成

文件不能直接暴露 MinIO 地址,否则任何人拿到 URL 就能下载。正确做法是后端生成预签名 URL,有效期设短一点,比如 15 分钟。

public String getPresignedUrl(String objectName) throws Exception { return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(objectName) .expiry(15, TimeUnit.MINUTES) .build()); }

前端拿到这个 URL 后直接打开即可,过期后需要重新请求。注意expiry最大不能超过 7 天,MinIO 服务端有硬限制。如果要做更细的权限控制,比如只有导师能下载自己学生的材料,就在生成 URL 前查一次process_node表,校验当前用户和student_id的师生关系。

4. 避坑与排查:毕设系统上线后最容易翻车的 5 个地方

4.1 现象:学生上传 20MB 论文报 413,前端却提示「网络错误」

原因:SpringBoot 默认的spring.servlet.multipart.max-file-size是 1MB,max-request-size是 10MB。超过限制时 Tomcat 直接拒绝,返回 413,但前端 axios 拦截器可能把非 200 都归为网络错误,误导排查方向。

解决:在application.yml里显式调大,同时 Nginx 的client_max_body_size也要改,两处缺一不可。

spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB

4.2 现象:导师确认选题后,学生端刷新还是「待确认」

原因:前端用了浏览器缓存或者 Vuex 里的旧数据,没有在确认成功后重新拉取列表。另一种可能是后端@Transactional方法里抛了异常但被 catch 吞掉,事务没回滚但状态也没更新。

解决:确认接口返回后前端强制刷新列表;后端检查confirmSelection方法里是否有try-catch把BizException吞了。事务方法里不要写try-catch,让异常抛到全局异常处理器。

4.3 现象:MinIO 上传成功但下载 403

原因:桶的访问策略默认是 private,预签名 URL 生成时如果method和实际请求方法不一致,或者客户端时间和服务端时间偏差超过 15 分钟,签名校验会失败。

解决:检查服务器 NTP 时间同步;确认getPresignedObjectUrl的method是Method.GET;如果用了 Nginx 反代 MinIO,Host头要透传,否则签名里的 host 对不上。

4.4 现象:定时任务把未提交开题的学生全部标记为「逾期」,但部分学生其实已提交

原因:定时任务查询条件写的是submit_time IS NULL,但学生提交时更新的是process_node的status字段,submit_time没写值。字段语义不一致导致误判。

解决:统一用status判断,submit_time只做展示。定时任务 SQL 改成WHERE node_type = 'OPENING' AND status = 0 AND create_time < DATE_SUB(NOW(), INTERVAL 30 DAY)。

4.5 现象:宝塔 Docker 部署后,SpringBoot 连不上 MySQL,报Communications link failure

原因:容器内localhost指向容器自身,不是宿主机。application.yml里写jdbc:mysql://localhost:3306/...在容器里必然失败。

解决:用 Docker 网络别名或宿主机内网 IP。宝塔面板里创建 Docker 网络后,MySQL 容器和 SpringBoot 容器加入同一网络,JDBC URL 里的 host 改成 MySQL 容器的服务名。如果 MySQL 装在宿主机,用172.17.0.1这个 Docker 默认网桥地址。

5. 从能跑到好用:用定时任务和消息通知把流程闭环

5.1 用 SpringBoot 定时任务做逾期提醒和状态巡检

系统能跑起来只是第一步,教务老师真正需要的是「不用天天登录看谁没交」。SpringBoot 的@Scheduled可以满足这个需求,不需要引入 Quartz 那么重的框架。下面这个任务每天早上 8 点扫描开题节点,对提交截止前 3 天还没提交的学生发提醒。

@Component public class ProcessReminderTask { @Autowired private ProcessNodeMapper nodeMapper; @Autowired private NotifyService notifyService; // 每天 8:00 执行 @Scheduled(cron = "0 0 8 * * ?") public void remindOpeningDeadline() { // 查出开题状态为未提交、且选题已确认的学生 List<Long> studentIds = nodeMapper.findPendingOpeningStudents(); for (Long studentId : studentIds) { notifyService.send(studentId, "开题报告提交截止还有3天,请尽快提交"); } } }

cron表达式0 0 8 * * ?表示每天 8 点整。注意在启动类上加@EnableScheduling,否则任务不生效。如果部署了多个实例,定时任务会重复执行,解决方案是用 Redis 分布式锁或者把定时任务单独部署一个实例。毕设系统一般单实例够用,但要知道这个边界。

5.2 消息通知的三种实现方式与选型

通知渠道常见三种:站内信、邮件、短信。站内信最简单,建一张notification表,前端轮询或 WebSocket 推送。邮件用spring-boot-starter-mail,适合正式通知。短信需要第三方服务,成本高,毕设系统一般不用。

我一般会站内信加邮件双通道:站内信保证学生登录能看到,邮件保证不登录也能收到。邮件发送用异步线程池,不要阻塞主流程。

@Async("notifyExecutor") public void sendMail(String to, String subject, String content) { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(to); message.setSubject(subject); message.setText(content); mailSender.send(message); }

@Async需要配合@EnableAsync和自定义线程池,线程池核心数设 2 到 4 即可,队列容量给 100,拒绝策略用CallerRunsPolicy,避免通知任务丢失。

5.3 答辩分组的一个实用技巧:用贪心算法做导师回避

答辩分组最麻烦的是导师回避——不能让自己导师评自己。手工排容易出错。我一般用简单的贪心:先把学生按导师分组,然后逐轮把学生分配到答辩组,每组内检查导师是否冲突,冲突就换下一组。代码不复杂,但能省教务老师半天时间。

public List<List<Long>> groupStudents(List<Student> students, int groupSize) { // 按导师分组,同一导师的学生尽量分散 Map<Long, List<Student>> byTeacher = students.stream() .collect(Collectors.groupingBy(Student::getTeacherId)); List<List<Long>> groups = new ArrayList<>(); for (int i = 0; i < Math.ceil(students.size() * 1.0 / groupSize); i++) { groups.add(new ArrayList<>()); } // 轮询分配,同导师学生落入不同组 int idx = 0; for (List<Student> sameTeacher : byTeacher.values()) { for (Student s : sameTeacher) { groups.get(idx % groups.size()).add(s.getId()); idx++; } } return groups; }

这个算法不保证最优解,但实际用下来冲突率很低,而且逻辑透明,教务老师能看懂分组依据。如果要求严格无冲突,需要上回溯搜索,但毕设场景没必要。

5.4 上线前我会做的三件事

第一,把spring.jpa.show-sql和logging.level.root调回 INFO,开发期的 DEBUG 日志在生产环境会拖慢响应。第二,用spring-boot-maven-plugin打出可执行 jar,在服务器上用nohup java -jar启动,日志重定向到文件,配合 logback 的滚动策略防止磁盘写满。第三,数据库连接池用 HikariCP,maximum-pool-size设 10 到 20 之间,不要照搬教程里的 100,毕设系统的并发量根本用不到,连接数过多反而增加 MySQL 负担。

这套系统我从头搭过两遍,第一遍踩了文件丢失和越权确认的坑,第二遍把状态机和对象存储补上才敢交给教务用。如果你也在做这个方向,建议先把状态流转和文件存储这两块做扎实,界面丑一点没关系,流程跑通才是核心。希望帮到你。

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

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

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

立即咨询