Spring Boot宠物领养系统:状态模型与并发控制实践

发布时间:2026/9/13 14:22:40
Spring Boot宠物领养系统:状态模型与并发控制实践 简介基于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_statuspetAVAILABLE、APPLYING、ADOPTED宠物当前是否可被申请application_statusadoption_applicationPENDING、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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;user表需要避讳因为USER是 SQL 关键字所以表名用app_user核心字段是id、username、password_hash、phone、role、created_at。密码不要存明文password_hash字段长度至少设置为 60BCrypt 编码后的字符串长度固定是 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?page1size10speciescat分页条件查询宠物信息POST /api/pets管理员录入宠物宠物信息POST /api/files图片视频上传返回 URL领养申请POST /api/adoptions用户提交领养申请领养申请PUT /api/adoptions/{id}/approve审批通过领养申请PUT /api/adoptions/{id}/reject审批拒绝系统管理GET /api/adoptions?statusPENDING后台申请列表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 PagePetVO 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管 nullNotBlank管空字符串和纯空格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 实体上有VersionHibernate 生成的 UPDATE SQL 会带上版本条件update pet set adoption_status?, version1 where id? and version0。两个管理员同时执行时数据库的行锁保证只有一个事务先更新成功第二个事务执行同样的 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 version0影响行数是 0。5. 部署配置文件安全加固与接口自检技巧代码跑通后离交付还差最后一步把项目变成别人能直接启动、能稳定运行的服务。这一章的内容是从开发环境走向测试环境时最值得检查的配置项。5.1 生产环境的配置文件要点开发环境里application.yml可以写死密码在生产环境必须用环境变量注入。下面这份配置覆盖了数据源、JPA、日志和 JSON 序列化几个关键点。spring: datasource: url: jdbc:mysql://localhost:3306/pet_adoption?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 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?page1size1 | jq .content[0].id PET_ID$(curl -s http://localhost:8080/api/pets?page1size1 | 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这个细节最容易在联调时浪费半天时间。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询