如果你正在为毕业设计或课程设计找选题,看到“SpringBoot + Vue + MySQL 流浪宠物管理系统”这个组合,应该不会太陌生。这套技术栈几乎覆盖了 Java Web 开发的核心环节:后端接口、前端页面、数据库建模、状态流转、权限控制、文件上传,最后还能把前后端合并打成一个可运行的项目,非常适合用来展示“我真的独立完成了一个系统”。
流浪宠物的场景天然有业务闭环:宠物从被救助、登记到被领养,中间要经历审核、状态变化、用户申请、管理员处理,每一步都能对应到真实的管理系统设计。相比图书管理、学生管理这类“纯增删改查”题目,它的难点更集中、故事更好讲,也更容易在答辩时拿出几个像样的设计点。
这篇文章我按实际做项目的顺序来讲:从需求边界、功能拆解、数据库设计,到后端接口、前端页面、部署避坑,最后聊答辩时怎么把设计讲清楚。无论你是打算直接用这套思路去复刻,还是想在里面加自己的功能,都应该能少走一些弯路。
1. 选这套题之前,先把需求边界圈清楚
1.1 流浪宠物管理为什么不只是一个“增删改查”
很多管理系统容易做成“伪业务”:建个表,写五个接口,页面套个表格,就结束了。流浪宠物管理如果也这样做,确实很浪费,因为这个题目真正的价值在于“状态流转”。
一只流浪宠物从进入系统开始,就要经历几个阶段:待审核、可领养、已领养、下架。每一个阶段之间不是随便跳的,而是有条件的。比如只有“可领养”的宠物才能被用户提交领养申请;管理员审核通过后,宠物才变成“已领养”。这个流程和电商订单很像,但比订单简单,刚好适合课设。
还有一个容易被忽略的点:流浪宠物系统是面向公众的,所以要有“游客可浏览、用户可申请、管理员可审核”的层级。角色不同,看到的页面和能做的操作都不一样。这就把“权限设计”这个知识点带出来了,而权限设计往往是答辩时老师最喜欢的提问方向。
1.2 这套技术栈的“教学性价比”在哪儿
SpringBoot 负责后端 API,Vue 负责前端展示,MySQL 负责持久化存储,三者各管一段,边界非常清楚。
选择SpringBoot而不是更老旧的 Servlet 或 SSH,是因为它现在已经是企业里 Java 后端的主流姿势,自动配置、内嵌容器、Starter 机制都是能写进简历的东西。选择 Vue 是因为组件化、响应式、路由、状态管理这些概念足够通用,而且 Vue 对初学者比 Angular 友好,比 React 更容易上手。MySQL 则是最常见的关系型数据库,表关系设计、索引、SQL 优化这些基本功都能在这里练到。
如果题目换成 SpringCloud 微服务,或者 Redis 缓存、消息队列,工作量立刻翻倍,课设周期根本撑不住。如果换成纯后端模板渲染,前端技能又没体现出来。所以 SpringBoot + Vue + MySQL 不是“最炫”的组合,而是“性价比最高”的组合。
1.3 工作量排期:单人完成大概需要多久
我给 A 同学梳理过一个参考排期。这个同学属于 Java 基础中等、Vue 之前只写过静态页面的情况,最终用了大约三周做完整套项目。
第一周主要做三件事:建库建表、完成后端登录注册和用户接口、把宠物模块的 CRUD 写完。第二周集中做前端,先把宠物列表、详情页、申请表单跑通,再做管理后台的页面。第三周用来联调、处理跨域和图片上传、补公告模块,最后打包测试。
如果还想加统计图表和 Excel 导出,就再加三到四天。所以我一般建议:不要一上来就想着功能越多越好,先把主流程跑通,再谈加分项。
2. 功能清单拆解:前台、后台和“加分项”
2.1 用户端要做的核心页面与动作
普通用户能看到的页面不需要太多,但每一步都要能接上后端接口。
首页展示宠物列表,支持按品种、性别、是否绝育等条件筛选,也支持关键词搜索。宠物详情页展示图片、健康状况、救助故事和一个关键的“申请领养”按钮。点击按钮后弹出表单,填写领养理由、居住情况、联系方式等信息,提交后进入待审核状态。
这里的重点有两个。第一,未登录用户只能浏览,点击申请时跳转到登录页,这就把“登录态判断”落到了实际场景。第二,表单提交后前端要能及时反馈“等待管理员审核”,而不是直接消失,这要求后端把申请状态保存下来。
个人中心里,用户可以查看自己提交的申请记录、申请进度,还可以修改头像和手机号。公告模块放一些领养须知和活动信息,属于锦上添花但结构上需要有的内容。
2.2 管理端要管的对象和审核流
管理后台是这套系统的“重头戏”,因为它体现的是业务控制力。
管理员登录后,首先看到统计面板:宠物总数、待审核宠物数、待处理领养申请数。随后是宠物管理,管理员可以录入救助的流浪宠物,也可以处理用户端提交的宠物信息审核。这里要小心,很多团队会把宠物录入和宠物审核做成两个接口,其实可以合并:管理员新增宠物时直接设为“可领养”,用户提交的宠物动态则在“审核”流程里处理。
领养审核是核心,管理员能看到所有待处理申请,点进去查看申请人的资料和理由,然后选择“通过”或“拒绝”。通过后宠物状态自动变为“已领养”,拒绝后宠物回到“可领养”。这个联动逻辑如果不写在服务端,就会出大问题。
用户管理主要做禁用/启用账号和角色查看。公告管理就是发布、编辑、上下线通知内容。整体来看,管理端大约四个模块,工作量适中。
2.3 想拿高评价,这几个增强功能值得做
如果你的课设不想只停留在“跑通”,我建议按顺序加三个功能。
第一个是数据可视化。用 ECharts 画两个统计图:近六个月领养趋势折线图、宠物品种分布饼图。这个功能实现成本低,但视觉效果一下子就不一样。
第二个是 Excel 导出。把领养记录导出成表格,用的是阿里开源的 EasyExcel,代码量不大,但在答辩时很受认可,因为它是“真实办公场景中经常出现”的需求。
第三个是定时提醒。用 Spring 的 @Scheduled 注解,每天扫描超过 7 天仍未处理的领养申请,给管理员生成待办提醒。这个功能让系统看起来有“主动服务”的能力,不再只是被动等用户操作。
3. 数据库设计:先用字段把业务状态“钉”住
3.1 四张核心表的字段设计
数据库设计是整个项目的地基,我建议至少准备四张表:用户表、宠物表、领养申请表、公告表。
用户表除了 username、password、phone 这些常规字段,重点加一个 role 字段,用字符串存 USER 或 ADMIN。虽然也可以用 int,但字符串可读性强,课设里完全够用。password 字段长度要预留到 60 位以上,因为后面要用 BCrypt 加密,密文比较长。
宠物表里有几个关键字段要提前想清楚:species(猫/狗)、breed(品种)、age、gender、vaccinated(是否已打疫苗)、neutered(是否绝育)、health_status(健康描述)、images(图片路径,多张图用逗号分隔)、status(当前状态)。这里的 status 是整个系统的灵魂字段,我建议用整数:0 待审核、1 可领养、2 已领养、3 下架。
领养申请表要关联 pet_id 和 user_id,另外还要有 apply_reason、contact、status。status 也用整数:0 待审核、1 已通过、2 已拒绝、3 已完成。公告表就相对简单:title、content、cover、create_time、update_time,再加一个 status 控制是否显示。
如果还想做评论功能,可以加一张评论表,关联用户和宠物,但四张表已经能撑起一套完整系统,不会显得单薄。
3.2 宠物状态与领养状态的状态机
很多同学建完表就开始写接口,结果写着写着发现状态乱了:宠物都已经领养了,申请还能再提交;申请被拒绝了,宠物状态还是“待审核”。要避免这个问题,最好的办法是先把状态流转图用文字写清楚。
宠物状态变化只有几条路径:管理员录入新宠物 -> 直接设为可领养;用户提交求助信息 -> 先设为待审核,管理员通过后变成可领养;用户提交领养申请 -> 管理员审核通过后,宠物从可领养变成已领养;如果宠物信息有误或暂时不适合展示,管理员可以设为下架。
领养申请的状态更直接:用户提交 -> 待审核;管理员通过 -> 已通过;管理员拒绝 -> 已拒绝;确认完成回访后 -> 已完成。
这两个状态不是孤立的,而是联动的。管理员审核通过一条领养申请,不仅要把申请状态改成“已通过”,还要把对应的宠物状态改成“已领养”。这两步必须是一个事务操作,否则会出现一半成功一半失败的情况。我在后面后端实现部分会专门讲这个。
3.3 自动填充、索引与数据约束
数据库里每个表都应该有 create_time 和 update_time。如果用 MyBatis-Plus,我建议不要手写 set 语句,而是用 MetaObjectHandler 做自动填充。插入时自动填 createTime 和 updateTime,更新时自动填 updateTime,省心且不容易漏。
索引方面,宠物表的 status 字段、领养申请表的 pet_id + status 组合,都是高频查询条件,加普通索引即可。不用过度设计,课设阶段索引建多了反而会影响插入性能。
数据约束里最容易忽略的是重复申请问题。同一个用户对同一只宠物提交了多次申请,如果用唯一索引直接限制,用户申请被拒绝后就没有办法再次申请了。所以正确做法是:数据库不加唯一约束,而是在 Java 代码里判断“当前是否存在状态为待审核或已通过的同宠物申请”。这点后面会有代码示例。
4. 后端落地:SpringBoot 实现时的三个关键链路
4.1 分层结构与 MyBatis-Plus 的配置
我建议后端项目结构用最经典的 controller / service / mapper / entity 四层。Controller 只接收参数、返回结果,Service 写业务逻辑,Mapper 访问数据库,Entity 映射表结构。
src/main/java/com/example/adopt ├── config // 跨域配置、拦截器配置、MyBatis-Plus配置 ├── controller // 登录、宠物、领养申请、公告等接口 ├── entity // 用户、宠物、领养申请、公告实体 ├── mapper // MyBatis-Plus 的 Mapper 接口 ├── service // 业务逻辑接口和实现类 ├── interceptor // JWT 登录拦截器 └── util // JWT工具类、统一返回结果类MyBatis-Plus 最大的优势是单表 CRUD 不用写 SQL。宠物模块的增删改查、分页查询都用它内置的方法就够了,能把大量时间留给核心业务逻辑。分页需要引入分页插件,在配置类里注册一个 MybatisPlusInterceptor,添加 PaginationInnerInterceptor。
4.2 JWT 登录态与接口拦截
登录注册模块我建议用 JWT + 拦截器,而不是直接引入 Spring Security。为什么?因为课设阶段重点要展示的是“理解登录状态如何保持”和“接口访问如何控制”,而不是背一堆 Security 的过滤器链配置。用 JWT 能说清楚两个关键点:无状态、自包含。
用户登录成功后,后端生成一个 token 返回给前端,前端存在 localStorage 里。之后每次请求,前端在请求头里带上Authorization: Bearer <token>。后端写一个拦截器,拦截所有/api/**请求,通过 token 解析出用户 id 和 role,放入当前请求上下文。
拦截器里要做的事情很简单:取请求头 -> 解析 token -> 放行或返回 401。要注意放行登录、注册接口,以及/uploads/**下的静态图片资源,否则图片加载会被拦截。
JWT 工具类我一般这样写:
public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor( "replace-with-a-long-random-string-for-signing-tokens".getBytes() ); public static String generateToken(Long userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000L)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }拦截器核心逻辑:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String auth = request.getHeader("Authorization"); if (auth == null || !auth.startsWith("Bearer ")) { writeUnAuth(response); return false; } try { Claims claims = JwtUtil.parseToken(auth.replace("Bearer ", "")); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { writeUnAuth(response); return false; } } }4.3 领养申请接口的校验与状态流转
领养申请是整个后端里最值得认真写的接口。因为它不是简单的 insert,而是“带着业务规则的 insert”。
我拆成两步。第一步是用户提交申请,接口要做三个判断:宠物是否存在、宠物当前状态是否可领养、当前用户是否已经提过申请。第三步经常有人漏掉。
@Transactional public void createAdoption(AdoptionCreateDTO dto, Long userId) { Pet pet = petMapper.selectById(dto.getPetId()); if (pet == null || pet.getStatus() != 1) { throw new BizException("宠物不存在或当前不可领养"); } Long count = adoptionMapper.selectCount(new LambdaQueryWrapper<Adoption>() .eq(Adoption::getPetId, dto.getPetId()) .eq(Adoption::getUserId, userId) .in(Adoption::getStatus, 0, 1)); if (count > 0) { throw new BizException("你已提交过申请,请勿重复操作"); } Adoption adoption = new Adoption(); adoption.setPetId(dto.getPetId()); adoption.setUserId(userId); adoption.setApplyReason(dto.getApplyReason()); adoption.setContact(dto.getContact()); adoption.setStatus(0); adoptionMapper.insert(adoption); }第二步是管理员审核。这里有并发风险,比如两个管理员同时审核同一只宠物的不同申请,或者同一个用户重复点击通过按钮。最简单可靠的做法是使用带条件更新的 SQL:只更新 status = 1 的宠物,如果影响行数为 0,说明宠物状态已经被改了。这样可以从数据库层面兜底,防止重复领养。
@Transactional public void auditAdoption(Long adoptionId, Integer auditResult, String remark) { Adoption adoption = adoptionMapper.selectById(adoptionId); if (adoption == null || adoption.getStatus() != 0) { throw new BizException("申请不存在或已处理"); } if (auditResult == 1) { int rows = petMapper.updatePetStatusIfAdoptable(adoption.getPetId(), 2); if (rows == 0) { throw new BizException("宠物状态已变化,请刷新后处理"); } } Adoption update = new Adoption(); update.setId(adoptionId); update.setStatus(auditResult); update.setAuditRemark(remark); adoptionMapper.updateById(update); }4.4 图片上传与本地存储
图片上传看似简单,实际坑不少。我先明确需求:管理员录入宠物时上传照片,用户也可以上传头像或求助照片。后端接收 MultipartFile,存到本机的 uploads 目录,再把访问路径返回给前端。
上传接口大致如下:
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; File dest = new File(uploadDir + "/" + filename); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); return Result.success("/uploads/" + filename); }用 UUID 重命名图片,能避免中文文件名乱码和重名覆盖问题。上传目录不要放在项目根目录下的随机位置,最好在配置文件里用绝对路径指定,我后面讲部署时会再说明。
同时要在配置类里加静态资源映射,把/uploads/**指向本机目录:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadDir + "/"); }5. 前端 Vue 开发:页面能跑通只是第一步
5.1 前端工程目录与请求封装
前端我用 Vue3 + Vite + Element Plus,组合式 API 写起来比 Vue2 的 Options API 直观。工程目录按模块拆,不要把所有页面堆在 views 下面。
src ├── api // 所有请求接口定义,一个模块一个文件 ├── assets // 静态资源 ├── components // 公共组件,比如宠物卡片、状态标签 ├── router // 路由配置 ├── store // 用户状态管理 ├── utils // 请求封装、格式化工具 └── views // 页面组件,按前台/后台分目录请求封装是所有接口调用的入口。统一处理 token 注入和错误提示,避免每个页面重复写 localStorage 读取逻辑。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' 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( res => { if (res.data.code !== 200) { ElMessage.error(res.data.msg) return Promise.reject(new Error(res.data.msg)) } return res.data.data }, err => { if (err.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(err.response?.data?.msg || '请求失败') return Promise.reject(err) } ) export default request5.2 路由守卫与菜单权限
前端也要做权限控制,否则会出现一个普通用户在地址栏输入/admin就能进后台的问题。
路由分成两部分:公开路由和需要登录的路由。后台管理路由统一加 meta.role = 'ADMIN',在全局前置守卫里做判断。登录后把用户信息存到 store,刷新页面再从接口重新拉取。
核心逻辑是:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.public) { next() } else if (!token) { next('/login') } else if (to.meta.role && to.meta.role !== userStore.role) { next('/403') } else { next() } })菜单权限对应更细的按钮级控制,做法也不复杂。在组件里根据当前登录用户的 role 判断是否渲染某个按钮。比如审核按钮只对 ADMIN 显示,普通用户看到的是“撤销申请”。这种细节在答辩时很加分,说明你考虑到了越权问题。
5.3 联调时最容易翻车的三个细节
第一个坑是日期格式。后端返回 LocalDateTime,默认序列化出来是2025-06-01T10:30:00,前端直接显示很难看。我在后端加了统一的 JSON 序列化配置,让时间输出为yyyy-MM-dd HH:mm:ss,前端就省了很多事。
第二个坑是图片路径拼接。上传接口返回的是/uploads/xxx.jpg,但前端列表里图片地址不能直接写死,要根据当前的接口域名做拼接。比较好的习惯是后端返回一个完整的图片 URL,或者在全局配置一个 baseUrl 变量,统一拼前缀。
第三个坑是表单校验过于依赖前端。用户在前端填了领养理由,后端接口里不验证,别人用 Postman 就能提交空数据。所以前端要校验、后端更要校验。用 Spring Validation 对参数加 @NotBlank、@Size 注解,再在 Controller 上加 @Valid,这不算复杂,但很多人会漏。
6. 联调、打包与部署:我实际踩过的坑
6.1 数据库初始化与常见连接问题
数据库连接配置是第一个容易出问题的地方。本地用 MySQL 8 的话,驱动要写com.mysql.cj.jdbc.Driver,URL 里要带上编码和时区参数,否则中文会乱码,时间也会差八小时。
spring: datasource: url: jdbc:mysql://localhost:3306/adopt_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver注意 utf8 和 utf8mb4 的区别。如果宠物描述里要存 emoji 表情,比如“这只猫的性格很亲人😊”,数据库表必须用 utf8mb4,否则插入报错。项目里的 SQL 脚本我建议直接写成CREATE DATABASE adopt_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,一步到位。
6.2 跨域处理:后端 CORS 配置怎么写
前后端分离项目最常遇到的就是跨域问题。浏览器访问前端地址和接口地址不一致时,请求会被拦截。解决办法是在后端加 CORS 配置,让后端允许指定来源访问。
一个能直接用的配置类如下:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有个坑:allowedOrigins("") 和 allowCredentials(true) 同时使用时会报错,因为浏览器不允许带着凭证的请求指向所有来源。用 allowedOriginPatterns("") 可以绕过这个限制,开发阶段很方便。生产环境如果前后端合并部署,就没有跨域问题了,这个配置可以不必开启。
6.3 前后端合并部署:一个 Jar 包搞定
如果课设要求“能演示就行”,最省心的部署方式是前后端合并成一个 SpringBoot 项目。前端执行npm run build后,把 dist 目录里的文件复制到 SpringBoot 的src/main/resources/static目录下,然后打成 jar 包。
启动后访问http://localhost:8080,默认加载前端页面,接口调用走/api/**,因为前端代码和后端接口同源,连跨域问题都不需要处理。
打包命令很简单:
mvn clean package -DskipTests java -jar target/adopt-system-1.0.0.jar要注意的是,图片上传目录 uploads 不要放在项目源码里,否则每次重新打包都会丢。更好的做法是配置一个服务器上的绝对路径,比如/data/adopt/uploads,启动时自动创建。我之前因为偷懒把目录写在工程内部,结果打了一次包,所有上传的测试图全没了,只能重新传,白白浪费了一个下午。
6.4 容易被忽略的数据安全细节
课设阶段不要求你实现企业级的复杂安全体系,但至少有三个细节要做。
密码不能明文存储。用 BCrypt 加密,Spring Security Crypto 库里的 BCryptPasswordEncoder 可以直接用。注册时加密,登录时校验。
SQL 注入要防。用了 MyBatis-Plus 框架后,单表操作的 SQL 都是参数预编译,注入风险很低。但如果你自己手写 SQL,比如按关键字模糊查询,一定要用${}以外的参数方式,也就是#{}而不是字符串拼接。
接口参数要校验。领养理由、联系方式这类字段,既要有非空校验,也要有长度检验。前端限制只是用户体验,后端校验才是真正在保证数据质量。
7. 答辩和后续扩展:怎么把项目讲“活”
7.1 答辩时最该讲清楚的三个设计理由
老师最常问的问题,其实不是“这个功能怎么写的”,而是“你为什么要这样设计”。
第一个问题:为什么用 SpringBoot 而不是传统 SSH?你可以回答:SpringBoot 自动配置解决了大量繁琐的 XML 配置,内嵌 Tomcat 让部署变成一条命令,非常适合快速交付一个完整项目。这不是背概念,而是你真的做过的体会。
第二个问题:领养状态是怎么管理的?这是主业务问题。你可以把状态机讲清楚,再强调通过条件更新 SQL 防止重复领养。讲到这里,老师基本就能确认你不是照着网上的代码改一改。
第三个问题:如果大量用户同时申请同一只宠物怎么办?假设场景下,可以回答:数据库层面用条件更新保证同一时刻只有一个申请能成功,业务层面增加排队或候选列表机制。这个追问没有标准答案,但能体现你有并发意识,比支支吾吾强很多。
7.2 低成本但有亮点的扩展方向
如果时间充裕,我建议加两个扩展。一个是数据看板,把领养数据用 ECharts 做成图表,放在管理后台首页。这种功能视觉冲击强,代码量不大,但会让整个系统看起来很“完整”。
另一个是带时间触发的消息提醒。比如用户申请领养后,如果三天没审核,系统自动给管理员发送一条站内消息。用 Spring 自带的 @Scheduled 定时任务就够了,不需要引入消息中间件。这个功能能体现你对业务细节的思考,答辩时被称为“加分项”的概率很高。
7.3 做完整个项目后的一些体会
这套系统做完,我最深的感受是:真正花时间的不是写代码,而是把状态和权限理清楚。只要数据库状态定义准确,后端业务规则写清楚,前端只是把这些规则翻译成页面。
如果你是第一次做前后端分离项目,建议不要急着加功能。先把登录、宠物管理、领养申请这三级流程完整跑通,再考虑统计、导出、消息提醒。基础链路稳定以后,每加一个扩展功能,都是在原来结构上“长出来”的,而不是推倒重来。
整个过程里最值得多花心思跟进的,就是宠物状态和领养申请状态的联动。把这个逻辑想明白了,你的答辩就成功了一大半。