做宠物爱心组织管理系统,最容易被忽视的其实不是技术难点,而是业务状态梳理。我去年帮一个本地动物救助站做过类似的系统,聊需求时对方张口就是“我要一个能看宠物列表的后台”,真做起来才发现:一只流浪猫从救助进站到被领养走,中间要经历体检、驱虫、疫苗、暂养、审核、回访,每个环节的数据都要留痕,光“领养申请”这一个动作就有六种状态要流转。今天这篇就把这套系统的完整落地过程拆开讲清楚,从数据库设计到前后端联调,再到部署上线,全程基于SpringBoot + Vue3 + MyBatis + MySQL这套前后端分离方案,包含完整的源码级实操细节。
1. 项目整体设计与思路拆解
1.1 业务需求的核心痛点解析
宠物爱心组织(救助站、流浪动物收容所)的日常管理,痛点是极其琐碎且充满“异常分支”的。一只狗进站后,它可能被领养、被寄养、被退回、甚至走失;一笔捐款可能对应指定用途(比如“给三花猫做绝育”);一位志愿者可能有多个服务时段。这些场景如果用Excel硬扛,数据冗余和状态错乱是迟早的事。
从系统建设角度,我的建议是先把核心业务实体拆成六大块:
- 宠物档案管理:基本信息(品种、年龄、毛色)、健康记录(疫苗、驱虫、绝育)、在站状态(在站、暂养中、已领养、已离世)。
- 领养业务流程:申请提交、资质审核、签订协议、宠物交接、回访记录。这是整个系统最核心、状态流转最复杂的模块。
- 捐赠与物资管理:资金捐赠记录、物资入库(猫粮、猫砂、药品)、库存领用出库。
- 志愿者管理:志愿者信息登记、服务时长记录、排班管理。
- 活动管理:领养日活动、义卖活动、线下宣传活动的发布与报名。
- 系统管理:用户登录,RBAC权限控制(管理员、工作人员、志愿者等不同角色)。
1.2 技术选型背后的取舍逻辑
这套系统我会选SpringBoot + Vue3 + MyBatis + MySQL,而不是SpringBoot + JPA、或者SpringBoot + MyBatis-Plus,是有明确考虑的:
- MyBatis胜过JPA:这个领域业务查询条件极不稳定,比如“查询3岁以下、已绝育、未领养的猫咪,按入站时间倒序”,这类多条件动态组合查询,MyBatis的XML动态SQL写起来非常自然,改动灵活,而JPA的Specification写起来偏重,学习成本更高。
- 为什么可以不用MyBatis-Plus:虽然MyBatis-Plus的IService模板确实能省不少CRUD代码,但宠物组织管理系统存在大量多表关联查询(如领养申请关联宠物档案、申请人和回访记录),这些复杂SQL用通用Mapper反而别扭,自写SQL带结果映射更可控。而且面试中如果聊到MyBatis源码级工作流程,手写XML方式更容易讲清楚原理。
- Vue3 + Composition API:管理后台的页面逻辑包含大量自定义的筛选组件、表单校验组件和状态标签组件,Composition API对逻辑复用极其友好。Vue3配合Pinia做用户会话状态管理,配合Vue Router做路由级权限控制,这套组合在中小型后台项目中已经相当成熟。
- MySQL 5.7及以上版本:这套系统数据量撑死到几十万行,MySQL完全够用,5.7版本即可支持JSON字段,业务上有需要额外扩展字段时方便做柔性方案。
技术栈本身不算新奇,真正考验人的是怎么把存量业务状态机梳理成数据库可落地的模型。
2. 数据库设计与核心表结构实战
2.1 核心表结构设计(可直接抄作业)
我一直认为,很多管理系统的代码写乱,根因是表设计时忽略了状态字段的扩展性。直接上这套系统的核心建表SQL,宠物档案表这样设计:
CREATE TABLE `pet` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `pet_no` VARCHAR(32) NOT NULL COMMENT '宠物编号,例如SZ20240001', `name` VARCHAR(50) DEFAULT NULL COMMENT '宠物昵称', `species` TINYINT NOT NULL COMMENT '物种:1-猫 2-狗 3-其他', `breed` VARCHAR(50) DEFAULT NULL COMMENT '品种', `gender` TINYINT NOT NULL COMMENT '性别:1-公 2-母', `age_month` INT DEFAULT NULL COMMENT '月龄', `vaccine_status` TINYINT DEFAULT 0 COMMENT '疫苗状态:0-未接种 1-接种中 2-已完成', `neutered` TINYINT DEFAULT 0 COMMENT '是否已绝育:0-否 1-是', `health_status` VARCHAR(255) DEFAULT NULL COMMENT '健康状况描述', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '在站状态:1-在站 2-暂养中 3-已领养 4-已离世', `entry_date` DATE NOT NULL COMMENT '入站日期', `entry_channel` TINYINT DEFAULT NULL COMMENT '入站渠道:1-救助 2-弃养 3-流浪发现', `photo_url` VARCHAR(255) DEFAULT NULL COMMENT '宠物照片URL', `description` TEXT COMMENT '宠物描述', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_pet_no` (`pet_no`), KEY `idx_species_status` (`species`, `status`), KEY `idx_entry_date` (`entry_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物档案表';设计这张表时最需要注意的就是status字段。我见过很多初学者把它设计成is_adopted布尔值,但实际业务中一个宠物可能正在走领养流程但还没完成,此时它既不是“在站”也不是“已领养”,所以必须建立一个可扩展的状态枚举体系。
领养申请表如下,核心是申请状态的流转:
CREATE TABLE `adoption_application` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `application_no` VARCHAR(32) NOT NULL COMMENT '申请编号', `pet_id` BIGINT NOT NULL COMMENT '宠物ID', `applicant_id` BIGINT NOT NULL COMMENT '申请人ID(关联用户表)', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-待初审 2-初审通过待家访 3-家访通过待签协议 4-已签协议待交接 5-已完成 6-已拒绝 7-已取消', `apply_reason` VARCHAR(500) DEFAULT NULL COMMENT '领养原因', `house_type` VARCHAR(50) DEFAULT NULL COMMENT '居住类型(自有/租房)', `has_yard` TINYINT DEFAULT NULL COMMENT '是否有院子', `family_agreement` TINYINT DEFAULT NULL COMMENT '家人是否同意', `audit_user_id` BIGINT DEFAULT NULL COMMENT '审核人ID', `audit_comment` VARCHAR(500) DEFAULT NULL COMMENT '审核意见', `audit_time` DATETIME DEFAULT NULL COMMENT '审核时间', `adoption_agreement_url` VARCHAR(255) DEFAULT NULL COMMENT '领养协议文件URL', `handover_time` DATETIME DEFAULT NULL COMMENT '实际交接时间', `followup_records` TEXT COMMENT '回访记录(JSON数组)', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_application_no` (`application_no`), KEY `idx_pet_id_status` (`pet_id`, `status`), KEY `idx_applicant_id` (`applicant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='领养申请表';2.2 状态机设计与状态流转约束
表结构好建,难的是控制状态流转的合法性。领养申请绝不是任何状态都能跳到任何状态的。一个理智的状态机应该是单向受限的:
- 1(待初审)可以流转到2(初审通过)或6(拒绝)
- 2(初审通过待家访)可以流转到3(家访通过)或6(拒绝)
- 3(家访通过待签协议)可以流转到4(签协议)或6(拒绝)
- 4(待交接)可以流转到5(完成)
- 任何状态下申请人都可以取消(状态7)
这个状态流转的“合法性校验”建议放在Service层统一处理,用枚举加Map定义好允许的流转路径。千万不要相信前端的校验,因为接口是可能被直接调用的。
public enum AdoptionStatus { PENDING_REVIEW(1, "待初审"), INITIAL_PASSED(2, "初审通过待家访"), HOME_VISIT_PASSED(3, "家访通过待签协议"), AGREEMENT_SIGNED(4, "已签协议待交接"), COMPLETED(5, "已完成"), REJECTED(6, "已拒绝"), CANCELLED(7, "已取消"); public static boolean canTransit(int from, int to) { // 定义合法流转Map<当前状态, Set<可达状态>> return TRANSITION_MAP.getOrDefault(from, Collections.emptySet()).contains(to); } }这个设计有效防止了工作人员误操作或恶意绕过流程,尤其是“拒绝后直接改成已完成”这种不合逻辑的操作。
3. 后端核心模块实现:SpringBoot整合MyBatis全流程
3.1 项目初始化与依赖配置要点
SpringBoot版本选择,我建议直接用3.2.x或3.3.x版本,避免踩旧版本的安全坑。但要注意,SpringBoot 3.x要求JDK17及以上,如果你本机还是JDK8,可以先装个JDK17或者从spring initializr里选个2.7.x的版本,两者包结构和注解基本一致,只有底层差异。
pom.xml里的核心依赖如下:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>注意一个坑:mybatis-spring-boot-starter的版本号不再是跟着SpringBoot的大版本走的,最新版已经到了3.x,如果使用旧版1.3.2,大概率会出现SqlSessionFactory自动配置失效问题,排查起来很头疼。建议一开始就用3.x版本。
3.2 application.yml配置与MyBatis关键配置项
application.yml里我比较关注几个容易被忽略的参数:
spring: datasource: url: jdbc:mysql://localhost:3306/pet_adoption?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.petorg.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl几个配置背后的原因:
useSSL=false:MySQL 8.x默认开启SSL认证,本地开发环境不配证书会出现SSL连接错误,加上这个参数提前规避。allowPublicKeyRetrieval=true:MySQL 8.x使用caching_sha2_password认证时,如果连接没配置SSL,必须加这个参数,否则报Public Key Retrieval is not allowed。map-underscore-to-camel-case:数据库字段create_time自动映射到Java实体createTime,省掉大量@Results注解。log-impl:开发阶段打印SQL日志,排查动态SQL问题必备技能,生产环境记得把它们关掉。
3.3 手写XML动态SQL:多条件分页查询实战
领养后台的核心页面是“宠物列表页”,这个页面必须支持多条件组合查询:物种、状态、品种关键词、入站日期范围,还要按入站时间倒序分页。这种查询用MyBatis动态SQL写再合适不过。Mapper接口定义如下:
@Mapper public interface PetMapper { List<Pet> selectPetPage(@Param("species") Integer species, @Param("status") Integer status, @Param("keyword") String keyword, @Param("startDate") String startDate, @Param("endDate") String endDate, @Param("offset") int offset, @Param("limit") int limit); Long countPetPage(@Param("species") Integer species, @Param("status") Integer status, @Param("keyword") String keyword, @Param("startDate") String startDate, @Param("endDate") String endDate); }对应的XML映射文件:
<select id="selectPetPage" resultType="com.petorg.entity.Pet"> SELECT * FROM pet <where> <if test="species != null"> AND species = #{species} </if> <if test="status != null"> AND status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR pet_no LIKE CONCAT('%', #{keyword}, '%') OR breed LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="startDate != null and startDate != ''"> AND entry_date >= #{startDate} </if> <if test="endDate != null and endDate != ''"> AND entry_date <= #{endDate} </if> </where> ORDER BY entry_date DESC, id DESC LIMIT #{offset}, #{limit} </select>写这个XML时我踩过一个经验教训:如果你用了CONCAT('%', #{keyword}, '%'),不要直接写'%${keyword}%',后者会造成SQL注入风险。MyBatis的#{}是预编译参数占位符,${}是字符串拼接,动态排序字段名可以用$,但动态值绝对要用#。
3.4 XMLConfigBuilder与MyBatis启动流程的底层原理
很多人在调试MyBatis时遇到了Invalid bound statement (not found),这就是Mapper XML没被正确解析到。理解MyBatis启动流程能帮你快速定位这类问题。
当我们调用SqlSessionFactoryBuilder.build(inputStream)时,MyBatis内部会创建XMLConfigBuilder对象,它的核心职责是解析mybatis-config.xml全局配置文件(或者在Spring Boot场景下,读取application.yml中mybatis.*配置项)。XMLConfigBuilder会依次解析:
<settings>标签(驼峰映射、二级缓存等全局开关)<typeAliases>标签(实体类别名注册)<mappers>标签(加载Mapper接口,并绑定对应的XML文件路径)
在Spring Boot整合场景下,MyBatis的AutoConfiguration会先通过SqlSessionFactoryBean创建XMLConfigBuilder,并传入Configuration对象。XML文件解析最终走XMLMapperBuilder,它会遍历<select>|<update>|<delete>|<insert>节点,用MappedStatement对象封装SQL语句(包括动态SQL的SqlSource),注册进Configuration对象。之后每次调用Mapper接口,实际上是通过动态代理进入MapperProxy,根据方法名找到对应的MappedStatement去执行。
这个底层认知非常实用:遇到找不到SQL的报错,先确认XML文件是否在mapper-locations指定的目录里,再确认XML的namespace是否与Mapper接口全限定名一致,最后确认方法id是否与接口方法名完全一致。
4. 前端Vue3实现:从登录鉴权到领养流程页面
4.1 项目搭建与核心依赖
前端工程我习惯用Vite搭建,比Webpack冷启动快很多,配置也省心:
npm create vite@latest pet-admin -- --template vue-ts cd pet-admin npm install npm install vue-router@4 pinia element-plus axios目录结构按“功能模块”而不是“文件类型”划分,这是我个人认为管理后台项目最不容易乱的方式:
src/ |-- api/ # 接口请求封装,按模块拆文件 |-- assets/ # 静态资源 |-- components/ # 通用组件 |-- layout/ # 整体布局(侧边栏、顶栏) |-- router/ # 路由配置 + 路由守卫 |-- stores/ # Pinia状态 |-- views/ # 页面级组件 |-- pet/ # 宠物档案管理 |-- adoption/ # 领养申请管理 |-- donation/ # 捐赠管理 |-- volunteer/ # 志愿者管理 |-- system/ # 用户角色管理4.2 Vue3组合式API实践:以宠物列表页为例
Vue3里我全是使用<script setup>语法。以宠物管理页面为例,组合式API带来的最大优势是逻辑复用和状态聚拢。页面里既有筛选条件又有表格数据,既有权重判断又有弹窗表单状态,用ref和reactive管理起来非常直观:
<template> <el-card> <el-form :inline="true" :model="queryParams"> <el-form-item label="物种"> <el-select v-model="queryParams.species" placeholder="全部" clearable> <el-option label="猫" :value="1" /> <el-option label="狗" :value="2" /> <el-option label="其他" :value="3" /> </el-select> </el-form-item> <el-form-item label="状态"> <el-select v-model="queryParams.status" placeholder="全部" clearable> <el-option label="在站" :value="1" /> <el-option label="暂养中" :value="2" /> <el-option label="已领养" :value="3" /> <el-option label="已离世" :value="4" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleQuery">查询</el-button> <el-button @click="resetQuery">重置</el-button> </el-form-item> </el-form> <el-table :data="tableData" v-loading="loading"> <!-- 列配置 --> </el-table> <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize" :total="total" @change="fetchList" /> </el-card> </template> <script setup lang="ts"> import { ref, reactive, onMounted } from 'vue' import { getPetPage } from '@/api/pet' import { ElMessage } from 'element-plus' const loading = ref(false) const tableData = ref([]) const total = ref(0) const queryParams = reactive({ species: undefined, status: undefined, keyword: '', pageNum: 1, pageSize: 10 }) const fetchList = async () => { loading.value = true try { const res = await getPetPage(queryParams) tableData.value = res.data.records total.value = res.data.total } finally { loading.value = false } } const handleQuery = () => { queryParams.pageNum = 1 fetchList() } const resetQuery = () => { queryParams.species = undefined queryParams.status = undefined queryParams.keyword = '' fetchList() } onMounted(() => { fetchList() }) </script>这里我踩过的坑是:Element Plus的分页组件事件名在最新版本里改了,老版本用@current-change,新版本直接监听@change事件,且可以同时双向绑定当前页和每页条数。如果你用的组件库版本不一致,接口对不上会导致翻页失效,整整浪费了我半天时间。
4.3 前端路由守卫与权限控制
管理系统不能把路由都暴露出来,需要做到登录后根据角色动态生成菜单。我是通过Pinia存储用户登录状态和角色权限,在路由的beforeEach守卫里做判断:
// router/index.ts import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/user' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('@/views/login/index.vue') }, { path: '/', component: () => import('@/layout/index.vue'), redirect: '/dashboard', children: [ { path: 'pet', component: () => import('@/views/pet/index.vue'), meta: { roles: ['admin', 'staff'] } }, { path: 'adoption', component: () => import('@/views/adoption/index.vue'), meta: { roles: ['admin', 'staff'] } }, { path: 'volunteer', component: () => import('@/views/volunteer/index.vue'), meta: { roles: ['admin'] } } ] } ] }) router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.path === '/login') return next() if (!userStore.token) return next('/login') const roles = to.meta.roles as string[] | undefined if (roles && !roles.includes(userStore.role)) { return next('/403') } next() })前端的路由守卫只是体验优化,真正保底的是后端接口的权限注解。SpringBoot端我在关键的写操作接口上加了@PreAuthorize("hasRole('admin')"),防止有人绕过前端直接调接口。
5. 核心业务场景实现:领养申请状态流转与文件上传
5.1 领养申请状态流转接口的实现细节
领养审核流程的后端实现,核心在于两个地方:状态机的合法性校验,和申请日志的留痕。我在状态流转方法里这样设计:
@Service public class AdoptionServiceImpl implements AdoptionService { @Override @Transactional(rollbackFor = Exception.class) public void updateStatus(AdoptionStatusUpdateDTO dto) { // 1. 查库获取当前状态 AdoptionApplication application = adoptionMapper.selectById(dto.getApplicationId()); if (application == null) { throw new BizException("申请记录不存在"); } int fromStatus = application.getStatus(); int toStatus = dto.getTargetStatus(); // 2. 状态机合法性校验 if (!AdoptionStatus.canTransit(fromStatus, toStatus)) { throw new BizException("非法的状态流转: " + fromStatus + " -> " + toStatus); } // 3. 更新状态并记录审核信息 application.setStatus(toStatus); application.setAuditUserId(dto.getOperatorId()); application.setAuditComment(dto.getAuditComment()); application.setAuditTime(new Date()); // 4. 写入申请状态变更日志表,用于审计 adoptionMapper.updateStatus(application); adoptionLogMapper.insert(new ApplicationLog(application.getId(), fromStatus, toStatus, dto.getOperatorId(), dto.getAuditComment())); } }这个方法的重点是加锁问题:并发场景下,两个工作人员同时审核同一个申请单,可能都读取到“待初审”然后各自改成不同状态。MySQL的行锁在某些隔离级别下并不能完全防止这种并发覆盖。严谨做法是在Mapper里加乐观锁版本号字段,UPDATE语句带上WHERE version = #{oldVersion},或者使用SELECT ... FOR UPDATE。这个细节在面试或者代码评审时能展示你对并发的思考深度。
5.2 文件上传方案的落地实践(宠物照片与领养协议)
系统涉及宠物照片上传和领养协议PDF上传,可以选择存本地磁盘或阿里云OSS等对象存储。作为本地项目演示,存本地磁盘最省事:
@RestController @RequestMapping("/api/v1/file") public class FileUploadController { private final String uploadPath = "/data/petorg/upload/"; @PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { // 1. 校验文件类型:只允许jpg/png/pdf String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); if (!Arrays.asList(".jpg", ".jpeg", ".png", ".pdf").contains(ext.toLowerCase())) { throw new BizException("不支持的文件类型"); } // 2. 生成唯一文件名,避免中文名乱码和覆盖问题 String filename = UUID.randomUUID().toString().replaceAll("-", "") + ext; File dest = new File(uploadPath + filename); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); // 3. 返回可访问的URL路径 return Result.success("/upload/" + filename); } }这里我补一个重要的经验:MultipartFile.transferTo方法有个坑,如果目标路径的文件已存在,不同环境下表现不一致。稳妥做法是先判断dest.exists(),存在则删除或改用另一个UUID。还有,前端上传的Content-Type一定要是multipart/form-data,用axios时要额外注意不要手动设置Content-Type,否则浏览器自动生成的boundary会丢失,后端解析文件就会失败。
5.3 导出Excel报表:领养成功统计
组织需要定期汇总数据向资助方汇报。前端表格虽然能看,但实用场景还是导出Excel。我用的是Apache POI方式,在后端直接生成字节流返回给前端下载:
@Override public void exportAdoptionReport(HttpServletResponse response, int year) { List<AdoptionReportVO> list = adoptionMapper.selectYearlyReport(year); try (Workbook workbook = new XSSFWorkbook()) { Sheet sheet = workbook.createSheet("领养统计"); String[] headers = {"月份", "申请数", "初审通过数", "完成领养数", "被拒绝数"}; Row headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); } int rowIdx = 1; for (AdoptionReportVO vo : list) { Row row = sheet.createRow(rowIdx++); row.createCell(0).setCellValue(vo.getMonthName()); row.createCell(1).setCellValue(vo.getApplyCount()); row.createCell(2).setCellValue(vo.getPassCount()); row.createCell(3).setCellValue(vo.getAdoptCount()); row.createCell(4).setCellValue(vo.getRejectCount()); } response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=adoption_report_" + year + ".xlsx"); workbook.write(response.getOutputStream()); } catch (IOException e) { throw new BizException("导出失败"); } }做这个功能时我在POI的版本上栽过跟头:引入了poi-ooxml后,如果SpringBoot的依赖连带引入了旧版本的commons-compress,可能导致XSSFWorkbook初始化时就炸掉。解决方案是显式声明commons-compress版本,比如1.26.0。
6. 前后端联调:问题排查与避坑经验
6.1 跨域问题处理方案
前后端分离项目,前端跑在localhost:5173,后端跑在localhost:8080,跨域问题绝对绕不开。我在项目里用了SpringBoot的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); } }这里一个重点是allowedOriginPatterns("*")和allowedMethods里必须包含OPTIONS。浏览器发送非简单请求时(比如POST加JSON的Content-Type),会先发一个OPTIONS预检请求,如果后端不处理OPTIONS请求,前端控制台一直报CORS错误但后端日志只字未提,这个现象是最容易让人懵的。
6.2 MyBatis运行期常见问题排查速查表
把几个高频问题整理成表,方便你遇到时直接对号入座:
| 报错现象 | 根本原因 | 解决方案 |
|---|---|---|
Invalid bound statement (not found) | XML文件没被加载,或namespace/id不匹配 | 检查mapper-locations路径,检查XML命名空间和方法id |
There is no getter for property named | 实体类没写getter,或lombok未生效 | 确认类上有@Data注解,检查lombok依赖 |
BadSqlGrammarException | SQL本身语法错误,或表名/字段名不对 | 把日志里的SQL拷出来直接在Navicat跑一遍 |
TooManyResultsException | 接口返回单个对象但结果有多个 | 修正SQL条件,或改用List返回 |
| 分页查出重复数据 | 多条排序字段不唯一 | 排序加主键兜底:ORDER BY create_time DESC, id DESC |
| MySQL锁等待超时 | 事务未提交导致行锁占用 | 检查@Service方法上是否漏加@Transactional |
6.3 前端常见的联调Bug处理
后端接口全部配好后,前端联调还会有几个经典问题:
- 图片加载404:后端返回的
/upload/xxx.jpg这个相对路径,前端部署在5173端口解析时,会指向前端域名下的/upload,自然是404。解决思路是前端在表格渲染时对图片URL做拼接,或者后端返回绝对路径(http://localhost:8080/upload/xxx.jpg)。我倾向于后者,因为前端在本地起服务的时候,路径拼接逻辑太多太容易乱。 - 日期格式化问题:后端返回的
Date对象序列化后是时间戳或yyyy-MM-dd HH:mm:ss,Vue直接显示出来就是一串数字。解决方案是后端实体上加上@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")。 - 接口请求体格式不一致:后端用
@RequestBody接收JSON对象,前端POST时传的却是Query参数(params:而不是data:),结果就是后端收到一堆null。这类问题用Network面板看请求Payload格式就能定位。
7. 部署上线与功能扩展方向
7.1 前端打包集成进SpringBoot的两种方式
项目常规做法是分开部署:Nginx代理前端静态资源与后端API接口。但如果你只是给一家救助站使用,不想维护两台服务器,可以一台服务器搞定。
第一种方式也简单:前端执行npm run build,生成dist目录,把整个目录复制到SpringBoot的src/main/resources/static下,重新打包,SpringBoot会自动把静态资源作为web根路径。只改接口请求地址为相对路径,然后访问http://服务器IP:8080即可。
但这种方式有个隐患:如果前端用了createWebHistory模式,刷新页面时(比如访问/pet),后端只配了/没有配/pet的映射,就会404。
解决方法是加一个路由转发规则:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}").setViewName("forward:/index.html"); } }更推荐第二种方式:用Nginx。前端dist目录由Nginx托管,location /api/反向代理到SpringBoot的8080端口。这样前后端各自独立管理,上线热更新只替换前端文件,不需要重启后端,生产环境维护更舒服。
7.2 系统上线后的扩展思路
这套系统的核心框架搭好后,业务的扩展点很多,后续值得做的时间投入方向:
- 微信小程序端:救助站日常大量沟通在微信里,开发一个面向公众的小程序,用户浏览可领养宠物、在线提交领养申请、查看活动报名,后端接口完全复用SpringBoot的API。
- 消息通知模块:集成邮件或企业微信机器人,领养申请状态变化、志愿者排班提醒、到期回访提醒自动推送,减少工作人员的人工通知负担。
- 回访任务定时提醒:领养交接完成7天后、30天后分别需要回访,可以用SpringBoot的
@Scheduled定时任务扫描回访日期,生成待办任务列表。 - 宠物健康档案联动:跟宠物医院对接体检数据,将疫苗本、驱虫记录线上化,后端预留
health_records表的扩展字段,采用冗余冗余设计调高容错。
8. 总结的替代:我从这套系统里沉淀的关键经验
这套系统开发下来,我最大的体感是:技术框架永远是成熟固定的,真正的复杂度都藏在对业务状态的梳理和对边界条件的处理上。尤其是领养申请的状态机这块,如果起初没有设计好约束关系,到了后期就会演变成到处打补丁的状态。
还有一个让我印象极深的事情:救助站的工作人员年龄跨度很大,有的志愿者完全不会用复杂系统,所以前端页面不论多酷炫,都不如一个“当前处理到哪一步”的清晰化状态展示有用。考虑到这一点,我把领养申请列表做成了带时间轴和进度条的模式,工作人员扫一眼就知道哪些卡了很久没处理。
最后再分享一个小技巧:开发时期一定要打开MyBatis的SQL日志,别觉得打印日志影响性能。联调时你能直接看到每次请求执行的SQL长什么样,排查问题至少能省一半时间。等确定上线了,再把log-impl改掉或关闭控制台输出即可。这个习惯我在多个项目里尝过甜头,强烈建议保留。
如果你正在打算给本地动物救助组织做一个日常业务管理工具,希望这篇内容能让你少走一些弯路。有具体实现的疑问也欢迎交流,毕竟这类小而美的系统,做出来真的能实打实帮到一些毛孩子。