
在学校里学生活动申报一直是个让人头疼的事。一张纸质审批表要在辅导员、团委、分管领导之间来回转签完字还得拍照存档到了年底统计活动学时的时候翻聊天记录、翻纸质表能把人逼疯。做这个Spring Boot学生活动申报管理系统最初就是为了治这个痛点。这个系统说白了就是一套线上化的活动申请、审批、执行和总结归档流程学生提交活动策划老师在线审批管理员统一管理活动类型和统计报表所有状态全程留痕。技术上就是标准的Spring Boot MyBatis Plus MySQL Vue/Thymeleaf这一套虽然不是什么高大上的架构但麻雀虽小五脏俱全正好覆盖了一个真实业务系统从0到1的全部环节。这篇文章我会把这个系统的完整设计思路、数据库怎么建、核心流程怎么走、部署怎么搞还有那些实操中容易踩的坑一口气讲明白。不管你是拿它做毕业设计还是想通过一个小项目搞懂Spring Boot开发套路都可以按着这篇内容去复现和扩展。1. 项目整体设计与技术选型1.1 为什么是Spring Boot而不是其他框架前几年很多学校项目还在用SSHSpring Struts Hibernate或者SSMSpring 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-javaMySQL驱动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 登录与身份认证登录方案我选的是JWTJSON 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主键自增longactivity_name活动名称varchar(100)activity_type_id关联活动类型表applicant_id申请人id关联user表teacher_id审批老师id关联user表location活动地点start_time开始时间datetimeend_time结束时间datetimeexpected_attendees预计参与人数intbudget_amount预算金额decimal(10,2)description活动详细描述textstatus活动状态tinyint对应枚举值create_time创建时间datetimeupdate_time更新时间datetimedeleted逻辑删除标志tinyint预算金额用decimal而不是double这是基本素养。double在数据库里存钱会出现精度问题0.1 0.2可能等于0.30000000000000004。虽然学生活动预算一般就几百上千块但这种规范一旦确立后面扩展成经费报销模块也不会翻车。3.3 审批记录的独特设计审批记录和状态变更日志很多人会搞混其实它们各自承担不同职责。审批记录表存的是业务层面的“审批意见”一条record对应一次审批动作字段包括审批人id、审批结果通过/驳回、审批意见内容、审批时间。而活动日志表存的是系统层面的“操作痕迹”任何状态变更都往这写一条包括变更前状态、变更后状态、操作人。这两张表并存的价值在于审批记录表是面向业务查询的老师在历史记录里可以看到自己给出过的每一条审批意见活动日志表是面向审计和排查的如果发现某个活动的状态被非法修改过可以通过日志表还原操作链条。设计数据库的时候就问自己一个问题这个业务里哪些数据是“给人看的”哪些数据是“给系统排查用的”然后分别建表。3.4 索引设计实战这部分的实操经验对系统性能很关键。最常见的查询场景是什么学生登录后查“我发起的活动”老师登录后查“待我审批的活动”管理员查“所有活动按时间倒序”。所以我在activity表上建了这几个索引idx_applicant_idapplicant_ididx_teacher_idteacher_ididx_statusstatusidx_create_timecreate_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版本8Java 8稳定且绝大多数高校教材都基于此版本Spring Boot版本2.3.12.RELEASEMyBatis Plus版本3.4.2MySQL版本5.7Node.js前端Vue项目14.xJDK为什么不用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服务管理而不是直接终端运行CtrlC就挂了。我都推荐在服务器上写一个简单的shell脚本# start.sh nohup java -jar activity-system.jar --spring.profiles.activeprod activity.log 21 echo $! app.pid--spring.profiles.activeprod指定生产环境配置。生产环境数据库密码不能和本地开发用同一个也不建议明文写在application.yml里至少用环境变量引用。5.3 数据库迁移与初始化初始化数据库时准备了schema.sql和data.sql两个文件schema负责建表data负责导入初始数据管理员账号、活动类型、测试账号。第一次启动时通过spring.sql.init.modealways自动执行后续每迭代一版升级脚本用独立的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以上的驱动另外连接串里必须带useSSLfalse和serverTimezoneAsia/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 数据库中文乱码连接串没指定characterEncodingutf8的情况下MySQL默认编码如果不是utf8mb4中文会直接变成???。这个问题在检查时用SQL语句查询一下库的建表语句就知道。需要在创建数据库时指定字符集CREATE DATABASE activity_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;连接串再加characterEncodingutf8。实体类里如果有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天。刚开始上线的时候可以每天花几分钟看一眼日志重点关注那些异常堆栈出现频率最高的接口它们往往就是运行了一个学期之后即将出问题的隐患。这个习惯我保持了很久确实帮我在系统运行期间提前处理过几次隐患。