资讯动态

SpringBoot+Vue3+MyBatis-Plus宠物领养系统实战解析

发布时间:2026/10/16 2:57:32 来源:尧图企业网站定制
项目概述与技术选型解析前阵子帮一个朋友整理他毕业设计用的宠物领养系统正好是SpringBoot2Vue3MyBatis-PlusMySQL8.0这套组合。断断续续跑了小半个月从数据库设计到前后端联调踩了不少坑也攒了不少经验今天干脆把它完整拆开写一遍。先说结论这套技术栈放在2024年前后做中小型Web项目是我个人最推荐的一套组合没有之一。原因有三个。第一SpringBoot2.M x 版本生态非常成熟网上能查到的资料比星空还多遇到报错基本都能搜到答案这对初学者来说太重要了。第二Vue3的Composition API写起来比Vue2的Options API更直观配合Vite开发体验极高。第三MyBatis-Plus几乎把单表CRUD的模板代码消灭了不需要手写大量XML Mapper把精力留给核心业务逻辑。至于MySQL8.0窗口函数、JSON类型、公用表表达式这些特性比5.7版本强了不止一个档次。宠物领养系统本身也选得非常好。它不是纯拼业务的商城系统也不是复杂的交易系统而是一个功能边界清晰、业务链路完整、前后端交互典型的Web应用。用户注册登录、宠物信息发布、领养申请流程、后台审核管理、图片上传、多条件检索这些模块几乎把Web开发中最高频的知识点全部覆盖了。把它吃透等于把Java Web项目开发的主干技能都过了一遍。这套系统的完整源码加文档是开源的我拿到后从头到尾跑通了本地开发和部署有什么问题基本都验证过下面把每个部分掰开揉碎讲。1. 项目整体设计与功能模块拆解1.1 系统面向的两类用户与核心功能宠物领养系统的用户角色划分非常清晰普通用户和系统管理员。普通用户的核心场景是浏览宠物、提交领养申请、管理个人信息管理员的核心场景是宠物信息审核、领养申请审批、用户管理、数据统计。普通用户侧的功能可以拆成这样浏览宠物列表按品种、年龄、性别、所在城市等条件组合筛选查看宠物详情包括多张图片、性格描述、健康状况、免疫记录搜索宠物支持关键字模糊匹配提交领养申请填写个人情况、养宠经验、居住环境查看申请状态从待审核到审核通过/驳回个人信息维护修改手机号、密码、头像管理员侧功能登录后台区别于普通用户的权限控制宠物信息管理包括新增、编辑、下架、删除领养申请审核通过或驳回并填写审核意见用户管理查看注册用户列表、禁用违规账号宠物分类管理维护品种类型简单的数据看板展示当前宠物总数、申请数量等统计信息这套功能设计给出的信号非常明确它是按照真实可用的业务系统标准来设计的不是那种随便写个CRUD糊弄事的演示项目。尤其是领养申请流程的状态流转——从提交到审核再到最终结果——把状态机这种后端核心概念落地了。这个流程做一遍比看十遍理论都有用。1.2 技术栈为什么这样选SpringBoot 2.7.xSpringBoot不用多吹它的核心价值就是约定大于配置。内嵌Tomcat打包即运行不需要单独部署Web容器。选2.x而不是3.x的原因很务实3.x要求JDK17起步很多学校机房和公司服务器还在用JDK8。2.7.x可以用JDK8跑兼容性和稳定性都兼顾了。Vue3 Element PlusVue3带来的Composition API让组件逻辑复用变得非常简单用setup语法糖写业务代码比Vue2清爽很多。Element Plus这套组件库覆盖了表单、表格、弹窗、分页、上传这些常用组件后台管理界面几乎不用自己手写样式开发效率翻倍。MyBatis-PlusJPA虽然开发快但复杂查询和SQL调优能力偏弱原生MyBatis灵活但样板代码太多。MyBatis-Plus恰好取了中间值单表CRUD直接用BaseMapper继承的方法条件查询用LambdaQueryWrapper构建物理分页用现成的PaginationInnerInterceptor插件复杂联表自己写XML。它还有一个逆向工程能力可以根据数据库表自动生成实体类、Mapper接口和Service省掉最开始的一大堆机械劳动。MySQL 8.08.0的默认字符集是utf8mb4emoji表情也能直接存不需要像5.7那样额外设置。窗口函数配合统计数据非常方便一个ROW_NUMBER()就能搞定分组排名。JSON字段类型在存宠物图片URL列表、宠物性格标签这些半结构化数据时是神器。前端工程化Vite Vue Router Pinia AxiosVite的冷启动速度比Webpack快一个数量级开发体验极好。状态管理选PiniaAPI设计简洁配合setup语法非常顺手。Axios做HTTP请求库拦截器统一处理token注入和错误提示属于前端标配。2. 数据库表设计与核心细节2.1 五张核心表的字段规划宠物领养系统的数据库设计是整套系统的生态底座。我拿到源码后第一件事不是跑代码而是先打开SQL脚本把所有表结构过了一遍因为表设计决定了业务的扩展空间。这套系统的表结构如下user表用户表字段类型说明idbigint主键自增usernamevarchar(50)登录用户名唯一passwordvarchar(255)BCrypt加密后的密码nicknamevarchar(50)昵称phonevarchar(20)手机号emailvarchar(100)邮箱avatarvarchar(255)头像URLroletinyint角色1普通用户0管理员statustinyint状态正常/禁用create_timedatetime注册时间注意密码字段长度写成255而不是默认的50因为BCrypt加密后的字符串长度是60如果字段太短会导致插入报错。这是新手最容易踩的坑。pet表宠物表字段类型说明idbigint主键namevarchar(50)宠物名category_idbigint分类ID关联分类表breedvarchar(50)品种ageint年龄月gendertinyint性别imagesvarchar(1000)图片URL逗号分隔health_infovarchar(500)健康状况如已驱虫、已打疫苗personalityvarchar(200)性格描述cityvarchar(50)所在城市statustinyint状态0待审核1已发布2已领养3下架create_timedatetime发布时间images字段用逗号分隔多个图片URL虽然不是第三范式但实际项目中很常用避免了额外建一张附件表。adoption_apply表领养申请表字段类型说明idbigint主键pet_idbigint申请的宠物IDuser_idbigint申请用户IDreasonvarchar(500)申请理由experiencevarchar(500)养宠经验addressvarchar(200)居住地址statustinyint状态0待审核1已通过2已驳回apply_timedatetime申请时间audit_timedatetime审核时间audit_remarkvarchar(500)审核意见category表宠物分类表id、name、create_time用于维护猫、狗、兔子这些基础分类。adopt_info表领养信息表记录每一次领养行为的归属信息比如领养人姓名、联系方式、领养时间用于事后追溯。五张表互相关联外键逻辑清晰没有多余字段该冗余的冗余该规范化的规范化。表设计这块没有特别哗众取宠的技巧设计的合理比设计的复杂重要。2.2 逻辑删除与字段冗余设计这套系统有两个设计细节很值得学习。第一个是逻辑删除。宠物表和数据表没有用物理删除而是用status字段控制状态。比如用户下架一只宠物只是把status从不发布的1改成3数据仍然留在库里。这样做有三个好处数据不会误删丢失、管理员可以追溯历史记录、统计数据不会因删除而出现偏差。日常小型项目里逻辑删除是标配方案。第二个是状态字段的语义划分。pet表的status包含待审核、已发布、已领养、下架四种状态这是一道状态机的精妙设计。用户在创建宠物时状态必须是待审核这是第一道关卡。管理员审核通过后变成已发布此时用户可以申请领养。一旦某个领养申请通过宠物状态自动变为已领养此时所有搜索接口都会把它过滤掉。这套流程通过一个字段就能完整串联比多张表关联实现简单得多。3. 后端核心实现细节3.1 用户认证与JWT方案用户认证用的是JWT方案流程是登录成功后服务端生成一个带用户ID和过期时间的Token返回给前端。前端把Token存在localStorage里后续每次请求在Axios请求拦截器中拼到Authorization头里后端用拦截器统一鉴权。要注意的是密码存储方案用的BCrypt加密而不是MD5或SHA。BCrypt是加盐哈希算法同一个密码每次加密结果都不同而且加密强度可以调节即使数据库泄露了明文密码也很难被反推出来。MD5和SHA已经不再安全了查彩虹表就能跑出一堆。实际项目里点名要BCrypt。关键代码Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login) .excludePathPatterns(/api/user/register); } }Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { String jwtToken token.substring(7); try { Claims claims JwtUtil.parseToken(jwtToken); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); response.getWriter().write({\code\:401,\message\:\token无效或已过期\}); return false; } } response.setStatus(401); response.getWriter().write({\code\:401,\message\:\未登录\}); return false; } }这段代码解决的关键问题是哪些接口需要登录才能访问哪些是公开接口。放行登录注册其余接口全部检查Token。后端用request.setAttribute把用户ID传给ControllerController后续就能直接拿到当前登录用户的身份。3.2 宠物发布与图片上传实现宠物发布的核心流程是前端选图片、传文件、后端接收、返回URL、前端再把URL连同宠物信息一起提交。图片上传有两种主流方案本地存储上传和云存储上传。这套系统用的是本地存储方案把文件写到服务器的/upload/目录下再把文件路径存数据库。实现方式如下注意文件大小的限制配置。spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MBRestController RequestMapping(/api/upload) public class UploadController { PostMapping(/image) public Result uploadImage(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件不能为空); } // 检查文件类型只允许图片 String contentType file.getContentType(); if (contentType null || !contentType.startsWith(image/)) { return Result.error(只能上传图片文件); } // 生成唯一文件名避免重名覆盖 String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString().replace(-, ) suffix; // 按日期分目录存储 String datePath LocalDate.now().toString(); File dir new File(upload/ datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir.getAbsolutePath() / newFileName)); String url /upload/ datePath / newFileName; return Result.success(url); } }这段代码有几处细节值得学习。第一校验文件类型只允许图片防止有人传个可执行脚本上去。第二文件名用UUID重命名避免用户上传两个同名文件互相覆盖。第三按日期分目录方便后续按时间清理过期的图片文件。注意本地存储只适合学习和小型项目。真实部署到云服务器时要把upload目录配置到有持久化能力的空间否则项目重新部署后用户上传的图片就丢了。生产环境更推荐对象存储服务。3.3 领养流程的状态切换与并发安全领养申请的核心逻辑非常考验后端的细节处理能力。用户提交申请后后端要做这几件事第一检查宠物是否存在并且处于已发布状态。如果宠物已经被别人申请走或者已经下架直接拒绝。第二检查这个用户是否已经申请过这只宠物防止重复提交。第三创建申请记录状态置为待审核。第四管理员审核通过后要把申请状态改成已通过同步把宠物状态改成已领养同时把这条宠物其他所有待审核状态的申请都置为驳回因为宠物已经被领走了。这里有个并发问题如果两个用户同时抢同一只宠物可能出现两个申请都审核通过的情况。解决方法是数据库层面的条件更新而不是代码层面的if判断。Override Transactional public boolean approveApply(Long applyId) { AdoptionApply apply adoptionApplyMapper.selectById(applyId); if (apply null || apply.getStatus() ! 0) { throw new ServiceException(申请不存在或已处理); } // 更新申请状态 apply.setStatus(1); apply.setAuditTime(LocalDateTime.now()); adoptionApplyMapper.updateById(apply); // 把宠物状态置为已领养并防止竞态条件 int rows petMapper.updateStatusIfPublished(apply.getPetId(), 2); if (rows 0) { throw new ServiceException(宠物已被领养); } // 把同一宠物的其他待审核申请置为驳回 adoptionApplyMapper.rejectOtherPending(apply.getPetId(), apply.getId()); return true; }核心在updateStatusIfPublished这个方法SQL长这样UPDATE pet SET status 2 WHERE id #{petId} AND status 1这条SQL利用了数据库的行锁机制把检查状态和更新状态合并成了一条原子操作。如果宠物已经被别人领走status不再是1这条更新语句就更新不到任何记录返回影响行数0代码里就抛出异常回滚。这套方案比先查询再判断的普通写法安全得多是并发安全的教科书做法。4. 前端页面与交互实现4.1 Vue3工程结构与路由设计前端工程用Vite初始化的结构分成了几个部分组织得很清晰src/ api/ # 每个模块的API函数封装 user.js pet.js apply.js views/ # 页面文件 home/ # 首页 pet/ # 宠物列表、详情 user/ # 用户中心 admin/ # 后台管理 router/ # 路由配置 index.js store/ user.js # 用户状态 utils/ request.js # Axios实例和拦截器路由设计上用了路由懒加载component用箭头函数动态导入实现按需加载。首页、宠物列表这种核心页面用户访问频率不高没必要全加载到首包懒加载可以显著减少首屏加载时间。路由守卫是前端权限控制的关键router.beforeEach((to, from, next) { const userStore useUserStore(); if (to.meta.requiresAuth !userStore.isLogin) { next(/login); } else if (to.meta.requiresAdmin userStore.role ! 0) { next(/403); } else { next(); } });如果目标页面需要登录而用户未登录直接踢回登录页。如果需要管理员权限而当前用户不是管理员跳转到无权限页面。这段守卫代码非常简单但解决了前端页面级权限控制的核心问题。4.2 Axios请求封装与拦截器设计请求封装这块代码非常典型用Axios实例请求拦截器响应拦截器三层const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器带上token request.interceptors.request.use(config { const userStore useUserStore(); if (userStore.token) { config.headers.Authorization Bearer userStore.token; } return config; }); // 响应拦截器统一处理code request.interceptors.response.use( response { const res response.data; if (res.code 200) { return res; } else if (res.code 401) { // token失效清除登录信息跳转登录页 const userStore useUserStore(); userStore.logout(); router.push(/login); return Promise.reject(new Error(res.message)); } else { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );这样封装的好处是所有接口的错误处理逻辑统一了。页面里不需要每个请求都写try catch拿到数据直接使用拿不到就自动弹错误提示。代码重复度直线下降。4.3 宠物列表页的分页筛选与状态联动宠物列表页是一个典型的组合筛选场景分类筛选、城市筛选、关键字搜索、分页加载四者组合逻辑极其精妙。核心思路是维护一个筛选条件对象任何筛选条件变动都触发重新查询const queryParams reactive({ pageNum: 1, pageSize: 12, categoryId: null, city: , keyword: }); // 监听筛选条件变化重置页码并查询 watch(queryParams, () { queryParams.pageNum 1; fetchList(); }, { deep: true }); async function fetchList() { const res await getPetList(queryParams); petList.value res.data.records; total.value res.data.total; }但这里有一个细节如果前端把每改变一个筛选项就触发请求设计成每个表单项单独发请求会出现重复请求覆盖的问题。所以正确的做法是像上面的代码统一收集到queryParams对象里用watch深度监听任何变动统一走一个fetchList方法。如果筛选条件改变也要重置页码否则会出现停留在第5页但实际筛选结果只有2页的尴尬。分页组件的关键配置// 前端分页组件绑定三个核心值 div classpagination el-pagination v-model:current-pagequeryParams.pageNum v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[8, 12, 16] layouttotal, sizes, prev, pager, next size-changefetchList current-changefetchList / /div这里的双向绑定v-model:current-page很关键用户点击分页按钮页码就是新的页码再调用fetchList完全不需要额外的变量同步。5. 常见问题与调试经验实录5.1 跨域问题前后端联调的第一道坎用Vite开发服务器跑前端前端地址默认是localhost:5173后端跑在localhost:8080端口不同就会被浏览器的同源策略拦截。新手最常见的报错就是网络请求在Network面板是CORS error后端日志却显示请求正常到达了。解决方案有两个。第一是后端加跨域配置第二是前端利用Vite的代理解决。更推荐的是第二种因为生产环境前后端通常部署在同一个域名下根本用不上跨域配置开发环境用代理模拟同域环境更接近真实部署场景。// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };配置完这个以后前端请求/api/xxx时开发服务器自行转发到后端8080端口浏览器的视角里所有请求都是同域的跨域问题就不存在了。经验教训优先用Vite代理解决跨域后端不要放开所有跨域请求。生产环境如果前后端同域部署后端完全不需要配跨域反向代理服务器会统一转发。5.2 MyBatis-Plus分页查询失效的经典坑这是所有MyBatis-Plus用户都会遇到的问题分页插件没有配置PaginationInnerInterceptor时调用selectPage方法返回的记录永远是全部数据total永远是0。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置必须放在能被Spring扫描到的位置否则分页插件不会生效。排查这种问题的方法很简单打断点看看selectPage返回的total字段如果分页生效total是有值的SQL语句末尾会带LIMIT字样。另一个和分页搭配的问题分页查询时如果执行了关联查询或者自定义SQL分页插件可能会失效。特别是自定义XML里写了COUNT(*)之类的子查询分页插件生成的count SQL有时会报错。遇到这种情况直接手写count SQL是最快的解决方案。5.3 图片上传后前端访问404本地开发的时候有个坑前端通过Vite代理访问后端接口没问题但上传图片后拿到了类似/upload/2024-01-01/xxx.jpg的URL把这个URL直接贴到浏览器里访问却404。原因很简单后端没有把upload目录配置成静态资源映射Spring Boot默认不会直接暴露磁盘上的任意目录。解决方案Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file:upload/); } }配置完之后访问http://localhost:8080/upload/2024-01-01/xxx.jpg就能正常显示图片了。但注意开发环境下前端代理只代理了/api开头的请求如果页面直接渲染/upload/xxx这种完整的图片URL浏览器访问的是前端开发服务器的地址还要在Vite代理里把/upload也转发到后端。5.4 日期时间格式化前后端不一致MySQL8.0默认时区是UTCJava程序跑在中国时区UTC8如果数据库连接串没有指定时区存进去和查出来的时间会差8小时。spring: datasource: url: jdbc:mysql://localhost:3306/pet_adopt?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4在数据库连接URL里强制指定serverTimezoneAsia/Shanghai这是时间问题的治本方案。另外返回给前端的LocalDateTime字段默认序列化格式是一串带T的ISO格式如2024-01-01T10:00:00前端展示时需要处理。后端加一个统一的Jackson配置格式化日期spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样前端拿到的就是干净的2024-01-01 10:00:00格式直接展示即可。5.5 文档的阅读顺序源码配套文档怎么用最高效整套源码附带一份完整的项目文档里面包含环境配置、表结构说明、接口文档、部署手册。我的建议是不要从头到尾按顺序读而是按这个顺序先读项目介绍和环境要求部分确认自己的JDK、Node.js版本对不对然后直接看SQL脚本建库建表把五张核心表结构和字段含义搞懂接着看接口文档了解每个接口的路径、请求参数、返回结构最后再对照源码看实现。补充一个实操技巧项目里有一个sql目录里面有完整的建库语句和测试数据。手动建库坑很多直接用他们的SQL脚本导入数据库是最顺的方案。有几个表里带了预设的测试数据比如已经内置了一个管理员账号和一些测试宠物记录这样你跑起来之后不需要手动创建数据就能看到效果。5.6 打包部署时前端路由404问题开发环境一切正常前端路由访问首页没问题但打包部署之后直接访问某个嵌套路由比如/pet/detail/1会报404。这是前端路由模式的问题createWebHistory模式HTML5 History模式需要服务器配置fallback把所有路由都重定向到index.html否则服务器找不到对应的文件。解决方式有两种。第一种开发环境用的createWebHistory改为createWebHashRouterURL里带个#号不会请求服务器端的文件路径部署后直接能跑。第二种生产环境用配置Web服务器把所有请求都try_files重定向到index.html。我自己的倾向小项目图省事用Hash模式最稳大项目要做SEO才考虑History模式搭配服务器配置。后端Spring Boot如果直接打jar包部署建议把前端打包后的dist目录放进src/main/resources/static里这样前后端就不存在跨域问题了也不需要配置任何CORS。访问http://localhost:8080就能看到整个系统对新手非常友好。6. 写在最后的实操心得这套系统整体跑通之后我最大的体会是一份优秀的开源项目源码价值不在于抄代码而在于理解它为什么这么写。状态字段的语义设计、并发场景的条件更新、MyBatis-Plus插件的正确配置、前端拦截器的统一封装这些知识点单独拎出来都很零散但在一个完整的项目里串起来每个决策都有它对应的业务场景和坑。如果你也是正在学习SpringBootVue3的开发者建议拿到源码后不要急着复制代码去交作业先按我上面说的方法把表结构和接口文档过一遍动手改几个功能试试——比如给宠物表加一个是否绝育的字段从前端表单到后端实体类再到数据库完整走一遍这个链路比看十遍视频都有效。最后分享一个有点实用的小技巧跑这套系统之前先看一眼pom.xml里的SpringBoot版本号和package.json里的依赖版本很多为什么启动报错的问题根源都是本机JDK版本或者Node版本和项目要求的版本不一致。版本对齐了后面就顺了。宠物领养系统这套源码是我近期见过最适合练手和学习的Java Web项目之一技术选型主流、代码规范性能不错、文档资料齐全认真过一遍你的前后端联调能力和业务设计思维都会有实打实的提升。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