上个月帮一家宠物爱心救助站做内部系统优化,负责人跟我吐槽:现在所有待领养宠物都记在Excel里,领养人信息靠微信聊天记录,经常一只猫同时被三个人看中,谁先谁后全靠人工协调;还有宠物已经被领养走了,官网页面还挂着“待领养”,网友打电话来问能不能领养,场面相当尴尬。后来我们又聊到捐赠记录、志愿者排班、活动报名,全是Excel加纸质台账,数据散得到处都是。
这就是我把这套SpringBoot+Vue宠物爱心组织管理系统完整做下来并开源分享的原因。它是一个典型的Java全栈管理类项目,后端用SpringBoot + MySQL + MyBatis,前端用Vue + Element UI,覆盖宠物收容登记、领养申请审核、捐赠管理、志愿者管理、活动发布等全流程。文章会从需求拆解、数据库设计、后端核心实现、前端交互到部署上线逐层展开,适合正在做毕业设计、想练手全栈项目,或者确实要给公益组织做信息化的同学参考。
1. 宠物爱心组织管理系统到底在管什么:需求拆解与模块边界
很多人拿到这种题目第一反应是“不就是个CRUD吗”,但真到设计阶段才发现,宠物爱心组织和普通后台管理系统最大的区别在于业务状态多、角色杂、流程长。先把这个想清楚,后面写代码才有方向。
1.1 核心业务链路:收容、领养、回访的完整闭环
宠物爱心组织的主线业务其实是一条很清晰的链路:收容登记 → 体检免疫 → 档案上架 → 领养申请 → 人工审核 → 家访探视 → 领养交接 → 回访跟踪。每一环都对应系统的核心数据状态。
比如一只流浪猫被救助站接收,工作人员先登记它的品种、年龄、性别、是否绝育、疫苗情况、健康状况,上传照片,生成宠物档案。档案状态是“待领养”,这时候才会出现在前台领养列表里。网友看到后提交领养申请,填写居住情况、养宠经验、家庭成员是否同意等信息。管理员在后台审核,审核通过后还要安排一次家访或者视频探视,探视通过才允许签订领养协议。领养完成之后,宠物状态改成“已领养”,系统还会在约定时间生成回访任务,确保宠物没有被二次遗弃。
这条链路拆下来,系统至少要有四个核心实体:用户、宠物、领养申请、回访记录。围绕它们再延伸出捐赠、志愿者、活动、公告这些外围模块。我见过不少项目一上来就直接写宠物表和管理员表,结果领养申请不知道怎么落库,这就是业务没拆干净。
1.2 技术选型:SpringBoot、Vue、MySQL、MyBatis为什么合适
技术栈不是越新越好,要看项目规模和维护成本。这套系统定位是中小型组织内部使用的管理平台,SpringBoot是目前Java后端最主流的一站式框架,内置Tomcat、自动配置、生态成熟,招人好招、资料好查;Vue作为前端框架配合Element UI组件库,开发后台管理界面效率非常高;MySQL是关系型数据库的标准答案,免费、稳定、社区资料多;MyBatis则胜在SQL可控,像宠物档案这类带大量动态查询条件的场景,多条件组合检索SQL写在XML里比ORM自动生成的清晰得多。
这套组合在国内Java全栈项目里几乎就是“默认配置”,遇到问题基本上都能搜到现成答案,对学习者来说门槛也最低。如果你用过JPA,会发现它对这种多条件动态查询其实挺别扭,要么写Specification,要么搞DTO直查,反而绕远路。
1.3 功能模块清单与角色边界
基于上面的业务链路,我整理出来的功能模块如下:
| 模块 | 功能点 | 主要角色 |
|---|---|---|
| 用户认证 | 登录、注册、密码加密、Token鉴权 | 所有用户 |
| 宠物管理 | 档案新增、编辑、状态变更、多条件检索、图片上传 | 管理员、志愿者 |
| 领养管理 | 申请提交、审核、家访安排、领养交接、回访记录 | 领养人、管理员、志愿者 |
| 捐赠管理 | 捐赠登记、金额统计、捐赠明细导出 | 管理员、捐赠人 |
| 志愿者管理 | 报名、审核、服务时长记录 | 志愿者、管理员 |
| 活动管理 | 活动发布、报名、签到 | 管理员、志愿者 |
| 公告管理 | 公告发布、前台展示 | 管理员 |
| 数据看板 | 宠物总量、领养率、捐赠总额统计 | 管理员 |
角色我刻意只切了三种:管理员(admin)、志愿者(volunteer)、普通用户(adopter)。有些系统喜欢再拆一个“超级管理员”,实际使用中发现没必要,权限字段设计成int类型预留扩展就行。角色多了反而增加权限判断的复杂度。
2. 数据库设计:从领养流程倒推核心表结构
数据库是这类管理系统的地基。我设计表的时候不是按“有哪些页面”去建,而是按“业务流转需要记录哪些状态”去建。一张领养申请表,背后牵出用户、宠物、回访、操作日志好几张表。
2.1 用户、角色与权限的三层关系
用户表是所有业务参与者的载体。我没有单独建志愿者表来存用户信息,而是在user表里加role字段区分角色,志愿者相关的扩展信息单独放一张volunteer_profile表,用user_id关联。
CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` VARCHAR(50) COMMENT '显示昵称', `phone` VARCHAR(20) COMMENT '手机号', `role` TINYINT NOT NULL DEFAULT 3 COMMENT '1-管理员 2-志愿者 3-普通用户', `avatar` VARCHAR(255) COMMENT '头像地址', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这里有三个容易被忽略的细节。第一,role字段单独建索引,因为所有拦截器鉴权都要查它;第二,username唯一索引是必须的,注册接口并发时会撞主键;第三,表名叫user在MySQL里是保留字,建表时要用反引号或者干脆叫sys_user,我图省事直接在代码里用反引号包起来了。
2.2 宠物档案与领养申请的状态机设计
宠物表是系统的核心档案表,我用了大量冗余字段来少连表:
CREATE TABLE `pet` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) COMMENT '宠物昵称', `species` TINYINT NOT NULL COMMENT '1-猫 2-狗', `breed` VARCHAR(50) COMMENT '品种', `gender` TINYINT COMMENT '1-公 2-母', `age_month` INT COMMENT '月龄', `weight` DECIMAL(5,2) COMMENT '体重kg', `health_status` VARCHAR(100) COMMENT '健康状况描述', `sterilized` TINYINT DEFAULT 0 COMMENT '0-未绝育 1-已绝育', `vaccine` TINYINT DEFAULT 0 COMMENT '0-未接种 1-已接种', `description` TEXT COMMENT '详细介绍', `cover_image` VARCHAR(255) COMMENT '封面图', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-待领养 2-审核中 3-已领养 4-不可领养', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物档案表';领养申请表则是整个系统状态最复杂的一张表,它把申请审核流程拆成五个状态:1待审核、2待探访、3已通过、4已拒绝、5已取消。
CREATE TABLE `adoption_application` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `pet_id` BIGINT NOT NULL COMMENT '宠物ID', `user_id` BIGINT NOT NULL COMMENT '申请人ID', `applicant_name` VARCHAR(50) NOT NULL COMMENT '申请人姓名', `phone` VARCHAR(20) NOT NULL, `address` VARCHAR(255) COMMENT '居住地址', `has_other_pet` TINYINT DEFAULT 0 COMMENT '是否养过其他宠物', `reason` TEXT COMMENT '领养理由', `experience` TEXT COMMENT '养宠经验', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-待审核 2-待探访 3-已通过 4-已拒绝 5-已取消', `audit_remark` VARCHAR(255) COMMENT '审核备注', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_pet_id` (`pet_id`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='领养申请表';设计状态机的时候要注意:同一只宠物同时只能有一条有效领养申请。这个约束我用Java代码在Service层控制,申请前先查pet表状态,如果已经是“审核中”或者“已领养”,直接抛业务异常。数据库层面也可以加唯一索引,但因为有“已拒绝”“已取消”这种终态存在,没法简单用唯一索引表达,所以业务层判断更灵活。
2.3 捐赠、志愿者、活动、公告等外围表
外围表相对简单,但注意和主流程的关联关系。捐赠记录表我留了donation_type字段区分“现金”“物资”“定向捐赠”,管理员登记时还能关联某个具体宠物ID,方便后续统计“这只猫一共收到多少帮助”。志愿者表则和用户表分开,用户表的role字段决定身份,志愿者表只存可服务时间、擅长方向、服务时长累计。活动表多了location字段和报名人数上限,用户报名后会在activity_signup表里存一条记录,通过活动id就能查报名列表。
2.4 索引、时间字段与逻辑删除的实战取舍
关于删除,我特别说一下:业务数据不要物理删除。宠物档案被删除后,关联的领养申请记录可能还在,如果外键约束严格,删起来会报错;如果没外键约束,又会留下一堆脏数据。所以我的方案是所有核心表都加status或is_deleted字段,删除操作统一改成状态置为“不可领养”或逻辑删除标记,历史记录保留下来还能做统计。
时间字段统一用DATETIME,默认值用CURRENT_TIMESTAMP,更新时间加ON UPDATE CURRENT_TIMESTAMP,省得每次更新都要手动set时间。索引方面,pet表的status字段、adoption表的pet_id和user_id字段、活动表的start_time字段都要建索引,因为这些是高频查询条件。我遇到过一张表数据量才几万条,但因为status没索引,查询直接走全表扫描,接口响应从几十毫秒飙到两秒,后来补了索引立刻恢复正常。
3. 后端实现:SpringBoot + MyBatis的关键编码细节
后端这层是最能看出一个开发者工程素养的地方。很多人觉得CRUD简单,但一个完整的业务闭环,涉及统一返回结构、事务控制、异常处理、状态流转校验,环环相扣。
3.1 统一返回结构与全局异常处理
接口和前端联调时最怕各写各的返回格式。我的做法是定义统一的Result 结构,包含code、message、data三个字段,code为200表示成功,其他为业务错误码。所有Controller方法都返回Result,配合全局异常处理器兜底,业务层抛出的自定义异常会被统一包装成JSON返回,不会把Java堆栈信息直接甩给前端。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }这里有个小经验:业务异常不要用Exception类一把梭,建议自定义BizException,包含错误码和错误消息,全局异常处理器里根据异常类型判断是返回业务提示还是系统错误。这样前端拿到的message就能直接弹窗展示,用户体验好很多。
3.2 领养申请审核流程的Service实现
领养申请是整个系统最核心的业务,它的审核流程我放在Service层做状态机流转。提交申请时先校验宠物状态,必须为“待领养”,同时校验用户有没有重复申请,然后把宠物状态改成“审核中”。管理员点击“通过”时,申请状态从“待审核”变成“待探访”;探访完成后再点“确认领养”,申请状态变成“已通过”,宠物状态变成“已领养”。每一步都必须在同一个数据库事务里完成,防止出现宠物状态和申请状态不一致。
@Service public class AdoptionApplicationService { @Transactional public void submitApplication(AdoptionApplyDTO dto, Long userId) { Pet pet = petMapper.selectById(dto.getPetId()); if (pet == null || pet.getStatus() != 1) { throw new BizException("该宠物当前不可领养"); } int count = adoptionApplicationMapper.checkDuplicateApplication( dto.getPetId(), userId); if (count > 0) { throw new BizException("你已提交过该宠物的领养申请"); } AdoptionApplication application = new AdoptionApplication(); BeanUtils.copyProperties(dto, application); application.setUserId(userId); application.setStatus(1); adoptionApplicationMapper.insert(application); Pet update = new Pet(); update.setId(pet.getId()); update.setStatus(2); petMapper.updateById(update); } }事务注解一定要加在public方法上,而且不能同类内部调用,否则事务失效。我刚开始做的时候就是在同一个Service里用this调用另一个加@Transactional的方法,结果数据不一致排查了半天,后来才知道Spring事务是基于代理实现的,内部调用根本不走代理,这个坑建议大家都记住。
3.3 MyBatis动态SQL:多条件宠物检索的正确写法
宠物档案列表页的筛选条件很多:按品种、按状态、按是否绝育、按月龄范围排序。这种场景用MyBatis动态SQL最舒服,在XML里写<where>标签自动处理AND拼接,条件为空就跳过,不产生多余的WHERE子句。
<select id="selectPetList" resultType="com.petorg.entity.Pet"> SELECT * FROM pet <where> <if test="species != null"> AND species = #{species} </if> <if test="status != null"> AND status = #{status} </if> <if test="sterilized != null"> AND sterilized = #{sterilized} </if> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR breed LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>注意MySQL的LIMIT分页参数,MyBatis传参时offset需要自己计算,用PageHelper插件的话直接传页码就行。我在项目里没用插件,因为这种系统数据量不大,手写分页SQL够用,还能少引入一个依赖。
3.4 JWT登录与接口权限控制
认证方案我选了JWT,也就是无状态Token。用户登录成功后,后端用用户名和角色生成Token返回给前端,前端存在localStorage里,每次请求放在请求头Authorization字段里。后端写一个拦截器统一解析Token,解析不到或者过期就返回401,让前端跳回登录页。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException("未登录或登录已过期"); } // 解析Token,把用户信息和角色放入request attribute Claims claims = JwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }权限分级我用一个简单的注解加AOP或者直接拦截器里判断角色。简单做法是定义一个@RequireRole注解,标注在需要管理员权限的Controller方法上,拦截器里解析到注解后比对当前用户角色,不满足就返回“无权限”。这个方案比集成Spring Security轻量得多,也更适合中小型项目。
3.5 图片上传、回访提醒与定时任务
宠物档案要传照片,上传接口我用MultipartFile接收,存储到服务器本地磁盘的upload目录,文件名用UUID重命名,防止中文文件名乱码和覆盖冲突。实体类里只存相对路径,前端通过Vite代理转发到后端静态资源映射来访问图片。
回访提醒是用Spring自带的@Scheduled定时任务实现的。每天凌晨跑一次任务,查询所有领养时间超过30天、还没有生成回访记录的领养单,自动创建待回访任务并通知管理员。
@Scheduled(cron = "0 0 9 * * ?") public void generateFollowUpTasks() { List<Long> adoptionIds = adoptionApplicationMapper.selectAdoptionsForFollowUp(30); for (Long adoptionId : adoptionIds) { followUpTaskMapper.insertNewTask(adoptionId); } }这里要意识到一个问题:多实例部署时@Scheduled任务会重复执行。虽然这种小系统一般单机跑,但如果你以后要水平扩展,记得加分布式锁或者把定时任务单独拆成服务。
4. 前端实现:Vue + Element UI的页面与状态交互
前端部分我用的是Vue 2 + Element UI,工程结构标准,路由前置守卫做登录校验,Axios封装统一请求与拦截。如果你熟悉Vue 3,换成Element Plus也完全可以,逻辑是一样的。
4.1 前端工程结构与路由权限
前端目录我按模块分包:views下面按业务模块建文件夹,每个模块下包含list.vue、form.vue或者detail.vue;router配置里把需要登录的页面统一加meta属性,路由守卫里判断Token和角色:
router.beforeEach((to, from, next) => { const token = localStorage.getItem("token"); if (to.meta.requiresAuth && !token) { next("/login"); } else { next(); } });管理员和普通用户看到的侧边栏菜单不一样,做法是在菜单数据里加一个role字段,渲染时根据当前用户角色过滤。不要在前端做真正的权限判断,前端只是隐藏入口,真正的权限校验必须依赖后端拦截器。
4.2 首页数据看板与宠物档案列表
首页数据看板我用了ECharts的柱状图和饼图,展示各品种宠物数量、领养转化率、每月捐赠趋势。数据来自后端统计接口,比如查询宠物表按species分组计数,查询领养申请表按status分组计数。宠物档案列表页则是典型的表格加搜索条件,筛选栏用下拉选择器绑定查询参数,点查询按钮重新调用list接口。
这里有个数据回显的细节:编辑宠物档案时,El-Select的v-model绑定的是数字类型的species,但接口返回的可能是字符串,类型不一致会导致下拉框不选中任何项。解决方案是接口返回时强制转成数字,或者前端赋值时用Number()包一层。这种类型不一致的问题在前后端联调里非常常见,值得留意。
4.3 领养申请表单校验与审核页联动
领养申请表单在用户端是一个多字段的步骤表单,我用Element UI的el-form加rules规则做校验,手机号用正则校验11位数字,领养理由设置textarea限长200字,必填项没填完不能提交。提交成功后前端要通知用户等待管理员审核,同时把宠物卡片状态改成“审核中”不可重复申请。
管理员审核页面是分状态展示的Tab页:待审核、待探访、已通过、已拒绝。点击“通过”时弹出对话框填写探访时间,点击“拒绝”时必须要填写拒绝原因。这些交互设计不是花架子——真实场景里,没有审核备注的拒绝记录会引发大量电话投诉,你一定要把“拒绝原因”做成必填项。
4.4 Axios请求封装与跨域踩坑
Axios封装的核心是拦截器。请求拦截器统一添加Authorization头,响应拦截器处理统一返回结构:code为200时直接返回data,code为500时弹出ElMessage.error提示,HTTP状态401时清掉Token跳转登录页。这样每个页面调用接口不用各自处理错误,代码干净很多。
service.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message || "请求失败"); return Promise.reject(new Error(res.message)); } return res.data; }, (error) => { if (error.response && error.response.status === 401) { localStorage.removeItem("token"); router.push("/login"); } return Promise.reject(error); } );跨域问题在开发阶段一定会遇到。我在Vue项目的vite.config.js里配置了server.proxy,把/api开头的请求代理到后端8080端口,这样前端代码里统一使用相对路径;生产环境部署时再用Nginx做反向代理,从根源上规避开发模式的跨域问题。注意后端拦截器里一定要放行OPTIONS预检请求,否则前端调用会卡在预检这一步。
5. 部署与调试:从源码到上线的高频坑位
这套系统做完之后要跑起来,中间还有不少坑。我把常见的整理出来,照着排查能省很多时间。
5.1 数据库初始化与环境准备
项目自带的SQL脚本要先导入MySQL。我遇到过两种情况:一种是没有按顺序执行脚本导致外键关联的表先建失败;另一种是MySQL版本差异,比如某些低版本不支持utf8mb4的排序规则。我的建议是脚本里建库语句和建表语句分开,先建库再建表;统一用utf8mb4字符集;如果你用的MySQL 8.0,注意驱动要换成com.mysql.cj.jdbc.Driver,URL里加serverTimezone=Asia/Shanghai,否则时间字段会差8小时。
5.2 前后端分离部署与Nginx反代
生产环境我把后端打成jar包,用java -jar pet-org-server.jar启动,前端执行npm run build生成dist目录,Nginx配置静态文件指向dist,同时把/api请求转发到后端服务。下面是精简版本的Nginx配置片段:
server { listen 80; server_name localhost; location / { root /opt/pet-org-web/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /opt/pet-org-upload/; } }Vue Router如果用了history模式,必须配置try_files回退到index.html,不然刷新页面会404。这是前端部署最常见的坑,没有之一。
5.3 常见报错与解决对照表
我把这套系统开发和部署期间踩过的典型问题整理成一张表,排查效率会高很多:
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 前端请求返回404但接口存在 | 路由冲突或Nginx的proxy_pass路径拼接错误 | 检查Nginx配置,确认/api路径转发逻辑 |
| 时间字段差8小时 | JDBC连接串未设置serverTimezone | 添加serverTimezone=Asia/Shanghai |
| 图片上传成功但访问404 | 后端静态资源映射没配置或上传目录权限不足 | 配置WebMvcConfigurer的addResourceHandlers |
| Token过期后前端无反应 | 响应拦截器没处理401状态 | 拦截器里统一清除Token并跳转登录页 |
| 删除宠物报外键约束错误 | 关联表有外键且做了物理删除 | 改逻辑删除,统一用update语句 |
| 数据库中文乱码 | 连接串未指定characterEncoding | 添加characterEncoding=utf8mb4 |
5.4 性能与安全的小优化
虽然是小系统,但基本安全不能缺:密码存库前必须BCrypt加密,不能用MD5;所有查询参数用#{}预编译方式传参防止SQL注入;上线前把SpringBoot的默认错误页关掉,避免泄露内部接口信息;文件上传要做大小限制和类型白名单校验,防止上传可执行文件到服务器。
6. 这套系统的后续演进方向
项目交付不是终点。宠物爱心组织管理系统这类项目有一个天然优势——业务场景真实且丰富,可以沿着很多方向继续演进。
6.1 领养人信用评估
现在领养审核基本靠人工经验判断,后续可以在用户表增加领养记录评分,比如成功领养并完成回访的加分,有弃养记录的扣分并进入黑名单。这样管理员审核时能直接看到申请人的信用数据,辅助决策。
6.2 小程序端与消息触达
大部分爱心组织的前台面向普通网友,他们更习惯在手机上浏览待领养宠物。可以做一个微信小程序端,复用后端接口,用户在小程序里浏览宠物、提交领养申请,审核状态通过微信公众号模板消息推送给用户。这样系统的应用半径会大很多。
6.3 数据大屏与公益透明化
公益组织的公信力来源于透明。可以做一个数据大屏,把每月宠物收容数、领养率、捐赠资金流向、志愿者服务时长等数据可视化展示,放在救助站的大厅屏幕上或者官网首页,让捐赠人能看到每一笔钱、每一份物资的去向。
我在实际开发这套系统时最深的感受是:这种垂直领域的管理系统,真正难的不是某个技术点,而是把业务流程理解透,再用代码把流程“翻译”成状态和接口。数据库设计阶段多想一步,后端Service层把事务和状态管好,前端把用户体验和错误提示做完整,整个项目自然水到渠成。如果你正在做同类项目,建议先花一天梳理业务流程和状态机,这比急着写代码的收益大得多。