宠物领养系统可能是Java Web学习者动手做过的最有温度的项目之一。它业务场景完整用户注册登录、宠物发布、领养申请、管理员审核、公告推送几乎把后端常见的增删改查、文件上传、权限控制、分页搜索全串起来了同时它又不像学生管理系统那样枯燥做出来的东西能真实讲出一个“帮助流浪动物找到家”的故事。我最近把一套基于SpringBoot2、Vue3、MyBatis-Plus和MySQL8.0的宠物领养系统从零到一重新整理了一遍这里把核心设计、关键代码和踩坑过程全部记录下来。不管你是准备毕业设计还是想拿一个完整项目练手这篇内容可以直接照着改。1. 项目全景这套宠物领养系统到底做了什么1.1 需求背景为什么宠物领养系统值得做宠物领养系统的核心痛点并不复杂线下领养信息分散、审核流程不透明、用户与救助站之间缺少一个在线沟通的桥梁。传统做法靠微信群、Excel表登记信息容易过期也没办法做规范的领养回访。于是把“宠物展示、领养申请、审核管理”搬到Web端就成了一个非常标准的信息管理类系统。这类系统的目标用户大体分两类。一类是普通访客和领养人他们需要浏览宠物列表、按品种和状态筛选、查看宠物详情、提交领养申请另一类是平台运营人员和管理员他们需要录入新宠物、更新领养状态、审核用户申请、管理公告内容和用户账号。理解这两类用户后续的模块划分和权限设计才有的放矢。从学习角度看宠物领养系统不像电商系统那样过多纠结支付、库存、秒杀也不像纯后台管理系统那样只有表单。它刚好处于一个舒适区业务规则足够清晰又不至于复杂到让人劝退。对于毕设、简历项目、个人作品集来说它都是一个性价比很高的选题。1.2 技术选型背后的考虑技术栈选定为SpringBoot2、Vue3、MyBatis-Plus、MySQL8.0不是拍脑袋决定的而是这套组合在当前项目场景下确实省心。后端用SpringBoot2主要原因是生态成熟、资料多、兼容性好。SpringBoot3虽然在性能和GraalVM支持上有优势但很多第三方库和教程还停留在2.x时代作为一个需要快速交付、稳定运行的项目SpringBoot2.7几乎是不会出错的版本。选2.7还有一个好处javax命名空间和老项目、旧教程保持一致遇到问题搜一下就有答案。持久层用MyBatis-Plus目的是少写重复的CRUD代码。单表操作基本不需要手写SQL内置的分页插件、逻辑删除、自动填充能帮我们省掉大量样板代码。相比原生MyBatis它的学习曲线更平缓相比Spring Data JPA它的SQL可控性更强尤其在联表查询和复杂统计场景下写XML反而更直接。数据库选MySQL8.0看中的是性能、JSON类型、窗口函数这些增强特性。虽然这个项目用到的核心功能不多但8.0是当前主流版本直接用新版本可以避免后续升级的麻烦也符合生产环境的主流诉求。前端选Vue3组合式API让逻辑复用变得很舒服。宠物筛选、申请表单、图片上传这些功能拆成自定义hook之后代码组织比Vue2的Options API清晰不少。搭配Vite构建冷启动和热更新速度比Webpack快得多开发体验完全不在一个层级。1.3 模块拆解和角色权限设计这个系统的功能模块可以拆成几块宠物信息模块、领养申请模块、用户中心模块、后台管理模块、公告模块、文件上传模块。角色上我设计了三种普通用户、管理员、超级管理员。普通用户能浏览宠物、查看详情、提交领养申请、维护个人信息管理员负责宠物信息审核、领养申请审核、公告发布、用户禁用超级管理员额外拥有管理员账号的分配和数据统计权限。权限控制没有一上来就引入Spring Security因为项目的接口量不大用拦截器配合注解就能搞定。我在后端定义了一个RequireRole注解配合HandlerInterceptor做角色校验前端再根据用户角色动态渲染菜单和按钮。这样实现成本低逻辑也直观。如果后续要接入微信登录或更复杂的权限模型再换成Spring Security也不迟。2. 后端核心技术拆解SpringBoot2 MyBatis-Plus MySQL8.02.1 后端工程结构与分层设计我习惯把后端按职责分层而不是按功能模块堆文件。基础包结构大致是这样com.example.pet ├── common // 通用类结果包装、异常、常量、基类 ├── config // 配置类跨域、MyBatisPlus、拦截器 ├── controller // 接口层接收参数、返回结果 ├── service // 业务层核心逻辑 ├── mapper // 数据访问层MyBatis-Plus Mapper ├── entity // 数据库实体 ├── dto // 接收前端参数的对象 ├── vo // 返回前端的对象 └── utils // 工具类分包的时候有一个容易被忽视的点实体entity不要直接暴露给前端。数据库字段和页面展示往往不是一一对应的比如创建时间、逻辑删除标记、密码字段这些都不应该直接返回。我通常用DTO接收前端参数用VO返回接口结果三层之间做一次拷贝。工具类我用过BeanUtils.copyProperties也用过MapStruct如果是小项目Spring自带的BeanUtils够用了。分层设计的核心是让每一层只做自己的事。Controller里不应该写业务判断Service里不应该拼SQLMapper里不应该处理参数格式。这样调试的时候能快速定位问题后面加需求也不会牵一发动全身。2.2 MyBatis-Plus 如何简化常用数据操作MyBatis-Plus帮我把基础的增删改查和分页全包了。实体类继承BaseMapper之后单表操作直接调方法省掉大量重复的XML映射文件。Mapper public interface PetMapper extends BaseMapperPet { }Service层再套一层ServiceImpl常用的save、removeById、page、list方法就都有了。Service public class PetService extends ServiceImplPetMapper, Pet { }条件查询是它最香的地方。比如按宠物状态、品种、性别组合筛选再用LambdaQueryWrapper写起来非常顺畅关键是类型安全字段名写错会编译报错而不是等运行时报SQL异常。LambdaQueryWrapperPet wrapper Wrappers.lambdaQuery(); wrapper.eq(Pet::getStatus, status); wrapper.like(StringUtils.hasText(breed), Pet::getBreed, breed); wrapper.orderByDesc(Pet::getCreateTime); PagePet page this.page(new Page(current, size), wrapper);注意分页插件要单独配置否则Page对象查出来还是全量数据。我在配置类里注册了MybatisPlusInterceptor添加PaginationInnerInterceptor并指定数据库类型为MySQL。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; }设置maxLimit是我踩过一次坑后的习惯。如果不限制最大分页数量万一前端传个size100000数据库压力会很大。加这行代码等于给接口上了一道保险。2.3 MySQL8.0 的接入配置与细节MySQL8.0和5.7相比驱动类名和时区问题是最容易踩的。8.0的JDBC驱动类名是com.mysql.cj.jdbc.DriverURL中一般要配置时区。我通常用serverTimezoneAsia/Shanghai而不是用GMT%2B8这种写法后者在部分场景下会出现时间偏移的怪问题。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pet_adoption?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码allowPublicKeyRetrievaltrue这个参数是MySQL8.0连接时容易忽略的。如果数据库用户使用了caching_sha2_password插件而且不是通过SSL连接驱动会要求先获取公钥不加这个参数就会报错。开发环境加上它省事生产环境如果走内网SSL则另当别论。数据库连接池我用的是HikariCPSpringBoot2默认就是它不用额外配置。唯一要留意的是max-pool-size和minimum-idle的值不要拍脑袋写一般小型项目20个最大连接、10个最小空闲足够太多反而浪费内存。MySQL8.0的字符集我直接用utf8mb4不是utf8。因为emoji表情在utf8下面存不进去宠物详情里如果用户输入了表情就会报字符集错误。建表语句里统一加上DEFAULT CHARSETutf8mb4从一开始就避免这个坑。2.4 逻辑删除、自动填充和枚举处理逻辑删除几乎是每张业务表都会用到的能力。用户误删宠物、管理员误删公告都应该有恢复的余地。MyBatis-Plus的TableLogic注解配合全局配置删除操作会变成UPDATE ... SET deleted 1查询时自动追加deleted 0条件。TableLogic private Integer deleted;注意一个容易踩的坑如果实体里字段名是deleted但是数据库列名是is_deleted要加TableField(is_deleted)显式指定。否则默认驼峰转下划线可能匹配不上导致逻辑删除不生效。自动填充则是把创建时间、更新时间这种公共字段统一管理。实现MetaObjectHandler接口在insertFill和updateFill里统一赋值。Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }这里有个小坑strictInsertFill要求实体类字段必须有默认值如果字段是null它不会覆盖。所以实体类里写private LocalDateTime createTime;就行不要提前初始化成别的值。枚举类型处理也是老生常谈。宠物状态字段如果用Integer存代码里到处都是魔法数字可读性差。我直接用枚举类然后用EnumValue注解指定数据库存储的整数值。public enum PetStatus { PENDING(0, 待审核), ADOPTABLE(1, 可领养), ADOPTED(2, 已领养), REMOVED(3, 已下架); EnumValue private final Integer value; private final String desc; PetStatus(Integer value, String desc) { this.value value; this.desc desc; } }实体里直接用枚举类型private PetStatus status;这样查询、插入、更新都能自动完成枚举和数据库值的转换代码里再也不出现if (status 1)这种模糊写法。3. 前端实战Vue3 Vite 构建现代化页面3.1 Vue3 项目搭建与目录结构我用Vite创建Vue3项目推荐使用组合式API和script setup语法。它最大的优势是把相关逻辑放在一起比如宠物列表页的加载数据、筛选状态、分页控制全部写在一个函数里比Options API的data/methods分散写法直观得多。目录结构我习惯这样组织src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── composables // 组合式函数hooks ├── router // 路由配置 ├── store // Pinia状态管理 ├── views // 页面组件 └── utils // 工具函数components和views的边界要注意。components放通用组件如表单控件、图片上传、分页组件views放页面级组件且一个文件只放一个页面。很多新手把业务组件直接堆在views里页面一多就乱套。Vue3的响应式特性开始用容易犯迷糊。用ref定义基本类型和对象用reactive定义嵌套对象但要注意reactive对象被解构后会丢失响应性。如果必须解构用toRefs或者直接写成const { list, total } storeToRefs(store)。宠物筛选表单我一开始就是因为解构赋值丢了响应性改了半小时才发现问题。3.2 Axios请求封装与统一处理接口请求必须统一封装不然每个页面各自处理loading、错误码、token过期代码会非常散。我封装了一个request模块基于axios实例做了三件事请求拦截器加token、响应拦截器统一拆包、错误拦截器处理后端返回的业务异常。// src/utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000, }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(error.message || 网络错误) return Promise.reject(error) } ) export default request统一处理的好处是前端不用在每个接口调用里写错误提示业务代码只关心成功数据。另外baseURL设为/api配合Vite的代理配置开发环境可以绕过跨域生产环境由Nginx转发到后端接口。3.3 宠物图片上传与预览的实现宠物领养系统最核心的信息就是图片。用户看到一张可爱的宠物照片才会产生领养意愿。图片上传这块我分了前端预览和后端存储两部分。前端使用Element Plus的el-upload组件上传前先用URL.createObjectURL生成本地预览地址上传成功后后端返回图片访问URL再把它绑定到表单字段。需要注意createObjectURL生成的地址在页面刷新后会失效所以列表页展示必须用后端返回的真实URL。后端接收文件时要注意文件名处理不能直接用用户上传的原始文件名防止路径注入和文件名冲突。我用UUID重命名文件并保留原始扩展名String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename UUID.randomUUID() ext;图片存储路径我放在服务端配置的上传目录里数据库只保存相对路径访问时通过配置拼接完整URL。这样生产环境把图片迁移到对象存储时只需要改配置不需要改表结构。3.4 动态菜单与路由权限控制后台管理界面的菜单不应该写在死代码里。不同角色的用户进入后台看到的菜单应该是不同的。我用的方案是登录成功后后端返回当前用户角色和对应的菜单列表前端根据菜单列表生成路由和侧边栏。Vue Router支持动态添加路由在用户登录后调用router.addRoute注册权限路由。注意一个常见问题动态添加的路由在刷新页面后会丢失所以路由初始化逻辑要放在全局守卫里保证每次刷新都能重新加载权限路由。router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } if (token !store.userInfoLoaded) { await store.fetchUserInfo() // 根据角色动态添加路由 next({ ...to, replace: true }) return } next() })这种方案比单纯用v-if控制菜单显示更安全。菜单可以隐藏但路由不注册用户即使手动输入URL也进不去权限控制才算到位。4. 完整实操从数据库设计到前后端联调4.1 数据库表结构设计要点数据库设计是这个项目的地基。我设计了六张核心表用户表、宠物表、领养申请表、公告表、系统配置表以及一张关联用户收藏宠物用的收藏表。用户表不必复杂id、用户名、密码、昵称、电话、角色、状态、创建时间就够了。密码字段要特别注意长度使用BCrypt加密后约60个字符所以varchar(100)更保险。宠物表是核心业务表包括宠物名称、品种、性别、年龄、疫苗状态、绝育状态、描述、图片URL、状态、发布人、审核人。领养申请表要记录宠物ID、申请用户ID、申请原因、联系方式、审核状态、审核意见、申请时间。索引设计容易被忽略。宠物列表页的筛选条件是状态和品种所以要给status单独建索引给breed建普通索引。领养申请表经常按用户ID和宠物ID查询这两个字段都要建索引。小项目可能感受不到索引的重要性但数据量上到几千条后全表扫描的速度差距就很明显了。外键我没有用数据库层面的外键约束而是在Service层维护引用关系。线上生产环境很少用物理外键原因是不方便分库分表也不方便做逻辑删除。把关联关系放在业务代码里灵活性更高。4.2 后端核心接口实现过程宠物发布接口算是业务的重点。用户提交宠物信息时服务端不能直接写入所有字段必须重新校验。比如待审核宠物不能设置成“可领养”状态字段要由后端重新设置为“待审核”而不是信任前端传过来的值。PostMapping(/pet) public ResultLong publishPet(RequestBody Valid PetPublishDTO dto) { Pet pet new Pet(); BeanUtils.copyProperties(dto, pet); pet.setStatus(PetStatus.PENDING); pet.setPublisherId(CurrentUser.getId()); pet.setViewCount(0); petService.save(pet); return Result.success(pet.getId()); }领养申请的处理也有讲究。用户申请领养之前要校验宠物是否处于“可领养”状态同时要校验该用户是否已经申请过同一只宠物避免重复提交。这属于典型的前置校验逻辑写在Service里而不是Controller里。管理员审核领养申请时操作要更加谨慎。审核通过意味着宠物状态要同步改成“已领养”同时要把这只宠物的其他待处理申请自动驳回。这是一个事务操作如果先改宠物状态、再改申请状态中间发生异常就会数据不一致。我用Transactional把两个操作包在一个事务里保证要么全成功、要么全回滚。Transactional public void adoptApplication(ApproveDTO dto) { AdoptionApply apply applyService.getById(dto.getApplyId()); if (apply null || !Objects.equals(apply.getStatus(), ApplyStatus.PENDING.getValue())) { throw new ServiceException(申请不存在或已处理); } apply.setStatus(dto.isApprove() ? ApplyStatus.APPROVED : ApplyStatus.REJECTED); apply.setAuditOpinion(dto.getOpinion()); applyService.updateById(apply); if (dto.isApprove()) { // 宠物状态改为已领养 Pet pet petService.getById(apply.getPetId()); pet.setStatus(PetStatus.ADOPTED); petService.updateById(pet); // 自动驳回其他待审核申请 applyService.rejectOtherPending(apply.getPetId(), apply.getId()); } }4.3 前后端联调的关键流程联调时最常遇到的问题就是接口地址不一致。我先把后端接口文档整理好再让前端按照文档对接。最简单的方式是用Vite代理把/api开头的请求转发到后端服务的实际地址。// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }这里有个容易忽略的点changeOrigin: true必须设置否则后端服务在反向代理场景下拿到的Host头还是前端地址部分中间件校验会误判。联调过程中我习惯先跑通一个最核心的链路用户登录、发布宠物、管理员审核、用户申请领养、管理员通过。这条完整链路只要走通其他接口大概率不会有太大问题。接口出了问题先看后端日志再看请求参数用浏览器的Network面板确认请求体和响应体是否符合预期一般两三分钟内就能定位到问题。4.4 文档整理与项目交付规范项目标题里写明了“含文档”文档质量直接影响这个项目的可用性。我在交付时整理了四类文档数据库初始化脚本、接口文档、部署文档、前端说明文档。数据库脚本要能直接执行包含建库、建表、初始数据。初始数据很重要不然跑起来是空页面体验不好。我塞了几条宠物示例数据、一个管理员账号、一个普通用户账号并在文档里写明账号密码。接口文档我使用Markdown格式手写按模块分类认证接口、宠物接口、申请接口、管理接口。写清楚每个接口的URL、请求方法、请求参数、响应示例。如果项目再大一点建议接入在线接口文档工具但这个小项目里手写完全够用。部署文档要覆盖开发环境启动和服务器部署两种情况。开发环境的坑通常集中在JDK版本、Node版本、数据库密码服务器部署则要写清楚Jar包打包命令、前端npm run build后的静态文件如何放置、Nginx如何反向代理。5. 常见问题与排查技巧实录5.1 跨域问题前后端请求的经典拦路虎开发环境下如果我没配Vite代理就会出现前后端跨域。前端跑在http://localhost:5173后端跑在http://localhost:8080浏览器默认不允许跨端口请求。解决方案有两种。开发环境优先用Vite代理生产环境优先用Nginx反向代理。后端同时也可以配置跨域过滤器作为兜底但要注意如果已经用了代理还配置了后端allowedOrigins(*)某些浏览器预检请求会重复不一定会出问题但建议保持一种方案不要双写。另一个跨域相关的问题是带Cookie的请求。如果前端和后端域名不同需要设置withCredentials: true而且后端跨域配置中的allowedOrigins不能写成*必须指定前端域名。实际项目中我建议用token认证而不是依赖Cookie这样跨域问题会少很多。5.2 MyBatis-Plus 的字段映射坑第一个坑是驼峰和下划线转换。MySQL默认配置下create_time可以自动映射到createTime没问题。但如果数据库字段名是is_deletedJava字段是deleted自动映射就失效了。我后来统一用TableField显式标注容易混淆的字段宁可多写几行注解也不留隐患。第二个坑是逻辑删除字段参与唯一索引。比如某张表对pet_id和user_id做了唯一索引用于防止重复收藏。逻辑删除后删除的那条记录还在表里重新插入相同组合时唯一索引就会冲突。解决办法是设计唯一索引时把deleted字段也加进去或者用deleted_id这种冗余字段让每条删除记录都拥有不同的逻辑删除值。第三个坑是分页插件不生效。很多时候不是配置漏了而是引错依赖。MyBatis-Plus在SpringBoot2下要引入mybatis-plus-boot-starter在纯Spring项目里要用mybatis-plus的另一个启动器。版本号也容易和SpringBoot版本冲突我建议直接用官方推荐的starter版本别自己随意升级。5.3 Vue3 响应式数据丢失的典型场景我在开发宠物筛选功能时遇到过两次响应性丢失。一次是用reactive定义表单对象然后在方法里直接解构赋值结果改了解构后的变量页面上数据纹丝不动。另一次是调用接口返回列表后直接把整个数组重新赋值给reactive对象却忘了reactive对象应该是对象形式数组赋值后索引访问不触发视图更新。现在统一用ref来定义业务数据等于给所有变量加了一层保障。ref在模板中会自动解包在JavaScript中操作时记得加.value。reactive只用于定义固定结构的嵌套表单对象。这个习惯帮我避开了绝大多数响应性坑。另一个容易被忽略的是异步渲染问题。宠物图片列表渲染时图片URL还没从后端返回v-for绑定的数组为空。有时候不是数据没更新而是页面渲染时机和接口返回时机不一致。我习惯在请求完成前显示loading状态保证数据返回后再渲染列表组件。5.4 服务器部署时的注意点本地跑得好好的部署到服务器就出问题这种情况我遇到不少。最常见的是文件路径问题。Windows和Linux的路径分隔符不一样不能直接写死C:/upload这种路径。我建议把上传目录配置到application.yml里服务器上通过--spring.config.location指定外部配置这样换环境只改配置文件不动代码。另一个是端口和防火墙问题。后端服务监听了8080端口服务器安全组没放行前端请求一直超时。部署前最好用一个简单的健康检查接口比如/actuator/health先确认服务是否在运行再排查网络问题。数据库连接问题也很典型。服务器上的MySQL版本和本地不一致可能在本地正常的SQL到服务器上就变慢或者报错。部署前我会把数据库脚本在目标环境的MySQL8.0上完整跑一遍再启动后端服务。5.5 避坑速查表场景常见问题解决思路MySQL8.0连接Public Key Retrieval错误URL加allowPublicKeyRetrievaltrue时间时区查询时间差8小时URL使用serverTimezoneAsia/Shanghai表情存储插入emoji报错表和库统一使用utf8mb4分页查询Page对象不生效检查是否注册分页插件、是否引入正确starter逻辑删除唯一索引冲突删除字段纳入唯一索引Vue3响应性解构后数据不更新统一使用ref解构时使用toRefs图片回显刷新后图片失效用后端返回URL不用blob本地地址动态路由刷新后404在路由守卫中重新加载权限路由跨域请求被浏览器拦截开发用Vite代理生产用Nginx反向代理事务一致审核通过后数据不一致用Transactional包裹联动更新我个人在实际操作中的体会是宠物领养系统最大的价值不在于某个技术点有多高深而在于它把CRUD、权限、事务、文件上传、分页这些Web开发基本功串成了一个完整闭环。做这个项目的过程中真正花时间的不是写代码而是想清楚每个状态流转、每个边界条件、每个校验规则。把这些细节处理好项目跑起来才真正稳定。最后再分享一个小技巧不论项目大小坚持写接口文档和部署文档哪怕只是给自己看过一个月后再回来改动你会感谢当初把文档写清楚的自己。
阅读完成 · 觉得有帮助?