SpringBoot + Vue3 + MyBatis-Plus 前后端分离实战:流浪动物救助网站系统设计与实现

发布时间:2026/9/25 17:11:38
SpringBoot + Vue3 + MyBatis-Plus 前后端分离实战:流浪动物救助网站系统设计与实现
1. 为什么选这个课题业务价值与需求边界1.1 需求从哪来目标用户和业务场景流浪动物救助网站这个题目我在很多技术交流群里见过不止一次。选它的原因很直接业务场景真实需求边界清楚技术栈主流。SpringBoot2负责后端接口Vue3做前端页面MyBatis-Plus简化数据库操作MySQL8.0做持久化存储这套组合基本覆盖了前后端分离开发的完整链路。做课程设计、毕业设计或者想练手完整项目的同学都可以拿它当蓝本。但要做这个项目第一步不是写代码而是先把业务理清楚。救助站的真实工作流程大概是这样的有人在路边发现流浪动物拍照上报到平台管理员对信息进行核验审核通过后把动物信息发布到领养区爱心人士看到之后提交领养申请管理员审核申请安排线下互动和回访。整个流程里牵扯到三类角色普通游客、爱心用户、后台管理员。游客可以浏览公告和动物信息但只有注册登录之后才能申请领养、提交救助线索管理员负责审核动物信息、处理领养申请、发布公告、维护用户数据。在抽象成系统功能时我习惯把它们拆成几个模块动物信息管理、救助线索上报、领养申请流转、公告发布、用户登录注册。如果一个源码里连这些基本模块都没有那它多半只是个空壳反过来如果只有增删改查没有申请审核和状态流转那也谈不上“救助”系统。这个业务闭环是项目的灵魂前端页面和后端接口都是给它服务的。1.2 技术栈选型逻辑为什么是这套组合很多同学拿到项目第一步就问能不能换成SpringBoot3能不能用Vue2我的建议是如果是为了交作业或快速落地先不要换。SpringBoot2是目前存量项目和企业教学里最普及的版本网上资料多遇到的坑几乎都有人踩过。SpringBoot3虽然新但和MyBatis-Plus的整合配置、部分第三方依赖的兼容性都需要额外适配没必要为一版新特性给自己增加排查成本。Vue3配合Vite构建工具开发效率比Vue2的webpack方案高不少组合式API也更容易拆分业务逻辑。尤其是管理后台这种大量表格、表单、弹窗的页面用script setup写起来非常舒服。MyBatis-Plus最大的价值不是花哨的代码生成而是单表CRUD几乎不需要写SQL。单表操作直接继承BaseMapper复杂的多表查询再写自定义XML开发速度提升很明显。MySQL8.0已经是大势所趋性能、窗口函数、JSON能力都比5.7好。这个项目里的动物信息表、领养申请表都可能有比较复杂的查询条件8.0对 SQL 规范要求也更严格正好能逼着你写出更健康的SQL。我整理过一个简单的对比可以看看为什么不用更低版本技术组件使用版本选择理由常见替代方案后端框架SpringBoot 2.7.x生态成熟、资料多、与MyBatis-Plus兼容稳定SpringBoot3新但坑多前端框架Vue 3 Vite组合式API、构建速度快Vue2Webpack维护成本高ORMMyBatis-Plus 3.5.x单表零SQL、分页插件好使MyBatis手写XML、JPA数据库MySQL 8.0JSON支持、窗口函数、通用版本MySQL5.7老但也能跑这套组合不是追求最新而是追求“跑得起来、查得到答案、改得动代码”。对学习阶段来说这是最务实的路线。2. 后端核心设计从数据库表到接口实现2.1 数据库设计核心表结构与关系一个系统的数据表设计直接决定了后续开发顺不顺。流浪动物救助网站至少需要这几张核心表用户表、动物信息表、救助线索表、领养申请表、公告表。如果要做轮播图或站点配置可以再加一张配置表但核心业务就这五张。用户表不用多说除了基础账号字段建议加一个role字段取值可以是USER和ADMIN省得单独建角色表。动物信息表是核心中的核心我给出一个比较实用的表结构片段CREATE TABLE animal ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 动物昵称, species VARCHAR(20) NOT NULL COMMENT 品种/物种, gender TINYINT DEFAULT NULL COMMENT 性别1公2母, age VARCHAR(20) DEFAULT NULL COMMENT 年龄描述如2个月, health_status VARCHAR(100) DEFAULT NULL COMMENT 健康状况, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待审核1待领养2已领养3下架, cover_image VARCHAR(255) DEFAULT NULL COMMENT 封面图, description TEXT COMMENT 救助故事/描述, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );领养申请表要关联动物和用户同时记录审核结果CREATE TABLE adopt_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, animal_id BIGINT NOT NULL, user_id BIGINT NOT NULL, reason VARCHAR(500) COMMENT 领养理由, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核1通过2拒绝, audit_remark VARCHAR(255) COMMENT 审核备注, apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME DEFAULT NULL );救助线索表则记录下来源信息上报人、联系方式、发现地址、描述和照片。公告表字段就简单很多只要求标题、内容、发布时间、是否置顶。表的设计有几个容易被忽略的点。第一animal表一定要有status字段而不是通过关联最新的领养申请记录反查状态。因为列表页要高频展示“待领养”的动物如果每次都用子查询去判断数据库压力大而且逻辑难维护。第二所有状态字段都用数字比如0、1、2不要直接存中文。中文可读性好一点但代码里写死字符串很容易因为一个“已领养”和“领养完成”的差异导致 bug用常量或枚举最稳。2.2 MyBatis-Plus 如何把 CRUD 从重复代码里解放出来如果用传统 MyBatis每张表都要写一个 Mapper 接口再对应一个 XML里面全是 INSERT、UPDATE、SELECT 之类的模板SQL。做五张表就是五个循环没有技术含量还会占用时间。MyBatis-Plus 的BaseMapper直接把这些常用方法都内置了只要继承它selectById、insert、updateById、deleteById就都有了。比如动物列表的分页查询最普通的写法是用 LambdaQueryWrapper 构造条件PageAnimal page new Page(current, size); LambdaQueryWrapperAnimal wrapper new LambdaQueryWrapper(); wrapper.eq(Animal::getStatus, 1) .like(StringUtils.hasText(keyword), Animal::getName, keyword) .orderByDesc(Animal::getCreateTime); animalMapper.selectPage(page, wrapper);这段代码的意思是查状态为待领养的动物如果前端传了关键词就模糊匹配昵称最后按创建时间倒序。StringUtils.hasText(keyword)这种写法很值得推荐条件为空时不会拼 SQL避免出现WHERE status 1 AND name LIKE %%的无效查询。使用 MyBatis-Plus 有一个特别容易踩的坑分页插件不配置分页不生效。selectPage不报错但返回的数据永远是全部页面上的分页组件就跟假的一样。正确的做法是配置一个拦截器Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置类必须放在 SpringBoot 启动类能扫描到的地方保险起见可以放在启动类的子包下。分页插件加了之后selectPage才会自动生成LIMIT也才能正确返回total总数。另外我建议实体类上加上TableName注解因为 Java 里的驼峰命名和数据表的下划线命名不一定一致比如animal对得上但applyTime对应的是apply_time。MyBatis-Plus 默认开启了驼峰映射大多数情况下没问题但表名如果和实体类名不一样就一定要显式指定。2.3 领养申请状态机一个容易写崩的核心业务这个系统里最值得认真设计的就是领养申请的状态流转。用户提交申请后管理员要么通过要么拒绝。但通过之后那只动物必须立刻变成“已领养”同时其他所有待审核的申请都应该自动关闭。如果不做这一步就会出现一只猫同时被三个人领养成功的尴尬情况。我建议用状态校验 事务注解来实现而不是在 Service 里堆一堆 if-else 然后只更新单个字段。一个比较完整的实现思路是这样的Transactional(rollbackFor Exception.class) public void auditApply(Long applyId, Integer status, String remark) { AdoptApply apply applyMapper.selectById(applyId); if (apply null) { throw new BizException(申请不存在); } if (!apply.getStatus().equals(AdoptApplyStatus.PENDING)) { throw new BizException(该申请已处理请勿重复操作); } apply.setStatus(status); apply.setAuditRemark(remark); apply.setAuditTime(new Date()); applyMapper.updateById(apply); if (AdoptApplyStatus.APPROVED.equals(status)) { // 1. 更新动物状态为已领养 animalMapper.update(null, new LambdaUpdateWrapperAnimal() .eq(Animal::getId, apply.getAnimalId()) .set(Animal::getStatus, AnimalStatus.ADOPTED)); // 2. 拒绝当前动物下的其他待审核申请 applyMapper.update(null, new LambdaUpdateWrapperAdoptApply() .eq(AdoptApply::getAnimalId, apply.getAnimalId()) .eq(AdoptApply::getStatus, AdoptApplyStatus.PENDING) .set(AdoptApply::getStatus, AdoptApplyStatus.REJECTED) .set(AdoptApply::getAuditRemark, 该小动物已被其他爱心人士领养)); } }为什么强调Transactional因为“更新申请状态”和“更新动物状态”需要绑定在一个事务里中间任何一个步骤失败数据库就应该回滚不能出现状态改了但动物还是待领养的脏数据。另外要注意LambdaUpdateWrapper里更新其他待审申请时一定要带eq(AdoptApply::getAnimalId, apply.getAnimalId())条件别把不同动物的申请也全拒绝了。这种细节一旦漏掉线上就会出一堆莫名其妙的 bug。3. 前端Vue3组件化改造和后台管理交互3.1 项目搭建目录结构怎么分前端部分我用的是 Vite 创建 Vue3 项目配套 vue-router、pinia、axios 和 Element Plus。Vite 的启动速度真的快改代码热更新也跟手。目录结构建议这样分src/ ├── api/ # 所有请求接口一个模块一个文件 ├── assets/ # 静态资源 ├── components/ # 通用组件如表单弹窗、图片上传 ├── router/ # 路由配置 ├── store/ # Pinia 状态管理 ├── utils/ # 请求封装、工具函数 ├── views/ # 页面 │ ├── admin/ # 后台管理页面 │ └── portal/ # 前台展示页面 └── App.vue为什么强推把api独立成目录而不是在页面里直接写axios.get很简单接口统一管理联调时改域名、改前缀、加拦截器都只需要动一处。团队协作时每个人维护自己的模块文件合并代码也不容易冲突。Pinia 和 Vuex 的差别对这个项目来说没那么复杂。Pinia 的 API 更简洁不需要写那么多 mutation一个 store 里直接放 state 和 action配合defineStore用起来很顺手。这个项目的全局状态主要就是用户登录态、头像、角色信息用 Pinia 足够。路由设计上要注意一点后台管理页需要登录且需要管理员角色不能只靠菜单隐藏。可以在路由配置里加一个 meta 字段标记requiresAuth: true和requiresAdmin: true然后在全局前置守卫里判断。这样就算用户手动输入后台地址也会被拦下来。前端只能防君子权限的安全底线必须在后端做前端只是体验优化。3.2 接口封装Axios 拦截器统一处理 Token 和错误码前后端分离之后接口请求都会经过 Axios。如果每个页面都写一遍“拿 token、塞请求头、解析响应、弹错误提示”代码就会变得啰嗦且难以维护。我习惯在utils/request.js里封装一个实例import axios from axios import { ElMessage } from element-plus 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) window.location.href /login return Promise.reject(new Error(登录已过期)) } ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request这里有几个细节。第一baseURL设置成/api开发环境靠 Vite 的 proxy 转发生产环境靠 Nginx 转发前端代码里就不用写死 IP换环境时不容易出错。第二后端很关键的一点是返回统一结构比如{code: 200, msg: success, data: ...}前端才能在这个拦截器里做统一的“拆包”动作。第三请求拦截器里如果有 token 就加 Authorization 头后端通过这个 header 解析当前用户信息。在我接触过的一些源码里前后端接口返回很随意有的直接返回一个数组有的用{status: 1, rows: []}前端处理起来需要到处判断联调效率特别低。统一响应结构这件事说小很小说大很大直接决定代码能不能规模化复用。3.3 列表页、详情页和申请表单核心交互的实现思路前台领养列表页是整个网站流量最大的页面之一。它要展示动物卡片、支持搜索关键词、分页加载。用 Vue3 组合式 API 写起来很直观列表、加载状态、搜索条件都是响应式变量操作逻辑集中在同一段代码里。示例结构可以参考script setup import { ref, onMounted } from vue import { getAnimalList } from /api/animal const query ref({ page: 1, pageSize: 12, keyword: }) const list ref([]) const total ref(0) const loading ref(false) async function loadData() { loading.value true try { const data await getAnimalList(query.value) list.value data.records total.value data.total } finally { loading.value false } } function handleSearch() { query.value.page 1 loadData() } onMounted(loadData) /script页面模板部分用 Element Plus 的表格或卡片都能做。卡片场景推荐用el-card配合v-for渲染每个卡片点击跳详情页。详情页需要展示动物的图片、健康情况、救助故事然后是领养按钮。点击领养按钮时要先判断用户是否登录如果没登录跳转登录页已经登录就弹出申请表单。申请表单注意几个重点领养理由必填联系方式可以由用户信息默认填充提交成功后刷新详情页状态把按钮变成“已申请等待审核”。前端要做到按钮防重复提交在提交方法里加一个submitting变量刚开始为false点击后为true请求结束再改回来。这是防止用户手滑连点导致重复申请的简单手段。后台管理页面的交互就相对固定左侧菜单栏、右侧路由出口用el-table展示申请列表和动物列表。每个申请行要有“通过”“拒绝”按钮操作之后刷新整个列表。这部分代码量大但技术含量不高核心还是把接口约定理清楚然后照着接口文档逐项实现。4. 前后端联调与部署阶段常见的坑4.1 MySQL8.0驱动、时区和密码认证三座山只要是 MySQL8.0基本绕不开这三个问题驱动类变了、时区设置要显式指定、认证插件可能与老驱动不兼容。驱动类在 MySQL8.0 中应该是com.mysql.cj.jdbc.Driver老版本 MySQL 才用com.mysql.jdbc.Driver。用错驱动类启动时就会报ClassNotFoundException。在 SpringBoot2 的application.yml里完整的数据源配置一般长这样spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/animal?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456serverTimezoneAsia/Shanghai是必须的。如果不设置某些环境下 Java 会拿 UTC 时间做转换数据库里的create_time读出来比本地时间少 8 小时。之前帮别人排查一个“发布时间显示未来时间”的问题最后定位就是时区参数缺失。MySQL8.0 默认的认证插件是caching_sha2_password如果项目里的 MySQL 驱动版本太老连接时会报Public Key Retrieval is not allowed或认证失败。解决方式有三种升级 MySQL 驱动到 8.0.x在 JDBC URL 上加allowPublicKeyRetrievaltrue或者创建用户时指定mysql_native_password。一般更推荐前两种不要为了兼容老驱动把数据库的认证方式改回去那会给服务器增加安全风险。4.2 CORS 和前端代理开发环境跟生产环境各管各的前后端分离之后前端运行在 5173 端口后端运行在 8080 端口直接请求必跨域。开发环境最简单有效的处理是用 Vite 代理不需要后端额外配 CORS。在vite.config.js里加上export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/animal/listVite 会替你转发到http://localhost:8080/api/animal/list浏览器看到的始终是同源请求跨域问题从根源上被绕开了。但生产环境就不适合走 Vite 代理了常见的是 Nginx 做反向代理统一转发。不过如果你只是交一个课设可能在本地演示就够了这时候后端配置一个全局 CORS 过滤器更省事Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意这里用的是AllowedOriginPattern(*)而不是addAllowedOrigin(*)。因为当AllowCredentials为true时Spring 不允许通配符 Origin 直接生效用 pattern 才能正确匹配。很多旧项目在这个细节上报错前端调试时明明配置了跨域浏览器里还是会红一大片。另外要提醒的是如果开发环境已经配置了前端代理前端请求会被转发后端的 CORS 配置大概率不会影响什么。但上线后如果后端有多个域名访问或者你挂了个接口给别的站调用这个 CORS 配置就有用了。建议常用账号登录、上传图片等接口都要配合 CORS 一起测不要只测普通 GET 请求。4.3 拿到含文档的源码后怎样快速跑起来不出乱子标题里写了“含文档”这个含金量其实很高。很多源码只丢一堆前后端代码没有数据库脚本也没有 README拿到手光猜表结构就得浪费半天。拿到这种项目不要急着看代码先看文档里的部署步骤。一个标准的启动顺序应该是这样的先在 MySQL 里创建数据库执行sql文件夹里的初始化脚本再改后端的application.yml确认端口、数据库名、账号密码对得上启动后端项目看到Started Application之后再启动前端前端要先npm install依赖装好之后npm run dev浏览器访问 Vite 提供的地址。这里列一个我平时排查启动问题的小表全是实际高频出现的问题现象可能原因处理方式后端启动失败提示端口占用8080端口被其他进程占用netstat -ano查看PID杀掉进程或修改后端端口npm install卡住网络问题导致依赖下载慢切换 npm 镜像源后重新执行前端请求接口 404代理没生效或后端没有/api前缀检查 Vite 代理配置和后端 Controller 的RequestMapping前缀后端接口报 SQL 语法错误MySQL8 严格模式或多表字段没指定别名检查 SQL看看有无保留字段名冲突验证码/图片显示不出上传目录没有映射确认静态资源映射配置是否正确这些坑看起来都很低级但在项目交付时几乎天天遇到。尤其几个同学一起协作时你改一个连接配置他改一个 npm 包版本项目很容易变成一个“只有本机能跑起来”的状态。我个人的习惯是拿到源码后先把整个项目从零跑一遍跑通之后再开始加功能或者改页面。顺序错了后面出 bug 就会分不清是原有问题还是自己改出来的。5. 上线前的功能自测与性能小优化5.1 权限控制后端不能只靠前端藏按钮很多前期开发的项目会把“管理员菜单”在前端路由里隐藏或者只判断有没有登录就算完成任务了。但这个项目涉及用户数据、领养申请审核不做角色校验的话用户只要知道接口地址就能通过手动请求修改别人的申请或者下架动物。后端做权限控制最轻量级的方式是写一个拦截器在 Handler 执行之前判断请求路径和 Token。配合自定义注解可以做到更精准的控制比如在需要管理员权限的接口上打上RequireAdmin然后拦截器里读取注解判断当前用户角色。不过这里要注意Token 解析出来的是用户 ID你需要查一次用户表拿角色或者直接从 Token 里放入角色字段。简单项目我倾向于在登录成功后把role放进 JWT这样拦截器不用频繁访问数据库。权限控制有一个常见的坑只校验了“有没有登录”没有校验“是不是管理员”。前台用户可以调用后台接口导致越权。所以我建议在拦截器里明确区分两级必须登录以及必须管理员。漏掉一级系统的安全性就是漏的。5.2 图片上传和静态资源映射别把文件塞进数据库动物照片是救助网站的基本需求。有些源码图省事直接把图片转成 Base64 存进 MySQL 字段里。如果只是几张照片这个方案还能用一旦图片多起来数据库体积会膨胀得非常快查询速度也被拖下来。更合理的方案是图片上传到服务器磁盘数据库里只存访问路径。SpringBoot 里做本地存储核心就是配置虚拟路径映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: System.getProperty(user.dir) /upload/); } }这样浏览器访问/upload/xxx.jpg就会直接命中本地的upload目录。上传接口里要注意文件名不能直接用用户传过来的原始名字否则可能重名还可能有非法字符风险。我一般用UUID.randomUUID()拼接原始文件的后缀名重新生成一个文件名。另外SpringBoot 的默认上传大小限制是 1MB图片稍微大一点就会报MaxUploadSizeExceededException。在application.yml里要显式调大spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB如果部署到服务器还要注意磁盘路径的写权限。我之前遇到过打包上传到服务器后图片一直显示 403 或者 404最后发现是目录不存在或者 nginx 用户没有读权限。这类问题调试起来不难但很容易被忽略。5.3 统一日志和全局异常排错效率靠这些细节越是看起来不起眼的模块越是决定项目能不能长期维护。全局异常处理是最推荐优先做的事。后端接口一旦报错如果直接抛一堆堆栈信息给前端用户看到的是一串吓人的英文而且还可能泄露代码结构。更合理的是统一返回一个业务码和友好的中文提示。简单的方式是定义一个ResultT然后写一个全局异常处理器RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBizException(BizException e) { return Result.error(e.getCode(), e.getMessage()); } ExceptionHandler(Exception.class) public ResultVoid handleException(Exception e) { log.error(系统异常, e); return Result.error(500, 系统繁忙请稍后重试); } }日志方面开发环境建议把 MyBatis-Plus 的 SQL 打印打开配置logging.level可以让控制台输出 SQL排错时能直观看到数据库执行的语句logging: level: com.example.mapper: debug这样能直接看到 MyBatis-Plus 生成的 SQL 和参数很多时候“数据没查出来”的原因就藏在条件拼接上。我在做完这个项目后最大的体会是核心业务的状态流转不能偷懒。比如“领养申请被拒绝后如果之前动物还在待领养要不要恢复如果同一个动物已经成功被别人领养再点拒绝会不会误改”这些边界问题才是真正区分一个程序能不能交付的分水岭。如果你拿到的源码没有覆盖这些边界建议自己补测试数据多走几遍流程把动物从“待审核”到“待领养”再到“已领养”的整条链路手动操作一次确认没有状态错乱再继续改样式。否则功能看着齐全实际一操作就露馅。