做这个系统之前,我先说个背景。我接触过好几个动保组织,他们的日常管理基本靠微信群加Excel表格来完成:谁家狗被领养了、哪只猫在治疗中、钱花了多少、志愿者排班是几号……数据散落在各个人手里,想查个信息得来回翻聊天记录。这个宠物爱心组织管理系统,就是冲着这种痛点去做的。整个项目技术栈很清晰:SpringBoot2负责后端业务逻辑,Vue3做前端页面,MyBatis-Plus处理数据库操作,MySQL8.0作为底层存储,前后端分离,代码和文档齐整,适合刚学完Java Web想找个完整项目练手的人,也适合公益组织拿去做二次开发。
先说这个系统能干什么。核心就是五个字:管人、管宠物。系统里有三种主要角色:管理员、志愿者、普通用户。管理员维护宠物档案、审核领养申请、管理捐献记录;志愿者报名活动、填写服务日志;用户可以浏览宠物列表、提交领养申请、查看公示公告。整个项目从后端接口到前端页面,从数据库表到部署脚本,都有对应文档说明,复现起来不需要太多额外摸索。我的评价是:这是一个标准的、能跑起来的、有完整业务闭环的后台管理型项目。
1 项目整体设计与技术选型思路
1.1 宠物爱心组织管理需要解决的核心问题
动保组织的信息管理,和普通商业后台不一样。宠物不是商品,领养不是下单,整个业务流程掺杂着大量非标操作,比如:
档案杂乱。每只宠物的来源渠道不一样——有流浪救助、走失寻主、弃养接收、执法移交。每只宠物的健康状态也在变:待驱虫、治疗中、康复、可领养、已领养、已回访。如果没有系统,这些信息基本靠记忆和口头交接。
领养流程无闭环。常规领养流程是:浏览宠物 → 提交申请 → 管理员初审 → 家访/聊聊 → 签协议 → 接宠物 → 定期回访。很多组织走到第二步就断档了,申请表发出去没有回调机制,到底批没批、卡在哪个环节,全凭运气。
数据统计难。管理者和捐助人最关心的几个数字——每月收容多少只、成功领养多少只、治疗花了多少钱、各类捐款渠道占比——纯靠人工汇总,一个月底就要熬几个晚上。
所以系统设计的首位目标不是界面多漂亮,而是把“流程”固化下来。宠物有状态机,领养申请有流转状态,志愿者活动有报名和签到,钱和物资有台账。所有数据落到表里,随时可以查、可以统计、可以追溯。
1.2 技术选型背后的逻辑
选这套技术栈不是拍脑袋,每一层都有明确理由。
SpringBoot2,业内用得最广、资料最多、踩坑解决方案最全的企业级Java框架。SpringBoot2相比SpringBoot1最大的好处是自动配置和starter机制,以前写SpringMVC + Spring + MyBatis整合要配N个XML,现在一个spring-boot-starter-web就搞定,内置Tomcat,打jar包直接跑。
Vue3,前端目前的主流版本。相比Vue2,Vue3的Composition API解决了复杂组件逻辑复用难的问题,性能上也有提升——更小的打包体积、更快的渲染速度。对于这个项目的后台管理页面,用setup语法糖写起来比Options API清爽得多,代码量直接少三分之一。
MyBatis-Plus,有人叫它“增强版MyBatis”。MyBatis本身已经够灵活,但用起来老是写重复的增删改查SQL。MyBatis-Plus把这些通用的CRUD方法全部封装在BaseMapper和IService里,实体类加个@TableName注解,单表操作基本不用手写SQL。这个项目里大部分基础接口,都是直接调用封装方法。
MySQL8.0,目前社区最稳定的开源关系型数据库。相比5.7,8.0的默认字符集升到utf8mb4(emoji表情不会乱码)、支持窗口函数、支持WITH查询,对索引优化也有改进。更重要的是,8.0是长期支持版本,网上教程多、云数据库默认版本也是它,跟项目环境匹配度高。
1.3 系统角色与功能模块划分
整个系统按角色权限分成三个入口,管理员后台、志愿者端、普通用户端。权限控制虽然没上Spring Security那一套重量级方案,但也做到了基于拦截器和角色字段的接口级控制。
| 模块 | 管理员 | 志愿者 | 普通用户 | 核心功能说明 |
|---|---|---|---|---|
| 登录注册 | 是 | 是 | 是 | JWT令牌鉴权,角色区分 |
| 宠物管理 | 是 | 只读 | 只读 | 宠物档案增删改查、状态变更 |
| 领养管理 | 是 | 查看 | 申请 | 申请提交、审核、状态流转 |
| 志愿者活动 | 是 | 报名 | 查看 | 活动发布、报名管理、签到 |
| 捐赠管理 | 是 | 查看 | 查看 | 资金和物资台账、统计报表 |
| 公告管理 | 是 | 查看 | 查看 | 信息发布、置顶 |
这里我特别想强调的是,不要把权限控制做成前端按钮隐藏就完事。前端隐藏只是体验上的优化,真正的安全性必须放后端。这个项目里每个Controller方法都加了自定义拦截器校验,管理员接口会检查用户角色,普通用户拿到了接口地址也调不通。
2 数据库设计详解(MySQL8.0)
2.1 核心表结构与关系设计
整个库的表不算多,但每张表都用得扎实。最核心的有七张:用户表、宠物信息表、领养申请表、志愿者表、活动表、捐赠记录表、公告表。
用户表sys_user:因为系统里管理员、志愿者、普通用户共用一张表,所以有一个role字段区分角色,取值1管理员、2志愿者、3普通用户。表结构长这样:
CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT '加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `role` tinyint NOT NULL DEFAULT 3 COMMENT '角色:1管理员,2志愿者,3普通用户', `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';宠物信息表pet_info,这是全系统业务量最大的表,字段比较细:
CREATE TABLE `pet_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `pet_name` varchar(50) NOT NULL COMMENT '宠物名字', `pet_type` varchar(20) NOT NULL COMMENT '种类:犬/猫/其他', `breed` varchar(50) DEFAULT NULL COMMENT '品种', `age_month` int DEFAULT NULL COMMENT '月龄', `gender` tinyint DEFAULT NULL COMMENT '性别:1公,2母,0未知', `color` varchar(30) DEFAULT NULL COMMENT '毛色', `health_status` varchar(100) DEFAULT NULL COMMENT '健康状况描述', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0待领养,1审核中,2已领养,3治疗中,4已离世', `avatar` varchar(255) DEFAULT NULL COMMENT '照片URL', `description` text COMMENT '详细介绍', `source` varchar(50) DEFAULT NULL COMMENT '来源:流浪救助/走失寻主/弃养接收/执法移交', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status` (`status`), KEY `idx_type` (`pet_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物信息表';为什么单独建idx_status索引?因为系统里使用频率最高的一类查询是“当前状态为待领养的宠物”,如果宠物总量上万,没有索引的情况下MySQL会全表扫描。这种高频查询字段建索引,查询速度能快一个量级。
2.2 关键表关系与业务约束
领养申请表adoption_apply是连接用户与宠物的核心业务表:
CREATE TABLE `adoption_apply` ( `id` bigint NOT NULL AUTO_INCREMENT, `pet_id` bigint NOT NULL, `user_id` bigint NOT NULL, `apply_reason` varchar(500) DEFAULT NULL COMMENT '领养理由', `contact_phone` varchar(20) NOT NULL, `address` varchar(200) DEFAULT NULL COMMENT '居住地址', `has_yard` tinyint DEFAULT NULL COMMENT '是否有院子/阳台:1有,0没有', `family_agree` tinyint DEFAULT NULL COMMENT '家人是否同意:1同意,0不同意', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0待审核,1已通过,2已拒绝,3已取消,4已完成', `audit_remark` varchar(500) DEFAULT NULL COMMENT '审核备注', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_pet_id` (`pet_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='领养申请表';这里要特别注意业务约束的取舍。从纯数据库设计角度,应该给pet_id和user_id加外键确保引用完整性,但实际开发中,外键我建议不加。原因很简单:外键约束虽然能保数据一致性,但会让删除操作变复杂、在高并发插入时产生额外的锁开销。这个项目采用逻辑外键,也就是表里的关联字段在代码层面维护,数据库层面不建FOREIGN KEY。比如领养审核通过时要同时把宠物状态改成已领养,这个原子操作放在一个事务方法里做,靠代码保证一致性,效果等同外键但灵活得多。
还有一个细节:adoption_apply表里我用idx_pet_id加idx_user_id分别建索引,而不是建联合索引。原因是我既可能需要按宠物查看所有申请列表(管理员查某只宠物被哪些人申请了),也可能按用户查看申请历史(用户看自己的申请记录),两种情况都是独立查询,分别建索引覆盖度更好。如果你确定只有“按宠物查申请”这一个场景,那建idx_user_pet联合索引(pet_id, user_id)更省空间。这块没有绝对标准,取决于业务查询模式。
2.3 MySQL8.0带来的实际收益
开发这个项目时用的是MySQL8.0.36,有几个切切实实的好处值得说:
utf8mb4默认字符集。MySQL8.0的默认字符集是utf8mb4,而不是5.7的utf8(utf8mb3)。这意味着你不需要在建表时手动加DEFAULT CHARSET=utf8mb4,宠物昵称里就算有emoji表情也能正常存储。在5.7时代,很多人建表忘了指定字符集,插入一个表情直接报Incorrect string value错误,这个坑在8.0里从源头堵上了。
窗口函数。做统计报表时非常香。比如要查“每个月的领养成功数量及占总量的百分比”,5.7得写子查询嵌套,8.0直接:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(*) AS adoption_cnt, ROUND(COUNT(*) / SUM(COUNT(*)) OVER() * 100, 2) AS percent FROM adoption_apply WHERE status IN (1, 4) GROUP BY DATE_FORMAT(create_time, '%Y-%m');SUM(COUNT(*)) OVER()这个窗口函数直接算出全局总数,不需要自连接,SQL可读性高得多。
更好的JSON支持。如果后续要给宠物档案增加自定义体检指标,直接加一个JSON类型的字段就可以,无需要求每只宠物都有体检数据。这里JSON字段的好处是字段可以缺失,MySQL不会像普通字段那样要求每行都有值。
3 后端开发:SpringBoot2 + MyBatis-Plus实战
3.1 项目初始化与依赖配置
后端工程结构用标准的三层架构:controller/service/mapper,额外加了一个config包放配置类和拦截器,一个common包放统一返回结果和异常处理。这也是我个人习惯,适合中小型项目。
pom.xml里的核心依赖一定要说清楚,新手最容易在这个地方被各种版本冲突折磨:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里有个细节值得强调:SpringBoot2.7.18是2.x系列的最后一个版本,选它是因为网上大部分教程和经验帖子都基于2.x,遇到问题能搜到参考。如果你自己开新项目且不依赖老旧代码,我更推荐直接上SpringBoot3.x + JDK17,但那个组合对JDK版本有硬性要求(必须17+),如果你的服务器上一堆老项目用的JDK8,SpringBoot2 + JDK8反而是最稳的选择。这个项目定位就是兼容性优先,所以我锁的2.7.18。
另外一个重要变化是驱动的groupId。MySQL官方从Connector/J 8.0.31开始,把groupId从mysql:mysql-connector-java改成了com.mysql:mysql-connector-j。如果你在pom里同时引入了两个坐标或者是老坐标,可能遇到ClassNotFound异常。这个项目直接用的新坐标。对应的application.yml配置如下:
server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/pet_org?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: autoserverTimezone=Asia/Shanghai这行一定要写。不写的话,如果数据库地区设置和JDK默认时区不一致,查时间字段会整体偏移8小时,插入时间更是错得离谱。我在排查时见过太多新手在这个小地方阴沟翻船。
map-underscore-to-camel-case属性一定要设为true,这样数据库字段pet_name才能自动映射到Java属性petName。MyBatis-Plus默认是开启的,但如果你在application.yml里覆盖了configuration,一定要把它加上。
3.2 通用CRUD服务的设计
MyBatis-Plus最省心的地方就在这里。实体类写完之后,基础增删改查不写一行SQL:
@Data @TableName("pet_info") public class PetInfo { @TableId(type = IdType.AUTO) private Long id; private String petName; private String petType; private String breed; private Integer ageMonth; private Integer gender; private String color; private String healthStatus; private Integer status; private String avatar; private String description; private String source; private LocalDateTime createTime; private LocalDateTime updateTime; }Mapper接口,三行搞定:
@Mapper public interface PetInfoMapper extends BaseMapper<PetInfo> { }Service接口和实现类:
public interface PetInfoService extends IService<PetInfo> { Page<PetInfo> pageWithFilter(int page, int size, String keyword, Integer status); } @Service public class PetInfoServiceImpl extends ServiceImpl<PetInfoMapper, PetInfo> implements PetInfoService { @Override public Page<PetInfo> pageWithFilter(int page, int size, String keyword, Integer status) { LambdaQueryWrapper<PetInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), PetInfo::getPetName, keyword) .eq(status != null, PetInfo::getStatus, status) .orderByDesc(PetInfo::getCreateTime); return this.page(new Page<>(page, size), wrapper); } }这段代码的精髓是LambdaQueryWrapper的条件拼装。like方法的第一个参数是个boolean值,当keyword为空时,整个条件直接不参与SQL拼接,不需要你手动写if判断。这种写法比MyBatis的XML动态SQL直观得多,也避免了字符串拼接SQL注入风险。
@RestController @RequestMapping("/pet") public class PetController { @Autowired private PetInfoService petInfoService; @GetMapping("/page") public Result<Page<PetInfo>> page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword, @RequestParam(required = false) Integer status) { return Result.success(petInfoService.pageWithFilter(page, size, keyword, status)); } @GetMapping("/{id}") public Result<PetInfo> detail(@PathVariable Long id) { return Result.success(petInfoService.getById(id)); } }Controller返回统一封装类Result,结构是{code: 200, message: "success", data: {...}}。这样前端axios拦截器只需要判断code,不需要为每个接口写异常处理。
3.3 领养申请业务流实现
领养申请是整个系统业务复杂度最高的部分,因为涉及状态流转和数据一致性。流程是:用户提交申请 → 管理员审核通过/拒绝 → 通过后宠物状态变为已领养 → 用户确认接收后状态变为已完成。
核心方法长这样:
@Service @RequiredArgsConstructor public class AdoptionApplyServiceImpl extends ServiceImpl<AdoptionApplyMapper, AdoptionApply> implements AdoptionApplyService { private final PetInfoService petInfoService; @Override @Transactional(rollbackFor = Exception.class) public boolean submitApply(AdoptionApply apply) { PetInfo pet = petInfoService.getById(apply.getPetId()); if (pet == null) { throw new BizException("宠物不存在"); } if (pet.getStatus() != 0) { throw new BizException("该宠物当前不可申请领养"); } // 同一个人不能对同一宠物重复申请 long count = this.count(new LambdaQueryWrapper<AdoptionApply>() .eq(AdoptionApply::getPetId, apply.getPetId()) .eq(AdoptionApply::getUserId, apply.getUserId()) .ne(AdoptionApply::getStatus, 3)); // 3为已取消 if (count > 0) { throw new BizException("您已申请过该宠物的领养"); } apply.setStatus(0); return this.save(apply); } @Override @Transactional(rollbackFor = Exception.class) public boolean audit(Long applyId, Integer status, String remark) { AdoptionApply apply = this.getById(applyId); if (apply == null || apply.getStatus() != 0) { throw new BizException("申请不存在或已处理"); } if (status == 1) { // 审核通过:先更新申请状态,再锁定宠物 PetInfo pet = petInfoService.getById(apply.getPetId()); if (pet.getStatus() != 0) { throw new BizException("宠物已被其他人领养,请重新确认"); } pet.setStatus(1); // 审核中状态,相当于预订 petInfoService.updateById(pet); } else if (status == 2) { // 拒绝:原状态不变 } apply.setStatus(status); apply.setAuditRemark(remark); return this.updateById(apply); } }这个实现里有几个关键点:
@Transactional(rollbackFor = Exception.class)必须是默认策略的补充。Spring默认只对RuntimeException回滚事务,如果你在业务方法里catch了业务异常又抛出Exception,事务不会回滚。这里显式指定所有异常都回滚,防止脏数据。
状态校验放事务内做。尤其在audit方法里,先查申请状态是否为待审核,再查宠物当前状态,两个检查都在同一个事务里,避免并发审核导致同一只宠物被两个人同时领养。
乐观锁没有硬上。这个系统的用户并发极低,用事务加状态判断足够。如果以后要对接真实场景、领养申请并发暴涨,就需要在pet_info表加version字段,配合MyBatis-Plus的@Version注解做乐观锁,防止ABA问题。
3.4 登录鉴权与权限控制
登录这块没有引入Spring Security,因为对这个体量的项目来说,Spring Security的学习成本和配置复杂度都偏高。我自己写了一个轻量级JWT + 拦截器方案:
@Component public class AuthInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); Long userId = jwtUtil.parseToken(token); if (userId != null) { request.setAttribute("userId", userId); return true; } } response.setStatus(401); return false; } }再定义一个@RequireRole注解,用于管理员接口:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { int[] value(); }在拦截器里读取用户token后,进一步从UserService查出角色,和@RequireRole里的要求比对,不匹配直接返回403。这套方案虽然简陋,但对这个项目够了——它的核心价值在于让你理解“token校验”和“权限判断”是两个独立环节,前端也许会漏配,后端拦截是最后一道防线。
4 前端开发:Vue3实战记录
4.1 Vue3项目结构与Composition API
前端我用Vite作为构建工具创建,相比vue-cli的webpack,Vite的开发服务器启动速度快得肉眼可见,一个稍微大点的项目,webpack冷启动可能要十几秒,Vite基本秒开。
项目结构如下:
src/ ├── api/ # 封装axios请求 │ ├── pet.js │ ├── apply.js │ └── user.js ├── components/ # 公共组件 ├── layout/ │ └── AdminLayout.vue ├── router/ │ └── index.js ├── store/ # 全站状态 ├── views/ │ ├── admin/ # 管理员页面 │ ├── volunteer/ # 志愿者页面 │ └── public/ # 公共页面 ├── utils/ │ ├── request.js # axios实例 │ └── auth.js └── main.jsVue3最核心的写法变化就是<script setup>语法糖,配合Composition API。拿宠物列表页举例:
<script setup> import { ref, reactive, onMounted } from 'vue' import { fetchPetPage } from '@/api/pet' const loading = ref(false) const petList = ref([]) const total = ref(0) const queryParams = reactive({ page: 1, size: 12, keyword: '', status: null }) async function loadPets() { loading.value = true try { const { data } = await fetchPetPage(queryParams) petList.value = data.records total.value = data.total } finally { loading.value = false } } function handleSearch() { queryParams.page = 1 loadPets() } onMounted(loadPets) </script>这里有一个新手极容易踩的坑:reactivevsref的选择。上面代码里,queryParams我用reactive包,petList和total我用ref包。原因是:
ref适合包装基本类型和独立的对象,读取时要写.value,模板里自动解包。reactive适合包装整个“响应式状态对象”,比如表单数据、查询参数,因为不需要每次queryParams.value.page这样写。
如果你用ref包一个本来要用.访问内部属性的复杂对象,代码里全是obj.value.xxx,繁琐不说,还容易忘记。反过来,用reactive包基本类型值是不允许的,比如let count = reactive(0)是不生效的,必须ref(0)。明白这两者的边界,Vue3写起来就顺了。
4.2 核心页面实现要点
宠物卡片列表页是访客第一眼看到的页面,设计成卡片流式,每个卡片上突出显示宠物照片、名字、性别、年龄、状态标签。状态标签我直接对接后端返回的status字段,在前端做一个映射:
const statusMap = { 0: { text: '待领养', type: 'success' }, 1: { text: '审核中', type: 'warning' }, 2: { text: '已领养', type: 'info' }, 3: { text: '治疗中', type: 'danger' }, 4: { text: '已离世', type: 'danger' } }然后在模板里{{ statusMap[pet.status].text }},配上Element Plus的Tag组件的type属性,颜色和语义一次对应。
领养申请表单要特别注意校验。宠物领养不是简单的表单提交,有几个字段对审核决策影响很大:居住环境、家人是否同意、养宠物经验。前端用了Element Plus的form组件的rules做必填和格式校验,比如手机号必须11位、地址必填。提交前调formRef.validate(),通过后才发送请求。
管理后台表格页是管理员使用频率最高的页面。要关心的问题不是样式,而是数据加载效率和操作便捷性。我在宠物管理页用了分页表格 + 顶部筛选区,筛选条件有种类、状态、来源、关键字搜索,全部条件通过queryParams对象传给后端,后端用MyBatis-Plus动态拼SQL。这里我有一个经验:表格的搜索按钮和重置按钮必须分开,搜索是重新请求第一页,重置是清空所有条件后重新请求。很多新手把重置写成queryParams = {},直接导致分页页码等字段丢失。
志愿者活动报名页比较简单清晰:活动列表、活动详情、报名按钮三件套。报名时后端会校验是否已经报过,前端只做友好提示。
4.3 前后端数据交互与跨域处理
所有请求统一走utils/request.js里封装的axios实例:
import axios from 'axios' const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }) http.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) http.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { // 业务错误统一提示 return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response?.status === 401) { // 登录过期,跳回登录页 localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } ) export default http跨域问题在开发环境用Vite的proxy解决,不需要后端单独开放CORS:
// vite.config.js export default { server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这个配置的意思是:前端页面请求/api/pet/page时,Vite开发服务器会把这个请求转发到后端http://localhost:8080/api/pet/page。注意changeOrigin: true是必须的,否则某些情况下请求头里的Host还是前端域名,后端过滤器和框架可能会判断为跨域拒绝。
生产环境一般用Nginx做同源代理,把前端静态资源放在80端口、后端接口通过/api路径反向代理到8080,这样浏览器只认一个域名,天然没有跨域。部署细节下一节详细说。
5 环境搭建、联调与部署实录
5.1 MySQL8.0环境搭建:Docker方式最省心
项目文档里写了两套MySQL8.0环境搭建方案:本机安装和Docker。我的建议是开发环境直接用Docker,生产环境再用本机安装或云数据库。本地敲两行命令,一个干净的MySQL8.0就出来了:
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=pet_org \ -v /home/user/mysql-data:/var/lib/mysql \ mysql:8.0解释一下参数:
-d后台运行--name mysql8容器名-p 3306:3306把容器内3306映射到宿主机3306-e MYSQL_ROOT_PASSWORD=root123设置root密码-e MYSQL_DATABASE=pet_org启动时自动创建数据库-v挂载持久化目录,这个必须加。不加的话容器删了数据全没了。
启动后用docker exec -it mysql8 mysql -uroot -proot123进入容器验证,能进说明环境OK。
MySQL8.0的密码认证方式是caching_sha2_password,跟MySQL5.7的mysql_native_password不同。如果你的JDBC驱动版本太老(5.x),会报Public Key Retrieval is not allowed。解决办法就是本项目这样用最新的com.mysql:mysql-connector-j驱动,或者连接URL里加allowPublicKeyRetrieval=true。
5.2 后端打包启动与前端构建
后端项目是标准Maven工程,打包执行:
mvn clean package -DskipTests在target目录下会生成pet-org-system-0.0.1-SNAPSHOT.jar。启动命令就一行:
java -jar pet-org-system-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod这里我建议所有配置项都走application.yml外部化——用application-prod.yml放生产配置,启动时通过--spring.profiles.active指定环境。千万不要把数据库密码直接写在代码或配置文件里提交到git仓库,用环境变量引用更安全,比如:
spring: datasource: password: ${DB_PASSWORD}前端构建:
npm run build生成dist目录,里面是纯静态文件。部署到Nginx:
server { listen 80; server_name pet.example.com; # 前端静态文件 root /var/www/pet-frontend/dist; index index.html; # 解决Vue Router history模式刷新404 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这行不能省。Vue Router用history模式时,刷新一个内部路由(比如/admin/pet),如果Nginx找不到对应文件会返回404,这行配置让Nginx把这种情况回退到index.html,由前端路由接管。
5.3 初始数据导入与功能验证
项目自带一个db/pet_org.sql初始化脚本,建表 + 插入初始数据一次性搞定:
mysql -uroot -proot123 pet_org < db/pet_org.sql验证系统是否完整跑通,我建议按下面这个清单走一遍:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 管理员账号登录 | 跳转管理后台,token写入localStorage |
| 2 | 新增一只宠物 | 刷新列表能查到,状态为待领养 |
| 3 | 普通用户注册并提交领养申请 | 管理后台“待审核”列表出现该申请 |
| 4 | 管理员审核通过 | 宠物状态自动变为审核中 |
| 5 | 用户确认接收 | 宠物状态变为已领养,记录完成 |
| 6 | 管理员发布活动 | 志愿者端能看到,可报名 |
| 7 | 捐赠记录录入 | 统计页数据变化正确 |
走完这七步,说明系统的核心业务闭环是通着的。如果卡在中间某一步,基本问题都出在接口联调或数据库状态机不一致,排查思路放到下一节。
6 常见问题与排查技巧实录
6.1 新手最容易踩的坑Top6
坑1:MySQL8.0驱动类名错误。老教程里写的com.mysql.jdbc.Driver在8.0里已经废弃,必须用com.mysql.cj.jdbc.Driver。如果不小心用错,启动项目报ClassNotFoundException。排查方法:先确认pom依赖是mysql-connector-j而非老坐标,再核对配置。
坑2:MyBatis-Plus查询字段自动填充失效。如果实体字段有createTime、updateTime,你希望它插入时自动填值,需要在字段上加@TableField(fill = FieldFill.INSERT)注解,并实现MetaObjectHandler处理器。否则你会发现新增记录后create_time是NULL。项目里我建议直接放到数据库层用DEFAULT CURRENT_TIMESTAMP兜底,代码层也做了处理器双保险。
坑3:前端请求跨域报CORS错误。开发环境配了Vite proxy还报CORS,绝大概率是你请求的URL没有走Vite代理——比如直接把http://localhost:8080/api/xxx写死在代码里,前端实际是发到3000端口代理的,两者不一致。确认baseURL是/api而不是完整的后端地址。
坑4:Vue3响应式丢失。最常见的是从接口拿到的数据,直接赋值给ref对象,却在模板里不显示。大概率是你用reactive包了一个数组,然后整个数组重新赋值:state.list = res.data.records,这在Vue3里会丢失响应式。正确做法是state.list.splice(0, state.list.length, ...res.data.records),或者干脆用ref。
坑5:时间格式8小时偏移。后端查出来的时间是UTC,前端显示比北京时间早8个小时。排查方向:数据库、JDBC连接URL、Jackson配置三处必须统一Asia/Shanghai。见3.1节配置,三个地方我都写了。
坑6:Nginx部署刷新404。按5.2节配置,try_files那行就是解药。忘记加的人,前端路由切换没事,一刷新白屏或404,非常典型。
6.2 几个排查思路的实操记录
有一次用户反馈,管理后台审核领养申请时,明明点了通过,但宠物状态没变。我第一时间怀疑是事务问题,后来发现是updateById的乐观锁字段没配好。MyBatis-Plus的@Version注解如果放在一个值为null的字段上,更新时生成的SQL会带WHERE version = ?,但set语句没把version+1,导致第二次更新永远成功不了。排查方式是在控制台打开SQL日志(log-impl: StdOutImpl),看到输出的UPDATE语句就能立刻发现。
另一个记录是前端志愿者活动页面,搜索关键字后点重置,表格不见了。原因是重置时把queryParams对象整个置空了,后端收到size为null,SQL分页参数异常。后来我把重置逻辑改成只重置筛选字段,保留page和size:
function handleReset() { queryParams.keyword = '' queryParams.status = null queryParams.page = 1 loadActivities() }6.3 项目后续扩展方向
如果这个系统需要真正投放到动保组织使用,我建议往三个方向扩展:
文件存储。目前宠物照片是用URL字符串存的,部署时得自己准备静态资源服务器或把图片丢到Nginx目录。更好的方案是接入对象存储服务(OSS或MinIO),后端提供预签名上传接口,前端直传,能大幅减轻后端带宽压力。
消息通知。领养审核结果、活动报名成功、公告发布,这些场景目前只能靠用户主动刷新看。扩展一个通知中心模块,或者在关键状态变更时调用邮件/短信接口,体验会好很多。
移动端适配。很多动保组织的志愿者是用手机工作的。前端Vue3代码可以复用业务逻辑,用uni-app或直接把现有页面做成响应式,让手机浏览器访问体验更好。管理后台的复杂表格在手机上还是受限,可以考虑单独做一个小程序端。
我个人在做这个项目时最大的体会是:一个系统能不能被真正用起来,不是看技术多花哨,而是看业务流程有没有梳理清楚。宠物状态机、领养审核流转、捐赠台账,这些业务规则的合理性直接决定了使用者愿不愿意长期用。技术栈只是工具,真正花时间的还是把动保场景下的非标需求一点点结构化成代码逻辑。这个项目提供了一个相对完整的起点,剩下的,就是在真实使用中不断打磨了。