这两年用 Spring Boot 做项目,我最大的感觉是:技术本身不难,难的是把真实业务里那一堆混乱的状态和角色,用代码理清楚。最近刚好完成了一套图书捐赠管理系统,从最开始的几个 Controller 堆接口,到最后切模块、分角色、接存储、上 Docker,中间踩了不少坑。这篇就把整个设计和实现过程拆开来讲,包括我是怎么定模块的、为什么选这套技术栈、事务和文件上传哪里最容易被绕晕,以及最终部署时的 Docker 配置。如果你正准备做类似的 Spring Boot 管理系统,或者刚开始接触前后端分离项目,这篇文章应该能帮你少走很多弯路。
1. 业务模块划分:图书捐赠系统的关键流程不止是单表 CRUD
很多人听到"图书捐赠管理系统",第一反应就是:捐书人表、图书表、捐赠记录表,然后写几个增删改查接口就完事了。但真实业务里,捐赠动作不是一个点,而是一条线。
1.1 从提交申请到图书上架:业务状态机怎么设计
捐赠的完整链路是这样的:捐赠人提交图书信息 -> 管理员线下核对实物 -> 审核通过后入库 -> 图书在平台展示 -> 受赠人申请领取 -> 管理员出库。
这里最核心的不是表结构,而是状态字段。我一开始给图书表只加了一个status字段,用 0 和 1 表示"在架"和"下架",后来发现完全不够用。审核中和审核通过本质上都是"未上架"状态,但业务动作完全不同。最后我重新梳理了状态机:
PENDING:待审核,捐赠人提交后初始状态;APPROVED:审核通过,等待入库;REJECTED:审核不通过,需要退回给捐赠人;IN_STOCK:已入库,上架展示,可被申请;APPLIED:已被申请,等待线下领取;OUT_STOCK:已出库,流程完结。
对应到数据库,就是一个status字段存枚举值。但光有状态不行,状态之间的流转必须受控。如果直接暴露一个updateStatus接口,前端想传什么就传什么,那后台管理和数据统计全乱套。我最后是把状态变更收敛到几个特定的 Service 方法里,比如approve(bookId)、reject(bookId, reason)、stockIn(bookId),每个方法内部判断当前状态是否合法,不合法直接抛业务异常。这比提供一个通用状态修改接口要安全得多。
1.2 角色拆分:菜单、权限和数据范围
图书捐赠系统看着不大,但用户类型其实很杂。我最后拆了三种角色:系统管理员管理用户和菜单;图书管理员负责审核入库、出库操作;普通捐赠人可以提交捐书、查看自己的捐赠进度和申请记录。这里如果不做角色隔离,所有权限都靠前端按钮隐藏,后端接口全部放行,那基本等于裸奔。
权限这块我参考了 RBAC(基于角色的访问控制)模型,但不是把整个权限框架做得很重,只拆到菜单级别和接口级别。数据库里有五张表:用户表、角色表、菜单表、角色菜单关联表、用户角色关联表。登录的时候把用户的角色和菜单列表查出来,前端根据菜单列表渲染侧边栏,后端通过 Spring Security 或者拦截器校验接口权限。你可以直接用现成的spring-boot-starter-security,也可以像我这样只用一个拦截器做轻量级校验。后面我会详细说接口安全,这里先记住一个结论:菜单和接口权限要分开存,菜单管展示,接口管访问,两者不能混在一起。
1.3 数据范围隔离:为什么查询条件不能只靠前端传参
做管理后台最容易忽略的是数据范围。比如图书管理员查询"待审核捐赠列表",接口收到的参数是status=PENDING,这没什么问题。但如果是捐赠人登录,查询"我的捐赠记录",如果接口只接收status而用户 ID 从前端传过来,那么用户把userId改成别人的 ID 就能看到别人的记录,这就是典型的水平越权。
我的做法很简单:所有涉及当前登录用户数据的查询,用户 ID 一律从 Token 里解析,不从前端参数取。后端定义一个CurrentUserHolder,在拦截器里把解析出来的用户信息放进 ThreadLocal,Service 层直接从CurrentUserHolder.getUserId()获取。这样即使前端恶意传参,后端也不认,数据范围始终是安全的。
2. 技术选型与配置:Spring Boot 2.x 和 3.x 之间怎么选,配置踩了哪些坑
2.1 版本选择的纠结:JDK 8 还是 JDK 17
从网上搜索趋势能看到一个问题,很多人总在纠结 Spring Boot 版本太高。这个系统我刚启动的时候有两条路:用 Spring Boot 3.x,用 JDK 17,哪哪都是新特性;或者继续用 Spring Boot 2.7,搭配 JDK 8,虽然老但生态成熟。
我最后选择了 Spring Boot 2.7.18,不是因为我保守,而是因为这个项目涉及大量基础框架的兼容。当时团队里有些同事本机 JDK 还是 8,部署服务器上的 Docker 基础镜像也是按 JDK 8 配置的。如果强行上 Spring Boot 3.x,很多老版本的 MyBatis 插件、代码生成器、第三方 SDK 都可能不兼容,改造成本远大于收益。
如果你是新项目,没有历史包袱,我建议直接上 Spring Boot 3.x + JDK 17,毕竟是未来趋势。但如果你是要做毕业设计或者企业内部系统,希望快速落地,那 Spring Boot 2.7 + JDK 8 依然是稳如老狗的选择。不要盲目追求新版本,项目能跑起来、后期维护不费劲才是第一位的。
2.2 热词里的坑:配置不生效、注解扫描不到
搜索热词里有不少关于 Spring Boot 配置的,比如springboot项目搭建、springboot配置、idea 创建springboot项目,这些关键词背后其实是一堆新手常见问题。这里分享两个配置层面的教训。
第一个是application.yml不提示的问题。很多人在 IDEA 里创建完项目,发现写spring.datasource.url等配置时没有代码提示,第一反应是 IDEA 坏了。实际上绝大多数情况是因为缺少spring-boot-configuration-processor依赖。加上这个依赖后,IDEA 能识别自定义配置类的元数据,提示就回来了。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>第二个是配置项覆盖问题。我遇到过数据库配置application.yml里明明写对了,但应用启动后连的是另一台数据库,排查半天才发现是application-dev.yml里残留了旧的配置。Spring Boot 的多环境配置文件优先级比主配置文件高,application-{profile}.yml会覆盖application.yml里的同名配置。所以如果配置不生效,先看看当前激活的是哪个 profile,再检查这个 profile 下是不是有多余配置,往往能省下很多排查时间。
2.3 多环境配置:开发、测试、生产不能一套配置走天下
图书捐赠系统涉及文件上传、数据库连接、第三方存储等配置,开发环境和生产环境差异很大。开发的时候我本机用的 MySQL,数据库名是book_donation_dev,密码是弱口令;但生产环境不能这么干。所以我把配置拆成了三份:
application.yml:公共配置,比如应用名、端口号、Jackson 序列化规则;application-dev.yml:本地开发配置,数据库地址、打印 SQL 日志、关闭某些安全校验;application-prod.yml:生产配置,数据库密码通过环境变量注入,开启日志文件输出。
在启动命令里通过--spring.profiles.active=prod或者环境变量SPRING_PROFILES_ACTIVE=prod指定当前环境。这里有一个容易被忽视的死角:生产环境的数据库密码绝对不能明文写在配置文件里。我一般用 Jasypt 对密码加密,或者在 Docker Compose 里通过环境变量传入。搜索热词里有一条关于 SM4 加密数据库密码并且集成 Jasypt 的,思路大概就是先把密码加密到配置里,运行时再用密钥解密注入,这个后面可以单独写一篇。
3. 捐赠审核与库存管理:事务和状态一致性最容易出问题的地方
3.1 图书入库的事务边界:不只是 insert 一条记录
图书审核通过后的入库动作,看起来就是bookService.stockIn(bookId),把状态从APPROVED改成IN_STOCK。但真实业务里,入库动作往往伴随着一系列关联操作,比如:
- 更新图书状态为
IN_STOCK; - 给图书生成一个唯一入库编号(比如
RK20250101xxxx); - 在库存表里增加一条库存记录;
- 给捐赠人发送一条站内信通知:"您的图书已入库上架"。
如果某一步操作失败,前面的步骤就必须回滚。比如库存记录插入失败,但图书状态已经变成了IN_STOCK,那用户看到的状态就是"已入库",可库里却查不到这本书,这就是数据不一致。解决方式就是给整个方法加上@Transactional注解。
这里我要重点说一个@Transactional失效的经典场景。我在开发的时候写过类似这样的代码:
@Service public class DonationService { public void stockIn(Long bookId) { // 一些入参校验 checkBookExists(bookId); // 更新状态 updateBookStatus(bookId, BookStatus.IN_STOCK); // 写入库存记录 inventoryRepository.insert(bookId); // 发送站内信 messageService.send(bookId, "入库成功"); } }一开始我在类内部直接调用了另一个方法,因为@Transactional是通过 Spring AOP 代理实现的,如果在同一个类内部直接调用方法,会绕过代理,导致事务失效。我看到日志里明明报错了,数据库数据却已经更新成功,就是因为这个原因。
正确的做法有两种:一是把需要事务的方法放到独立的 Service 类里,通过注入调用;二是自己注入自己(@Autowired self),或者通过ApplicationContext.getBean()获取代理对象后调用。我把入库相关的所有操作抽到了一个独立的InventoryService里,事务就生效了。
3.2 上传图书封面和捐赠凭证:文件存储的取舍
图书捐赠系统离不开文件上传。捐赠人要上传图书封面,管理员要上传捐赠凭证照片,有时候还会上传 PDF 格式的捐赠清单。这里我主要处理了两个问题:存哪和怎么限制大小。
存哪这个问题,开发阶段我用的是本地磁盘存储,就是配置一个上传目录,比如/data/donation/images/,然后把MultipartFile写到这个目录。但在生产环境,如果应用部署了多台实例,本地存储就会出现问题——用户这次请求打到 A 服务器,图片存到了 A,下次请求打到了 B,B 上找不到图片。所以我引入了MinIO,一个兼容 S3 协议的对象存储。Spring Boot 集成 MinIO 并不复杂,引入io.minio:minio依赖,配置连接信息,然后封装一个上传方法。搜索热词里频繁出现springboot minio和springboot minio 配置,说明用 MinIO 处理文件是 Spring Boot 项目绕不开的一条路。
限制文件大小这块,一开始我只在application.yml里配置了:
spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MB以为这样就够了。结果前端上传一个 4.9MB 的图片,后端处理完要生成缩略图,内存直接占用暴涨。后来我调整了策略:上传接口做前置大小校验,超过 5MB 直接拒绝,不进入业务逻辑;图片文件统一压缩为 WebP 格式再存储。别小看这一步,对于图书封面这种展示场景,WebP 相比原图能减小 70% 的体积,前端加载速度提升明显。
3.3 大文件上传的思考:分片还是直传
搜索热词里有一条springboot 如何上传下载大文件,这个捐赠系统里虽然没有特别大的文件需求,但我在设计上传服务时还是预留了分片上传的接口。原理是把一个文件切成多个块,前端并发上传,后端按块暂存,全部传完后合并。Spring Boot 实现分片上传的核心在于接收入参时要带chunkIndex和totalChunks,后端用FileUtils按偏移写入临时文件,最后合并。如果只是做内部系统,一般直传就够用,不需要过度设计。
4. 权限模型与 API 安全:前后端分离下的用户认证怎么设计
4.1 JWT + Redis:解决登录态和登出失效
图书捐赠系统采用前后端分离架构,后端提供 REST API,前端用 Vue 构建。分离架构下最常见的方案就是JWT(JSON Web Token)登录。用户登录成功后,后端生成一个 token 返回给前端,前端存在本地存储,后续每次请求都在 Header 里带上Authorization: Bearer <token>。
JWT 有个特点:它是无状态的,服务端不存储 token 信息。这就带来一个问题,如果用户主动退出登录,或者管理员要把某个用户踢下线,单纯的 JWT 做不到立刻失效,因为 token 在有效期内一直可用。我的折中方案是JWT + Redis 黑名单。
- 用户登录成功后生成 JWT,同时把 token 的
jti(唯一标识)存到 Redis,设置过期时间; - 写一个拦截器,每次请求先解析 JWT,判断签名和过期时间,然后查 Redis 里是否存在这个
jti; - 如果存在,说明 token 有效;如果不存在,说明 token 已被注销,直接返回 401;
- 用户退出登录时,只需要删除 Redis 里对应的
jti,token 立即失效。
这样的好处是,既保留了 JWT 不需要服务端存储用户状态的优势,又弥补了无法主动失效的短板。Redis 里只存一个短字符串,内存开销极低。
4.2 统一异常处理:别让前端看到看不懂的 Error
前后端分离开发时,我最烦的就是后端接口在出错时返回一堆乱七八糟的异常信息,什么NullPointerException、SQLIntegrityConstraintViolationException,前端拿到后根本无法判断是参数错误还是业务逻辑错误。所以我在项目里做了全局异常处理,核心就是利用 Spring Boot 的@RestControllerAdvice注解。
我定义了一个统一的返回体结构:
{ "code": 400, "message": "图书不存在或已下架", "data": null }所有 Controller 成功返回时code为 200,异常时根据类型返回不同的code和message。业务异常(比如图书状态不允许直接出库)我自定义一个BizException,在@RestControllerAdvice里捕获并返回 400;参数校验异常MethodArgumentNotValidException返回 422;未登录或登录过期返回 401;没有权限返回 403。
这一套做好之后,前端只需要统一封装一个请求方法,根据code做不同处理,再也不用在 catch 里解析千奇百怪的异常字符串了。
4.3 API 接口设计细节
在做图书捐赠系统的接口时,有几条约定很关键:
- URL 使用名词复数,比如
/api/books、/api/donations,不要用动词,动词交给 HTTP Method(GET 查询、POST 新增/提交、PUT 更新、DELETE 删除)。 - 分页参数统一:
page和size,响应用PageResult<T>包裹,包含total、pageNum、pageSize、records四个字段。 - 状态字段不要用魔法数字:接口返回的状态码要用枚举的字符串值,比如
"status": "PENDING",前端看得明白,也不容易写错。 - 时间字段统一格式:默认返回
yyyy-MM-dd HH:mm:ss,通过 Jackson 配置全局格式化,别让每个接口自己格式化,容易出现时区不一致的问题。
5. 从开发到上线:环境搭建、Docker 部署和常见问题排查
5.1 项目搭建时的版本坑:Docker 和 JDK 版本失配
搜索热词里有一条很具体:springboot jdk1.8打包到docker desktop。这个我太有感触了。Spring Boot 项目本地用java -jar跑得好好的,打包成 Docker 镜像后启动直接报UnsupportedClassVersionError或者Invalid or corrupt jarfile。排查到最后发现,Dockerfile 里的基础镜像用的是openjdk:latest,而最新版 Docker Desktop 的 latest 标签指向的是 JDK 21。项目是用 JDK 8 编译的,在 JDK 21 的环境下某些字节码版本不兼容,自然跑不起来。
现在我的 Dockerfile 写得很明确,不再依赖 latest 标签,而是固定镜像版本和基础镜像:
FROM openjdk:8-jdk-alpine VOLUME /tmp ARG JAR_FILE=target/book-donation-1.0.0.jar COPY ${JAR_FILE} app.jar ENV JAVA_OPTS="-Xms256m -Xmx512m -Dfile.encoding=UTF-8" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]如果你使用 JDK 17 或者 JDK 21,同样要选对应版本的基础镜像,比如eclipse-temurin:17-jre或amazoncorretto:17。镜像版本跟着 JDK 版本走,不要随意用 latest,这是 Spring Boot 项目 Docker 化最基本的一条。
5.2 Maven 打包与配置文件外部化
Spring Boot 项目打 jar 包时,默认会把src/main/resources里的配置文件也打进去。但在生产环境,如果 MySQL 密码或 MinIO 密钥变了,我们不应该重新打包,而是通过外部化配置覆盖。我的做法是:
- 项目内置的
application.yml只放必要的公共配置; - 生产环境通过
SPRING_PROFILES_ACTIVE=prod指定 profile,再由外部挂载一个application-prod.yml; - Docker 部署时,把宿主机上的配置文件目录挂载进容器,比如说
-v /data/conf:/app/config,Spring Boot 会自动加载/app/config/application-prod.yml,而且外部配置优先级高于 jar 内置配置。
这样运维只需要修改宿主机上的配置文件,不需要重新构建镜像,非常灵活。
5.3 数据库连接的时区问题
图书捐赠系统上线后,我发现捐赠记录的创建时间总是比本地时间慢 8 个小时。排查了一圈,问题出在 MySQL 连接串上。jdbc:mysql://localhost:3306/book_donation没有指定serverTimezone参数,而 MySQL 连接器默认使用服务器的时区,和本地时区不一致。
正确的连接串至少要加:
jdbc:mysql://localhost:3306/book_donation?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai同时,数据库中建议用datetime类型而不是timestamp来存业务时间,避免时区转换带来的二次干扰。如果是用LocalDateTime映射datetime,效果最稳定。
5.4 轻量级测试与接口调试
开发这个系统的时候,我除了用 Postman 测接口,还引入了spring-boot-starter-test写单元测试。但真正让我效率上来的,是给核心业务逻辑写瘦测试。比如状态机的流转,我直接 new 一个 Service 对象,不启动 Spring 容器,用 Mockito 把依赖 mock 掉,只验证状态是否按预期流转,异常路径是否抛错。这样跑一次只要几秒钟,比启动整个项目再通过接口调要快太多。
另外,网上很多人问springboot 单元测试最佳实战,对于管理系统来说,我最想强调的是:不要为了覆盖率而写测试,优先测试有复杂状态流转和事务边界的 Service 方法,Controller 层用 MockMvc 简单校验 HTTP 状态码和返回结构就足够了。
5.5 线上 JVM 参数与日志排错
系统上线后,遇到内存不足、接口响应慢,我一般先看两样东西:JVM 堆内存使用情况和 GC 日志。启动参数里加了:
-Xms256m -Xmx512m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/app/logs/gc.log这个捐赠系统并发量不大,256M 到 512M 的堆就足够了。但如果要处理大量文件压缩,堆内存可以适当调到 1G,而且要关注 Metaspace 大小,防止因为加载过多类导致OutOfMemoryError: Metaspace。
日志方面,我用 Logback 把输出切分成两个文件:info.log记录业务日志,error.log单独记录异常。配合@Slf4j注解在关键节点打日志,比如:捐赠人提交申请时记录donation submitted, bookId={}, userId={},审核通过时记录book approved, bookId={}, adminId={}。线上排错时,顺着日志 ID 串联整个流程,定位问题非常快。
6. 总结之外的几句实话:这套系统还能怎么扩展
图书捐赠系统的核心其实不在 Spring Boot,而在业务状态的梳理和数据权限的隔离。如果你只是照着网上的教程搭一个 Demo,写几个 CRUD,那你学到的始终是框架的壳子。但如果你能把捐赠流程的状态机、审核和入库的事务边界、文件上传的存储选型、前后端分离的权限设计想清楚,再回头去看任何管理系统,都会觉得一通百通。
如果你正准备做类似的系统,我个人建议先把核心流程跑通,不要一上来就搞权限、搞角色、搞复杂的菜单管理。先实现捐赠提交、审核、入库、申请出库这条主线,保证数据一致性和状态流转正确,然后再逐步加用户角色、加日志、加文件存储,最后才是上 Docker 部署。顺序反了,很容易陷入"框架学习"的泥潭,项目却迟迟做不出来。
最后,这套系统后续如果想继续扩展,我比较推荐的方向是:引入消息队列(比如 RabbitMQ 或 ActiveMQ,网上很多 Spring Boot 整合教程)做审核通过后的站内信异步通知;引入定时任务(比如 Spring 自带的@Scheduled或 Quartz)定期清点未领取的图书库存;再走远一点,可以接一个扫码功能,给每本书生成一个二维码标签,出库时扫码核销,这样整个捐赠闭环就更加完整了。开发没有尽头,但每一步扎扎实实踩过来,经验就是自己的。