前阵子帮动物救助站做内部管理系统,核心业务是流浪猫的线上认养。折腾了一个多月,最终落地的是一套 SpringBoot + Vue + MyBatis + MySQL 的完整 web 项目,今天把整个思路和源码脉络理一遍。这套系统解决的不只是“哪只猫能领养”的问题,更把猫咪档案、认养申请、管理员审批、回访记录、数据统计全部串成一条业务闭环,适合手里有类似公益项目、想找一套能跑通的毕业设计、或者想系统学习前后端分离全流程的朋友直接参考。
整套代码不是那种只写了个登录页的玩具项目,而是把“用户端认养 + 后台管理端”完整分开,权限、状态机、并发控制、文件上传这些企业项目里常见的点都做了。下面按我实际开发的顺序,从技术选型讲到底层表结构,再一步步拆接口和页面,最后把部署和踩过的坑全摆出来。
1. 项目形态与架构选型:为什么锁定这套组合而不是微服务全家桶
1.1 这套系统的定位与适合人群
宠物猫认养系统本质上是一个“轻电商 + 政务审批”混合体:前台的猫咪展示页像电商,认养申请的审核流程又像行政审批。如果用传统的单体 JSP 硬写,页面静态资源多、交互复杂,维护起来非常痛苦;如果直接上 Spring Cloud 微服务,一个救助站项目人手不够也没必要,运维成本反而把业务拖死。
所以项目定位非常明确:单体能搞定、但必须预留清晰模块边界。选择 SpringBoot + Vue + MyBatis + MySQL,正好卡在这个节点上。SpringBoot 负责提供稳定的接口服务,Vue 做单页应用来承载用户端和管理端的高交互页面,MyBatis 让每一条 SQL 都掌握在开发手里,MySQL 作为核心数据仓库足够支撑中小并发。
1.2 技术选型背后的取舍逻辑
先聊后端。为什么用 SpringBoot 而不是纯 Spring MVC?无非是自动配置和内嵌容器省掉一堆 XML。救助站的人不会部署 Tomcat,你给他一个 java -jar 就能跑的东西,后续维护压力会小很多。SpringBoot 内嵌 Tomcat,打成一个可执行 jar,部署时只需要 JDK 环境,这个优势在实际交付时非常明显。
Vue 的选择则是因为前端交互太依赖“无刷新”体验:用户看猫咪列表要筛选状态、点开详情看相册、提交认养表要实时校验。如果用多页面的 thymeleaf,每次筛选都刷新页面,体验会很割裂。Vue 的组件化开发还能把“猫咪卡片”“申请表单”“后台表格”拆成独立部件,后续改样式、加字段都非常方便。
MyBatis 是我个人坚持的。这个项目里有大量联表查询和动态条件,比如“查询所有可认养猫咪 + 当前用户是否已申请过”,如果用 JPA,要么写派生方法名长得离谱,要么用 @Query 拼接字符串,调试起来很不直观。MyBatis 的 XML 文件可以让 SQL 和 Java 代码分离,线上排查问题时直接打开 XML 就能定位慢查询,这一点是企业项目最看重的。
1.3 前后端模块划分与目录结构
整个项目分成 user-web(用户端)和 admin-web(管理端)两个前端应用,后端只有一个 api-service。这样拆分的好处是权限边界清晰:用户端走 JWT 里的 ROLE_USER,管理端走 ROLE_ADMIN,两个页面部署在 Nginx 不同 location 下,互不干扰。
后端按包分:
com.example.petadoption ├── controller # 接口层,只做参数接收和结果包装 ├── service # 业务层,事务边界都在这层 ├── mapper # MyBatis 数据访问层 ├── entity # 数据库实体 ├── dto # 前端交互对象 ├── common # 统一返回体、异常处理、工具类 └── config # JWT过滤器、跨域配置、MyBatis配置如果你拿到这套代码想改造,最应该先看 dto 和 entity 的区别。entity 对应数据库字段,dto 是根据前端需要“拼装”出来的对象,比如猫咪列表页不仅要有猫的信息,还要带“当前用户是否已申请”的标记,这个字段只能放在 dto 里,不能污染实体。
2. 认养业务链路:从猫咪档案到审批回访的状态机设计
2.1 猫咪档案状态流转
认养系统和普通商品系统的最大区别是:猫咪不能像商品一样“被下单后自动扣库存”。猫咪从进入系统到被领养,要经历多个状态,每个状态变更都需要管理员介入或业务事件触发。
我设计的猫咪状态字段是一个 tinyint 类型:
| 状态值 | 枚举名 | 含义 |
|---|---|---|
| 0 | DRAFT | 待完善档案,管理员录入但未上架 |
| 1 | AVAILABLE | 可认养 |
| 2 | APPLYING | 已有用户提交申请,等待审批 |
| 3 | ADOPTED | 已认养 |
| 4 | OFF_SHELF | 已下架(因病、死亡或留观) |
这里有个很关键的细节:状态值 2(APPLYING)不能设置成“锁死”。如果有用户提交申请但管理员审核不通过,猫咪状态应该回到 1(AVAILABLE),而不是停在 2,否则后面用户会一直看到“审核中”而无法再次申请。我在 Service 里用状态机方法专门处理这种流转,拒绝任何业务代码直接修改 status 字段。
2.2 认养申请与审批流程
用户提交认养申请时,前端表单包含这些字段:真实姓名、手机号、居住类型(租房/自有)、是否有养猫经验、家庭是否同意、申请理由。表单校验是前端做一层,后端做第二层,后端绝对信任前端校验是大忌。
提交申请的后端逻辑顺序是:
- 校验 token 并取到当前用户 ID。
- 校验猫咪状态必须为 AVAILABLE。
- 查询同一用户是否已有“待审批”或“审批通过”的申请,防止重复认养。
- 开启事务,将猫咪状态改为 APPLYING。
- 插入一条认养申请记录。
- 提交事务。
管理员审批端有“通过”和“驳回”两个动作。通过后,猫咪状态变为 ADOPTED,同时生成一条回访周期记录;驳回时,猫咪状态恢复 AVAILABLE,申请记录保留驳回原因。这样整个审批过程都有迹可循,后续用户投诉或纠纷时能拿出完整数据。
2.3 回访记录与后续保障
“企业级”体现在认养不是批准后就结束了,而是要求定期回访。我加了 visit_record 表,记录每次回访时间、回访方式、猫咪健康状况、回访人。管理端在猫咪状态变为 ADOPTED 时自动生成第一条回访任务,并设置提醒。
这个设计最早是救助站提出的:以前用 Excel 记录认养信息,回访全靠微信私聊,漏了很久都不知道。系统上线后,管理端首页能直接看到“本周待回访”的数量,回访记录和猫咪档案关联,点进去就是完整时间线。
3. MySQL 表设计:五张核心表加索引,搞定认养场景
3.1 核心表结构与字段说明
数据库我总共建了 5 张核心业务表,加上一张回访表,另外还有字典表存猫咪品种和毛色。这里把最重要的字段列出来。
用户表(sys_user):
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密密码', real_name VARCHAR(50) COMMENT '真实姓名', phone VARCHAR(20) COMMENT '手机号', role TINYINT NOT NULL DEFAULT 0 COMMENT '0-用户 1-管理员', delete_flag TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';猫咪表(cat):
CREATE TABLE cat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '猫咪名字', breed VARCHAR(50) COMMENT '品种', age_month INT COMMENT '月龄', gender TINYINT COMMENT '0-男孩 1-女孩', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-草稿 1-可认养 2-申请中 3-已认养 4-已下架', avatar_url VARCHAR(255) COMMENT '封面图', description TEXT COMMENT '猫咪介绍', vaccinated TINYINT DEFAULT 0 COMMENT '是否已打疫苗', dewormed TINYINT DEFAULT 0 COMMENT '是否已驱虫', neutered TINYINT DEFAULT 0 COMMENT '是否已绝育', delete_flag TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='猫咪表';认养申请表(adoption_apply):
CREATE TABLE adoption_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, cat_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审批 1-已通过 2-已驳回', apply_reason VARCHAR(500) COMMENT '申请理由', reject_reason VARCHAR(500) COMMENT '驳回原因', apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME DEFAULT NULL, audit_user_id BIGINT, delete_flag TINYINT DEFAULT 0, KEY idx_user_id (user_id), KEY idx_cat_id (cat_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认养申请表';设计上我把 user_id 和 cat_id 单独建索引,而不是建联合唯一索引。原因是用户可能对同一只猫申请后被驳回,然后再次申请,所以不能简单限制一对多。防重复交给代码逻辑做,避免数据库索引卡死合理的业务流程。
回访表(visit_record)这里不贴完整 SQL,核心字段是 apply_id、visit_time、visit_user、visit_type(电话/上门/微信)、content、next_visit_time。每次回访生成一条,按 apply_id 关联到认养记录。
3.2 通用字段和索引规范
所有业务表我都保留了 create_time、update_time、delete_flag 这三个字段。delete_flag 用逻辑删除而不是物理删除,这样即使认养申请被用户删除,管理员后台仍能查看历史流水,数据安全性和可追溯性都更好。
索引这件事容易被初学者忽略。猫咪表查列表最常用的过滤条件是 status,所以我建了 idx_status 索引;认养申请表的查询入口总是 user_id 或 cat_id,所以两个字段分别建索引。建索引不是越多越好,写入频繁的表要控制索引数量,这里的写入量很小,反而更强调查询效率。
3.3 为什么坚持手写 MyBatis SQL 而不是 JPA
说到数据访问层,很多人会问“现在 JPA 那么火,为什么不直接用它”。我的理由很简单:这个项目里有好几条 SQL 是需要动态拼接的。比如猫咪列表接口,前端传来的筛选条件可能有品种、年龄、状态、是否绝育,用 JPA 写 Specification 或条件查询,代码可读性很差;而 MyBatis 的 XML 里用 标签把条件拼出来,团队成员审 SQL 时一眼就知道是否走索引、有没有性能隐患。
另外,MyBatis 对复杂联表更友好。管理端需要展示“认养申请 + 用户名 + 猫咪名 + 品种”这种三表联查,用 XML 写一次 join 就能解决,返回结果映射到 dto,效率非常高。配合 MyBatis 的驼峰映射配置,数据库下划线字段能自动映射到 Java 属性,省掉一大半 ResultMap 手写工作。
4. MyBatis 后端实现:从接口定义到事务控制的完整细节
4.1 SpringBoot 项目配置与 MyBatis 集成
后端工程创建时要注意版本匹配。我用的 SpringBoot 2.7.x + MyBatis Starter 2.3.x + MySQL 8.0,JDK 用的 8。如果你的 JDK 是 11 或 17,也没问题,但 SpringBoot 版本最好升到 3.x,这里不多展开。
最关键的配置文件 application.yml 里有两处必须写对:
spring: datasource: url: jdbc:mysql://localhost:3306/pet_adoption?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.petadoption.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case 这个配置强烈建议打开,否则数据库里 create_time 字段映射不到 Java 的 createTime 属性。我见过很多新手在实体类上写一堆 @TableField,其实一个全局配置就解决了。
4.2 认养申请核心接口代码示例
认养申请接口是整个后端业务逻辑最密集的地方,直接看 Service 层代码:
@Transactional(rollbackFor = Exception.class) public void applyAdoption(AdoptionApplyDTO dto) { // 1. 查询猫咪当前状态 Cat cat = catMapper.selectById(dto.getCatId()); if (cat == null || !cat.getStatus().equals(CatStatus.AVAILABLE.getValue())) { throw new BusinessException("猫咪当前不可认养"); } // 2. 校验用户是否已申请过这只猫 int count = adoptionApplyMapper.countActiveApply(dto.getUserId(), dto.getCatId()); if (count > 0) { throw new BusinessException("您已申请过这只猫咪,请等待审核"); } // 3. 乐观更新猫咪状态,防止并发重复提交 int updated = catMapper.updateStatusById(dto.getCatId(), CatStatus.APPLYING.getValue(), CatStatus.AVAILABLE.getValue()); if (updated != 1) { throw new BusinessException("猫咪已被人抢先申请了,换一只吧"); } // 4. 插入认养申请记录 AdoptionApply apply = new AdoptionApply(); apply.setUserId(dto.getUserId()); apply.setCatId(dto.getCatId()); apply.setApplyReason(dto.getApplyReason()); adoptionApplyMapper.insert(apply); }这里最关键的是第 3 步,我通过 UPDATE cat SET status = ? WHERE id = ? AND status = ? 的方式做乐观锁。如果两个用户同时提交,数据库行锁会保证只有一个更新成功,另一个更新影响行数为 0,从而抛出“被人抢先”的提示。这比纯代码判断再 insert 靠谱得多。
4.3 统一返回体与全局异常处理
前后端对接最容易出现“这个接口返回 data,那个接口返回 result”的混乱。我定义了全局统一返回体:
@Getter public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }同时写一个 @RestControllerAdvice 全局异常处理器,把 BusinessException、参数校验异常、未知异常分别包装成对应的 Result 返回。这样前端 axios 拦截器只需要判断 code 是否为 200,不用再针对每个接口写重复的错误提示。
5. Vue 前端联调:列表、详情、后台管理的页面与接口对接
5.1 Vue 工程初始化与反向代理配置
前端我拆了两个 Vue 应用,都是通过 Vue CLI 创建,组件库选的 Element Plus。这里重点说代理配置,因为开发环境下前后端端口不同,直接请求后端接口会跨域。
vue.config.js 里这样配置:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }后端接口统一以 /api 开头,前端发起请求时写成 /api/cat/list,Nginx 或 devServer 会去掉 /api 前缀转发到后端。生产环境部署时,Nginx 里同样配置 location /api 的反向代理即可。这个思路必须想明白,否则前后端分离项目上线后全是跨域报错。
5.2 用户端认养大厅与详情页
用户端页面结构是:首页顶部导航、猫咪认养大厅、我的申请、登录注册。认养大厅是整个项目的门面,采用卡片瀑布流布局,每张猫咪卡片显示封面图、名字、品种、年龄和“可认养/申请中”状态。因为图片没有统一尺寸,我在卡片上用了 aspect-ratio 和 object-fit: cover,让所有图片看起来整齐。
详情页除了猫咪五张图和详细信息外,还有个很关键的按钮状态判断:
- 猫咪状态非 AVAILABLE:按钮置灰,显示“已被认养”或“审批中”。
- 当前用户已申请过这只猫:按钮文字变成“已申请,等待审核”,且不能重复点击。
- 未登录用户点击申请:跳转登录页并带上 redirect 参数。
这个判断的数据来源就是后端详情接口返回的 applied 标记,前端不需要自己维护任何状态,所有动态性交给接口数据驱动。
5.3 管理后台表格与图片上传
管理端我用的 Element Plus 的 el-table 和 el-dialog。猫咪管理页的表格列包括:封面上传、名称、品种、状态、疫苗/绝育标记、操作按钮。操作按钮根据状态渲染:可认养显示“编辑”和“下架”,申请中出现“审批”和“查看申请列表”。
图片上传组件用的是 el-upload,后端提供 /api/upload 接口,接收 multipart 文件后保存到服务器 /data/uploads 目录,返回访问 URL。这里要提醒一点:前端的图片 URL 如果是相对路径 /uploads/xxx.jpg,生产环境 Nginx 需要额外配置 location /uploads 指向磁盘目录,否则前端图片会 404。
6. 本地启动与生产部署:一套可以照抄的配置流程
6.1 本地开发环境搭建五步走
拿到完整版源码后,按下面的顺序操作基本不会出错:
- 安装 JDK 8、Maven 3.6+、Node 14+、MySQL 8.0。
- 在 MySQL 中创建 pet_adoption 库,导入项目根目录的 sql/init.sql,里面包含全部建表语句和测试数据。
- 修改后端 application.yml 里的数据库账号密码,然后在项目根目录执行 mvn spring-boot:run。
- 后端启动成功后,进入 vue-frontend 目录执行 npm install,然后 npm run serve,访问 http://localhost:8080。
- 用测试账号 admin/admin123 登录管理端,用 user/user123 登录用户端。
这里最容易被坑的是 MySQL 时区问题。如果连接串里不配置 serverTimezone=Asia/Shanghai,高版本 MySQL 会直接报错。另外 MySQL 8 的驱动必须是 com.mysql.cj.jdbc.Driver,不是旧版的 com.mysql.jdbc.Driver。
6.2 生产环境 Nginx 部署方案
后端打成 jar 包后,用 nohup java -jar pet-adoption.jar > app.log 2>&1 & 启动。生产环境我用 Nginx 统一托管前端静态文件和反向代理,配置大致如下:
server { listen 80; server_name pet.example.com; root /opt/pet-adoption/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /data/uploads/; } location / { try_files $uri $uri/ /index.html; } }location / 里的 try_files 配置至关重要。Vue Router 如果使用 history 模式,用户从管理端刷新页面时 URL 是 /admin/cat,Nginx 如果不在根路径配置 try_files,就会直接 404。加上 $uri/ /index.html 的回落规则后,所有前端路由都会回到 Vue 应用再自行匹配。
6.3 生产环境安全加固
部署上线不是能访问就完事,至少要做三件事:
第一,数据库密码不要明文写在 application.yml 里。可以用 Jasypt 加密,或者通过环境变量的方式注入:spring.datasource.password=${DB_PASSWORD}。我实际项目里用的就是环境变量注入,配合 systemd 的 EnvironmentFile 配置,密钥不会落到代码仓库。
第二,管理端接口必须做权限校验。后端 JWT 拦截器里,对所有 /api/admin/** 的请求检查当前用户的 role 是否为 1,不是就直接返回 403。前端隐藏菜单只是体验优化,真正的安全防线永远在后端。
第三,Nginx 层开启 HTTPS。申请免费的 SSL 证书,然后在 server 块里配置 443 端口和证书路径,并把 80 端口重定向到 443。猫认养系统里有用户手机号、申请理由这些个人信息,不上 HTTPS 等于明文传输,这点不能省。
7. 开发中踩过的坑:并发、事务、缓存、联调
7.1 本地运行时的高频报错速查表
这里直接把我当时踩过的和群里朋友常遇到的问题整理成表格:
| 报错信息 | 原因 | 处理方式 |
|---|---|---|
| Access denied for user 'root'@'localhost' | 数据库密码错误或账号不允许远程 | 核对 application.yml;本地 root 密码是否与配置一致 |
| Port 8081 was already in use | 后端端口占用 | 换端口或杀掉占用进程,Windows 用 netstat -ano 排查 |
| Cannot load driver class: com.mysql.cj.jdbc.Driver | 驱动版本太老或没引入依赖 | 确认 pom 中 mysql-connector-java 版本 |
| Invalid bound statement (not found) | MyBatis mapper 接口和 XML 不匹配 | 检查 XML 的 namespace、方法 id,确认 mapper-locations 路径 |
| Failed to configure a DataSource | 启动时没扫描到数据库配置 | 确认 config 里没写死数据源初始化 |
| 前端图片 404 | uploads 目录没映射 | Nginx 添加 location /uploads 配置 |
7.2 并发抢猫导致重复认养
这个坑最开始是我没想到的,直到测试阶段两个人同时点“申请认养”同一只猫,数据库里生成了两条申请记录。原因是我先判断猫咪状态再插入申请,中间没有事务和锁。
后来修复方案就是前面说的乐观更新 SQL。这样即使请求并发再大,数据库行锁也会保证只有一个 UPDATE 操作成功,业务逻辑不用显式加分布式锁。这里想强调一点:任何“先查后改”的操作,在高并发下都必须想清楚竞态条件,不能靠感觉写代码。
7.3 事务不生效的三种典型场景
我踩过事务失效,而且查了很久。总结三个典型场景:
第一,异常被 catch 掉且没有重新抛出,事务自然无法回滚。必须确保 Service 方法里异常向上传播,或者在 catch 块里手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
第二,@Transactional 加在 private 方法或同类内部调用上。Spring 的事务是基于 AOP 代理的,同类内部调用 this.xxx() 不会走代理,事务就失效。解决办法是把需要事务的方法拆到另一个 Service 类,或者注入自身代理。
第三,事务方法里调用加了 try-catch 的 RPC 或本地方法,理论上也是可以理解的坑。这里不展开,但记住 rollbackFor = Exception.class 必须显式声明,默认只在 RuntimeException 上回滚,某些业务异常不会触发。
7.4 扩展建议:从管理后台到完整生态
整套代码跑通后,如果想继续扩展,我建议按这三个方向走。
第一个方向是增加小程序或移动端点。认养大厅和详情页的 API 本来就是 JSON 接口,小程序端直接复用后端接口,只需要重新写一套前端页面,工作量集中在登录态适配。
第二个方向是做缓存优化。当前猫咪列表每次请求都查数据库,如果数据量大了,可以用 Redis 缓存列表接口 5 分钟,后台操作猫咪时主动删除缓存。认养审批后要同步清理相关用户的缓存,避免出现“已认养但列表还是可认养”的视觉错误。
第三个方向是数据分析和周边功能。比如在 dashboard 页面增加按月统计认养成功率的折线图、按品种分析被认养周期。技术上可以用 MyBatis 写 group by 语句,前端用 ECharts 展示,配合定时任务生成日报,整个系统看起来就更“企业级”了。
在我个人看来,这个项目的难度不在某个技术点,而在于把认养这种线下流程用系统完整表达出来。如果你手头也有一份类似的源码,建议先画一遍状态流转图,再对照表结构和事务逻辑,就会发现代码里每一个 if 判断都有业务意义。把猫的状态管住了,这套系统的主力功能也就稳了。