前阵子帮一个本地动物救助站做内部管理系统对方提了一堆需求猫咪档案要能传照片、有人想领养得在线填表、管理员要能审核、领养之后还得留记录。聊到最后发现本质就是一个典型的web管理后台。于是我用SpringBootVueMyBatisMySQL这套非常经典的组合把整个宠物猫认养系统从零到一搭了出来。这篇文章就把完整的开发思路、表结构设计、核心接口实现、前端页面链路以及部署上线时踩过的坑全部捋一遍给准备做类似管理系统或毕业设计的朋友做个参考。1. 宠物猫认养系统到底解决了什么问题在做系统之前先把业务想清楚。很多救助站或宠物店处理领养还停留在“微信群发照片 纸质表格 人工打电话回访”的阶段。猫咪信息分散在聊天记录里领养人提交的资料没地方归档审核状态靠记忆时间一长谁领走了哪只猫、还剩哪些猫可领养完全说不清楚。这套系统的核心诉求就三个信息公开、流程留痕、状态可查。先说信息公开。救助站要定期把待领养猫咪的照片、年龄、品种、疫苗情况、性格描述发布出来领养人不需要加微信才能看到猫咪信息直接在网页上就能浏览和筛选。再说流程留痕。领养不是简单点个“我要”必须有完整的申请、审核、确认环节。领养人填表提交后管理员在后台逐条审核通过或驳回都要留下记录。这样既防冲动领养也能在出现纠纷时回溯整个链路。最后是状态可查。每一只猫的状态必须是明确的待领养、审核中、已领养。领养人不能对一只已经被申请中的猫继续提交申请管理员也能一眼看到当前待处理的事项。整个系统的角色也很清晰就两类管理员和普通用户。管理员做宠物档案管理、认养申请审核、公告发布、用户管理普通用户浏览猫咪、收藏猫咪、提交认养申请、查看申请进度。技术难度不高但业务边界要想清楚不然做着做着就成了“猫版电商”逻辑就乱了。2. 技术栈选型为什么是SpringBootVueMyBatisMySQL这套组合被问到太多次说句实在话它确实不是最时髦的但一定是最稳、资料最多、出现问题最好查的。适合中小型管理系统也适合学习性质的项目。2.1 后端SpringBoot负责“开箱即用”SpringBoot最大的价值是简化配置。以前用SSH或原生Servlet写项目配置一堆XML、还得手动管理对象生命周期现在SpringBoot通过自动配置把大部分样板工作处理掉了。我这套系统用的是SpringBoot 2.7.x为什么不用3.x这是个很实际的版本选型问题。SpringBoot 3.x要求JDK 17而很多服务器上跑的还是JDK 8生产环境升级成本高。如果只是做管理系统2.7.x已经非常成熟MyBatis、PageHelper、MinIO这些中间件的兼容资料也最全。真遇到“SpringBoot版本太高”导致的各种诡异问题多数情况都是starter和框架版本不匹配造成的。2.2 前端Vue负责“页面组件化”Vue在这个项目里的定位是单页应用框架。组件化开发让页面复用变得很容易比如宠物卡片组件在列表页用、在首页推荐位也用改一处全局生效。我选的是Vue 2.7 Element UI的组合语法简单组件丰富。这里有个细节Vue需要Node环境建议用nvm管理Node版本不要直接在官网装最新的否则很容易出现node-sass编译失败的问题。Vue项目开发时用npm run serve启动热更新生产环境执行npm run build生成dist静态文件后面再考虑怎么部署。2.3 ORMMyBatis负责“SQL可控”这个项目里MyBatis和MySQL是配套出现的。选MyBatis而不是JPA/Hibernate核心原因是认养系统的查询条件非常多且动态按品种、年龄、性别、是否绝育、健康状态筛选字段组合不确定MyBatis动态SQL写起来非常顺手而JPA在这种场景下反而要拼Specification复杂且不直观。MyBatis把SQL和Java代码分离Mapper接口定义方法XML里写SQL。改动SQL不需要重新编译Java代码排查问题时也能直接复制SQL到Navicat里跑非常方便。2.4 数据库MySQL负责“数据落盘”MySQL 8.x是目前的主流选择默认字符集必须是utf8mb4否则存不了表情符号。猫咪档案简介里如果写了“”这种emoji用utf8mb3会直接报错。另外排序规则用utf8mb4_general_ci就够不需要上unicode_ci性能上有一点点差异但管理系统数据量不大区别基本感受不到。3. 数据库设计认养业务的核心表结构表结构是整个系统的地基。我设计的时候遵循一个原则把“业务流程的每一个环节都有对应的记录位置”也就是认养申请从提交到结束每一步都能在数据库里找到对应的字段。3.1 用户表区分管理员和普通用户用户表是最基础的表字段包括id、username、password、nickname、phone、avatar、role、create_time。password存的是BCrypt加密后的密文不存明文这是底线。role用字符串区分admin和user不用单独建角色表因为系统只有两种角色建五张表的RBAC模型反而过度设计。3.2 宠物猫档案表状态字段是核心宠物表是整个产品的内容中心。主要字段有id、name基本信息breed品种如英短、布偶age_month月龄因为猫咪认养偏幼猫按月份比按年份精确gender性别0母1公is_neutered是否绝育vaccine_status疫苗状态0未接种、1已接种一针、2已完成health_status健康状态描述personality性格描述比如“亲人、粘人、适合有小孩的家庭”cover_image封面图URLstatus0待领养、1审核中、2已领养create_time、update_timestatus字段是认养业务的关键。所有列表查询、申请校验都跟它相关而且我会在代码层面保证状态流转是单向的待领养→审核中→已领养不允许逆流程。3.3 认养申请表流程的数据载体认养申请表记录每一次申请行为字段有id、user_id、cat_id谁申请了哪只猫reason领养理由和用户信息分开存因为领养人可能有多个理由要按次记录has_house、has_experience是否有稳定住所、是否有养猫经验这两个是管理员审核的关键参考status0待审核、1已通过、2已驳回create_time、review_time、review_remark设计失误的教训我吃过一次如果只记录最终状态不记录驳回原因那用户被拒之后完全不知道为什么被拒体验极差。所以review_remark必须有。3.4 辅助表收藏、公告、图片收藏表favorite字段简单id、user_id、cat_id、create_time加唯一索引约束同一用户不能重复收藏同一只猫。公告表notice用于发布领养须知、活动通知等字段是id、title、content、create_time。图片表我采用了比较灵活的方式pet_image表字段id、cat_id、image_url、sort_order一只猫可以关联多张图片。不把图片URL直接塞在宠物表里的原因是“一只猫多张图”这种一对多关系单独建表最干净。3.5 表关系梳理整体关系是用户1对多申请猫咪1对多申请申请表通过user_id和cat_id关联两端用户和猫咪通过收藏表形成多对多。认养成功后我会把申请表的status置为2已完成同时把宠物表的status置为2已领养这两条记录就是完整认养档案。提示所有表都建议加上create_time和update_time字段并且用数据库的DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP自动维护这样审计数据变动时非常有用。4. 后端接口实现认证、权限与认养核心流程后端接口是系统的心脏。我按功能域拆成用户、宠物、申请、收藏、公告几个模块下面重点讲几个核心点的实现思路。4.1 JWT登录与登录拦截管理系统必须区分游客、普通用户、管理员。我用JWT做无状态登录用户登录成功后后端签发一个token前端存在localStorage每次请求在header里带Authorization: Bearer token。拦截器这边不需要在每个Controller里重复判断只需要在WebMvcConfigurer里注册一个HandlerInterceptor按照路径前缀和角色做校验。管理员相关接口以/api/admin/开头普通用户接口以/api/user/开头拦截器里定义规则就行。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); // 解析token失败则返回401 // 根据路径判断是否需要管理员角色 return true; } }JWT的密钥我放在application.yml里用环境变量覆盖不硬编码在代码里——这种细节在真实项目里是审查重点。4.2 宠物列表的动态条件筛选宠物列表页有三个筛选条件品种、性别、状态后端接口用MyBatis动态SQL处理。select idselectCatsByCondition resultTypecom.example.pojo.Cat SELECT * FROM pet_cat where if testbreed ! null and breed ! AND breed #{breed} /if if testgender ! null AND gender #{gender} /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /selectwhere标签的智能之处在于如果没有条件匹配它会自动去掉WHERE关键字如果条件成立它会自动去掉第一条AND这样拼出来的SQL永远合法。我用分页插件PageHelper处理分页一行代码PageHelper.startPage(pageNum, pageSize)后面跟的查询自动追加LIMIT。4.3 认养申请的状态机与并发控制认养申请是整个系统最需要小心的地方。一只猫只能被一个人成功认养而同一时间可能有三个用户同时提交申请这一块必须做并发控制。我的做法分三步第一步提交申请时检查宠物状态。用SELECT status FROM pet_cat WHERE id #{catId}确认status为0待领养如果已经变成1审核中直接返回“该猫咪已被申请”。第二步更新宠物状态为审核中。这里必须用条件更新不能先查再更因为两步之间可能有并发操作。int rows catMapper.updateStatusWithCheck(catId, expectedStatus, targetStatus); if (rows 0) { throw new BusinessException(该猫咪已被其他用户申请手速慢了一步); }第三步创建申请记录状态为待审核。管理员审核通过后再把申请和宠物状态同步更新。这套“乐观锁式”的条件更新比直接加数据库悲观锁更轻量因为认养是高价值、低频操作冲突概率极低用乐观锁完全够。用这种思路处理既不用改数据库隔离级别也不用担心死锁。4.4 MyBatis实用细节边界条件改多了以后你会发现MyBatis的很多特性在管理系统中是每天都要碰的。第一个是resultMap。数据库的snake_case字段如create_time和Java的camelCase属性createTime可以通过一个配置搞定mybatis.configuration.map-underscore-to-camel-casetrue这样连resultMap都省了。第二个是缓存。MyBatis一级缓存是SqlSession级别的在Spring中默认是开启的但Spring整合后每次操作会新建SqlSession所以一级缓存基本没意义。二级缓存是Mapper级别的默认是关闭的涉及多表关联查询时也不要开容易出现脏读问题。管理系统这种数据实时性要求较高的场景默认配置就好。第三个是TypeHandler。宠物的性别和疫苗状态我用的是tinyint存储但Java代码里不想到处写魔法数字就写一个枚举TypeHandler把Integer自动映射成枚举类型。MappedTypes(GenderEnum.class) public class GenderTypeHandler extends BaseTypeHandlerGenderEnum { // 实现 setNonNullParameter、getNullableResult 等 }这样查询出来的0和1自动变成枚举对象代码可读性提升一个档次。4.5 事务边界一个操作涉及多张表时必须加注解提交认养申请涉及“插入申请表”和“更新宠物状态”两步操作必须用Transactional保证原子性。如果第二步失败第一步的申请记录也要回滚掉否则用户看到的状态和数据库实际状态不一致。这一步有个经典坑如果在同一个类里通过内部方法调用带Transactional的方法事务是不生效的因为Spring的事务是基于代理实现的内部调用不走代理。解决方法是把事务方法放到单独的Service类里或者用AopContext.currentProxy()。5. 图片上传与文件存储MinIO还是本地存储宠物认养系统最大的内容就是照片。一只猫至少要有正面、侧面、生活环境三四张图。图片上传如果做不好系统用起来会很痛苦。5.1 三种存储方案对比一开始我考虑过三种方案本地磁盘存储最简单创建一个upload目录MultipartFile直接保存。缺点是应用重启或多实例部署时文件可能丢失且无法扩展。云OSS功能全、稳定但需要配置密钥、有费用个人项目不太好整。MinIO开源、轻量、兼容S3协议可以用Docker一行命令跑起来非常适合这类中小型系统。最终我选的是MinIO。它本质就是一个私有化部署的对象存储服务数据和图片都在自己服务器上隐私方面也更可控。5.2 SpringBoot整合MinIO整合MinIO的步骤非常清晰。先引入依赖然后配置连接信息minio: endpoint: http://localhost:9000 access-key: admin secret-key: admin123 bucket-name: pet-images然后是上传接口核心代码大概这样PostMapping(/api/upload) public Result upload(MultipartFile file) { String fileName UUID.randomUUID().toString() getExt(file.getOriginalFilename()); minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(fileName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); return Result.success(minioConfig.getEndpoint() / bucketName / fileName); }文件名的UUID重命名是必须的不然用户上传两张都叫“猫咪.jpg”就会互相覆盖。另外MinIO默认的访问URL如果暴露在内网地址前端是访问不到的。我的做法是给MinIO配一个Nginx反向代理用图片域名代理9000端口这样前端拿到的URL就是公网可访问的。server { listen 9001; location /pet-images/ { proxy_pass http://127.0.0.1:9000/; } }5.3 前端上传组件前端我用Element UI的el-upload组件配上action属性指向后端上传接口。有一点要注意el-upload默认用ajax提交必须手动加上JWT的Authorization头不然上传会被拦截器挡住el-upload :actionuploadUrl :headers{ Authorization: Bearer getToken() } :on-successhandleUploadSuccess list-typepicture-card /el-upload上传完成后拿到图片URL提交表单时把URL作为字符串存到宠物表里列表页的el-image直接绑定src就能显示。5.4 图片列表与回显宠物详情页需要展示多张图片我的pet_image表存的是多行URL。前端详情页用一个轮播组件把所有URL遍历出来即可。这里有个体验优化点如果图片是原图用户加载会很慢建议在后端上传时做一次图片压缩或者用thumbnailator生成缩略图列表中显示缩略图详情页显示原图。6. 前端Vue页面从路由到认养表单的完整链路前端我用Vue 2.7加Element UI搭建。Vue脚手架创建的项目目录清晰src/views下每个页面一个文件夹。下面梳理核心页面的开发链路以及Vue打包后怎么处理和SpringBoot的关系。6.1 路由设计与导航守卫路由表设计成前端权限控制的基础。我的方案非常简单实用/首页宠物公开展示/cats宠物列表/cat/:id宠物详情/apply认养申请/login登录/admin后台管理布局嵌套路由/admin/cats宠物档案管理/admin/applications申请审核列表其中/admin下的所有路由都需要管理员权限前端通过Vue Router的beforeEach全局守卫做校验router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path.startsWith(/admin)) { const role localStorage.getItem(role) if (token role admin) { next() } else { next(/login) } } else { next() } })这只是前端权限控制真正的安全防护必须靠后端接口拦截器兜底前端判断只是为了用户体验——没有权限的人直接跳转到登录页。6.2 宠物列表页宠物卡片是我封装的一个Vue组件PetCard接收一个cat对象展示封面图、品种、年龄和状态标签。列表页主体结构是template div el-form inline el-select v-modelfilters.breed placeholder品种/el-select el-select v-modelfilters.gender placeholder性别/el-select el-button clickloadData查询/el-button /el-form el-row :gutter20 el-col :span6 v-forcat in catList :keycat.id PetCard :catcat / /el-col /el-row el-pagination :totaltotal current-changeloadData/el-pagination /div /templateaxios请求统一封装在src/utils/request.js里请求拦截器自动附加token响应拦截器统一解包Result结构并处理401跳转登录。6.3 认养申请表单这是一个需要认真设计交互的模块。用户点击“申请认养”按钮后不是直接通过申请而是弹出一个表单要求填写当前居住情况自有住房/租房/其他是否养过猫家庭人数认养理由前端表单校验至少要做“必填校验”饲养条件和理由这种长文本还要给字数限制提示。提交成功后页面提示“申请已提交请等待管理员审核”。6.4 后台管理页后台页面是管理员的日常操作界面。最关键的是待审核列表每行一条申请记录展示申请人信息、申请猫咪、提交时间、理由预览右侧审核操作按钮通过调接口更新申请状态和宠物状态驳回弹出dialog填写驳回理由表格里我加了状态滤镜让管理员优先处理待审核的数据。审核记录的review_time和review_remark会展示在详情弹窗里所有处理都有迹可循。6.5 Vue打包后如何放进SpringBoot这是很多初学者最头疼的一步。其实原理很简单把前端打包后的dist目录拷贝到SpringBoot的static目录下SpringBoot会自动托管这些静态文件并且和API接口共用同一个端口也就不存在跨域问题了。我是用Maven的maven-resources-plugin把前端dist目录在打包时自动拷贝到classpath:/static下这样一个命令mvn clean package -DskipTests就能打出同时包含前后端的完整jar包。plugin artifactIdmaven-resources-plugin/artifactId executions execution idcopy-vue-dist/id phaseprepare-package/phase goalsgoalcopy-resources/goal/goals configuration outputDirectory${project.build.outputDirectory}/static/outputDirectory resources resource directory${project.basedir}/../frontend/dist/directory /resource /resources /configuration /execution /executions /plugin有一个坑必须提醒Vue Router如果用的是history模式刷新某个路径比如/cat/3时后端会返回404因为SpringBoot找不到对应的静态资源。最简单可靠的方案是改用hash模式URL虽然带个#但部署省心很多。或者你配置一个Controller把非API路径全部转发到index.html但如果没配置好API请求也会被转发过去挺麻烦的。提示除非你有明确的SEO需求否则中小型管理系统的Vue路由直接用hash模式就好。我自己在部署阶段就吃过history模式的亏后来切回hash模式稳稳的。7. 编译部署与实测踩坑记录最后把开发和部署过程中实际遇到的问题梳理一遍这部分都是真金白银的教训照着可以少走弯路。7.1 环境准备本地开发环境我用的版本JDK 1.8Maven 3.6.3Node 14.17.4MySQL 8.0MinIODocker启动如果JDK版本太高比如JDK 16以上可能会遇到Lombok或旧版Maven插件不兼容的问题所以建议先确定JDK版本再选SpringBoot版本。7.2 数据库初始化建库SQL我直接写在项目根目录的sql/init.sql里包含建库、建表、插入初始管理员账号。管理员密码用BCrypt加密后的密文写入不写明文。MySQL连接串里有几个参数非常关键jdbc:mysql://localhost:3306/pet_adoption?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueMySQL 8.0默认的认证插件是caching_sha2_password很多驱动版本不支持会报Public Key Retrieval is not allowed错误。加上allowPublicKeyRetrievaltrue可以解决但注意这只是本地开发环境这么配生产环境建议换成更安全的SSL连接。7.3 Mapper扫描问题SpringBoot整合MyBatis时有个经典坑Mapper接口扫描不生效。常见做法是在启动类上添加MapperScan(com.example.mapper)或者在每个Mapper接口上加Mapper。我更推荐统一用MapperScan一个注解解决所有Mapper的注册不用每个接口都加。还有XML文件位置问题MyBatis的XML如果不放在resources目录下Maven打包时不会自动带上运行时会报Invalid bound statement (not found)错误。解决方案是在pom.xm里显式把xml文件纳入资源resources resource directorysrc/main/java/directory includesinclude**/*.xml/include/includes /resource /resources7.4 数据库连接失败排查实际部署时在Linux服务器上遇到过MySQL连接失败的情况当时报的是Communications link failure。排查步骤依次是看端口netstat -tunlp | grep 3306确认MySQL在监听看防火墙阿里云之类的云服务器需要在安全组额外放行3306端口看iptables本地防火墙规则可能挡掉请求用telnet 服务器IP 3306测试端口连通性最终定位到是云服务器安全组没加规则放行后立刻通了。这类底层网络问题光看应用日志不容易看出来必须按链路逐层排查。7.5 图片上传后的访问权限MinIO上传默认的bucket是没有公开读权限的。如果图片的访问URL不带签名前端直接访问会报AccessDenied。解决有两个思路思路一是开通bucket的匿名只读策略适合对安全性要求不高的场景宠物图片本来就是要公开给所有人看的。思路二是用预签名URL后端生成带过期时间的URL适合需要权限控制的文件。宠物认养系统的图片就是公开内容我用的是第一种省事。7.6 审核流程的边界情况最后说一个我实际遇到过的情况。管理员在审核通过之后用户又取消了认养比如家里突然不同意。这时有两种处理一种是把申请状态改成“已取消”宠物状态改回“待领养”另一种是彻底删除这条申请让用户重新提交。我选了第一种因为审计留痕比简单删数据更有价值管理员后台可以看到这只猫“曾经被申请过但被取消”对之后审核其他申请人也有参考意义。这类边界情况在系统设计阶段很容易漏掉我建议你在开发前就把状态流转图画清楚把所有可能的分支情况都列出来。写到这里整个系统的核心链路已经完整过了一遍。我自己的体会是这种管理系统真正花时间的从来不是CRUD本身而是业务规则的设计和对边界情况的处理。认养申请的状态怎么流转、并发提交怎么防、图片存储怎么选、打包部署怎么省心这些细节决定了系统上线后是让管理员用着顺畅还是天天找人填坑。如果你正在做类似的系统建议先从数据库表结构入手把状态字段和审核流程想清楚再动手写代码。