SpringBoot+微信小程序流浪动物救助系统:状态流转与并发控制实战
2026/9/12 21:58:40 网站建设 项目流程

简介:面向高校计算机专业毕业设计的流浪动物救助数据库项目,使用SpringBoot框架与微信小程序技术构建,覆盖后端Java服务、小程序前端展示、数据库表结构及完整部署说明。系统围绕流浪动物信息登记、领养申请与救助记录等核心功能展开,能帮助开发者深入理解前后端分离架构、小程序生命周期及接口联调流程。压缩包共681个文件,以97个Java源码、96个Vue页面、61个JavaScript脚本、22个WXSS样式与21个WXML模板等小程序开发文件为主,附有演示视频、一键启动脚本、SQL初始化文件和多种图标素材,整体体积约49.97MB,目录组织层次分明,便于按模块检索学习。项目已在Windows10/11环境严格调试,答辩评审获得97分,配套使用文档、部署教程及全部项目资料,下载配置后即可运行,既适用于毕业设计参考,也能作为期末课程作业的完整示例。目前已有208人学习下载,对于正在寻找全栈实战项目或入门小程序开发的Java学习者,这套项目能提供从代码到部署的完整参考。

1. 流浪动物救助程序,难点不是CRUD而是状态流转

拿到这个压缩包,第一眼看到的是五样东西:SpringBoot后端、微信小程序端、救助数据库、使用文档、演示视频。毕业设计做成这个标题,真正要回答的问题不是“有多少张表”,而是当一只动物同时有三个人提交领养申请时,系统怎样保证只有一个人能领养成功。我第一次做这类项目也会先把动物档案、领养申请、救助记录各建一张表,跑起来之后才发现,状态字段不一致会带来一堆脏数据。这篇文章会把数据库怎么设计、SpringBoot接口怎么把状态流转锁住、小程序端怎么把列表和表单串起来讲清楚,适合准备SpringBoot框架面试题时想找一个完整业务链路的读者,也适合开始接触微信小程序开发,想要一个能答辩的项目样板的人。

2. 拆解救助数据库:从ER图到SpringBoot实体与表结构

做数据库课程设计的时候,习惯先从ER图开始。流浪动物救助这个业务域,第一版只需要四张表:动物档案、领养申请、救助记录、小程序用户。实体之间的关系不复杂,真正的复杂度来自“状态联动”:领养申请审核通过时,动物要从待领养变成已领养,同时该动物其他待审核的申请必须全部作废。这个动作如果只在页面里改一个字段,数据很快就会脏。

表名业务含义关键字段
animal动物档案id, name, species, gender, status, cover_url, location
adopt_application领养申请id, animal_id, user_id, applicant_name, phone, status
rescue_record救助记录id, animal_id, rescuer_name, phone, address, description
sys_user小程序用户id, openid, nickname, phone, role

救助记录和动物档案是逻辑上的多对一:一只动物可能被同一个人救助过多次,救助记录只负责留痕,不参与领养状态判断。领养申请和动物档案是多对一,和用户也是一对多。这个项目不建物理外键,用逻辑外键加索引就够了,否则小程序端删除种子数据的时候会频繁触发外键约束,演示反而卡住。

2.1 救助域的三类核心实体与关系

实体关系理清之后,第二件事是给状态字段定边界。动物状态和申请状态不能混在一个枚举里,否则业务逻辑会越来越绕。我的建议是动物表单独维护 status,领养申请表单独维护 status,两个状态通过业务规则同步。

2.1.1 动物状态与申请状态的边界

动物状态建议用四个值:0 待审核、1 待领养、2 已领养、3 已下架。领养申请状态用四个值:0 待审核、1 已通过、2 已拒绝、3 已取消。救援人员提交救助记录后,默认进入待审核;管理员审核通过后变成待领养;用户提交领养申请后,管理员审核通过,动物状态变成已领养,同时把其他待审核申请置为已拒绝。

这里最容易错的是认为“动物状态 = 最新一条申请状态”。实际上申请状态只是流程单据状态,动物状态才是业务状态。比如动物体检不过被下架,状态变成 3,这时它的待审核申请仍然可能是 0,需要服务层在下架时统一处理。把状态边界写在表注释里,比写在代码注释里更有效,因为建表脚本会被多人反复导入。

2.2 用Navicat建表的字段与索引设计

