简介:基于Spring Boot的宠物领养管理系统完整Java源码包,面向正在学习Spring Boot、准备课程设计或毕业设计的后端开发者。项目覆盖宠物信息管理、用户管理、领养申请处理、系统管理四大核心模块,采用B/S架构与RESTful API设计,后端以Spring Boot结合Spring MVC、Spring Data JPA实现业务逻辑与数据持久化,前端使用HTML、JavaScript与CSS构建交互界面,数据库可适配MySQL等关系型数据库,从宠物资料录入、照片展示到领养申请提交、审核与反馈,形成完整业务闭环。压缩包共132个文件,核心为46个Java源码和17个HTML页面,另有18个JavaScript、11个CSS样式文件,以及XML配置、图片、字体、Maven构建脚本等辅助资源,整体仅2.5MB,轻量便于快速部署研读。目前已有79人学习下载,对想通过实际项目熟悉Spring Boot分层开发、数据持久化及前后端交互的读者来说,是一份可直接运行和改造的实用参考。
1. 为什么宠物领养管理系统用 Spring Boot 现成骨架能省一半时间
做过小项目的人都知道,宠物领养这类管理系统卡壳的地方往往不在页面,而在“同一只宠物被多人申请”这种数据关系上。这个基于 Spring Boot 的宠物领养管理系统,核心是把领养流程做成可落地的 RESTful 后台:宠物信息录入与展示、用户注册登录、领养申请提交审核、系统后台管理,四个模块通过一套状态机串起来。它适合两类人:一类是学生,需要结构清晰、答辩时能讲清楚设计亮点的完整项目;另一类是小动物救助组织,想用低维护成本的 Web 系统替换原先的 Excel 表格。Spring Boot 的价值在于自动装配,内嵌 Tomcat、Starter 依赖、Actuator 监控全都开箱即用,配合 Spring Data JPA 后实体关系直接映射到表结构,不用写一堆 XML 配置。下面的实现完全按这个思路展开。
2. 领养流程建模:从 ER 图到 Spring Data JPA 实体与状态机字段
在写任何 Controller 之前,先想清楚数据长什么样。领养系统的核心不是宠物列表接口,而是状态流:一只宠物被创建后处于可领养状态,用户提交申请后进入审核状态,管理员通过后宠物变成已领养。这一套流如果不在建模阶段定死,后面写业务逻辑时每加一个操作就要改一次表结构。
2.1 状态拆成两层:宠物状态与申请状态
一个常见的错误设计是把宠物状态和申请状态放在同一个字段里。比如宠物表里加status,取值是“待领养”“审核中”“已领养”,申请表里也有status,取值是“待审核”“通过”“拒绝”。表面上够用,实际上只要管理员多一个“取消领养”的操作,数据就乱了。因此,我会把两侧的状态完全拆开:pet.adoption_status只描述宠物当前能不能被领养,adoption_application.application_status才描述申请单走到哪一步。
| 状态位 | 表位置 | 枚举值 | 语义 |
|---|---|---|---|
| adoption_status | pet | AVAILABLE、APPLYING、ADOPTED | 宠物当前是否可被申请 |
| application_status | adoption_application | PENDING、APPROVED、REJECTED、CANCELED、COMPLETED | 申请单审批进度 |
为什么拆成两层?第一,查询“所有可领养宠物”直接按adoption_status = AVAILABLE过滤,不需要 join 申请表,索引也能走上;第二,审批动作会同时修改两条记录,但两条记录的状态互不干扰,审计时看到的是“宠物从未领养变成已领养”和“申请单从待审核变成通过”两个独立事实。这个设计在做月度领养统计时特别省事,COUNT(*) WHERE application_status = 'COMPLETED'一行 SQL 就能出数,不需要去解析状态文本。
2.2 四张核心表结构设计
领养场景里,信息管理和申请处理是核心流程,表尽量精简。四张表足够:pet宠物表、app_user用户表、adoption_application领养申请表、audit_log审计日志表。下面这个建表脚本可以直接跑在 MySQL 8 上,字符集用 utf8mb4,避免宠物昵称里的生僻字和 emoji 乱码。
CREATE TABLE pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, species VARCHAR(30) NOT NULL COMMENT '物种:cat/dog/other', breed VARCHAR(50) COMMENT '品种', age INT COMMENT '年龄,单位月', gender VARCHAR(10), health_status VARCHAR(200), photo_url VARCHAR(255), video_url VARCHAR(255), adoption_status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE adoption_application ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pet_id BIGINT NOT NULL, adopter_id BIGINT NOT NULL, application_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', apply_reason VARCHAR(500), home_condition VARCHAR(500), reviewed_by BIGINT, reviewed_at DATETIME, created_at DATETIME NOT NULL, CONSTRAINT fk_app_pet FOREIGN KEY (pet_id) REFERENCES pet(id), CONSTRAINT fk_app_user FOREIGN KEY (adopter_id) REFERENCES app_user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;user表需要避讳,因为USER是 SQL 关键字,所以表名用app_user,核心字段是id、username、password_hash、phone、role、created_at。密码不要存明文,password_hash字段长度至少设置为 60,BCrypt 编码后的字符串长度固定是 60。audit_log表记录谁在什么时间对哪个申请单做了什么操作,字段为operator_id、action、detail、created_at,它能支撑最基础的审计需求。
pet表里的version字段是为乐观锁准备的。管理员审批通过的一瞬间,这个字段会作为 UPDATE 的条件,直接解决两个操作员同时通过同一只宠物申请的问题,具体机制放到第 4 章展开。这里先记住:version是并发控制的关键,不是业务字段,前端永远不需要看到它。
2.3 JPA 实体映射与 @Version 注解
表结构确定后,Spring Data JPA 的实体类就很简单。注意状态字段用枚举而不是 String。
@Entity @Table(name = "pet") public class Pet { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Enumerated(EnumType.STRING) @Column(name = "adoption_status", length = 20) private PetAdoptionStatus adoptionStatus; @Version private Integer version; }@Enumerated(EnumType.STRING)把 Java 枚举映射成数据库里的 VARCHAR,存进去的是枚举名而不是序号。这样做的好处是,代码里可以安全地写if (pet.getAdoptionStatus() == PetAdoptionStatus.AVAILABLE),编译期就能发现拼写错误。为什么不用数据库原生的 ENUM 类型?因为数据库 ENUM 后续增加枚举值时需要执行ALTER TABLE改列定义,生产环境变更成本高;用 VARCHAR 加应用层枚举,新增状态只改 Java 代码,数据库列定义完全不用动。@Version会让 Hibernate 在每次 UPDATE 时自动带上WHERE version = ?条件,并用新版本号覆盖旧版本号,不需要手工写UPDATE pet SET adoption_status = ? WHERE id = ? AND version = ?这样的 SQL。
3. 宠物管理与领养申请的 RESTful API 实现与参数校验
状态和数据表定清楚后,接口只是把它们串起来。这一章按模块拆分接口,并落实到宠物列表、提交领养申请两个最核心的操作上。
3.1 先规划接口路径,再动手写 Controller
前后端分离项目里,路径规划决定了联调时要改多少东西。这套系统按资源划分路径,宠物和申请分别独立,不把审批动作设计成POST /api/adoptions/1/approve之外的第二种风格。
| 模块 | 方法与路径 | 说明 |
|---|---|---|
| 宠物信息 | GET /api/pets?page=1&size=10&species=cat | 分页条件查询 |
| 宠物信息 | POST /api/pets | 管理员录入宠物 |
| 宠物信息 | POST /api/files | 图片视频上传,返回 URL |
| 领养申请 | POST /api/adoptions | 用户提交领养申请 |
| 领养申请 | PUT /api/adoptions/{id}/approve | 审批通过 |
| 领养申请 | PUT /api/adoptions/{id}/reject | 审批拒绝 |
| 系统管理 | GET /api/adoptions?status=PENDING | 后台申请列表 |
GET /api/pets用 query 参数做过滤,POST /api/pets用 JSON body,审批用 PUT 而不是 GET,原因很简单,GET 请求会被浏览器预加载、也会被日志系统记录 URL,不能携带修改数据的语义。
3.2 宠物列表查询:分页参数从 1 开始还是从 0 开始
Spring Data JPA 的PageRequest.of(int page, int size)里 page 从 0 开始,但前端分页组件通常从 1 开始,这里必须做一个显式转换,否则第一页数据会变成第二页。
@RestController @RequestMapping("/api/pets") public class PetController { private final PetQueryService petQueryService; public PetController(PetQueryService petQueryService) { this.petQueryService = petQueryService; } @GetMapping public Page<PetVO> page(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String species) { Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending()); return petQueryService.page(species, pageable); } }这段代码把前端传入的 page 减 1 后交给PageRequest.of。Sort.by("createdAt").descending()让新录入的宠物排前面,符合救助站“最新到的动物优先展示”的习惯。species是可选参数,为空时查询全部,非空时按物种过滤。这里的 PetVO 不要直接返回实体类,原因很实际:实体类里包含version和updatedAt,直接序列化会把内部字段暴露给前端,也容易在 Jackson 序列化时碰到懒加载代理导致LazyInitializationException。
3.3 领养申请提交:校验、状态检查与事务边界
提交申请是所有写操作里最容易出问题的一个,因为它涉及两张表的联动:先查宠物,判断是否可领养,再写申请单,最后把宠物状态改成 APPLYING。
@Service public class AdoptionApplicationService { private final AdoptionApplicationRepository repository; private final PetRepository petRepository; @Transactional public Long createApplication(AdoptionApplicationCreateRequest request, Long adopterId) { Pet pet = petRepository.findById(request.getPetId()) .orElseThrow(() -> new BusinessException("宠物不存在")); if (pet.getAdoptionStatus() != PetAdoptionStatus.AVAILABLE) { throw new BusinessException("该宠物暂不可领养"); } boolean duplicate = repository .existsByPetIdAndAdopterIdAndApplicationStatus( pet.getId(), adopterId, AdoptionStatus.PENDING); if (duplicate) { throw new BusinessException("你已经提交过该宠物的领养申请"); } AdoptionApplication application = new AdoptionApplication(); application.setPetId(pet.getId()); application.setAdopterId(adopterId); application.setApplicationStatus(AdoptionStatus.PENDING); application.setApplyReason(request.getApplyReason()); repository.save(application); pet.setAdoptionStatus(PetAdoptionStatus.APPLYING); petRepository.save(pet); return application.getId(); } }这段代码的关键点是@Transactional。如果没有事务,先写申请单、再更新宠物状态,中间任何一个步骤抛异常,数据库里就会出现“申请单存在但宠物还是 AVAILABLE”的中间状态。加上事务后,两个写操作要么都成功要么都回滚。existsByPetIdAndAdopterIdAndApplicationStatus是 Spring Data JPA 的派生查询,方法名拆开看就是按宠物 ID、用户 ID、申请状态三个条件做存在性判断,它防止的是同一个用户对同一只宠物重复提交,防不了两个不同用户同时申请同一只宠物,后者要靠第 4 章的并发控制解决。
DTO 上的参数校验直接使用 Bean Validation 注解,Controller 方法里加@Valid就能在进入 Service 之前拦截非法参数:
public class AdoptionApplicationCreateRequest { @NotNull(message = "宠物ID不能为空") private Long petId; @NotBlank(message = "申请理由不能为空") @Size(max = 500) private String applyReason; }@NotNull管 null,@NotBlank管空字符串和纯空格,@Size限制长度,这三层覆盖了绝大多数脏数据。校验失败时 Spring 会抛MethodArgumentNotValidException,统一在@RestControllerAdvice里捕获,转成{ "message": "申请理由不能为空" }这样的结构,前端弹提示时不需要解析堆栈信息。
3.4 宠物照片和视频的上传路径
照片视频不要直接存数据库字段,数据库只存 URL。上传接口用 MultipartFile 接收文件后写入配置好的磁盘目录,再把 URL 存到pet.photo_url和pet.video_url。这里有一个很多人会踩的坑:把上传目录写死在代码里。我一般用@ConfigurationProperties(prefix = "custom.upload")读取application.yml里的custom.upload.photo-dir,这样开发环境用本地临时目录,生产环境改成云盘挂载路径,不用改代码。
4. 领养唯一性与并发控制:乐观锁、事务传播与 @Transactional 失效排查
领养系统最容易出现的数据问题不是 SQL 写错,而是并发。两个操作员同时看到一只宠物的待审核申请,先后点了通过,结果两个用户都收到了“领养成功”的短信。这不是业务流程错误,是缺少并发控制。
4.1 并发场景还原
假设宠物猫“豆包”状态是 AVAILABLE,用户 A 和用户 B 同时提交了申请。此时 application 表里多了两条 PENDING 记录,pet 表状态变成 APPLYING。管理员甲和乙同时打开后台,甲点第一条申请的通过按钮,乙点第二条申请的通过按钮。如果没有锁,两个事务都读取到豆包当前状态是 APPLYING,都认为可以更新为 ADOPTED,最终两条申请单都变成 APPROVED。用户感知就是一只猫被两个人成功领养。解决办法是在审批写操作里协作更新 pet 表,让后提交的事务感知到数据已经变化。
4.2 用 @Version 实现乐观锁
审批通过的核心逻辑放在一个事务方法里,同时更新宠物状态和申请单状态。
@Transactional public void approve(Long applicationId, Long operatorId) { AdoptionApplication application = applicationRepository .findById(applicationId) .orElseThrow(() -> new BusinessException("申请单不存在")); if (application.getApplicationStatus() != AdoptionStatus.PENDING) { throw new BusinessException("只有待审核的申请才能通过"); } Pet pet = petRepository.findById(application.getPetId()) .orElseThrow(() -> new BusinessException("宠物不存在")); if (pet.getAdoptionStatus() == PetAdoptionStatus.ADOPTED) { throw new BusinessException("宠物已被领养"); } pet.setAdoptionStatus(PetAdoptionStatus.ADOPTED); petRepository.save(pet); application.setApplicationStatus(AdoptionStatus.APPROVED); application.setReviewedBy(operatorId); application.setReviewedAt(LocalDateTime.now()); applicationRepository.save(application); }由于 Pet 实体上有@Version,Hibernate 生成的 UPDATE SQL 会带上版本条件:update pet set adoption_status=?, version=1 where id=? and version=0。两个管理员同时执行时,数据库的行锁保证只有一个事务先更新成功,第二个事务执行同样的 UPDATE 时匹配到 0 行,Hibernate 立刻抛出ObjectOptimisticLockingFailureException。这个异常会让整个事务回滚,后一个操作员看到的是“操作失败,请刷新后重试”,application 表里那条记录不会被错误地改成 APPROVED。
这里有一个隐含的边界:乐观锁只能在 UPDATE 时生效,所以审批动作必须走petRepository.save(pet)或者自定义的@Modifying更新语句,不能先查出 pet 数据然后只在 application 表上更新。只要最终没有产生对 pet 表的 UPDATE,锁就形同虚设。
4.3 事务不生效的三个经典场景
@Transactional 不生效的问题在项目里出现频率极高,而且一般测试环境很难复现,因为单线程下数据没问题。最容易踩的三个场景如下。
| 场景 | 现象 | 修复方式 |
|---|---|---|
| 同类内部自调用 | A 方法没加事务,B 方法加了事务,A 调用 B 不生效 | 把 B 拆到另一个 Service 里注入调用 |
| catch 吞掉异常 | 事务方法内部 try-catch 捕获 RuntimeException 不抛出 | catch 里重新抛出或手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 非 public 方法 | Spring AOP 默认只代理 public 方法 | 将方法改为 public |
自调用失效的原因是 Spring 的事务代理机制:调用方拿到的 Service 对象是代理对象,同类内部调用this.approve()绕过代理,@Transactional注解自然不会被解析。这个问题在审批逻辑里很容易不小心踩到,因为approve方法经常被同一个 Service 里的batchApprove调用,写的时候要特别注意。
4.4 用并发脚本复现验证锁是否生效
写完代码后需要验证锁确实在生效。最简单的方法是先准备一条 PENDING 申请单和两只不同用户账号,然后同时发两个审批请求:
curl -X PUT http://localhost:8080/api/adoptions/1/approve \ -H "Content-Type: application/json" \ -H "X-Operator-Id: 1" & curl -X PUT http://localhost:8080/api/adoptions/1/approve \ -H "Content-Type: application/json" \ -H "X-Operator-Id: 2" & wait两个curl同时执行后,观察响应:一个返回成功,另一个要么返回ObjectOptimisticLockingFailureException转成的业务错误,要么返回“宠物已被领养”。我一般还会在application.yml里临时把spring.jpa.show-sql打开,确认后一个事务生成的 UPDATE 语句where id=? and version=0影响行数是 0。
5. 部署配置文件安全加固与接口自检技巧
代码跑通后,离交付还差最后一步:把项目变成别人能直接启动、能稳定运行的服务。这一章的内容是从开发环境走向测试环境时最值得检查的配置项。
5.1 生产环境的配置文件要点
开发环境里application.yml可以写死密码,在生产环境必须用环境变量注入。下面这份配置覆盖了数据源、JPA、日志和 JSON 序列化几个关键点。
spring: datasource: url: jdbc:mysql://localhost:3306/pet_adoption?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: ${DB_PASSWORD} hikari: maximum-pool-size: 20 jpa: hibernate: ddl-auto: validate show-sql: false logging: file: name: logs/pet-adoption.log level: root: INFO org.hibernate.SQL: ${SQL_DEBUG:OFF}ddl-auto: validate是很重要的一项。开发阶段用update能省事,生产环境实体类和表结构不一致时它不会自动改表,而是启动直接报错,逼你先把数据库变更脚本写好再发布。show-sql: false避免日志被 SQL 刷爆,需要排查问题时通过SQL_DEBUG环境变量临时打开。DB_PASSWORD从环境变量读取,代码仓库里不出现任何明文密码。日志配置里的logs/pet-adoption.log按文件大小滚动,避免单文件无限增长把磁盘写满。
5.2 安全头、密码加密与敏感字段脱敏
部署时除了配置项,还有几个基础安全措施不能跳过。密码存储必须用BCryptPasswordEncoder,注册时编码、登录时matches校验,任何地方都不能出现明文密码。接口返回的用户信息里,手机号和家庭住址属于敏感字段,按业务需要脱敏,比如手机号中间四位用星号代替。
Nginx 层可以加上X-Content-Type-Options: nosniff和X-Frame-Options: DENY两个响应头,防止大部分基础嗅探和点击劫持。SSL 证书由 Nginx 终止,Spring Boot 内部继续走 HTTP,内网流量不做二次加密可以降低性能损耗。
5.3 用 Actuator 验证服务健康状态
Spring Boot Actuator 是自带的运维接口,只需要在pom.xml引入依赖,然后配置对外暴露端点:
management: endpoints: web: exposure: include: health,info启动后访问/actuator/health,返回{"status":"UP"}就说明应用和数据库连接正常。健康检查有两个额外用途:一是云服务器的负载均衡器可以定期轮询这个接口决定是否把流量转发过来;二是发布脚本可以等待这个接口返回 UP 后再切换流量,避免重启期间的请求打到还在启动中的应用。
5.4 用一条命令自检领养全流程
最后分享一个实用的验证技巧:部署完成后,不要只打开浏览器点页面,而是用一条命令把核心接口串起来跑一遍,前后端问题能快速分开。
curl -s "http://localhost:8080/api/pets?page=1&size=1" | jq '.content[0].id' PET_ID=$(curl -s "http://localhost:8080/api/pets?page=1&size=1" | jq -r '.content[0].id') curl -X POST http://localhost:8080/api/adoptions \ -H "Content-Type: application/json" \ -d "{\"petId\": $PET_ID, \"applyReason\": \"verified\"}"第一条命令拿到宠物 ID,第二条提交领养申请。如果第二条返回{"status": 200},说明分页、实体映射、事务写入、DTO 校验全部正常;如果返回 500,优先去看logs/pet-adoption.log中最后一个异常堆栈,而不是直接怀疑数据库配置。注意返回 JSON 里的字段名要和前端页面保持一致,比如审批接口返回的是applicationStatus而不是status,这个细节最容易在联调时浪费半天时间。
本文还有配套的精品资源,点击获取