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_pet)
CREATE 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_apply)
CREATE 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 Result<UserVO> 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,继承HandlerInterceptor:
@Override 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;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; }这个拦截器在SpringBoot 3.x里注册方式和旧版略有不同,需要在配置类里继承WebMvcConfigurer并重写addInterceptors方法,同时要setExcludePathPatterns放行登录、注册和宠物列表查询等公开接口。
4.2 宠物搜索:MyBatis动态SQL的实战价值体现
宠物列表页有一个组合筛选功能:按关键词(名称/品种)、按类型(猫/狗/其他)、按性别、按状态、按地区、按时间排序。这个需求的SQL没法写死,只能用MyBatis的<where>+<if>标签动态拼。
PetMapper.xml里的核心查询:
<select id="selectPetList" resultType="com.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 test="keyword != null and keyword != ''"> AND (p.name LIKE CONCAT('%', #{keyword}, '%') OR p.breed LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="type != null"> AND p.type = #{type} </if> <if test="gender != null"> AND p.gender = #{gender} </if> <if test="status != null"> AND p.status = #{status} </if> <if test="address != 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 PageInfo<PetVO> 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); List<PetVO> 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 id="rejectOtherApplies"> UPDATE t_adoption_apply SET status = 2, remark = '宠物已被其他用户领养' WHERE pet_id = #{petId} AND id != #{applyId} AND status = 0 </update>5. 前端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 class="pet-grid"> <el-row :gutter="20"> <el-col :span="6" v-for="pet in petList" :key="pet.id"> <el-card @click="goDetail(pet.id)" class="pet-card"> <img :src="pet.images?.split(',')[0]" class="pet-image" /> <div class="pet-name">{{ pet.name }}</div> <div class="pet-tags"> <el-tag size="small">{{ typeMap[pet.type] }}</el-tag> <el-tag size="small" type="info">{{ 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,浏览器的同源策略会拦截所有请求。
我的解决方案是后端全局配置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); } }这里有个容易踩的坑: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如何放进SpringBoot
"vue打包放进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.html:
@Controller public class SpaController { @RequestMapping(value = {"/{path:^(?!api).*}", "/{path:^(?!api).*}/**"}, method = RequestMethod.GET) public String forward() { return "forward:/index.html"; } }方案二:打包后用Nginx部署,Nginx配try_files:
location / { try_files $uri $uri/ /index.html; }6.3 Docker部署MySQL的命令
前面提到推荐用Docker装MySQL,这里给一条生产可用的命令:
docker run -d \ --name mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=pet123456 \ -e MYSQL_DATABASE=pet_adoption \ -v /data/mysql:/var/lib/mysql \ --restart=always \ mysql:8.0加--restart=always是让容器在服务器重启后自动拉起。如果遇到"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 API - JDK 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.validation,javax.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指定成JSON,Spring会认为这是表单提交,@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接收为空——都是必须跨过去的坎。
动物领养平台业务闭环清晰、复杂度适中、演示效果直观,作为前后端分离的实战项目,它能覆盖的技术面远超一般的管理类系统。如果你手头正想找一个能写进简历的项目,这个方向值得投入。真遇到卡壳的地方,顺着排查链路的思路去定位,八成能自己解决。