在学校里,学生活动申报一直是个让人头疼的事。一张纸质审批表要在辅导员、团委、分管领导之间来回转,签完字还得拍照存档,到了年底统计活动学时的时候,翻聊天记录、翻纸质表,能把人逼疯。做这个Spring Boot学生活动申报管理系统,最初就是为了治这个痛点。
这个系统说白了就是一套线上化的活动申请、审批、执行和总结归档流程:学生提交活动策划,老师在线审批,管理员统一管理活动类型和统计报表,所有状态全程留痕。技术上就是标准的Spring Boot + MyBatis Plus + MySQL + Vue/Thymeleaf这一套,虽然不是什么高大上的架构,但麻雀虽小五脏俱全,正好覆盖了一个真实业务系统从0到1的全部环节。
这篇文章我会把这个系统的完整设计思路、数据库怎么建、核心流程怎么走、部署怎么搞,还有那些实操中容易踩的坑,一口气讲明白。不管你是拿它做毕业设计,还是想通过一个小项目搞懂Spring Boot开发套路,都可以按着这篇内容去复现和扩展。
1. 项目整体设计与技术选型
1.1 为什么是Spring Boot而不是其他框架
前几年很多学校项目还在用SSH(Spring + Struts + Hibernate)或者SSM(Spring + Spring MVC + MyBatis)的祖传组合,配置XML能写几百行。但到了2024年,新开项目再这么搞就有点跟自己过不去了。Spring Boot的核心价值是“约定优于配置”,自带Tomcat、自动装配、起步依赖,一个mvn spring-boot:run就能把服务拉起来,对单人开发或者小团队维护的校内系统来说,省下来的时间是非常可观的。
这个项目我没有拆成微服务,也没有引入Nacos、Feign那一套,原因很简单:业务复杂度摆在那里,学生活动的申报审批流程再绕,也只是一条线性的状态流转,单体应用足够承载,而且部署成本低,一台普通配置的服务器甚至个人电脑就能跑。很多同学一上来就堆微服务全家桶,结果日志都查不明白,反而把核心业务做糊了。技术选型永远要匹配业务规模,这是我在这个项目里最想强调的一点。
1.2 分层架构与项目结构
项目采用了经典的四层结构,Controller层负责接参数、返回结果,Service层写业务逻辑,Mapper层操作数据库,entity/domain层做对象映射。代码结构按功能模块分包,不是按技术类型分包,这样改需求的时候找文件非常快。
src/main/java/com/example/activity ├── controller/ # 控制层 ├── service/ # 业务层 ├── mapper/ # 数据访问层 ├── entity/ # 实体类 ├── vo/ # 视图对象(用于前后端数据交互) ├── util/ # 工具类(Excel导入导出、文件上传等) ├── config/ # 全局配置(跨域、拦截器、自定义异常处理) └── common/ # 公共返回体、状态枚举、常量按业务模块走的好处是,比如我要改“活动申报”相关的代码,直接进activity这个包路径下找就行,controller、service、mapper都在一个目录里。而按技术类型分包的话,所有Controller堆在一个包,所有Service堆在另一个包,业务一多之后改一个功能得在三个大包里来回跳,心智负担很大。这个项目的包结构就是一个可以直接抄的作业。
1.3 核心依赖清单
pom.xml里的关键依赖就这么几个,每一个都有它存在的理由:
- spring-boot-starter-web:提供Web开发能力,内置Tomcat,同时包含Spring MVC、Jackson等基础组件
- mybatis-plus-boot-starter:数据持久层框架,相比原生MyBatis最大的优势是提供了BaseMapper,单表CRUD不用写XML,内置分页插件,减少大量样板代码
- mysql-connector-java:MySQL驱动
- lombok:通过注解自动生成getter/setter/构造器,让实体类不再被样板代码淹没
- spring-boot-starter-validation:参数校验,比如手机号格式、日期必填
- Apache POI:用于活动的Excel报表导出
- spring-boot-starter-test:单元测试
这里想单独说几句MyBatis Plus。我见过不少项目用的是JPA/Hibernate,写起来确实省事,但遇到复杂查询、多表联查、动态SQL的时候,JPA的复杂度和性能问题会逐渐暴露。MyBatis Plus等于在MyBatis的灵活性和开发效率之间取了一个平衡:简单的CRUD不用写SQL,复杂的联查靠注解或XML兜底。对于这个系统里大量的多表条件查询(按活动状态查、按学生姓名模糊查、按时间范围查),用MyBatis Plus的QueryWrapper就能写得非常简洁。
2. 核心功能模块与角色权限设计
2.1 三种角色三条业务线
这个系统面向三类用户:学生、指导老师/辅导员、团委管理员。三种角色的权限边界必须清晰,数据隔离必须明确,这是整个系统设计的地基。
我画权限的时候用的是基于RBAC(角色-权限)模型的简化版,不搞复杂的权限表、角色表关联,直接在登录用户的角色字段上做判断。原因还是那个:校内管理系统角色固定,不会出现“同一用户既是学生又是管理员”的情况,一张user表、一个role字段就够了。三个角色的主业务线分别是:
- 学生端:创建活动申请 -> 提交策划书附件 -> 等待审批 -> 查看审批意见 -> 活动完成后提交活动总结
- 教师端:待审批活动列表 -> 在线审批(通过/驳回并填写意见)-> 查看自己审批过的历史记录 -> 导出所辖学生的活动汇总
- 管理员端:用户管理(重置密码/禁用账号)-> 活动类型维护 -> 全校活动数据看板 -> 审批流程流转监控
这条链路里有几个值得细想的点。第一,状态流转必须是单向的,从“草稿”到“待审批”到“已通过/已驳回”,不能允许用户随机改状态,否则审批记录就乱套了。第二,审批意见必填,这是责任留痕,也是后期审计的依据。第三,活动总结必须在活动实际结束后才能提交,这个时间节点的控制可以防止学生虚报。
2.2 状态机的设计与落地
活动状态我用一个枚举类统一管理,而不是散落在业务代码里的魔法数字:
public enum ActivityStatus { DRAFT(0, "草稿"), PENDING(1, "待审批"), APPROVED(2, "已通过"), REJECTED(3, "已驳回"), ONGOING(4, "进行中"), COMPLETED(5, "已完成"), CANCELLED(6, "已取消"); private final Integer code; private final String desc; ActivityStatus(Integer code, String desc) { this.code = code; this.desc = desc; } }状态枚举的好处第一是语义清晰,代码里到处是魔法数字的话,看三个月前的代码自己也认不出来;第二是后端校验方便,用户提交的状态更新请求只允许在合法状态下进行。比如前端传一个把“草稿”改成“已完成”的请求,后端可以通过状态转移关系直接拦截掉。
状态流转图在心里要非常清晰:学生提交申请,DRAFT->PENDING;老师审批通过,PENDING->APPROVED;老师驳回,PENDING->REJECTED;活动开始后,APPROVED->ONGOING;结束后,ONGOING->COMPLETED。所有状态的变更都记录在activity_log表里,谁在什么时间把状态从什么改成了什么,一查便知。
2.3 登录与身份认证
登录方案我选的是JWT(JSON Web Token),而不是传统的Session + Cookie。不是Session方案有什么致命缺陷,而是JWT无状态的特点更适合前后端分离。前端拿到token存在localStorage里,每次请求在请求头里带上Authorization: Bearer token,后端通过拦截器解析出用户id和角色,放入ThreadLocal上下文,业务层直接取用。
JWT方案里最需要注意的就是token过期时间。我设置的过期时间是24小时,这个时长对校内系统来说比较合适:太长不安全,token泄露了别人能长时间伪装身份;太短影响体验,学生午休时间操作到一半token过期,又得重新登录。同时后台也做了退出登录的接口,前端直接丢弃本地token即可。
我有一个安全细节的处理是,登录校验密码用的BCrypt加密存储,而不是MD5。很多人图省事用MD5,但MD5撞库太容易了,用户密码一旦泄露到明文库,整个系统等于裸奔。Spring Security的BCryptPasswordEncoder可以很方便地生成带盐的哈希,这也是一个真实项目里带给新人的重要一课。
3. 数据库设计与核心表结构
3.1 表设计的总览
数据库一共设计了6张核心表和2张辅助表。核心表:user(用户表)、activity(活动申请表)、activity_type(活动类型表)、activity_log(状态变更日志表)、review_record(审批记录表)、attachment(附件表);辅助表:notice(公告表)、feedback(意见反馈表)。
初期设计时注意,表结构要尽可能做到“一个业务实体一张表”,但又不能过度冗余。比如attachment表单独拆出来的原因很务实:一个活动可能附多个附件(策划书、安全预案、经费预算表),如果全塞在activity表里用逗号拼接文件id,查询的时候要拆字符串,扩展新附件类型也要改表结构,异常麻烦。拆出来之后一行一个附件记录,查列表和查详情都能直接用关联查询搞定。
3.2 活动表的核心字段设计
这是整个系统最核心的一张表,字段设计直接影响业务逻辑的复杂程度。字段清单如下:
- id:主键,自增long
- activity_name:活动名称,varchar(100)
- activity_type_id:关联活动类型表
- applicant_id:申请人id,关联user表
- teacher_id:审批老师id,关联user表
- location:活动地点
- start_time:开始时间,datetime
- end_time:结束时间,datetime
- expected_attendees:预计参与人数,int
- budget_amount:预算金额,decimal(10,2)
- description:活动详细描述,text
- status:活动状态,tinyint,对应枚举值
- create_time:创建时间,datetime
- update_time:更新时间,datetime
- deleted:逻辑删除标志,tinyint
预算金额用decimal而不是double,这是基本素养。double在数据库里存钱会出现精度问题,0.1 + 0.2可能等于0.30000000000000004。虽然学生活动预算一般就几百上千块,但这种规范一旦确立,后面扩展成经费报销模块也不会翻车。
3.3 审批记录的独特设计
审批记录和状态变更日志很多人会搞混,其实它们各自承担不同职责。审批记录表存的是业务层面的“审批意见”,一条record对应一次审批动作,字段包括审批人id、审批结果(通过/驳回)、审批意见内容、审批时间。而活动日志表存的是系统层面的“操作痕迹”,任何状态变更都往这写一条,包括变更前状态、变更后状态、操作人。
这两张表并存的价值在于:审批记录表是面向业务查询的,老师在历史记录里可以看到自己给出过的每一条审批意见;活动日志表是面向审计和排查的,如果发现某个活动的状态被非法修改过,可以通过日志表还原操作链条。设计数据库的时候就问自己一个问题:这个业务里哪些数据是“给人看的”,哪些数据是“给系统排查用的”,然后分别建表。
3.4 索引设计实战
这部分的实操经验对系统性能很关键。最常见的查询场景是什么?学生登录后查“我发起的活动”,老师登录后查“待我审批的活动”,管理员查“所有活动按时间倒序”。所以我在activity表上建了这几个索引:
- idx_applicant_id(applicant_id)
- idx_teacher_id(teacher_id)
- idx_status(status)
- idx_create_time(create_time)
配合MyBatis Plus的分页插件,查询效率在几万条数据量级下基本是无感知的。另外我特别做了一个联合索引(teacher_id, status),因为老师端最常见的查询就是“status = 待审批 AND teacher_id = 当前登录人”,这个联合索引能直接命中,避免回表。索引不是建得越多越好,每个索引都有写放大,像这种小项目建4-5个核心索引已经完全够用。
4. 核心业务流程的实现细节
4.1 活动申报的完整链路
学生端提交申报是整个流程的起点,这里我踩过一个真实的坑:最开始的设计是让前端在提交按钮里同时做“保存活动信息”和“上传附件”两个动作,结果学生在填完表单没传附件的情况下直接提交,后端保存活动主记录成功,但附件表里一条记录都没有,活动成功了但策划书丢了。
后来就学乖了,提交逻辑改为两段式(事务分隔):
- 前端先上传附件,拿到附件id列表
- 提交活动表单数据时,把附件id列表一并传给后端
- 后端在一个事务里同时插入activity表和attachment表
这里有个关键细节:活动创建时状态是DRAFT,也就是草稿。只有点在“提交审批”按钮时,状态才会变成PENDING,这是两个接口、两个动作,不能混在一起。如果学生还在编辑活动信息,不想急着提交,它可以存为草稿,下次再进来继续编辑,这个体验是符合真实使用习惯的。
4.2 审批接口的幂等设计
审批接口是老师端最核心的方法,方法签名大概是这样的:
public Result reviewActivity(Long activityId, Integer reviewResult, String reviewComment) { // 1. 校验活动是否存在且状态为PENDING // 2. 校验当前登录人是否为该活动的审批老师,不是则越权 // 3. 校验审批意见不能为空 // 4. 更新活动状态为APPROVED或REJECTED // 5. 插入审批记录 // 6. 插入活动日志 }如果审批操作没有做好幂等,老师双击提交按钮,可能会出现同一活动被插入两条审批记录,活动状态被连续更新两次,虽然最终状态一致,但审批记录链是脏的。我的解决办法是给审批动作加了一个乐观锁控制:在查询活动记录时把version字段取出来,执行更新时用UPDATE activity SET status = ?, version = version + 1 WHERE id = ? AND version = ?,更新行数为0说明数据已被别人改过,直接返回“请勿重复操作”。这个方案比加分布式锁轻量得多,也符合当前项目的并发量级。
4.3 Excel导出功能的实现
管理员端有一个导出全校活动报表的功能,我用的Apache POI操作xls格式。导出这个功能看起来小,踩坑却不少。第一个坑是导出的Excel打开后中文乱码,排查了半天发现是前端接收blob后没有正确设置responseType: 'blob',导致文件被当作文本解析,换行符和编码全乱了。第二个坑是导出的时间字段格式化问题,POI默认拿到的是Java Date对象,但直接写到Excel里显示成y-m-d格式带着时分秒,用户看得很不友好,后来统一通过SimpleDateFormat先转成字符串再写入单元格。
导出超时也是需要预留的点。当活动数据量几千条的时候,POI单线程导出可能产生几百毫秒的延迟,如果后续数据量大,建议后台异步生成文件,导出完成后推送下载链接。这个系统目前没到非要异步不可的量级,但我会把导出逻辑单独拆了一个service方法,以后要加异步不用动Controller层。
4.4 文件上传与存储路径
附件存储我选的是本地磁盘存储,没有接云OSS。原因很简单:校内活动系统并发不大,且涉及隐私数据,本地存储更可控。文件上传路径通过配置文件管理:
file: upload-dir: /data/activity/uploads max-size: 10MB这里有个非常重要的信息安全问题:上传模块绝对不能信任用户传的文件名。用户传的filename可能包含..、/等路径穿越字符,危险。绝对不能直接拼接路径保存。我的做法是用UUID作为存储文件的文件名,原始文件名单独存数据库,这样后端完全没有路径穿越的窗口。
String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String storedName = UUID.randomUUID().toString().replace("-", "") + ext;5. 部署调试的实操与避坑记录
5.1 开发环境搭建
环境信息给需要复现的同学一个参照:
- JDK版本:8(Java 8,稳定且绝大多数高校教材都基于此版本)
- Spring Boot版本:2.3.12.RELEASE
- MyBatis Plus版本:3.4.2
- MySQL版本:5.7
- Node.js(前端Vue项目):14.x
JDK为什么不用11或17?我也知道新版本性能更好、特性更多,但考虑到整个技术生态的兼容性,包括旧项目插件、其他同事的本地环境,Java 8目前依然是这个领域最稳的一个选择。Spring Boot 2.x版本自带Tomcat 9,兼容Java 8,这是最保险的组合。
5.2 打包部署中的常见坑
前端Vue项目打包后生成dist目录,需要放到后端resources/static下,通过nginx或Spring Boot内置的Tomcat直接托管。这一步有很多同学栽跟头:本地开发时前端用8081端口,后端用8080端口,存在跨域问题,于是在后端写了一个CorsConfig跨域配置类。打包部署时前端静态资源已经由后端服务器托管,两个服务变成了同一个源,跨域配置反而会造成一些奇怪的请求头异常,所以部署前一定记得核对是否还需要跨域配置。
后端项目打成jar包:
mvn clean package -DskipTests打出的jar包放在服务器上执行java -jar activity-system.jar。如果要让程序在后台运行,用nohup或使用systemd服务管理,而不是直接终端运行Ctrl+C就挂了。我都推荐在服务器上写一个简单的shell脚本:
# start.sh nohup java -jar activity-system.jar --spring.profiles.active=prod > activity.log 2>&1 & echo $! > app.pid--spring.profiles.active=prod指定生产环境配置。生产环境数据库密码不能和本地开发用同一个,也不建议明文写在application.yml里,至少用环境变量引用。
5.3 数据库迁移与初始化
初始化数据库时准备了schema.sql和data.sql两个文件,schema负责建表,data负责导入初始数据(管理员账号、活动类型、测试账号)。第一次启动时通过spring.sql.init.mode=always自动执行,后续每迭代一版升级脚本用独立的migration目录管理。
这里要提醒一个老掉牙的坑:data.sql里插入密码字段时,如果是BCrypt加密过的密码字符串,字符串里可能包含$符号,直接写在SQL文件里会引起奇怪的解析问题,尤其在部分Windows环境下。解决办法是初始化密码统一走系统内置的初始化接口,而不是在SQL里硬编码。管理员首次启动后用默认密码登录,系统强制修改密码,这个逻辑又安全又省心。
6. 常见问题与排查技巧实录
6.1 启动报错:数据库连接失败
这个是最常见的启动问题。排查思路按顺序来:
- 检查MySQL服务有没有启动,netstat -ano看3306端口是否监听
- 检查application.yml里的url、username、password三个配置是否和本地环境完全匹配
- 检查MySQL驱动版本和MySQL服务版本是否兼容(5.7版本用mysql-connector-java 8.0.20以上的驱动)
另外连接串里必须带useSSL=false和serverTimezone=Asia/Shanghai两个参数,前者避免MySQL 8.0的SSL握手警告,后者避免时区偏移导致的时间字段错乱。
6.2 文件上传报错:超过大小限制
如果在测试时上传一个超过10MB的文件,Spring Boot内置文件上传组件默认只允许1MB。如果你没有在配置文件里显式设置max-file-size和max-request-size,你会收到一个超长的类型转换异常。配置如下:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MBmax-request-size必须大于max-file-size,因为一个请求可能包含多个文件,加起来的总大小不能超过请求大小限制。
6.3 前端页面跨域报错
前端localhost:8081,后端localhost:8080,浏览器f12控制台直接出现Access-Control-Allow-Origin报错。解决方式是后端加跨域配置类,但更稳妥且生产可用的方式是用nginx做反向代理,前端请求统一代理到/api路径,然后转发到后端8070端口,这样浏览器看到的始终是同源请求,没有跨域问题。当然校内分布式部署时如果前后端分离,跨域配置也很常见,两种方案各有利弊。
6.4 数据库中文乱码
连接串没指定characterEncoding=utf8的情况下,MySQL默认编码如果不是utf8mb4,中文会直接变成???。这个问题在检查时用SQL语句查询一下库的建表语句就知道。需要在创建数据库时指定字符集:
CREATE DATABASE activity_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;连接串再加characterEncoding=utf8。实体类里如果有emoji或生僻字,utf8mb4也能正常存储,这是utf8升级版的意义所在,强烈推荐直接上utf8mb4。
6.5 修改了代码但运行结果没变
这基本是缓存问题。Spring Boot项目热部署调试时,如果你改了Java代码但没重新编译,运行中的class文件还是老版本。我通常建议在idea里使用JRebel插件,或者简单暴力一点:改完代码就重启应用,避免无谓的时间浪费。前端Vue项目改了没生效,一般是缓存了旧编译文件,npm run dev的开发模式会自动热更新,只有生产环境的dist文件是需要手动重新打包的。
7. 心得与扩展思路
7.1 复盘:这个项目最大的收获
做这个系统的过程,正好对应了后端开发日常工作的完整闭环:需求分析 -> 数据库设计 -> 后端接口开发 -> 联调 -> 打包部署 -> 问题排查。每个环节都有那些“不做一遍就不知道”的坑。我认为比技术栈更重要的,是对业务状态的理解。活动申报不是一个简单的CRUD,它是有状态的业务流,状态和时间节点的约束在设计表结构之前就要想清楚。如果一开始就建表,边写代码边改状态,后面测试阶段迟早要推倒重来。
7.2 可以扩展的方向
如果这个系统想继续往下做,有几个很好的切入点。第一是加一个微信小程序端,学生端直接在小程序里发起活动申请,小程序那套登录态对接JWT并不算复杂,但能极大提升使用频率。第二是接一个通知推送渠道,审批通过之后给申请学生发短信或邮件通知,这个用Java Mail或对接校园短信平台都能实现。第三是把活动数据做大屏可视化展示,用ECharts画活动类型占比、月度活动数量趋势、各院系参与人数对比图,这些展示在校园文化建设汇报的时候非常有说服力。
最后想分享一个实际部署中的小技巧:把logback日志级别调成INFO而不是DEBUG,生产环境不打印DEBUG日志。日志文件按时间滚动保留30天。刚开始上线的时候,可以每天花几分钟看一眼日志,重点关注那些异常堆栈出现频率最高的接口,它们往往就是运行了一个学期之后即将出问题的隐患。这个习惯我保持了很久,确实帮我在系统运行期间提前处理过几次隐患。