
简介面向2025届计算机相关专业毕业设计或课程设计以宠物领养为业务场景整合SpringBoot3后端与Vue.js3前端采用前后端分离架构覆盖用户注册登录、宠物信息展示、领养申请提交与审核管理等完整流程并实现管理后台与用户前台的双端联动。资源包共6个文件包含项目源码、MySQL8数据库脚本、需求文档、操作录屏及返修源码等整体约103.3MBsql脚本可直接初始化数据表mp4教程演示启动与日常操作源码内区分后台管理端与前台展示端便于按模块研读。已有128人学习下载。除可运行的完整代码和数据库外还配套启动教程录屏与返修说明便于复现环境、排查常见问题需求文档则有助于快速把握模块划分与业务流程。适合需要完成同类选题、系统学习前后端分离开发或了解领养类业务系统的开发者参考。1. 宠物领养系统毕业设计技术栈不是越新越好是这一套最稳如果你现在打开招聘网站看 Java 岗位要求SpringBoot3 Vue3 几乎成了标配但这套组合放在毕业设计里很多人反而被“新”字坑了。SpringBoot3 要求 JDK17 起步包名从 javax 迁到 jakarta网上大量教程还停留在 SpringBoot2 时代复制过来直接编译报错。宠物领养系统这个选题好在业务边界清晰前台展示宠物、提交领养申请后台审核、管理宠物和公告天然适合拆成前后端分离的架构也容易在论文里把表结构和接口设计讲明白。这篇笔记我按自己做过的一版思路来拆需求怎么定、后端怎么搭、Vue3 页面怎么写、哪些坑必须提前躲开最后补一份能拿去答辩的验证方法。目标是让你照着做完能跑通、能讲清、能扛住老师追问。2. 把需求拆成能答辩的功能清单前后台边界、角色权限与状态机设计2.1 功能边界先划清楚哪些必须做哪些做了反而扣分宠物领养系统最忌讳一上来就堆功能。我见过不少同学把商城、论坛、在线支付全塞进去最后论文写得像拼盘答辩时被问“这个模块解决了什么实际问题”就卡住。作为毕业设计核心链路只有一条宠物发布 → 用户浏览 → 提交领养申请 → 管理员审核 → 领养确认。围绕这条链前台需要宠物列表、宠物详情、领养申请表单、个人中心我的申请、我的收藏、公告展示后台需要宠物管理、领养审核、公告管理、用户管理、数据看板。收藏和公告是值得做的两个“轻功能”。收藏可以让宠物详情页的交互多一点可写的内容公告则天然适合用富文本编辑器和时间排序来展示 CRUD 的完整性。相反在线聊天、支付、地图定位这类功能不建议碰——它们要么需要第三方服务要么涉及复杂的状态同步学生版实现容易流于表面答辩时反而成为追问的靶子。权限角色上我建议只保留两种普通用户和管理员。用 SpringBoot3 Sa-Token 做登录鉴权管理员接口统一挂在 /api/admin/** 前缀下拦截器按前缀校验角色即可。2.2 宠物状态机用一张表把领养流程的状态管住领养流程不是简单的一口价买卖宠物状态必须在数据库层面闭环。我给宠物表设计的状态字段是待审核0、待领养1、已预约2、已领养3、已下架4。管理员发布宠物时先进“待审核”审核通过变“待领养”用户提交申请且管理员初审通过后变“已预约”这时候其他用户不能再提交申请管理员确认领养手续完成后变“已领养”。已下架则用于宠物因病或其它原因临时撤下。很多同学在这里犯的错是把状态写成字符串比如“available”“adopted”然后在代码里到处比较字符串。正确的做法是用 tinyint 存数字状态码Java 侧定义一个枚举类统一管理入库和出库都走枚举转换。这样写的好处有三个数据库层面可读性好写 SQL 统计各状态数量时不用到处 like接口返回给前端时可以附带状态码和状态文案的映射前端不用自己写死MyBatis-Plus 的 LambdaQueryWrapper 可以直接用状态码做条件查询代码更简洁。Getter public enum PetStatus { PENDING(0, 待审核), AVAILABLE(1, 待领养), RESERVED(2, 已预约), ADOPTED(3, 已领养), OFFLINE(4, 已下架); private final int code; private final String desc; PetStatus(int code, String desc) { this.code code; this.desc desc; } public static PetStatus fromCode(int code) { for (PetStatus status : values()) { if (status.code code) { return status; } } throw new IllegalArgumentException(未知宠物状态: code); } }这段枚举的核心价值在fromCode方法。接口层接收前端传的状态参数时统一用这个方法做转换非法值直接抛异常交给全局异常处理器不会出现状态值穿透到 service 层的情况。数据库字段定义时记得加注释comment 宠物状态 0待审核 1待领养 2已预约 3已领养 4已下架论文画 ER 图时也方便。状态变更的操作一律走 service 层的方法比如auditPet()、applyAdoption()、confirmAdoption()不要在 controller 里直接 update 状态字段——状态流转必须有业务语义这样论文里的“业务逻辑”章节才有内容可写。2.3 数据库表设计七张表足够字段类型和索引一次到位宠物领养系统的表结构我建议控制在七张用户表sys_user、宠物表pet、领养申请表adoption_application、收藏表favorite、公告表announcement、宠物类别表pet_category、操作日志表operation_log。用户表不要自己设计密码加密逻辑直接用 SpringBoot3 自带的 spring-security-crypto 里的 BCryptPasswordEncoder密码字段长度设为 68 以上因为 BCrypt 密文是 60 位预留一点余量。宠物表是核心字段除了基本的名、品种、性别、年龄、描述、图片之外必须要有status状态码、audit_status审核状态、view_count浏览量、apply_count申请次数。这里有个容易被忽略的细节age字段不要用 int 存岁数用 decimal(4,1) 存 0.5 这样的月龄因为幼猫幼犬经常是几个月大。图片字段我建议只存相对路径或 URL不要存 base64具体原因在第五章的避坑里细说。索引方面pet表要给status create_time建联合索引因为前台列表页最常见的查询是“某状态下按发布时间倒序”adoption_application表给pet_id applicant_id加唯一索引防止同一用户对同一只宠物重复提交申请这个约束比代码里的判断可靠得多。CREATE TABLE pet ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL COMMENT 宠物昵称, category_id BIGINT NOT NULL COMMENT 类别id关联pet_category, breed VARCHAR(50) DEFAULT COMMENT 品种, gender TINYINT NOT NULL DEFAULT 0 COMMENT 性别 0公 1母, age DECIMAL(4,1) NOT NULL COMMENT 年龄按月计算, description TEXT COMMENT 宠物描述, cover_image VARCHAR(255) DEFAULT COMMENT 封面图URL, gallery VARCHAR(1000) DEFAULT COMMENT 图集逗号分隔, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态 0待审核 1待领养 2已预约 3已领养 4已下架, view_count INT NOT NULL DEFAULT 0, apply_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, KEY idx_status_time (status, create_time), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT宠物信息表;这里的deleted字段是给 MyBatis-Plus 的逻辑删除用的全局配置了逻辑删除后所有查询会自动追加WHERE deleted 0这个机制在论文里值得写一笔算是“软删除与数据审计”的落地实践。gallery字段用逗号分隔存储多张图片 URL虽然有点反范式但对毕业设计来说比建一张子表更省事前端拿到字符串后 split 一下就渲染九宫格。要注意事务和唯一约束别混为一谈——唯一索引一定建在数据库层不能只在 service 代码里判空因为并发请求可能同时绕过判断。3. 用 SpringBoot3 把后端立起来JDK17 基线、MyBatis-Plus 与领养流程的核心实现3.1 起步踩点SpringBoot3 的包名迁移和 JDK17 环境配置SpringBoot3 和 SpringBoot2 最大的区别有两个任何一个不注意都会让你浪费半天时间。第一是 JDK 版本基线SpringBoot3 强制 JDK17 以上所以你在写启动类之前先确认 IDEA 里 Project Structure 的 SDK 和 pom.xml 里的java.version都指向 17。第二是包名迁移SpringBoot3 把javax.servlet换成了jakarta.servlet这意味着网上大量使用import javax.annotation.Resource的 MyBatis-Plus 教程在 SpringBoot3 下要么爆红要么注入失败。如果你还停留在 JDK8 时代的环境变量配置习惯建议搜索“JDK17 环境变量配置详细教程”时多留个心眼——JDK17 在 Windows 上配置 JAVA_HOME 的方式跟 JDK8 一样但不再有JRE目录也不需要单独配置CLASSPATH。配好之后在命令行执行java -version确认是 17 版本再打开 IDEA 的 Settings → Build Tools → Maven → Runner把 JRE 指向 JDK17否则 Maven 编译时可能报“无效的目标发行版”。pom.xml 里的依赖要统一版本。SpringBoot3 用spring-boot-starter-parent3.2.x 版本MyBatis-Plus 必须用mybatis-plus-spring-boot3-starter而不是老的mybatis-plus-boot-starter。这两个依赖的名字非常像但后者依赖的是 SpringBoot2 的自动配置机制在 SpringBoot3 下会直接启动失败报错信息通常是ClassNotFoundException: javax.servlet.Filter。我第一次迁移时没注意这个点Debug 了快一个小时才反应过来。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version3.5.5/version /dependency /dependencies注意mybatis-plus-jsqlparser这个依赖。MyBatis-Plus 3.5.5 以上的版本把分页插件单独拆出来依赖了jsqlparser如果只引入主启动器不加这个分页查询会直接报错。很多同学做完列表页发现数据能查出来但分页总数不对或者干脆抛异常基本就是缺这个依赖。这是 MyBatis-Plus 官方文档里写得不够显眼的变更但在实际项目里必然遇到。3.2 配置 MyBatis-Plus逻辑删除、乐观锁和分页插件一次性配好MyBatis-Plus 的核心配置集中在启动类或配置类里。我习惯单独写一个MybatisPlusConfig把分页插件、乐观锁插件、逻辑删除配置放一起。这里的分页插件在 SpringBoot3 下不能像 SpringBoot2 那样自动装配必须手动声明MybatisPlusInterceptorBean否则Page对象传进去后查询结果不带 total。这是 MyBatis-Plus 从 3.4.0 开始的重大变化网上老教程基本都没跟上。乐观锁插件的价值在宠物领养审核这个场景里体现得很明显。管理员和用户可能同时操作一个宠物用户提交领养申请时把状态从“待领养”改成“已预约”管理员同时把宠物下架。如果不加锁两个操作互相覆盖最终宠物可能处于“已预约”但实际已下架的脏状态。用乐观锁解决的方式是在宠物表和领养申请表里加Version字段更新时 MyBatis-Plus 自动拼上WHERE version ?更新成功后 version 自增。版本冲突时更新行数为 0业务层捕获后提示“数据已变更请刷新后重试”。Configuration MapperScan(com.example.pet.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }这段配置的逻辑一眼能看明白分页插件指定了数据库类型为 MySQLDbType.MYSQL会告诉分页插件拼接什么样的分页方言比如LIMIT ?,?乐观锁插件拦截所有带Version字段的 update 操作自动追加版本条件。配置完之后实体类的version字段需要加上Version注解并且初始值设为 0。这里有一个容易忽略的点乐观锁不一定能解决所有并发问题它只能保证更新不丢失不能阻止状态机的非法流转所以业务层的状态校验仍然不能省。3.3 用户认证用 Sa-Token 代替 Shiro前后端分离下更省心宠物领养系统的登录认证我推荐 Sa-Token 而不是传统的 Spring Security JWT。原因很直白Spring Security 在 SpringBoot3 下的配置门槛偏高需要理解过滤器链、认证管理器、UserDetailsService 一堆概念对毕业设计来说学习成本不划算。Sa-Token 的 API 设计对新手友好得多StpUtil.login(userId)一行完成登录StpUtil.checkRole(admin)一行完成鉴权还自带踢人下线、记住我、token 续期这些功能论文里能写的点也够。用户表设计时不要把角色字段设计成字符串存“admin”或“user”用role_code存数字配合 Sa-Token 的权限配置使用。登录接口的整体逻辑是接收账号密码用LambdaQueryWrapper查用户BCryptPasswordEncoder.matches()校验密码成功后调用StpUtil.login()生成 token 返回给前端。前端拿到 token 后存在 Pinia 里每次请求在 axios 拦截器里加到请求头satokenSa-Token 默认从请求头读取不需要额外配置 custom 前缀。PostMapping(/api/auth/login) public ResultString login(RequestBody LoginDTO dto) { SysUser user userMapper.selectOne(new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, dto.getUsername())); if (user null) { return Result.error(用户不存在); } if (!BCryptPasswordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.error(密码错误); } if (user.getStatus() ! 1) { return Result.error(账号已被禁用); } StpUtil.login(user.getId()); return Result.success(StpUtil.getTokenValue()); }这段登录代码里有个细节值得解释StpUtil.login()之后 token 的存储和用户信息的获取都不需要你自己写 Redis 或 SessionSa-Token 默认用内存存储 token 和用户会话的映射。如果你想让 token 在重启后仍有效可以配置 Sa-Token 集成 Redis但毕业设计不强制。BCryptPasswordEncoder.matches()的第一个参数是明文密码第二个是数据库密文顺序不能写反。另外注册接口的密码加密我用的是BCryptPasswordEncoder.encode()不要用 MD5 —— MD5 加盐的写法在论文里虽然能写但面试聊到安全问题时容易减分。3.4 领养申请的核心接口状态校验与事务回滚怎么写领养申请是宠物领养系统业务逻辑最密集的接口也是论文里“核心业务实现”章节的素材。处理逻辑我把它拆成四步校验宠物存在且状态为“待领养”校验用户未对该宠物提交过申请插入领养申请表把宠物状态改为“已预约”。这四步必须放在同一个事务里任何一步失败都要全部回滚否则会出现申请表插进去了但宠物状态没改的脏数据。用Transactional注解时要注意默认回滚规则它只对 RuntimeException 和 Error 回滚对受检异常不回滚。如果你在 service 方法里 try-catch 吞掉了异常并抛出一个自定义BizException而这个异常继承了 Exception 而不是 RuntimeException事务就不会回滚。我建议全局异常处理器统一处理业务异常service 层只负责抛出运行时异常。Transactional(rollbackFor Exception.class) public Long applyAdoption(Long petId, Long userId, String reason) { Pet pet petMapper.selectById(petId); if (pet null) { throw new BizException(宠物不存在); } if (pet.getStatus() ! PetStatus.AVAILABLE.getCode()) { throw new BizException(该宠物当前不可申请领养); } Long count applicationMapper.selectCount(new LambdaQueryWrapperAdoptionApplication() .eq(AdoptionApplication::getPetId, petId) .eq(AdoptionApplication::getApplicantId, userId)); if (count 0) { throw new BizException(您已申请过该宠物请勿重复提交); } AdoptionApplication application new AdoptionApplication(); application.setPetId(petId); application.setApplicantId(userId); application.setReason(reason); application.setStatus(0); applicationMapper.insert(application); Pet update new Pet(); update.setId(petId); update.setStatus(PetStatus.RESERVED.getCode()); update.setVersion(pet.getVersion()); petMapper.updateById(update); return application.getId(); }这段代码有两个关键参数说明。第一Transactional(rollbackFor Exception.class)里的rollbackFor是必写的尤其实训时有些人习惯去掉风险就潜伏在那里。第二乐观锁的 version 来自查询出的 pet 实体更新时 set 进去MyBatis-Plus 的updateById会自动拼接WHERE version 条件。如果更新行数为 0说明在查询和更新之间有人改过这条数据后续可以抛“操作过于频繁请稍后重试”。这段逻辑你可能觉得繁琐但答辩时老师问“你怎么保证并发下数据一致”时这正好成为你最扎实的论据。4. Vue3 前台用组合式 API 搭页面路由守卫、Pinia 状态与领养申请表单4.1 项目脚手架与目录结构Vite 初始化后不要急着写页面Vue3 前端项目我推荐用 Vite 创建不要再用 Vue CLI。命令是npm create vuelatest按提示选择 Router、Pinia、ESLint 即可。创建完项目后先花十分钟把目录结构调整好不然写到后面组件、视图、工具函数混在一起找一个文件要找半天。我习惯的分层是views放页面级组件按front和admin分两个子目录components放可复用组件比如宠物卡片、状态标签、图片上传api放 axios 实例和按模块拆分的接口定义stores放 Pinia 模块utils放 localStorage 封装、日期格式化等工具函数。宠物领养系统的前端页面按角色分普通用户能访问的是首页、宠物列表、宠物详情、领养申请、个人中心管理员访问的是后台布局包含宠物审核、领养审批、公告管理、数据统计。这里需要区分前端路由的“访问控制”和“菜单显示”——我通过检查/admin路由的 meta 字段里配置的requiresAdmin: true在全局前置守卫里判断登录状态和角色不通过就跳转登录页。不要只做页面上的按钮隐藏因为前端路由是平铺在浏览器里的隐藏菜单并不能阻止用户直接输入 URL 访问后台页。import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /, component: () import(/views/front/Home.vue) }, { path: /pet/list, component: () import(/views/front/PetList.vue) }, { path: /pet/detail/:id, component: () import(/views/front/PetDetail.vue) }, { path: /admin, component: () import(/views/admin/AdminLayout.vue), meta: { requiresAuth: true, requiresAdmin: true }, children: [ { path: pet-audit, component: () import(/views/admin/PetAudit.vue) }, { path: adoption-audit, component: () import(/views/admin/AdoptionAudit.vue) } ] }, { path: /login, component: () import(/views/Login.vue) } ] }) router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.isLogin) { next({ path: /login, query: { redirect: to.fullPath } }) } else if (to.meta.requiresAdmin userStore.userInfo?.roleCode ! 1) { next({ path: / }) } else { next() } })这段路由守卫是宠物领养系统的访问控制核心。useUserStore()是 Pinia 的模块小程序包里得先执行storeToRefs解构或者直接userStore.isLogin取值——注意 Pinia 的 state 如果用解构取值会丢失响应性新手最容易在这里翻车。next({ path: /login, query: { redirect: to.fullPath } })表示把登录前想去的地址挂在 URL 参数里登录成功后再跳回去这是体验细节答辩时能体现你的工程思维。4.2 Pinia 状态管理用户信息和登录态不放在 localStorage 里靠手写宠物领养系统的登录态管理有一个常见误区很多同学直接把 token 存到 localStorage然后在每个页面里手动判断有没有 token。这样做的最大问题是刷新页面后用户信息是从后端重新拉还是从本地解析如果从本地解析token 过期了但用户信息还在。正确的做法是设计一个userStorestate 里放token和userInfoactions 里写login()、logout()、fetchUserInfo()三个方法。axios 的响应拦截器里遇到 401 时自动调用logout()并跳转登录页这个机制可以保证 token 失效后所有页面自动回到登录态而不是在页面里写一坨if (!token)的判断逻辑。fetchUserInfo()通常会放到 App.vue 的onMounted里执行如果 token 存在就用/api/auth/userinfo拉用户信息。也有人喜欢在路由守卫里每次跳转都拉一次但那样请求太过频繁。我建议只在 App 挂载时拉一次之后数据放 Pinia 内存里刷新页面时重新拉即可。这个设计有一个隐藏利好后端如果后续改成 Redis 存储用户信息前端不用动任何代码。export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(satoken) || , userInfo: null }), getters: { isLogin: (state) !!state.token, isAdmin: (state) state.userInfo?.roleCode 1 }, actions: { setToken(token) { this.token token localStorage.setItem(satoken, token) }, async fetchUserInfo() { const res await fetchUserInfoApi() this.userInfo res.data }, logout() { this.token this.userInfo null localStorage.removeItem(satoken) } } })这段代码里 getters 和 actions 的区别是一个必讲的知识点getters 是派生的状态像isLogin这种从 token 计算出来的值放在 getters 里组件里用storeToRefs(userStore).isLogin访问时会自动缓存只有 state 变化才重新计算actions 是修改状态的方法里面可以写异步逻辑。登录接口的 token 要同步到三处Pinia 的 state、localStorage、axios 默认的请求头。这里有个细节可能困扰新手为什么 Pinia 里存了 token 还要存 localStorage因为 Pinia 是内存态刷新页面就没了localStorage 是用来持久化的。两者是配合关系不是二选一。4.3 宠物列表与详情页图片懒加载、状态标签和浏览量统计宠物列表页是一个典型的“列表 筛选”场景。筛选条件有类别猫/狗/其他、性别、状态可选待领养/已预约。接口请求参数我建议用page、size、categoryId、gender、status组合。列表渲染时每张宠物卡片展示封面图、名称、品种、性别、状态标签。图片来源如果是本地后台上传的URL 可能是/uploads/xxx.jpg开头的相对地址前端 axios 的 baseURL 和图片 URL 的拼接要注意如果 baseURL 是/api而图片在/uploads路径下不能用 axios 实例直接请求图片要单独拼一个静态资源的 baseURL或者让后端在返回字段里直接返回完整 URL。宠物详情页的浏览计数的更新时机建议放在进入详情页时调一次viewCount增加的接口但要注意前端路由复用的问题——从列表进入详情 A 再返回列表再进入详情 BVue 路由组件默认不重新渲染onMounted可能不触发第二次。解决办法是在/pet/detail/:id路由上用watch监听路由参数变化或者直接用onBeforeRouteUpdate。我一般用后者它在 query 或 params 变化时必然触发代码也更语义化。// PetDetail.vue import { onBeforeRouteUpdate } from vue-router const petId ref(route.params.id) const petInfo ref(null) async function loadDetail(id) { const res await getPetDetailApi(id) petInfo.value res.data } loadDetail(petId.value) onBeforeRouteUpdate((to, from) { petId.value to.params.id loadDetail(petId.value) })这段代码的核心是onBeforeRouteUpdate它在当前路由参数变化时触发Vue 官方文档里常拿滚动条位置、搜索条件回填这类场景举例宠物详情页就是典型应用——每次切换到新的宠物 ID 就重新拉数据。这里有个细节route.params.id拿到的是字符串后端如果定义的 id 是 Long直接把字符串传过去可能会触发 MySQL 隐式类型转换隐患是在某些 MySQL 版本下索引会失效。稳妥做法是Number(to.params.id)转成数字。这个坑经常被忽视但实际查询慢的时候排查方向就在这些地方。4.4 领养申请表单与审核流前端做校验后端做兜底领养申请表单字段建议控制在四到五个申请人姓名、联系电话、现居地址精确到城市、领养理由、是否同意回访。前端用 Element Plus 的el-form做校验必填项标红星电话号码用正则校验。这里需要特别注意后端必须用Valid注解做 Bean Validation不能只信前端校验——因为 Postman 或其他接口调试工具可以直接绕过页面提交非法数据。我在后端用了NotBlank和Pattern注解配合全局异常处理器统一返回参数错误信息前端收到 422 状态码后弹出具体字段错误。审核流的页面在管理后台。管理员看到的是领养申请列表每条记录显示宠物名称、申请人、申请时间、状态状态包含待审核、已通过、已拒绝。审核操作是“通过”或“拒绝”通过后宠物状态置为“已预约”拒绝后宠物状态回退到“待领养”并在申请表里记录拒绝原因。这部分的接口设计要跟前端约定好PUT /api/admin/adoption/applications/{id}/approve和.../reject比一个通用的 update 接口更清晰也方便在拦截器按 URL 权限管理。审核通过时要校验宠物状态是否为“待领养”防止管理员在宠物已被下架时仍然通过申请——这个校验就是第三章乐观锁的用武之地。const formRef ref(null) const form reactive({ name: , phone: , address: , reason: , agreeVisit: false }) const rules { name: [{ required: true, message: 请输入姓名, trigger: blur }], phone: [ { required: true, message: 请输入手机号, trigger: blur }, { pattern: /^1[3-9]\d{9}$/, message: 手机号格式不正确, trigger: blur } ], reason: [{ required: true, min: 10, message: 请填写至少10个字的原因, trigger: blur }] } async function submitApplication() { await formRef.value.validate() await submitApplicationApi({ petId: route.params.id, ...form }) ElMessage.success(申请提交成功请等待管理员审核) router.push(/user/applications) }前端表单的核心是validate()返回一个 Promise校验不通过时会 reject因此await后面的接口请求不会执行。trigger: blur是触发校验的时机也可以改成change区别在于失焦时校验还是值变化时校验。电话号码的正则/^1[3-9]\d{9}$/是基础版如果你愿意可以再严格一点根据号段更新但毕业设计够用了。提交成功后跳转到个人中心列表页这里有个细节router.push(/user/applications)前要ElMessage.success提示成功但不要把提示放在接口请求前否则接口失败也会提示。个人中心里“我的申请”列表我建议展示申请状态和宠物封面缩略图这样用户一看就知道哪条申请是通过的。5. 宠物领养系统常见的 5 个坑数据库字段、图片存储、权限绕过和跨域排查5.1 图片上传后无法访问或刷新后 404做过图片上传功能的同学大概率都遇到过上传接口返回成功数据库也存了路径但浏览器访问图片显示 404或者页面刷新后图片就裂了。这个坑的根源在后端如何处理上传文件后的对外访问。SpringBoot 的src/main/resources/static目录在打包后位于 classpath 内如果你把上传的图片直接写到这个目录下开发环境下偶尔能访问但打成 jar 包运行后它不在磁盘的静态资源映射里访问必然 404。解决办法是在配置类里加一个虚拟路径映射把磁盘上的物理路径/usr/local/pet/uploads/映射到一个能对外访问的 URL 前缀/uploads/**。开发环境和生产环境用配置项区分。你在application.yml里写upload.path: ./uploads/配置类读这个值再注册映射。后端返回给前端的图片 URL应该是一个以/uploads/开头的相对路径前端页面直接拼上服务器的 host 就能访问。Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${upload.path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String absolutePath Paths.get(uploadPath).toAbsolutePath().toString(); registry.addResourceHandler(/uploads/**) .addResourceLocations(file: absolutePath File.separator); } }addResourceHandler定义的是外部访问 URL 的模式addResourceLocations指向的是物理磁盘目录两者一一对应。这里有两个细节容易踩坑。第一addResourceLocations的结尾一定要带File.separatorWindows 是反斜杠Linux 是正斜杠不然路径拼接会错位。第二Value(${upload.path})读取配置时如果 upload.path 是相对路径./uploads/Paths.get()会基于当前工作目录解析——开发环境是项目根目录打 jar 后是 jar 所在目录所以部署时记得把上传目录和 jar 放一起或者在启动脚本里指定绝对路径。这个问题不解决本地跑通了部署到服务器上图片全裂。5.2 后端返回的 JSON 序列化把 LocalDateTime 变成了数组或乱码SpringBoot 默认用 Jackson 序列化LocalDateTime 默认格式是2025-05-01T12:00:00但后端接口一般约定返回yyyy-MM-dd HH:mm:ss。如果不做全局配置前端拿到的时间字符串很别扭显示时要自己格式化而且有概率出现时区偏移问题。解决方法是定义一个 Jackson 全局配置统一处理日期格式和时区。更隐蔽的坑是 Jackson 在处理Long类型 ID 时如果 ID 超过了 JavaScript 安全数范围前端拿到的数字会失真。宠物领养系统的自增主键在数据量小的时候没问题但一旦你用了雪花算法生成 ID前端详情页的路由参数就有精度丢失的隐患。预防方法是给所有 Long 类型的序列化成字符串ToStringSerializer全局配置——这是面试里聊微服务时的高频点放到论文里作为“前后端数据类型对接的工程实践”也站得住脚。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializers(new ToStringSerializer(Long.class)); builder.serializationInclusion(JsonInclude.Include.NON_NULL); }; } }这里的ToStringSerializer(Long.class)能让所有 Long 字段在 JSON 里以字符串形式输出前端用字符串类型接收精度就保住了。simpleDateFormat设置了日期格式但要注意如果实体字段用的是JsonFormat注解注解的优先级高于全局配置。所以更稳妥的做法是统一走全局配置实体上不要再单独加JsonFormat避免两套格式掐架。NON_NULL表示 null 字段不输出减少传输体积——但它也有个副作用前端某些地方用if (res.data res.data.field)判断时会发现两种逻辑都成立不过对宠物领养系统影响不大。5.3 跨域配置后请求能通了但带 token 的自定义请求头被拦前后端分离部署时前端地址是http://localhost:5173后端是http://localhost:8080浏览器默认会拦截跨域请求。很多同学配置了CrossOrigin注解或用CorsFilterGET 请求没问题了但 POST 带satoken请求头时浏览器控制台报 “CORS error: Request header field satoken is not allowed”。这不是跨域配置本身失效而是没有在allowedHeaders里显式允许satoken这个自定义头。我建议用全局 CORS 配置类代替局部注解因为CrossOrigin需要每个 controller 单独加漏一个就出问题。全局配置里allowedHeaders(*)和allowedMethods(*)直接把所有请求头和请求方法放开这对毕业设计是够用的。如果你要严谨一点可以把allowedHeaders精确到satoken, Content-Type, Authorization避免不必要的暴露。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.setAllowCredentials(true); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这段配置注意两点。第一addAllowedOriginPattern(*)是 SpringBoot 的高版本写法老代码常见addAllowedOrigin(*)两个都能跑但addAllowedOriginPattern支持更多通配逻辑。第二setAllowCredentials(true)表示允许携带 Cookie但如果你把它设为 trueaddAllowedOrigin就不能写成*必须明确写具体域名或使用addAllowedOriginPattern否则请求依然会被拒。这个问题经常以组合拳的形式出现前端配置了 axios 的withCredentials: true后端没开allowCredentials结果就是联调时所有带登录态的请求全部失败。5.4 分页查询总数不变或列表数据漂移分页查询是宠物列表页的核心接口数据异常的表现有很多种总页数始终是 1、切换页码后重复数据、总数变了但列表内容不动。最常见的源头是 MyBatis-Plus 分页插件没有配置成功。我在第三章提过SpringBoot3 下必须手动声明PaginationInnerInterceptor如果你在启动类上扫描 Mapper 但忘了加这个 Bean分页查询实际执行的是不带 LIMIT 的全量查询Page 对象里只有当前页数据total 保持为构建 Page 对象时的初始值默认 0。另一个隐蔽的坑是排序字段没有唯一性约束。如果你的列表 SQL 是ORDER BY create_time DESC而同一秒内有多条数据MySQL 的排序结果不保证稳定翻页时可能出现某条数据在第一页和第二页同时出现。解决方法是把排序条件改为ORDER BY create_time DESC, id DESC用自增主键做次级排序。这个调整虽然只加一个字段但翻页稳定性提升明显。如果你用的是 MyBatis-Plus 的Page对象可以在page.addOrder()里设置多个排序条件。5.5 审核权限绕过前端按钮隐藏不等于后端接口安全宠物领养系统的管理后台如果只在前端根据角色隐藏“审核通过”按钮那安全防护就是形同虚设。攻击者可以直接用 Postman 调用PUT /api/admin/adoption/applications/{id}/approve只要知道接口路径不需要页面按钮也能操作。解决办法是在后端加 Sa-Token 的角色校验拦截器对/api/admin/**下的所有接口强制StpUtil.checkRole(admin)拦截器配置要有“兜底”思路——默认拒绝显式放行。关于 Sa-Token 的checkRole有两点要知道。第一StpUtil.checkRole()校验失败时抛NotRoleException需要在全局异常处理器里捕获并转成 403 返回。第二如果用户登录时没有刷新角色信息登录后角色被变了Sa-Token 的会话里存的还是旧角色checkRole不会实时感知。对毕业设计来说可以在管理员修改用户角色后调用StpUtil.logout()强制该用户重新登录这是“踢人下线”的经典场景Sa-Token 官方文档里叫“账号强制注销”论文的“权限管理”章节里写一笔是亮点。6. 给论文和答辩补一组验证压力测试、日志链路与一份可留档的测试报告6.1 用 JMeter 拉一组接口响应数据作为性能测试的图表材料毕业设计论文里如果只有功能测试显得单薄如果加上性能测试数据老师会觉得你做工程的意识到位。最轻量的做法是用 Apache JMeter 做一个线程组模拟并发领取申请。线程数设为 50、循环次数 10对一个查询宠物列表的 GET 接口和一个提交领养申请的 POST 接口分别压测。压测后把聚合报告里的Average、Throughput、Error% 三个指标截图放进论文就构成一份有说服力的验证记录。JMeter 压测前要确认数据库连接池大小和接口内部逻辑没有明显的慢查询。宠物列表接口建议在 SQL 上先做一次EXPLAIN检查确认idx_status_time索引被命中。如果压测时发现吞吐量上不去先看是否走了索引再看数据库连接池的maximum-pool-size是否太小。HikariCP 默认 1050 并发时会有排队等待可以把maximum-pool-size调到 20 再跑一轮前后两次数据对比在论文里很有说服力。6.2 给整个项目接上 logback 滚动日志出问题不用再瞎猜宠物领养系统开发生涯里遇到线上问题第一反应是到处加System.out.println()——这做法本身没问题但打印到控制台后日志一闪而过重启项目就丢了。我建议在项目里加上 logback-spring.xml把日志按级别和天数滚动输出到文件。debug和error分文件存储日志文件名带日期保留 30 天。配置好后前端页面偶尔 500 时直接去 logs 目录查 error 日志不用再盯控制台。logback 配置是工程化基本功不会加分但一定别在这里丢分。要注意LOG_PATH路径在打包部署后要用绝对路径写相对路径在 jar 启动时日志文件会落在启动目录不同方式启动会污染多处。日志格式里建议包含线程名、时间戳、类名、行号截图放进论文里时老师能看清你确实定位过问题。这里也有一个日常习惯不要在日志里打印用户密码和 token 的完整值脱敏后只打前几位和后几位面试聊到数据安全时不心虚。6.3 测试报告留档把功能测试、性能测试和问题清单整理成一份文档答辩时老师翻到论文的技术验证章节最想看到的是你做了什么测试、覆盖了什么场景、发现了什么问题、怎么修的。我建议做一份 Markdown 测试报告包含四块内容功能测试用例表模块、操作、预期、实际、结果、性能测试数据摘要、异常场景验证记录比如重复提交申请被拦截、越权访问后台接口返回 403、已修复问题清单。这份报告最好放进附录或刻录光盘的附件里答辩时可以展示原始记录。异常场景验证比正常场景更值得写。我帮读者整理一下宠物领养系统必测的异常场景未登录用户提交领养申请应该跳登录页而非报错普通用户直接访问后台接口应该返回 403 而非数据同一用户对同一宠物重复提交申请第二次应该被唯一索引拦下管理员审核已下架宠物的申请应该提示“状态已变更”宠物详情页连续切换 ID 时请求不能串数据。每一项异常验证都对应一段业务逻辑的防御能力答辩时主动说出“我还测了这些边界”比被动回答更能掌握节奏。6.4 答辩前用一张状态流转图把核心业务串成话术宠物领养系统答辩时最好准备一张状态流转图画在 PPT 里或白板上把所有模块串成一个完整的话术链条。链条可以这样组织管理员发布宠物进入待审核状态审核通过后进入待领养用户在列表页按类别筛选待领养宠物进入详情页看描述并提交申请申请提交后宠物锁定为已预约其余用户不能再申请管理员审批通过后进入已领养如果审核拒绝或用户取消宠物回退到待领养。这套链路的每一环都对应你的表结构、接口实现和权限校验相当于把整篇论文浓缩成一分钟的技术叙述。我习惯在答辩前准备三个追问的预案“并发场景下重复申请怎么防”答唯一索引加乐观锁“文件上传之后怎么管理生命周期”答磁盘路径映射加逻辑删除“如果用户量上来了系统怎么演进”答引入 Redis 做会话缓存、图片迁 OSS、走消息队列异步发送领养通知。这三个追问基本覆盖了老师能问的深度。最后说一个我自己曾经的教训答辩演示前一定要把前端项目npm run build后后端 jar 包重新跑一遍别只在开发环境演示因为开发环境启动快但部署环境的路径和依赖隔离都可能不同提前预演一次才不会现场翻车。希望这份笔记能帮你少踩几个我踩过的坑祝顺利。本文还有配套的精品资源点击获取