
做动物领养这块业务的时候最头疼的就是信息进不来、流程走不畅。救助站那边登记了一堆纸质档案领养人这边找不到合适的宠物入口申请提交之后进度全靠打电话问。所以我想做的这套动物领养平台核心就是把这些线下零散环节搬到线上管理员在后台上架动物档案、处理领养申请用户在前台浏览动物、提交领养意向、跟踪审核进度。技术栈选了SpringBoot2 Vue3 MyBatis-Plus MySQL8.0前后端分离既有完整业务闭环也适合用来学习主流Web开发流程。这篇文章就把这个项目的设计思路、核心实现和我在开发中踩过的坑完整梳理一遍如果你是准备做毕设或者想找一套贴近真实业务的练手项目内容可以直接参考。1. 动物领养平台到底做什么业务拆解与整体设计1.1 动物领养业务的真实痛点领养这个场景听起来简单但实际跑起来你会发现里面全是细节。信息不对称是最明显的问题——救助站收容的动物名单、健康状况、性格特点往往只存在于内部Excel表格里领养人根本看不到就算看到了想了解一只动物的完整背景也要通过层层转发消息最后往往对不上号。另一个痛点是领养流程不规范。一个合格的领养流程应该包含这样几个环节申请提交、资格审核、回访确认、领养完成。但在很多线下场景里这些步骤靠的是口头沟通和纸质单据领养人不知道自己的申请卡在哪一步管理员也没法高效管理几十份同时进行的申请。所以这个系统在设计之初就确定了三个核心价值信息公开透明、流程闭环可追踪、数据集中易管理。用户端解决“看得到、找得着”的问题管理端解决“管得好、审得快”的问题。1.2 系统功能模块全景围绕上面的需求整个平台分成两条业务线C端面向普通用户B端面向管理员。用户端功能注册与登录手机号或邮箱注册JWT令牌认证。动物浏览以卡片列表展示待领养动物支持按种类、年龄、性别筛选。动物详情查看动物照片、健康状况、性格描述、救助站信息。领养申请填写领养人基本信息、居住情况、养宠经验提交申请。个人中心查看自己提交的申请记录与审核状态。公告资讯查看平台发布的领养活动、科普文章。管理端功能动物档案管理新增、编辑、下架动物信息支持图片上传。领养审核查看申请详情通过或拒绝申请填写审核意见。公告发布维护首页公告与资讯内容。用户管理查看注册用户列表禁用违规账号。这套模块划分覆盖了一个信息管理类系统的典型结构业务逻辑不复杂但涉及面广很适合用来理解前后端协作和CRUD的完整链路。1.3 为什么选前后端分离架构开发这套系统时我直接定了前后端分离理由很实际首先前端和后端可以并行开发不用互相等其次Vue3项目可以通过Nginx或后端静态资源映射直接部署接口和页面解耦后续做小程序或者App端时后端接口可以复用。用SpringBoot2做后端接口服务Vue3做前端页面两者通过RESTful API通信数据格式统一为JSON。这种模式下后端只需要关注业务逻辑和数据处理前端只需要关注交互和展示职责划分非常清晰。你只要保证接口文档一致前后端各自开发完全不受影响。2. 后端这样写最省事数据库建模与SpringBoot2核心实现2.1 核心数据表设计数据库我用的是MySQL8.0字符集直接指定utf8mb4这样在存取宠物描述、留言这类文本时生僻字和表情符号都不会乱码。整个系统设计了五张核心表下面把每张表的职责拆开讲。用户表user字段类型说明idbigint主键自增usernamevarchar(50)用户名passwordvarchar(100)BCrypt加密后的密码phonevarchar(20)手机号roletinyint角色0普通用户1管理员statustinyint状态0正常1禁用create_timedatetime创建时间动物表animal字段类型说明idbigint主键namevarchar(50)动物昵称speciesvarchar(20)种类猫、狗、兔子等breedvarchar(50)品种ageint年龄月gendervarchar(10)性别health_statusvarchar(100)健康状况描述cover_imagevarchar(255)封面图URLdescriptiontext详细介绍statusvarchar(20)状态可领养、审核中、已领养create_timedatetime入库时间领养申请表adoption_apply字段类型说明idbigint主键user_idbigint申请人IDanimal_idbigint申请领养的动物IDreasonvarchar(500)领养理由experiencevarchar(500)养宠经验addressvarchar(255)居住地址statustinyint状态0待审核1通过2拒绝audit_commentvarchar(255)审核意见create_timedatetime申请时间公告表announcement、系统表sys_config这些就不逐一列字段了结构都比较常规。一个细节值得说明动物表的status字段会影响列表页的展示逻辑。用户端只查“可领养”的动物管理员端能看到全部这种区分我直接在SQL条件里控制而不是在页面写死。2.2 实体类与MyBatis-Plus映射表结构定完接下来就是实体类。这个项目用了MyBatis-Plus省去了大量手写XML和BaseMapper基础SQL的工作。实体类上加几个关键注解就能完成映射。Data TableName(animal) public class Animal { TableId(type IdType.AUTO) private Long id; private String name; private String species; private String breed; private Integer age; private String gender; private String healthStatus; private String coverImage; private String description; private String status; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }这里有两个容易踩坑的点。第一个是驼峰映射。MySQL字段是下划线命名create_timeJava属性是驼峰createTime默认情况下MyBatis-Plus是能自动映射的前提是配置里开启了map-underscore-to-camel-case。如果发现某些字段查出来是null优先检查这个开关。第二个是主键策略。我建议表里设成自增主键实体类配上TableId(type IdType.AUTO)这样插入数据时不用手动管理IDMyBatis-Plus会自动回填。2.3 Service与Controller实现MyBatis-Plus最大的好处是把单表的增删改查封装到了极致。Service层继承IService实现类继承ServiceImpl基础的list、page、save、remove方法全都有了。public interface AnimalService extends IServiceAnimal { } Service public class AnimalServiceImpl extends ServiceImplAnimalMapper, Animal implements AnimalService { }Controller里做分页查询时用一个LambdaQueryWrapper来构造查询条件既类型安全又直观。RestController RequestMapping(/api/animal) public class AnimalController { Autowired private AnimalService animalService; GetMapping(/page) public ResultIPageAnimal page(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, Animal query) { PageAnimal page new Page(pageNum, pageSize); LambdaQueryWrapperAnimal wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(query.getSpecies())) { wrapper.eq(Animal::getSpecies, query.getSpecies()); } if (StringUtils.hasText(query.getGender())) { wrapper.eq(Animal::getGender, query.getGender()); } wrapper.eq(StringUtils.hasText(query.getStatus()), Animal::getStatus, query.getStatus()); wrapper.orderByDesc(Animal::getCreateTime); return Result.success(animalService.page(page, wrapper)); } }筛选条件用StringUtils.hasText包裹一下前端传空字符串时不会拼入条件后端就能实现在一个接口里支持多种组合筛选。分页参数pageNum和pageSize也可以直接绑定Page对象MyBatis-Plus会自动处理。2.4 登录认证与权限控制登录认证这块我用的方案是JWT 拦截器。用户登录成功后后端生成一个带用户ID和角色的JWT令牌返回给前端。前端把token存在localStorage里每次请求在拦截器里加到Authorization请求头。后端写一个拦截器只拦截需要认证的路径比如用户中心、领养申请、管理端接口。拦截器里解析token解析成功就把用户信息放到ThreadLocal或请求属性里供业务代码使用。管理端接口额外校验角色字段不是管理员就直接返回无权限。这个方案的优点是简单不引入Spring Security那套复杂的过滤器链适合中小型项目。唯一要注意的是JWT密钥不能硬编码在代码里应该放到配置文件或环境变量中防止泄露后可以被伪造令牌。再就是这属于无状态方案想要主动让某个用户下线是做不到的如果需要这个能力后续可以引入Redis做令牌黑名单或会话管理。3. Vue3前端实战页面搭建与接口对接3.1 项目初始化与目录结构前端我用Vite搭建的Vue3项目。相比WebpackVite在开发环境下启动速度快、热更新也快省掉了很多等待编译的时间。npm create vitelatest animal-web -- --template vue cd animal-web npm install npm install vue-router4 pinia axios element-plus目录结构按业务模块划分而不是单纯按文件类型堆src/ api/ # 接口请求模块 assets/ # 静态资源 components/ # 公共组件 layout/ # 布局组件 router/ # 路由配置 stores/ # Pinia状态管理 views/ home/ # 首页与动物列表 detail/ # 动物详情 user/ # 个人中心 admin/ # 管理后台路由配置使用vue-router4createRouter加createWebHistory。对需要登录的页面在路由的meta里标记requiresAuth通过全局前置守卫检查token。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })3.2 Axios封装与接口对接前端所有请求都走一个封装好的axios实例不直接在各页面里new axios。这个封装主要做三件事统一baseURL、统一注入token、统一处理错误码。import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) 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?.status 401) { localStorage.removeItem(token) window.location.href /login } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )我在项目里规定后端返回格式统一为{ code, message, data }code为200表示成功。这样前端封装一层后页面里拿到的直接就是data数据不用到处判断code。3.3 核心页面实现动物列表页是这个系统的门面。我的实现方式是卡片网格展示动物封面图、昵称、种类和状态标签顶部做筛选区。筛选条件变化时重新拉取分页数据交互用Element Plus的el-select和el-pagination组合。Vue3里注意使用reactive或ref管理查询参数。我习惯用ref管理单个查询条件对象因为reactive对象如果整体赋值会丢失响应式。script setup import { ref, onMounted } from vue import { getAnimalPage } from /api/animal const query ref({ species: , gender: , pageNum: 1, pageSize: 12 }) const animalList ref([]) const total ref(0) async function loadData() { const data await getAnimalPage(query.value) animalList.value data.records total.value data.total } function handleSearch() { query.value.pageNum 1 loadData() } onMounted(loadData) /script管理后台的动物编辑表单我用了el-dialog嵌套el-form。弹窗打开时如果是编辑就回填当前行数据如果是新增就重置表单。一个容易忽略的地方是表单重置要调用resetFields但前提是初始时通过ref绑定form实例并设置好默认值。直接用Object.assign清空对象的话可能残留多余的响应式属性后续校验会有奇怪的问题。图片上传控件我用了el-uploadaction指向后端的上传接口。后端接收MultipartFile后保存到本地上传目录返回可访问的URL。生产环境建议把上传目录映射成静态资源路径或者接对象存储这个后面部署部分再说。4. 本地部署与联调从0到1跑起来4.1 环境准备这套系统涉及的环境有四个JDK、Maven、Node.js、MySQL。下面是我实际使用的版本你可以直接照着来JDK1.8或11。SpringBoot2.x这两个版本最稳定发版也最多。如果你用JDK17建议把SpringBoot升到2.7以上否则可能有兼容问题。Maven3.6以上用来拉依赖、打包。Node.js16或18。Vite4要求Node 14.18/16Node18是最省心的选择。MySQL8.0。官方安装包或Docker启动都行关键是驱动和方言要匹配。版本匹配这种事看着不起眼实际踩坑的十有八九都栽在这。团队里如果有多个人的环境不一致联调时出现的怪问题也会更多建议统一用同一套版本。4.2 MySQL8.0初始化数据库初始化分两步建库、导表。我习惯先建库指定字符集再导入项目里的sql文件。CREATE DATABASE IF NOT EXISTS animal_adopt DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;sql文件里包含建表语句和基础数据。我在项目文档里把管理员账号密码也一起写明了方便直接登录后台看效果。后端数据源配置要注意MySQL8.0的驱动类名和URL变化。MySQL5.x用的是com.mysql.jdbc.Driver8.0要写成com.mysql.cj.jdbc.Driver。URL里一定要显式指定时区否则默认连接时会按服务器时区来经常报Communications link failure或者时间差八小时。spring: datasource: url: jdbc:mysql://localhost:3306/animal_adopt?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver4.3 前后端启动与联调后端启动很简单进入项目根目录执行mvn spring-boot:run或者先mvn package再java -jar target/xxx.jar。看到Tomcat started on port 8080就说明起来了。前端启动分三步npm install npm run devVite默认端口是5173开发环境下我配置了代理转发这样前端请求/api开头的接口时由Vite服务器转发到后端8080端口绕开了跨域问题。server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这里细讲一下为什么用代理而不是在后端配CORS。开发阶段用Vite代理最省事浏览器看到的请求是同源的不用处理复杂的跨域握手。如果前端部署在Nginx上在Nginx层做反向代理也是同样的原理。后端配CORS虽然能做但会面临options预检请求、允许来源白名单这些额外配置能少一事就少一事。第一次联调建议先把后端跑起来用浏览器直接访问后端接口确认数据正常再启动前端。这样出了问题能快速定位是后端没起来还是前端配置有问题。5. 避坑清单开发过程中遇到的常见问题实录5.1 跨域请求被拦截我的排查思路是前端请求发出后先在浏览器Network面板看请求的状态。如果是CORS error说明请求根本没到后端或者响应被浏览器拦了。开发环境优先检查Vite代理配置有没有生效注意配置修改后要重启dev server。如果是生产环境就检查Nginx的location代理路径保证/api转发到后端服务。实际开发里还遇到过一次奇怪的情况接口在浏览器直接访问正常前端通过代理访问就报404。最后发现是前端请求URL里带了/api前缀后端Controller的RequestMapping也带了/api代理转发层又拼了一次导致路径变成/api/api/animal。这个问题很好排查看一眼请求URL的path就明白了。5.2 MySQL8.0时区与时区相关的报错刚开始连接MySQL8.0时报过程度不一的错误最常见的两个一是Connection refused二是server time zone value是乱码。前者通常是MySQL服务没启动或者端口没开后者就是URL没指定serverTimezone。MySQL8.0默认时区一般写CST但CST在MySQL里代表的是America/Chicago美国中部时间和我们期望的北京时间对不上。所以URL里写serverTimezoneAsia/Shanghai最靠谱别用CST也不是新的问题——MySQL8.0驱动对时区的处理比5.x更严格不显式指定直接报错。如果数据库服务器和应用程序服务器不在同一个时区比如数据库在云上应用在本地那存储LocalDateTime类型的字段也要注意。我建议统一约定数据库连接串指定serverTimezone后端Java使用LocalDateTime数据库字段用datetime这样MySQL会按照连接时区解释时间显示出来就是本地时间。5.3 MyBatis-Plus字段映射与自动填充有次联调时发现新增动物接口返回的id是null但数据库里确实插入成功了。排查后发现是主键策略配置问题实体类上没加TableId注解MyBatis-Plus默认按字段名id去找但表里的主键不叫id导致插入后拿不到自增ID。主键名称不一致时一定要显式标注。还有一个经常被忽略的功能是自动填充。createTime和updateTime这种字段如果每次insert和update都手动set代码会非常啰嗦。MyBatis-Plus提供了MetaObjectHandler接口实现后配合实体类上的TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)注解插入和更新时自动填充时间字段。这个机制也支持填充操作人ID之类的内容写业务代码的时候确实省了很多心。5.4 Vue3响应式丢失问题Vue3里我踩过一个典型的坑从接口拿到数据后操作reactive数组没反应。原因是直接对reactive对象整体赋值了比如state.list resp.data这样会切断原本的响应式代理。解决办法是把这个属性一直保持为引用类型不变或者改用ref来管理数据然后整体赋值.value。排查响应式问题我推荐两步第一步看代码里有没有把reactive对象整体赋值或解构第二步在控制台打印proxy对象如果是普通Object说明代理丢了。备选方案是改用ref数据变了页面还是没变那就优先怀疑是模板引用问题而不是数据问题。还有一个小坑是Element Plus的表格数据更新后分页组件当前页不变但数据已经是新条件下返回的用户会以为没刷新。我的做法是在搜索和筛选时手动把pageNum重置为1这样每次查询都从第一页开始符合直觉。6. 项目还能怎么扩展从练手项目到可上线系统做完这套系统你应该已经掌握了SpringBoot2 Vue3 MyBatis-Plus这套组合的完整开发流程。但如果目标是把它推向真实生产环境还有一些薄弱环节值得继续打磨。用户模块现在只是记录了基本信息真实领养场景通常需要实名认证和信用评估。可以接入实名认证接口或者增加芝麻信用分判断在申请领养时做一道硬性门槛能过滤掉很多不靠谱的申请。图片存储方面当前实现是上传到本地目录这在单机演示环境没问题。正式部署时图片和文件应该放到对象存储服务里后端只存储URL。否则你部署了两台后端实例上传的图片在一台机器、请求被负载均衡到另一台时就会404。领养流程也可以继续往深做。比如增加回访任务模块领养完成后定时生成回访任务要求工作人员在领养后一个月、三个月分别提交回访记录再比如动物档案里接入疫苗接种记录、驱虫记录的登记让领养人看到更完整的动物健康数据。管理后台目前只覆盖了基础CRUD还可以加数据统计面板。用ECharts画出每月新增动物数、领养完成率、热门动物品种排行这些指标对运营决策很有价值。这个功能实现难度不高但对提升系统的完整度帮助很大。最后说一句我自己的体会开发这类管理系统真正的难点从来不在某个框架的新特性上而在于把业务流程理清楚再熟练地组合你已有的技术栈去实现它。这套项目做完你会对从数据库设计到前后端联调的整个链路有一个非常完整的认识之后再去做更复杂的项目底子就有了。