建表脚本可以直接导入Navicat执行。我一般会关闭外键检查,避免初始化顺序导致导入失败。下面是核心的建表语句,已经去掉了不必要的冗余字段:

CREATE TABLE animal ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '动物昵称', species VARCHAR(20) NOT NULL COMMENT 'cat/dog/rabbit', gender TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1公 2母', age_months INT NOT NULL DEFAULT 0, health_status VARCHAR(100) NOT NULL DEFAULT '待体检', location VARCHAR(120) NOT NULL DEFAULT '' COMMENT '发现地或收容地址', cover_url VARCHAR(255) NOT NULL DEFAULT '', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1待领养 2已领养 3已下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_create (status, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='动物档案'; CREATE TABLE adopt_application ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, animal_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL, applicant_name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, reason VARCHAR(500) NOT NULL DEFAULT '', housing_info VARCHAR(500) NOT NULL DEFAULT '' COMMENT '居住情况', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2已拒绝 3已取消', apply_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME DEFAULT NULL COMMENT '审核时间', KEY idx_animal_status (animal_id, status), KEY idx_user_apply (user_id, apply_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='领养申请表';

id 使用 BIGINT UNSIGNED,避免以后数据量超过 INT 上限。状态字段用 TINYINT 而不是 INT,节省行空间,InnoDB 行越小单页能存的记录越多,列表查询越占优势。create_time 和 update_time 交给数据库默认值维护,Java 实体里只做读取,不手动 set。索引方面,首页列表最常见的查询是where status = ? order by create_time desc,所以建联合索引 idx_status_create;领养审核页最常见的查询是where animal_id = ? and status = ?,所以建 idx_animal_status。

这里不建议在 animal_id 和 status 上建唯一索引,因为业务规则要求的是“同一动物只有一个状态为 0 的待审核申请”,MySQL 普通唯一索引做不到只对某个状态值唯一。要不依赖数据库约束,把这个校验放到 Service 层事务里,逻辑更直观。

2.3 MyBatis-Plus还是Spring Data JPA:选型与代码落地

做 SpringBoot 项目,ORM 选 MyBatis-Plus 最常见。理由很实际:单表 CRUD 完全不用写 SQL,继承 BaseMapper 就有 selectById、selectPage、updateById,联表查询写 Xml 也能接住。Spring Data JPA 的派生查询写起来也省事,但面试时导师更容易追问代理对象、懒加载、N+1 问题,毕业设计时间紧的话没必要给自己加戏。pom 里引入 mybatis-plus-boot-starter 就行,版本选你之前跑通过的一个 3.5.x,不要追最新,SpringBoot 版本太高反而会出现分页插件拦截器不兼容的情况。

实体类和表字段做映射,注意 MyBatis-Plus 默认把驼峰转下划线,所以 Java 属性写 createTime,表字段写 create_time,不需要额外配置。

@TableName("animal") public class Animal { @TableId(type = IdType.AUTO) private Long id; private String name; private String species; private Integer gender; private Integer ageMonths; private String healthStatus; private String location; private String coverUrl; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }

接口层再写一个 Mapper 接口继承 BaseMapper。@TableName指定表名,@TableId(type = IdType.AUTO)对应自增主键。animal 表里有 localDateTime 字段,数据库 URL 参数里记得配 serverTimezone=Asia/Shanghai,否则本机时间和服务器时间对不上,演示视频里会出现“刚提交的申请时间比当前时间晚 8 小时”这种尴尬。

3. SpringBoot后端:救助状态机与小程序端API实现

后端接口设计最怕两种极端:一种是按页面拆接口,比如 getIndexData、getDetailData;另一种是每个表单独暴露 CRUD,小程序端可以直接改任意字段。正确做法是把接口按业务流程拆,小程序端只能看到自己需要的操作,比如提交领养申请、查询申请记录、上传救助信息,不能直接跨表改状态。

3.1 REST API 的路径设计与统一返回体

接口路径建议这样划分:

方法路径说明
GET/api/animals?status=&species=&page=1&size=10分页查询动物列表
GET/api/animals/{id}查询动物详情
POST/api/adopt/apply提交领养申请
PUT/api/adopt/{id}/audit?pass=true管理员审核申请
GET/api/rescue/records查询救助记录

统一返回体使用一个 Result 泛型类,code 为 0 表示成功,非 0 表示业务失败。不要直接用 HTTP 状态码做业务判断,因为小程序端和后端之间的网关可能会重写状态码,而且 200 之外的响应在 wx.request 里也能拿到,但处理逻辑会碎。

public class Result<T> { private int code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> error(int code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; } }

写 Controller 的时候,方法参数用 @RequestParam 给默认值,分页参数 page 和 size 一定不能让用户传负数。MyBatis-Plus 的 Page 对非法值会做修正,但业务上最好在入口就挡住。

@GetMapping("/animals") public Result<Page<Animal>> list(@RequestParam(defaultValue = "1") long page, @RequestParam(defaultValue = "10") long size, @RequestParam(required = false) Integer status) { LambdaQueryWrapper<Animal> wrapper = Wrappers.lambdaQuery(); wrapper.eq(status != null, Animal::getStatus, status) .orderByDesc(Animal::getCreateTime); return Result.ok(animalMapper.selectPage(new Page<>(page, size), wrapper)); }

eq 方法第一个参数是布尔条件,status 为 null 时不拼这个条件,避免出现where status = null。orderByDesc 直接用 Lambda 属性引用,不会因为字段改名而漏改字符串。

3.2 领养申请的状态流转实现:事务与行锁

审核领养申请是整个项目最核心的业务。很多人只写了普通 select,再 update,并发时会出大问题。两个管理员同时审核同一只动物的不同申请,都查到动物状态是待领养,于是两个都通过,动物就被“双领养”了。解决办法是先锁行再判断。

@Transactional(rollbackFor = Exception.class) public void auditApply(Long applyId, boolean pass) { AdoptApplication apply = adoptApplicationMapper.selectById(applyId); if (apply == null || apply.getStatus() != 0) { throw new BusinessException("申请不存在或已审核"); } Animal animal = animalMapper.selectByIdForUpdate(apply.getAnimalId()); if (animal == null || animal.getStatus() != 1) { throw new BusinessException("该动物当前不可领养"); } if (pass) { animal.setStatus(2); animalMapper.updateById(animal); apply.setStatus(1); apply.setAuditTime(LocalDateTime.now()); adoptApplicationMapper.updateById(apply); LambdaUpdateWrapper<AdoptApplication> update = Wrappers.lambdaUpdate(); update.eq(AdoptApplication::getAnimalId, apply.getAnimalId()) .eq(AdoptApplication::getStatus, 0) .ne(AdoptApplication::getId, applyId) .set(AdoptApplication::getStatus, 2); adoptApplicationMapper.update(null, update); } else { apply.setStatus(2); apply.setAuditTime(LocalDateTime.now()); adoptApplicationMapper.updateById(apply); } }

selectByIdForUpdate 不是 MyBatis-Plus 自带方法,直接写在 AnimalMapper 上即可:

@Select("SELECT * FROM animal WHERE id = #{id} FOR UPDATE") Animal selectByIdForUpdate(@Param("id") Long id);

这段逻辑有四个关键点。第一,先锁 animal 行,再查申请和更新申请,锁顺序固定,避免两个事务各自锁了不同行然后互相等待的死锁。第二,FOR UPDATE会把 animal 这一行锁到当前事务提交,第二个并发事务执行同样的 selectForUpdate 会阻塞,直到第一个事务提交后重新读取,此时 animal.status 已经是已领养,进入“不可领养”分支。第三,@Transactional 默认只对 RuntimeException 回滚,这里指定 rollbackFor = Exception.class,BusinessException 也是继承 RuntimeException,所以业务异常同样触发回滚。第四,批量把其他申请置为 2 时,用 ne 排除当前审核通过的这一条,避免把自己的申请重新写成已拒绝。

3.2.1 为什么不直接用乐观锁

乐观锁也可以做,比如 animal 表加 version 字段,update 时带 version 条件,影响行数为 0 就失败。但这种情况需要前端重试,或者后端循环重试,逻辑分发到多个入口后很容易漏。单机部署的毕业设计,悲观锁反而清晰:锁的那一行代码就代表了“这只动物同时只有一个审核事务能操作”。如果你面试被问到高并发,可以补充说生产环境会把锁上移到 Redis,但演示时不用。

3.3 SpringBoot配置、yml密文与上传参数

从 zip 里拿到项目后,最常改的就是 application.yml。数据库密码、文件上传大小、MyBatis-Plus 映射配置都在这一个文件里。建议密码用 Jasypt 加密,不要明文提交到压缩包。

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/animal_shelter?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: ENC(这里放密文) servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jasypt: encryptor: password: ${JASYPT_PASSWORD}

启动时指定环境变量:java -jar app.jar --JASYPT_PASSWORD=你的密钥。这里的密码不是数据库密码,是 Jasypt 加密用的盐,配置文件里的 ENC() 密文必须用它才能解开。multipart 配置只控制 SpringBoot 接受的文件大小,如果项目前端用 Nginx 转发上传请求,Nginx 的 client_max_body_size 也要同步放开,否则前端报 413,但后端日志里什么都没有。

SpringBoot 版本如果特别新,jasypt-spring-boot-starter 要用适配 SpringBoot 3 的分支,否则启动会直接报自动配置类不存在。这种问题很典型,属于“springboot版本太高”导致的依赖兼容性踩坑。不想引 Jasypt 的话,另一个更简单的方案是数据库密码不写进 yml,用环境变量password: ${DB_PASSWORD},部署时在系统环境变量里设置,同样能避免明文入库。

3.4 接口调试:从Postman脚本到Burp Suite抓取小程序请求

后端接口写完,先用 Postman 过一遍最基础的 CRUD,确认返回结构是 Result 的 JSON 格式。然后在小程序开发者工具里把 BASE_URL 指向本机局域网 IP,真机预览时手机和电脑连同一个 WiFi,后端要在 application.yml 里加server.address=0.0.0.0,否则真机访问不到。

如果需要查看小程序实际发出去的请求体、响应体、请求头,可以用 Burp Suite。常见做法是:Burp Suite 里新建一个监听 127.0.0.1:8080 的项目,微信开发者工具右上角“详情 - 本地设置”里把请求流量指向这个地址,再安装并信任 Burp 的 CA 证书,就能在 Burp 里看到 wx.request 从构建请求到收到响应的完整过程。这里要注意,抓包工具只能帮你看请求,不能帮你改数据库,调试状态联动还是要在后端日志里打关键节点。

4. 微信小程序端:列表、详情、状态更新与表单校验

小程序端更接近一个展示和录入层,业务规则尽量不放前端。列表页从 /api/animals 拉数据,详情页打开后展示动物档案和一个“申请领养”按钮,申请页提交表单,个人中心查看我的申请记录和审核状态。做到这四个页面,项目演示就已完整。

4.1 小程序目录结构与导航栏适配

小程序端目录结构建议这样组织:

miniprogram/ app.js app.json pages/ index/ detail/ apply/ mine/ utils/ request.js images/

app.json 里 pages 数组的第一项是启动页。很多新手遇到每次编译都进错页面,问题就出在这里。要修改刚进入的加载页面,直接调整 pages 数组顺序,而不是去 project.config.json 里找入口。

顶部导航栏高度是小程序开发里的高频问题。如果 app.json 里设置了自定义导航"navigationStyle": "custom",页面顶部状态栏会空出一块,直接写死 px 会在不同机型上错位。正确做法是拿系统状态栏高度和胶囊按钮位置计算:

function getNavInfo() { const windowInfo = wx.getWindowInfo(); const menu = wx.getMenuButtonBoundingClientRect(); const navBarHeight = (menu.top - windowInfo.statusBarHeight) * 2 + menu.height; return { statusBarHeight: windowInfo.statusBarHeight, navBarHeight: navBarHeight, menuHeight: menu.height }; }

这个公式把胶囊按钮到状态栏的距离翻倍,加上胶囊自身高度,就是自定义导航栏的完整高度。用它去设置容器 paddingTop,页面内容就不会被刘海屏和状态栏遮挡。

4.2 用Promise封装wx.request并处理加载页

wx.request 默认回调风格,页面一多会出现多层嵌套。先封装一层 Promise,后续页面统一调 request 方法,返回体已经帮终端把 Result 拆好了。

const BASE_URL = 'http://192.168.1.100:8080/api'; function request(url, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${url}`, method: method, data: data, header: { 'Content-Type': 'application/json' }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };

加载页的体验问题是列表页首次进入容易白屏。原因很简单:onLoad 发请求是异步的,页面 onReady 先执行,数据回来后 setData 才触发渲染。处理办法是在 onLoad 里 wx.showLoading,在请求结束后的 finally 里 wx.hideLoading。如果用了自定义导航栏,wx.showNavigationBarLoading 不显示,所以不要依赖它。

4.3 表单提交与radio-group单选框的坑

申请页的表单至少要包含姓名、电话、申请理由、居住情况。物种和性别筛选在列表页用 radio-group 实现,WXML 写法:

<radio-group bindchange="onSpeciesChange"> <label wx:for="{{speciesList}}" wx:key="value" class="species-item"> <radio value="{{item.value}}" checked="{{item.checked}}" color="#07c160" /> <text>{{item.label}}</text> </label> </radio-group>

对应的 JS:

data: { speciesList: [ { value: 'cat', label: '猫', checked: true }, { value: 'dog', label: '狗', checked: false } ] }, onSpeciesChange(e) { const selected = e.detail.value; this.setData({ selectedSpecies: selected }); }

这里有个典型坑:radio 的 checked 必须是布尔值,如果后端返回的 0/1 直接放进 WXML,字符串"false"在部分基础库版本里会被当作选中态,导致两个单选框同时高亮。正确做法是在接口返回后手动转换成布尔值。表单提交时手机号校验用正则:

const phoneReg = /^1[3-9]\d{9}$/; if (!phone || !phoneReg.test(phone)) { wx.showToast({ title: '请输入正确的手机号', icon: 'none' }); return; }

校验通过后再调 request 提交,提交按钮加 disabled 状态,防止用户连续点击产生两条申请,这样后端就算没加防重,前端也挡住了大部分重复请求。

4.4 小程序与web-view内嵌H5的页面坑

有的项目会把救助协议或多页面的免责声明用 web-view 内嵌 H5。常见问题有两个:一个是“微信小程序内嵌H5工具栏左侧返回箭头没有了”,还有一个是 H5 页面内跳转后返回层级错乱。

出现返回箭头消失,先检查承载 web-view 的那个页面的导航栏配置。如果这个页面设置了自定义导航,返回箭头会被自己的自定义按钮盖住或覆盖,web-view 全屏时原本由宿主提供的导航 UI 就不稳定。解决办法是不对 web-view 页面开自定义导航;如果必须自定义,就在 H5 页面用 wx.miniProgram.postMessage 告诉小程序原生层手动控制返回。另一个做法是 H5 内部用 history.back() 自己维护返回,而不依赖小程序的导航栈。

web-view 和业务页通信时,建议把参数通过 URL 查询串传递,比如/pages/webview/webview?url=xxx&animalId=1,这样 H5 侧原生 JS 拿到的参数和后端日志里的请求参数能一一对上,排查问题也容易。

5. 用演示视频反推验收清单:从数据库脚本到高分答辩

一个高分项目的演示视频不是随便点两下就行的,它其实暴露了完整验收路径。拿到 zip 后,先看演示视频里做了什么,再反过来检查自己能不能复现,这个顺序比直接看代码更高效。

5.1 演示视频里必须出现的4个场景

演示场景操作步骤答辩可说点
首页列表进入小程序,按状态筛选动物分页、状态过滤、数据库索引
提交领养申请打开详情,填写表单提交参数校验、申请写入
审核通过后端或管理端调用审核接口行锁、事务、状态联动
数据复查Navicat 查询申请和动物两张表索引设计、逻辑外键取舍

演示视频里如果有管理端页面,那么管理端代码也必须在 zip 中存在。很多资料包会混入两个版本的后端,演示视频里调用的接口和当前代码对不上,答辩时现场操作直接露馅。

5.2 数据库初始化脚本的三个检查点

先执行命令导入数据库:

mysql -u root -p animal_shelter < init.sql

如果导入报错,优先看三个检查点。第一,init.sql 里是否有 CREATE DATABASE 和 USE,保证库存在;第二,表是否都用了 InnoDB 和 utf8mb4,整库导出的脚本常见 DEFINER 绑定原用户,需要手动去掉;第三,演示数据覆盖 status 的 0、1、2 三种状态,没有已领养数据就没法演示审核通过后的联动效果。

5.3 从zip到能run的最后一公里

使用文档如果照着做还是起不来,大概率卡在三个本地差异:application.yml 里的数据库密码不对、小程序 appid 不是自己的、BASE_URL 写的 localhost。逐项改成自己的环境后,后端执行mvn spring-boot:run,前端在微信开发者工具里导入项目,本地开发时勾选“不校验合法域名”,真机预览再改成局域网 IP。把这一套动作在答辩前亲手走一遍,从数据库脚本到演示视频里最后一个点击,能全部复现,项目基本不会卡在半路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询