ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Spring Boot的流浪动物救助站系统:从需求到部署的完整实战

基于Spring Boot的流浪动物救助站系统:从需求到部署的完整实战 前段时间接了个活儿给本地一家流浪动物救助站梳理日常管理流程。去了现场才知道情况比我预想的糟糕得多动物登记靠纸质表格领养申请全靠微信群里一条条聊天记录物资出入库是用Excel记的经常对不上账。最要命的是领养人有没有定期回访、动物疫苗什么时候该打第二针这些完全没有系统记录全靠负责人脑子里记着。我调研完就决定与其零敲碎打地补丁不如直接做一套基于Spring Boot的流浪动物救助站系统把动物档案、领养审批、回访记录、物资管理这些核心流程一次性搬到线上。这篇文章就从项目落地的完整视角把需求梳理、技术选型、数据库设计、核心模块实现和实际踩坑的过程都过一遍希望能给正在做同类毕设、课程设计或者想用Spring Boot练手完整业务系统的朋友一些参考。1. 救助站的真实痛点纸质台账、微信群申请与Excel物资表在动手写代码之前我花了两天时间蹲在站里看他们怎么干活顺便把过去大半年的纸质记录翻了一遍。很多做系统的人容易犯一个毛病上来就画ER图、建表根本不关心业务现场长什么样。实际上救助站这种地方业务流程有强烈的线下色彩和电商、OA完全不是一回事。1.1 一套系统真正要解决什么先说救助站日常运转会遇到哪些让人头大的事。每周大概会接收十几只流浪动物每只都要登记发现地点、体况描述、是否受伤、是否驱虫、疫苗打到第几针。纸质表格记完就往文件夹里一塞三天后再想查这只狗打过狂犬疫苗没有得翻十分钟资料。领养环节更崩溃。有意向的人通常在微信里问“那只白猫还在吗”负责人要手动翻聊天记录确认再约线下见面。一旦同时有三四个人问同一只猫基本必乱。领养人填的申请表、身份证复印件、领养协议全部是纸质版归档靠夹子查一个人以前的领养记录得翻半天。物资这块也不乐观。猫粮狗粮、驱虫药、消毒液入库出库全在Excel里谁领了多少、还剩多少月底一盘点就发现账面和实物对不上。更别说志愿者值班排班全靠一张贴在墙上的手写表。所以这套系统的第一个设计原则就是不追求大而全先解决“信息分散、查询靠人、流程靠嘴”这三个核心问题。我当时把需求拆成了五大块——动物全生命周期档案、领养申请审批流程、领养后回访记录、物资出入库台账、志愿者值班管理。每一块都能在站里找到对应的“现有痛点”而不是凭空想出来的功能。1.2 从现场访谈得出的功能模块地图有一件事值得单独说需求访谈时不要只问负责人还要问一线志愿者。负责人眼里的流程和志愿者眼里的流程经常不一样。比如负责人觉得“领养申请都是线上提交的很方便”但志愿者说“很多人不会填表最后都是我们代为录入”。这个细节直接影响了用户权限设计——系统必须支持“工作人员代填申请”而且代填的时候要记录操作人。最终定下来的功能地图大致是这样动物管理登记档案、上传照片、维护疫苗/绝育/驱虫状态、变更领养状态领养管理提交申请、资格初审、安排线下见面、签订协议、完成领养、拒绝申请回访管理按领养记录生成回访计划记录回访结果和异常情况物资管理入库、出库、库存预警、领用记录志愿者管理值班排班、服务时长统计首页看板待审核申请数、在养动物数、库存不足提醒、本月领养完成数模块之间不是孤立的领养模块要读动物模块的状态回访模块要挂到领养记录下面物资模块又跟志愿者领用行为挂钩。这些联动关系在设计数据库表结构之前必须想清楚否则后面就是一堆if else硬凑。2. 技术选型的取舍为什么是Spring Boot 2.7而不是3.x技术选型这块我估计是很多朋友最关心的。毕竟网上Spring Boot 3的教程已经满天飞了还有人拿它跟Python FastAPI对比问到底哪个好。我的答案比较朴素选型不是选“最新”是选“能稳定跑起来且后续有人能维护”。2.1 版本选择背后的环境约束站里的电脑配置很一般4GB内存的老机器JDK 8是现成的。这就把Spring Boot 3直接排除了因为3.x强制要求JDK 17硬上不是不行但老机器跑起来内存占用明显更大而且团队里没人熟悉Java 17的新特性出了问题排查成本高。另一个原因是生态兼容性。救助站系统要做文件上传、Excel导出、WebSocket通知这些周边依赖在Spring Boot 2.7时代已经非常成熟。2.3.x和2.6.x我也简单对比过2.6之后配置项有调整而2.7.18是官方维护的最后一个2.x版本等于安全补丁吃到了最后一刻性价比是最高的。新旧版本的选择可以列个表给还在纠结的人一个直观参考对比项Spring Boot 2.7.xSpring Boot 3.xJDK要求8/1117内存占用相对低256MB能起步相对高建议512MB以上生态成熟度极高大部分老教程适用部分starter要升级坑较多长期维护已停止新功能但补丁维护最久持续更新适合场景老旧环境、稳妥交付新项目、团队熟悉JDK17如果学校毕设硬性要求Spring Boot 3那当然可以上3.2.x配上JDK 17只要别在老服务器上跑就行。但从“稳妥交付一个能长期运行的系统”这个角度2.7.18加JDK 8是我打心底推荐的高性价比组合。这套组合跑那个救助站的老电脑内存占用稳定在300MB以内非常舒服。2.2 持久层选型MyBatis-Plus的一站式体验持久层我选了MyBatis-Plus 3.5.x没选Spring Data JPA。原因很实际救助站这类管理系统的业务表单表增删改查占了八成剩下的复杂查询主要是多条件筛选和连表统计。MyBatis-Plus用LambdaQueryWrapper写条件查询几乎不用写SQL而且分页插件一加列表接口直接搞定。JPA当然也写得爽但它的级联关系和懒加载问题对新手来说调试成本偏高。救助站系统的实体关系并不复杂没必要引入一套重量级的ORM思维。反过来如果用了JPA一旦碰到N1查询日志刷屏的时候排查起来相当痛苦。配合MyBatis-Plus我在代码生成上也偷了个懒用MyBatis-Plus Generator根据表结构自动生成实体类、Mapper接口和Service骨架再手工补业务逻辑。这样建完表之后基础CRUD几乎零成本主要精力可以放在审批流程和状态流转上。2.3 前端与开发工具的务实安排前端没上Vue或者React直接用了服务端渲染模板。原因同样和团队背景有关救助站没有专职前端懂一点HTML的人维护模板比维护Node构建链路的成本低得多。Spring Boot集成Thymeleaf或者一个简单的模板引擎页面用Bootstrap框架表格、表单、弹窗都够了加载速度还快。这里要提一个很多人卡住的问题IntelliJ IDEA社区版怎么用Spring Boot。社区版确实没有Spring Initializr项目向导但完全不耽误使用。正确做法是直接上start.spring.io网站选好版本和依赖下载zip压缩包然后在IDEA里File - New - Project from Existing Sources选下载解压后的目录识别Maven工程等着依赖下载完就能开发了。我用这个方法开了好几个工程一点问题没有。另外提一句接口拆分。好多人纠结第三方接口是不是要单独起一个服务其实在项目初期完全没必要。我在工程里单独建了openapi包路径统一用/api/open/开头配合独立的鉴权拦截器将来真要开放给合作宠物医院查询领养档案时直接在这个包里加接口就行不需要拆服务。3. 数据库建模动物档案表、领养申请表与状态流转数据库设计是整个系统最不能省的部分。救助站业务有很强的线下属性状态变化多一个动物从一个状态到另一个状态之间还涉及真实世界里的见面、协议签署这些都需要在表结构里反映出来。3.1 两棵核心表怎么设计第一张核心表是动物档案表animal。字段设计上除了基础的名字、品种、性别、年龄还要有救助信息、健康信息、状态信息。参考设计如下字段名类型说明idbigint主键雪花IDanimal_novarchar动物编号如CAT20240001namevarchar动物名字speciesvarchar品种猫/狗等gendertinyint性别age_monthint月龄vaccine_infovarchar疫苗记录sterilizedtinyint是否绝育dewormedtinyint是否驱虫health_statusvarchar健康状态rescue_datedate救助日期rescue_addressvarchar发现地点adopt_statusvarchar领养状态descriptiontext详细描述create_timedatetime创建时间其中有几个字段值得单独说。vaccine_info不要只存一个“已打疫苗”应该存“狂犬20240115、猫三联20240201”这种明细文本方便后续核对。adopt_status是给动物用的状态后面会详细讲。animal_no这个编号非常有用线下交流时大家直接报编号就能定位比报“那只黄色的猫”靠谱多了。第二张核心表是领养申请表adoption_application。之所以单独立表而不是在animal表上直接加几个领养人字段原因很简单一只动物可能会被多个人申请过一个人也可能申请多只动物而且每一次审核、拒绝、取消都是历史记录需要被追溯。表设计大致是字段名类型说明idbigint主键animal_idbigint申请的动物applicant_idbigint申请人关联用户表applicant_namevarchar姓名phonevarchar联系电话id_cardvarchar身份证号addressvarchar住址pet_experiencevarchar养宠经验family_reasontext领养理由与家庭情况application_statusvarchar申请状态meeting_timedatetime预约见面时间admin_idbigint审核人员create_timedatetime申请时间update_timedatetime更新时间申请表把申请人的核心信息都冗余了一份而不是全部靠关联用户表去查。道理很简单领养审核时工作人员要在列表页快速看到联系方式每次join一张用户表没有任何必要。冗余字段在这种业务场景里不是设计坏味道而是查询效率的来源。3.2 状态机设计为什么需要“线下见面”这个中间态这是我认为整个系统最关键的设计决策。救助站工作人员审核通过领养申请之后距离领养真正完成还差着一大截——领养人必须到站里看动物、确认合得来、签纸质领养协议这一套流程在线上系统里必须有一个专属状态承接。我定义了两个状态机一个管动物一个管申请。动物的adopt_status流转是AVAILABLE可领养- APPLYING申请处理中- RESERVED已审核通过预留- ADOPTED已领养。另外还有UNAVAILABLE治疗中或暂不可领养。申请的application_status流转是PENDING待审核- APPROVED审核通过- MEETING线下见面- ADOPTED完成领养以及分支REJECTED已拒绝、CANCELLED已取消。为什么审核通过之后还要拆一个MEETING出来因为线下见面是一个真实存在且耗时较长的环节很可能出现审核通过了、人却不来看了的情况。如果没有这个状态系统里就会同时出现一堆“已审核通过但实际没领养成功”的数据月底统计完成率时口径完全混乱。有了MEETING状态管理员看一眼就知道有多少申请已经推进到了见面阶段有多少是审核完了就没下文了的可以安排人电话催访。3.3 冗余统计字段与报表便利除了业务表我还在数据库层面做了几个方便统计的冗余设计。比如回访表专门加了回访计划日期和实际回访日期两个字段这样首页看板可以直接查“今天该回访但没回访的记录”SQL只有一条简单查询不用在业务代码里做复杂计算。动物表上也冗余了当前申请数量、领养次数这类字段。每次申请提交时在事务里顺带更新动物表的申请数每次完成领养时更新累计领养次数。虽然这种字段严格来说属于可以算出来的数据但救助站的管理后台需要频繁展示这类列表指标与其每条记录都写子查询或者运行时统计不如在写入时就把数字维护好查询时直接用。这种“用存储换查询时间”的取舍在这种规模的项目里是划算的。4. 领养申请模块的实现状态机、事务边界与幂等控制领养申请是整个系统的核心业务也是最值得讲清楚的一个模块。它虽然不复杂却同时涉及状态机、事务和幂等三个问题每一个在生产环境里都能单独逼疯人。4.1 申请接口的完整实现逻辑先看核心Service方法的实现Transactional(rollbackFor Exception.class) public void applyAdoption(ApplyAdoptionCommand command) { Animal animal animalMapper.selectById(command.getAnimalId()); if (animal null || !AnimaleStatus.AVAILABLE.equals(animal.getAdoptStatus())) { throw new BusinessException(该动物当前不可领养); } Long count applicationMapper.selectCount( new LambdaQueryWrapperAdoptionApplication() .eq(AdoptionApplication::getAnimalId, command.getAnimalId()) .eq(AdoptionApplication::getApplicantId, command.getApplicantId()) .in(AdoptionApplication::getApplicationStatus, Arrays.asList(PENDING, APPROVED, MEETING)) ); if (count 0) { throw new BusinessException(您已提交过该动物的领养申请请等待工作人员处理); } AdoptionApplication application new AdoptionApplication(); application.setAnimalId(command.getAnimalId()); application.setApplicantId(command.getApplicantId()); application.setApplicantName(command.getApplicantName()); application.setPhone(command.getPhone()); application.setIdCard(command.getIdCard()); application.setAddress(command.getAddress()); application.setPetExperience(command.getPetExperience()); application.setFamilyReason(command.getFamilyReason()); application.setApplicationStatus(ApplicationStatus.PENDING); applicationMapper.insert(application); animal.setAdoptStatus(AnimalStatus.APPLYING); animalMapper.updateById(animal); }这段代码里有两个顺序很关键。第一先查动物状态再校验重复申请最后插入申请并更新动物状态。插入申请是主操作更新动物状态是副操作两者的顺序不能颠倒不然可能出现申请没插进去、动物状态倒是被改成APPLYING了。第二更新动物状态用的是updateById的全量更新只改了adoptStatus一个字段其他字段不会被覆盖所以不需要担心并发下字段丢失。4.2 事务为什么会“失效”两类经典场景很多人在本地测试时事务是好用的一上线就发现数据写了一半。这里有两个最常见的坑我在这个项目里都踩过。第一个坑是同一类内部方法调用。假设上面的applyAdoption方法内部又调用了另一个事务方法比如this.updateAdoptStatus()直接通过this调用时Spring代理是感知不到的等于第二个方法上的Transactional完全无效。解决办法是从Spring容器里注入自己或者把需要事务控制的方法拆到另一个Service类里通过容器调用。第二个坑是异常被吞。如果业务代码里catch了异常然后返回一个错误对象事务机制看到的是“方法正常结束了”根本不会触发回滚。我见过不少项目出现“明明报错了数据却写进去了”的诡异问题最后都是这个原因。所以我自己在项目里定了一个规矩业务异常统一继承RuntimeException在事务方法里只抛出不捕获由全局异常处理器统一转成前端提示信息。这样事务边界绝对干净。4.3 重复申请的幂等处理一张查询解决曾几何时系统上线第一天就出过一档子事一个用户对同一只猫连续点了三次“申请领养”结果表格里插入了三条申请记录工作人员都看懵了。事后我加了个接口层防重方案根据申请人ID和动物ID查询状态为PENDING、APPROVED、MEETING的记录只要存在就直接提示“您已提交过申请”。为什么不直接加唯一索引因为不能用“申请人动物”做唯一人家确实可以对同一只动物申请两次——第一次被拒绝了再申一次是允许的历史状态REJECTED/CANCELLED不算数。数据库唯一索引做不出“部分状态唯一”这种约束所以业务层查一次是最直观的方案。再配合前端的提交按钮置灰双保险基本就能挡住用户手滑了。这里要补充的是如果未来系统并发量大了光靠业务层查询还不够需要在应用层加分布式锁或者用数据库悲观锁但对于救助站这种个位数并发的场景一次select加事务隔离已经绰绰有余。5. 踩坑实录从本地联调到打包上线的排查链路这部分跟大家仔细过一遍系统从开发到上线遇到的几个印象深刻的坑。每个问题单独看都不大但连在一块的排查过程很能说明问题也希望读者看完能少走点弯路。5.1 主键策略冲突MyBatis-Plus自增ID与雪花ID的错位系统开发到第三天第一个恶心的问题出现了。我用MyBatis-Plus生成实体类时默认配置了TableId(type IdType.ASSIGN_ID)也就是雪花ID。但建表脚本里主键字段我却写成了AUTO_INCREMENT自增。结果一跑插入接口报错信息提示主键重复或者数据库直接reject了显式写入的自增列值。这个问题的本质是两套主键策略的冲突数据库要求自增框架却非要自己生成一个雪花长整型塞进去两边各干各的自然就打架了。解决办法很简单二选一要么把表的主键改成非自增、bigint类型配合ASSIGN_ID要么把实体类改成TableId(type IdType.AUTO)完全交给数据库生成。我选了前者理由是雪花ID在分布式场景下更通用将来如果拆多个服务主键冲突的概率为零。这里提醒一句MyBatis-Plus的代码生成器默认策略不是AUTO用之前务必跟表结构对齐一下很多诡异报错都是这个引起的。5.2 图片上传后404路径映射问题的定位过程上传宠物照片时本地一切正常但部署到服务器之后图片上传成功了访问却一直404。这个问题的排查链路值得说一下。我先确认了文件确实落盘了然后发现上传返回的URL是/upload/xxx.jpg这个路径在本地是IDEA里设置的静态资源映射路径但服务器的Spring Boot工程默认没有映射/upload/**到服务器磁盘目录所以404。本地正常是因为开发环境用了resources下的静态目录服务器上没有对应的文件。正确的做法是增加一个WebMvcConfigurer配置直接把/upload/**映射到磁盘上的真实目录Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${file.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }如果生产环境前面还挂了Nginx更好的方案是让Nginx直接处理静态资源请求Spring Boot只管业务接口。这一步配置好之后图片访问的问题彻底消失。现在回想这类问题本地永远测不出来因为根因就在环境和运行方式的差异上。5.3 部署到服务器后的环境差异内存、编码与日志第一次打包部署到服务器时我踩了一个特别经典的内存坑。服务器上Java进程跑了一会儿就挂了查日志发现是OutOfMemoryError。看了下启动方式——java -jar adoption-server.jar默认JVM堆内存按服务器物理内存的四分之一配置2GB的服务器理论上能分到500MB但救助站系统同时跑了MySQL和一些其他进程内存直接被瓜分干净。调整启动参数后问题解决java -Xms256m -Xmx512m -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/var/log/adoption/ \ -jar adoption-server.jar --spring.profiles.activeprod除了内存编码问题也出现过一次。服务器上MySQL默认字符集是utf8mb4但连接串没有显式指定导致从接口写入的中文在某些操作后成了乱码。解决方式是数据源URL统一加上characterEncodingutf8mb4同时在应用层用spring.datasource.hikari.connection-init-sql设置初始化SQL。这种问题在本地因为环境字符集碰巧一致看不出来一到服务器就现原形。总结一下这轮的几个坑列个表方便大家对照自查问题根因解决方式主键报错ID策略与自增冲突统一用雪花主键表去掉自增图片404静态资源未映射磁盘路径WebMvcConfigurer映射 /upload/**进程被系统杀JVM堆参数过大显式设置-Xms -Xmx中文乱码连接串未指定字符集URL加characterEncodingutf8mb46. 上线后的监控与扩展Spring Boot Admin和WebSocket通知系统上线不是终点救助站的值班人员能安稳用才是目标。上线一周后我开始考虑怎么做监控和增强通知毕竟服务器放在站里角落的机架上没人会天天盯着终端窗口看。6.1 接入Spring Boot Admin的完整步骤我先引入了一个独立的Spring Boot Admin Server小服务再把救助站系统作为Client注册上去。步骤很简单Admin Server端只需要一个Spring Boot工程加上依赖dependency groupIdde.codecentric/groupId artifactIdspring-boot-admin-starter-server/artifactId version2.7.15/version /dependency主类加EnableAdminServer注解就完事。客户端这边救助站系统加两个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdde.codecentric/groupId artifactIdspring-boot-admin-starter-client/artifactId version2.7.15/version /dependency然后在application.yml里配置spring: boot: admin: client: url: http://localhost:9080/admin-server management: endpoints: web: exposure: include: health,info,metrics,logfile endpoint: health: show-details: always关键在于Actuator的端点默认只暴露health和info必须手动把metrics和logfile端点开出来Admin界面才能看到JVM内存、线程、HTTP请求统计这些信息。配好之后仪盘表页面直接看得到当前堆内存使用量、垃圾回收次数、最近请求耗时感觉比每天ssh上去敲jstat命令踏实多了。6.2 关键监控指标与JVM参数配置很多人在这个环节会问监控到底要看哪些指标我的经验是对于救助站这种低并发的单体系统盯四个就够堆内存使用率、Full GC频率、数据库连接池活跃数、接口响应耗时。堆内存肉眼看着稳定增长说明可能有泄漏Full GC太频繁说明堆太小或者代码里有大对象数据库连接池被打满通常是慢SQL或者连接泄漏响应耗时突然升高往往是外部依赖异常。生产环境的JVM参数也按这个思路给的-Xms256m -Xmx512m -XX:MaxMetaspaceSize128m -XX:UseG1GC用G1收集器是因为它在低堆内存下的停顿控制优于ParallelGC配合Admin跑起来系统可以做到很平稳。另外如果服务器内存确实紧张可以削到-Xmx384m前提是别同时开太多其他应用。6.3 领养进度通知WebSocket的接入与yml配置救助站工作人员提了个需求用户提交领养申请后希望系统能主动推送进度变化比如“您的领养申请已通过审核请预约时间来看猫”。这个功能最自然是走WebSocket。这里有个网上提问率很高的误区很多人搜“spring boot 集成web socket yml 配置”以为要通过yml配什么参数。其实Spring Boot集成WebSocket核心靠Java配置类yml基本不用专门配置。我用的是Spring内建的WebSocket支持先写一个WebSocketHandler处理连接和消息再注册到WebSocketConfigurer里Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Resource private AdoptionProgressHandler adoptionProgressHandler; Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(adoptionProgressHandler, /ws/progress) .setAllowedOrigins(*); } }Handler里面收到前端传来的userId后把WebSocketSession缓存起来。等后台更新申请状态时通过userId找到对应Session推送消息。实践下来这套方案在救助站场景完全够用比引入RabbitMQ或者Kafka轻得多也省掉了运维负担。关于yml唯一的注意点是如果接入了Spring Security放行/ws/progress这个端点否则握手阶段就会被拦截。这个坑我倒是没踩因为前期设计时就把WebSocket路径放到了白名单里。后端里还可以预留一个思路如果将来合作宠物医院要查领养档案可以单独开一个openapi模块用服务鉴权的方式对接内部Service层复用现有逻辑不跟后台管理接口混在一起。这样既满足了对外开放的需求也不会把内部结构暴露出去。WebSocket加上之后工作人员那边的工作量小了很多用户也省心。我在实际运行中发现一个小技巧推送消息的模板里一定要带上动物编号和当前状态否则用户收到消息还要去翻详情页才能知道是哪只动物体验会差一截。最后再分享一点自己的体会这套系统从调研、设计、开发到上线跑稳定前后花了一个多月。最大的感悟是技术选型不要追新稳定压倒一切。Spring Boot 2.7 JDK 8 MyBatis-Plus这套组合看起来平平无奇但它把开发成本压到了最低让救助站这种小场景也能轻松拥抱信息化。站在写代码的角度领养申请模块的状态机设计、事务边界控制以及幂等处理可能才是这个项目真正的价值所在。如果只是照着教程做CRUD你永远遇不到也解决不了这些问题代码写再多都只是重复劳动。希望这篇实战复盘能帮准备做类似系统的人少踩几个坑把精力放到真正需要思考的业务细节上去。
返回列表