
1. 为什么做动物领养平台一个比电商更复杂的业务模型先说点实在的。动物领养平台这个项目在外人看来可能就是个宠物版闲鱼但真正动手做的时候你会发现它的业务复杂度比绝大多数CRUD管理系统高一个量级。原因很简单领养不是交易而是一条带有审核、契约和后续追踪的完整流程。普通电商的核心链路是下单→支付→发货→收货状态机清晰明了。而领养平台的核心链路是发布宠物→用户申请→平台/救助人审核→签订领养协议→回访跟踪这里面每一个环节都牵扯到状态流转、角色权限、时间节点三个维度的交叉控制。这个项目正好能覆盖SpringBoot Vue3 MyBatis这套技术栈的绝大多数核心知识点后端SpringBoot的RESTful API设计、MyBatis的动态SQL与结果映射、JWT登录鉴权、多表关联查询前端Vue3的组合式API、路由守卫、Pinia状态管理、Element Plus组件库、Axios请求封装数据库五张以上的核心业务表设计含外键关联、枚举状态字段、时间戳管理所以如果你正在找一个能写进简历、又能从头到尾跑通前后端的项目领养平台比图书馆管理系统、学生成绩管理系统这类传统课设有意思得多也比纯电商项目少了很多支付、库存这些繁琐但又不加分的环节。这个平台的完整功能闭环包括普通用户浏览宠物列表、查看宠物详情、提交领养申请、查看申请进度、收藏宠物救助人/管理员发布宠物信息、管理收到的领养申请、审核通过或拒绝、更新宠物状态系统支撑用户注册登录、JWT令牌管理、图片上传、搜索筛选、分页排序下面我把整个项目的技术选型、数据库设计、核心业务实现、前后端联调以及我实际踩过的坑一条一条拆开讲。2. 技术选型为什么是SpringBoot Vue3 MyBatis而不是其他组合这个技术栈组合在当前Java生态里属于绝对的主流标配。但主流的背后各有各的理由我把选型逻辑捋一遍方便你自己做判断。2.1 SpringBoot 3.x为什么敢用这么高的版本先回应热搜词里那个springboot版本太高的问题。当时我选的是SpringBoot 3.2.x很多人犹豫是因为它要求JDK 17而不少学校教材还在教JDK 8。我的建议是如果你不是被JDK版本硬性卡住直接上SpringBoot 3.x。理由有三JDK 17是LTS版本安全性、性能、生态兼容性都足够成熟不会再出现第三方库不兼容的老问题SpringBoot 3.x原生支持GraalVM、虚拟线程Virtual Threads后续扩展空间大面试时用过最新版本本身就是个加分项说明你在跟进技术发展唯一要注意的是SpringBoot 3.x的jakarta.*命名空间替换了旧版的javax.*导入包名时容易出错后面我会专门讲这个坑。2.2 Vue3 Vite开发体验的质的飞跃前端选了Vue3 Vite Pinia Vue Router Element Plus这套组合。Vite和Webpack的差别用过就回不去——开发服务器启动只需要几百毫秒热更新是即时的不用等那几秒的重新编译。Vue3的Composition API写起来比Options API更接近逻辑聚合的思维方式同一个功能的响应式数据、计算属性、方法都放在一起不用像以前那样data、computed、methods三块来回跳。关于vue3 composition api和option api这个热门问题我的实践感受是如果项目逻辑复杂像领养申请状态流转这种Composition API明显更清晰如果只是简单页面展示Options API也不是不行。但既然选了Vue3就建议直接学Composition API别已经2026年了还在用Vue2的写法。2.3 MyBatis比MyBatis-Plus更适合学习原理这里得说点反直觉的话。现在很多教程直接教MyBatis-Plus因为它内置了CRUD方法写起来快。但我的选择是纯MyBatis理由很现实领养平台的核心查询不是单表CRUD而是多表关联动态条件筛选分页这正是MyBatis动态SQL发挥威力的时候手写SQL能让你真正理解resultMap映射、#{}和${}的区别、一对多映射怎么处理这些是面试必问的用MyBatis-Plus确实方便但面试官问MyBatis缓存机制的时候纯MyBatis项目能让你答得更扎实当然实际开发中为了效率我也引入了PageHelper做分页但核心的业务SQL全部手写尤其是领养申请列表这种需要连三张表的场景手写SQL才能精准控制字段。2.4 MySQL 8.0存储引擎和字符集要注意的细节数据库用的MySQL 8.0存储引擎统一InnoDB字符集用utf8mb4。为什么强调这两点utf8mb4是必须的因为utf8在MySQL里最多存3字节而宠物名字和用户昵称经常包含emoji比如小橘子一个emoji占4字节用utf8直接就存储失败了InnoDB支持行级锁、外键约束和事务领养申请流程中多个表的状态更新必须在一个事务里完成关于mysql安装教程和mysql在windows10上怎么安装这类问题我统一说一句推荐用Docker装MySQL一条docker run命令就搞定了不用去官网下载安装包、配置环境变量、设置服务自启动省掉一大堆麻烦。后面部署部分我会给出具体的Docker命令。3. 数据库设计五张核心表怎么建才能支撑业务流转数据库是整个项目的地基。领养平台我设计了五张核心表每张表都对应业务中的一个关键实体。3.1 用户表t_user这张表存所有注册用户的基本信息。我的字段设计如下CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(50) COMMENT 昵称, avatar VARCHAR(255) COMMENT 头像URL, phone VARCHAR(20) COMMENT 手机号, role TINYINT NOT NULL DEFAULT 1 COMMENT 角色1-普通用户 2-救助人/管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1-正常 0-禁用, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );这里有两个设计细节值得展开密码加密。绝对不能明文存密码。我用的Spring Security的BCryptPasswordEncoder每次加密结果都不一样但matches方法可以验证。这样即使数据库泄露用户密码也是安全的。角色字段用TINYINT而不是字符串。有些人喜欢存admin、user这样的字符串虽然可读性好但比较和存储的效率都更低。用TINYINT加注释兼顾可读性和性能。3.2 宠物信息表t_petCREATE TABLE t_pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 宠物名字, type TINYINT NOT NULL COMMENT 类型1-猫 2-狗 3-其他, breed VARCHAR(50) COMMENT 品种, gender TINYINT COMMENT 性别1-公 2-母, age DECIMAL(4,1) COMMENT 年龄岁, vaccine_status TINYINT COMMENT 疫苗状态0-未接种 1-已接种, neuter_status TINYINT COMMENT 绝育状态0-未绝育 1-已绝育, health_description TEXT COMMENT 健康状况描述, story TEXT COMMENT 救助故事/来历, images VARCHAR(1000) COMMENT 图片URL多个用逗号分隔, address VARCHAR(100) COMMENT 所在地区, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0-待领养 1-已被申请 2-已领养 3-下架, publisher_id BIGINT NOT NULL COMMENT 发布者ID, view_count INT DEFAULT 0 COMMENT 浏览量, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );这个表最核心的字段是status它本身就是一个简化版的状态机。宠物从发布到被领养走的是0 → 1 → 2的流程待领养时可以被任意用户申请一旦有人提交申请且审核通过就变成已被申请防止多人同时申请最终完成领养变成已领养。3.3 领养申请表t_adoption_applyCREATE TABLE t_adoption_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pet_id BIGINT NOT NULL COMMENT 宠物ID, user_id BIGINT NOT NULL COMMENT 申请用户ID, apply_reason TEXT COMMENT 申请理由为什么想领养, experience TEXT COMMENT 养宠经验, family_status TEXT COMMENT 家庭情况, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0-待审核 1-已通过 2-已拒绝 3-已取消, remark VARCHAR(255) COMMENT 审核备注, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_pet_id (pet_id), INDEX idx_user_id (user_id), CONSTRAINT fk_apply_pet FOREIGN KEY (pet_id) REFERENCES t_pet(id), CONSTRAINT fk_apply_user FOREIGN KEY (user_id) REFERENCES t_user(id) );这里我建了fk_apply_pet和fk_apply_user两个外键目的是保证数据完整性——不可能出现申请一个不存在的宠物这种脏数据。3.4 收藏表t_favorite和领养协议表t_adoption_contract收藏表就是一个简单的关联表记录用户收藏了哪些宠物不做额外赘述。领养协议表记录领养完成后的合同信息包括双方用户ID、宠物ID、签订时间、协议内容。这张表是领养闭环的最后一步有了它整个业务流程才算完整。CREATE TABLE t_adoption_contract ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pet_id BIGINT NOT NULL, adopter_id BIGINT NOT NULL COMMENT 领养人ID, publisher_id BIGINT NOT NULL COMMENT 救助人ID, sign_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, contract_no VARCHAR(50) UNIQUE COMMENT 协议编号, content TEXT COMMENT 协议内容, status TINYINT DEFAULT 0 COMMENT 0-生效中 1-已完成 );3.5 表关系总结这五张表的关系可以这样理解t_user是核心主表发布宠物和申请领养都围绕它展开t_pet通过publisher_id关联t_user一个用户可发布多只宠物t_adoption_apply通过pet_id和user_id关联宠物表和用户表一个宠物可被多个用户申请但只能有一个最终通过t_favorite也是用户和宠物的多对多关联t_adoption_contract是领养流程的终点将t_pet、领养人和救助人三方关联起来这个ER关系在面试时一定要能画得出来因为它直接体现了你对业务的理解深度。4. 后端核心实现JWT鉴权、宠物搜索、领养审核三个硬骨头后端部分我挑了三个最具代表性的功能来拆解分别是JWT登录鉴权、带动态条件筛选的宠物搜索、领养申请的审核流转。4.1 JWT登录鉴权SpringBoot 3.x里最容易踩包的坑先给一个完整的登录鉴权链路用户提交用户名密码 → 后端BCryptPasswordEncoder验证密码验证通过 → 生成JWT Token返回前端前端把Token存到localStorage每次请求在Authorization头带上后端拦截器解析Token把用户信息放入ThreadLocal供后续使用JWT工具类里我用的是io.jsonwebtoken的jjwt库。这里必须强调一个SpringBoot 3.x的坑网上大量教程还是javax.xml.bind.DatatypeConverter这个类在JDK 11以后就移除了如果你用JDK 17跑大概率报ClassNotFoundException。解决办法有两种一是手动引入jakarta.xml.bind-api依赖注意是jakarta不是javax二是直接用Java 17自带的Base64.getUrlEncoder()代替DatatypeConverter。我推荐第二种少一个依赖少一份麻烦。登录接口的核心代码如下PostMapping(/login) public ResultUserVO login(RequestBody LoginDTO loginDTO) { // 1. 根据用户名查用户 User user userMapper.findByUsername(loginDTO.getUsername()); // 2. 用户不存在或密码错误统一返回用户名或密码错误 if (user null || !passwordEncoder.matches(loginDTO.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } // 3. 生成JWT有效期7天 String token jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); // 4. 组装返回信息 UserVO vo new UserVO(); BeanUtils.copyProperties(user, vo); vo.setToken(token); return Result.success(vo); }注意一个安全设计细节用户名不存在和密码错误返回的提示信息要一致都返回用户名或密码错误。这样能防止攻击者通过不同的报错信息来探测哪些用户名是注册过的。拦截器这边我实现了一个AuthInterceptor继承HandlerInterceptorOverride public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Long userId jwtUtil.parseToken(token); // 把userId放入请求属性方便Controller获取 request.setAttribute(userId, userId); return true; } catch (Exception e) { // Token无效 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; }这个拦截器在SpringBoot 3.x里注册方式和旧版略有不同需要在配置类里继承WebMvcConfigurer并重写addInterceptors方法同时要setExcludePathPatterns放行登录、注册和宠物列表查询等公开接口。4.2 宠物搜索MyBatis动态SQL的实战价值体现宠物列表页有一个组合筛选功能按关键词名称/品种、按类型猫/狗/其他、按性别、按状态、按地区、按时间排序。这个需求的SQL没法写死只能用MyBatis的whereif标签动态拼。PetMapper.xml里的核心查询select idselectPetList resultTypecom.pet.entity.PetVO SELECT p.*, u.nickname AS publisher_nickname, u.avatar AS publisher_avatar FROM t_pet p LEFT JOIN t_user u ON p.publisher_id u.id where if testkeyword ! null and keyword ! AND (p.name LIKE CONCAT(%, #{keyword}, %) OR p.breed LIKE CONCAT(%, #{keyword}, %)) /if if testtype ! null AND p.type #{type} /if if testgender ! null AND p.gender #{gender} /if if teststatus ! null AND p.status #{status} /if if testaddress ! null and address ! AND p.address LIKE CONCAT(%, #{address}, %) /if /where ORDER BY p.update_time DESC /select关于LIKE查询有两个点必须注意这里用#{keyword}而不是${keyword}。#{}是预编译参数MySQL会先解析SQL再用参数替换能防SQL注入${}是直接拼接字符串虽然能在LIKE里用形如%${keyword}%的写法但存在注入风险。正确做法是拿参数在Java层拼接好再传进去public PageInfoPetVO queryPets(String keyword, Integer type, Integer gender, Integer status, String address, int pageNum, int pageSize) { // MyBatis的LIKE参数需要在传入前拼好通配符 if (keyword ! null !keyword.isEmpty()) { keyword % keyword %; } // 用PageHelper分页内部拦截器自动拼接LIMIT PageHelper.startPage(pageNum, pageSize); ListPetVO list petMapper.selectPetList(keyword, type, gender, status, address); return new PageInfo(list); }LEFT JOIN还是INNER JOIN的选择。我这里用的LEFT JOIN目的是保证宠物列表页在没有用户头像和昵称时也能正常显示。INNER JOIN会把没有对应发布者的宠物过滤掉这在有外键约束下不会发生但LEFT JOIN更保险。4.3 领养审核一个需要事务保证的流程领养审核是整个项目中业务逻辑最复杂的部分。救助人看到一份申请点击通过后端需要处理的事情包括更新申请记录的状态为已通过将该宠物状态更新为已被申请防止其他用户继续提交申请通知申请用户这里用简单的系统消息表实现这三个操作必须在一个事务里否则可能出现申请通过了但宠物状态没变的数据不一致问题。Transactional(rollbackFor Exception.class) public void approveApply(Long applyId, Long publisherId) { // 1. 查询申请记录 AdoptionApply apply applyMapper.selectById(applyId); if (apply null) { throw new BusinessException(申请记录不存在); } // 2. 校验宠物是否属于当前操作者 Pet pet petMapper.selectById(apply.getPetId()); if (!pet.getPublisherId().equals(publisherId)) { throw new BusinessException(无权操作该申请); } // 3. 校验宠物当前状态必须是待领养或已被申请 if (pet.getStatus() ! 0 pet.getStatus() ! 1) { throw new BusinessException(当前宠物状态不允许通过申请); } // 4. 更新申请状态为已通过 applyMapper.updateStatus(applyId, 1); // 5. 更新宠物状态为已被申请 petMapper.updateStatus(apply.getPetId(), 1); }Transactional注解保证了步骤4和5要么都成功要么都不执行。这里有个细节rollbackFor Exception.class必须写。因为Spring默认只回滚RuntimeException如果你业务代码里抛的是自定义的BusinessException而它继承的是Exception而不是RuntimeException那么事务不会自动回滚这是个非常隐蔽的坑。另外在审核通过时还需要把同一宠物下的其他申请修改为已拒绝状态否则剩下的申请会一直挂着待审核。这一步是通过MyBatis批量更新实现的update idrejectOtherApplies UPDATE t_adoption_apply SET status 2, remark 宠物已被其他用户领养 WHERE pet_id #{petId} AND id ! #{applyId} AND status 0 /update5. 前端Vue3从登录页到管理后台的页面实现思路前端我用的Vite Vue3 Element Plus Pinia Axios。页面主要有登录/注册页、宠物列表页含搜索筛选、宠物详情页、领养申请表单、用户中心我发布的/我申请的/我收藏的、后台管理用户/宠物/申请管理。5.1 请求封装与Token携带先封装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 }) // 请求拦截器自动带token 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 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(登录已过期)) } return res }, error { ElMessage.error(error.response?.data?.message || 网络请求失败) return Promise.reject(error) } ) export default request5.2 宠物卡片列表Composition API的一个完整示范宠物列表页是前端的核心页面我用Composition API来组织代码template div classpet-grid el-row :gutter20 el-col :span6 v-forpet in petList :keypet.id el-card clickgoDetail(pet.id) classpet-card img :srcpet.images?.split(,)[0] classpet-image / div classpet-name{{ pet.name }}/div div classpet-tags el-tag sizesmall{{ typeMap[pet.type] }}/el-tag el-tag sizesmall typeinfo{{ pet.breed }}/el-tag /div /el-card /el-col /el-row /div /template script setup import { ref, onMounted } from vue import { getPetList } from ../api/pet const petList ref([]) const total ref(0) const searchForm reactive({ keyword: , type: null, gender: null, status: 0, address: }) const typeMap { 1: 猫, 2: 狗, 3: 其他 } // 核心查询函数把筛选条件传给后端并刷新列表 const queryPets async () { const res await getPetList({ ...searchForm, pageNum: pageNum.value, pageSize: 8 }) if (res.code 200) { petList.value res.data.list total.value res.data.total } } // 监听搜索条件变化防抖后重新查询 watch(searchForm, () { pageNum.value 1 queryPets() }, { deep: true }) onMounted(() { queryPets() }) /script这里关键点在watch(searchForm, ..., { deep: true })——reactive对象在直接修改属性时普通watch不会被触发必须开deep。这也解释了为什么热搜里有uni-app vue3 ref万能对象以及composition api和option api这些讨论Vue3的响应式原理变了ref包基础类型、reactive包对象类型用之前必须想清楚你要监听的是哪一层。5.3 路由守卫未登录用户不能访问个人中心前端权限控制的实现很简单就一个路由守卫// src/router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token) // 白名单登录页、注册页、宠物列表、宠物详情 const whiteList [/login, /register, /pets, /pet/detail] if (whiteList.some(path to.path.startsWith(path))) { next() } else { if (token) { next() } else { ElMessage.warning(请先登录) next(/login) } } })这个守卫只做了Token存在性判断没有做角色判断。如果需要区分普通用户和管理员可以在Token解析后读用户角色再根据路由的meta.roles数组做更细的控制。6. 前后端联调与部署从本地跑通到上线发布项目做完不代表结束联调部署阶段才是真正磨人的时候。我踩了几个典型的坑直接列出来供你避雷。6.1 跨域问题两种方案对比前后端分离的第一个坎就是跨域。开发模式下前端Vite跑在localhost:5173后端SpringBoot跑在localhost:8080浏览器的同源策略会拦截所有请求。我的解决方案是后端全局配置CORSConfiguration 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); } }这里有个容易踩的坑setAllowCredentials(true)之后addAllowedOrigin(*)会失效必须用addAllowedOriginPattern(*)。否则请求会报Cannot use wildcard with credentials。另一种方案是使用Vite的代理在vite.config.js里配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这种方案的好处是前端请求的是同源地址/api后端不需要配置跨域。两种方案我建议开发时用代理生产时用Nginx反代后端CORS配置只是为了兜底。6.2 生产环境打包Vue如何放进SpringBootvue打包放进springboot中这个热搜我太熟悉了因为确实是个高频操作。先在SpringBoot的src/main/resources下建一个static目录没有就新建前端执行npm run build把生成的dist目录下的所有文件复制到static文件夹重启SpringBoot即可。原理其实很朴素SpringBoot默认把sources下的static目录映射为静态资源根路径你复制dist里的index.html和assets进去后访问localhost:8080/index.html就能看到前端页面了。即便不加SPA路由配置首屏入口是能正常打开的。不过要注意刷新404问题如果用了Vue Router的history模式直接刷新/pet/detail/1这种地址访问的是后端Java代码会返回404。解决办法有两个方案一推荐后端加一个转发规则让它把所有非/api开头的请求重定向到index.htmlController public class SpaController { RequestMapping(value {/{path:^(?!api).*}, /{path:^(?!api).*}/**}, method RequestMethod.GET) public String forward() { return forward:/index.html; } }方案二打包后用Nginx部署Nginx配try_fileslocation / { try_files $uri $uri/ /index.html; }6.3 Docker部署MySQL的命令前面提到推荐用Docker装MySQL这里给一条生产可用的命令docker run -d \ --name mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDpet123456 \ -e MYSQL_DATABASEpet_adoption \ -v /data/mysql:/var/lib/mysql \ --restartalways \ mysql:8.0加--restartalways是让容器在服务器重启后自动拉起。如果遇到docker安装mysql失败多半是端口被占用或者镜像拉取超时docker logs mysql查看日志基本能定位到原因。有同学问rpm安装mysql和mysql 5.7.44安装过程这种方案我只能说如果你是想学Linux运维装一次rpm版没问题但如果你只是想快速把项目跑起来Docker是效率最高的选择。7. 项目代码落地的排查链路三个高频Bug的完整定位过程这个环节我把自己实际遇到过的三个典型问题完整记录在这里排查思路比答案本身更有价值。7.1 Bug一SpringBoot 3.2 JDK 21下启动报错找不到 javax.servlet现象很典型应用启动时提示NoClassDefFoundError: javax/servlet/Filter。定位思路先确认错误发生在启动阶段还是运行阶段——启动阶段说明Spring容器初始化时某个组件引用了旧包查看堆栈信息发现是spring-boot-starter-web内部用到了Servlet APIJDK 8的时候Servlet API是javax.servlet命名空间JDK 17以后Servlet API已经迁移到jakarta.servlet了根因网上很多教程是基于SpringBoot 2.x JDK 8写的教程里的依赖坐标是javax.*。你引入这些旧依赖在SpringBoot 3.x里自然就冲突了。修复把所有javax.*开头的依赖替换为jakarta.*的对应版本。特别注意javax.validation换成了jakarta.validationjavax.annotation换成了jakarta.annotation。这个坑几乎必踩所以这里单独拎出来写一段。建议一开始就用Spring Initializr生成项目生成的pom文件天然正确不要去改命名空间。7.2 Bug二MyBatis查询返回null但数据库里明明有数据排查链路直接在Navicat / MySQL客户端里执行同样的SQL能查出数据说明SQL本身没问题检查Mapper接口的返回值类型发现用了自定义的PetVO定位到问题PetVO里有publisherNickname字段但数据库表t_pet里没有对应列MyBatis默认遵循列名映射到驼峰属性名这个字段无列可映射所以是整个对象都没查出来修复方案两种一是SQL里用别名映射SELECT p.*, u.nickname AS publisher_nickname FROM t_pet p LEFT JOIN t_user u ON p.publisher_id u.id二是开启驼峰映射在application.yml里配mybatis: configuration: map-underscore-to-camel-case: true这样数据库的publisher_nickname列会自动映射到publisherNickname属性。如果没有这个配置属性名和列名不一致查询结果就是null。这个排查链路背后的问题其实是MyBatis的映射原理面试也爱问。7.3 Bug三前端提交的表单数据后端接收到的字段全部是null现象是前端Vue3用fetch提交数据后端Controller打印请求体发现字段全是null。排查链路打开浏览器开发者工具的Network面板看请求头的Content-Type发现请求头是application/x-www-form-urlencoded而不是application/json这是因为前端使用了qs库或者手动拼了表单字符串而非使用JSON.stringify修复前端统一在Axios里设置Content-Type: application/json后端Controller用RequestBody接收PostMapping(/apply) public Result? submitApply(RequestBody ApplyDTO dto) { // ... }后端的RequestBody是Jackson库把JSON字符串反序列化成DTO对象的入口。如果前端没把Content-Type指定成JSONSpring会认为这是表单提交RequestBody就接不到任何内容。这个Bug出现过无数次核心是前后端对数据格式的约定必须一致。作为后端如果你不确定前端用什么格式提交可以先用Postman测一遍Postman能通就说明问题在前端。7.4 关于MyBatis二级缓存的一个真实教训热搜词里mybatis二级缓存实现也是高频问题。我在项目里做过一个错误示范给宠物查询Mapper开启了二级缓存结果修改宠物信息后列表不刷新一度以为是缓存配置错了。其实根因是二级缓存的更新条件——MyBatis的二级缓存只有在Mapper的写操作insert/update/delete触发了对应缓存的清理机制时才会生效。如果你的查询用了JOIN并且关联了别表的字段二级缓存的失效判断就会有问题。最简单粗暴的做法是多表JOIN查询的Mapper不要开二级缓存让数据库查就完事了。数据量不大时开缓存收益低、风险高这是我在这个项目里学到的教训。8. 项目扩展方向这个源码还能怎么玩如果这个项目你做完还有余力我给出几个值得加的功能方向按难度递增排序8.1 消息通知模块在领养审核通过或拒绝时给用户发一条站内信。需要增加一张t_notification表在前端做一个消息铃铛组件通过轮询或WebSocket实时拉取新消息。8.2 宠物健康档案领养成功后为宠物建立疫苗记录、驱虫记录、体检报告等健康档案。这需要再加两张表同时前端做一个时间线组件展示记录难度不大但很加印象分。8.3 接入地图API宠物列表页增加附近宠物功能基于用户的定位展示距离最近的宠物。前端接入高德或百度地图SDK后端需要给t_pet表增加经纬度字段中间还涉及地理坐标的距离计算。8.4 微信小程序端用uni-app复刻一套小程序端复用已有的后端API。热搜里的uni-app vue3 ref说明很多人已经关注到小程序和Vue3的写法差异了。如果能把小程序端也做出来简历上的含金量直接翻倍。8.5 对接GPT做智能推荐根据用户浏览记录和行为数据生成个性化的宠物推荐文案。这个方向更偏算法需要额外加一张t_user_behavior表记录用户的浏览、收藏、申请行为然后用简单的协同过滤算法推荐宠物。9. 最后分享两个我做这个项目的实操心得第一数据库设计一定要先画ER图再动手建表。领养平台这种多表关系复杂的项目如果只凭脑内视觉直接建表后期改字段成本极高。我一开始设计的申请表和宠物表之间没有外键约束后面联调时经常出现用户申请了一个不存在的宠物导致前端展示空白后来加上外键和索引才根治。第二前端组件库的版本要仔细锁定。Element Plus 2.x和Vue 3.x的小版本更新比较频繁如果你有一周没升级某个依赖下次启动Vite可能就会报版本不兼容。建议在package.json里锁定精确版本号不要用^符号才做版本范围控制——否则某个早上你也许会发现自己什么都没改项目崩了。这个项目从数据库设计到上线发布我前后用了三周左右的业余时间不算长但遇到的技术挑战一点不少。如果你正在做类似的SpringBoot Vue3全栈项目上面提到的若干坑——尤其是javax迁移到jakarta、Transactional的rollback配置、Cross跨域和setAllowCredentials的冲突、前端Content-Type导致的RequestBody接收为空——都是必须跨过去的坎。动物领养平台业务闭环清晰、复杂度适中、演示效果直观作为前后端分离的实战项目它能覆盖的技术面远超一般的管理类系统。如果你手头正想找一个能写进简历的项目这个方向值得投入。真遇到卡壳的地方顺着排查链路的思路去定位八成能自己解决。