ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3宠物爱心组织管理系统实战:状态机设计与动态SQL

SpringBoot+Vue3宠物爱心组织管理系统实战:状态机设计与动态SQL 做宠物爱心组织管理系统最容易被忽视的其实不是技术难点而是业务状态梳理。我去年帮一个本地动物救助站做过类似的系统聊需求时对方张口就是“我要一个能看宠物列表的后台”真做起来才发现一只流浪猫从救助进站到被领养走中间要经历体检、驱虫、疫苗、暂养、审核、回访每个环节的数据都要留痕光“领养申请”这一个动作就有六种状态要流转。今天这篇就把这套系统的完整落地过程拆开讲清楚从数据库设计到前后端联调再到部署上线全程基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue 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几个配置背后的原因useSSLfalseMySQL 8.x默认开启SSL认证本地开发环境不配证书会出现SSL连接错误加上这个参数提前规避。allowPublicKeyRetrievaltrueMySQL 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 { ListPet 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 idselectPetPage resultTypecom.petorg.entity.Pet SELECT * FROM pet where if testspecies ! null AND species #{species} /if if teststatus ! null AND status #{status} /if if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR pet_no LIKE CONCAT(%, #{keyword}, %) OR breed LIKE CONCAT(%, #{keyword}, %)) /if if teststartDate ! null and startDate ! AND entry_date gt; #{startDate} /if if testendDate ! null and endDate ! AND entry_date lt; #{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 vitelatest pet-admin -- --template vue-ts cd pet-admin npm install npm install vue-router4 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 :inlinetrue :modelqueryParams el-form-item label物种 el-select v-modelqueryParams.species placeholder全部 clearable el-option label猫 :value1 / el-option label狗 :value2 / el-option label其他 :value3 / /el-select /el-form-item el-form-item label状态 el-select v-modelqueryParams.status placeholder全部 clearable el-option label在站 :value1 / el-option label暂养中 :value2 / el-option label已领养 :value3 / el-option label已离世 :value4 / /el-select /el-form-item el-form-item el-button typeprimary clickhandleQuery查询/el-button el-button clickresetQuery重置/el-button /el-form-item /el-form el-table :datatableData v-loadingloading !-- 列配置 -- /el-table el-pagination v-model:current-pagequeryParams.pageNum v-model:page-sizequeryParams.pageSize :totaltotal changefetchList / /el-card /template script setup langts 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 ResultString 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) { ListAdoptionReportVO 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;filenameadoption_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命名空间和方法idThere is no getter for property named实体类没写getter或lombok未生效确认类上有Data注解检查lombok依赖BadSqlGrammarExceptionSQL本身语法错误或表名/字段名不对把日志里的SQL拷出来直接在Navicat跑一遍TooManyResultsException接口返回单个对象但结果有多个修正SQL条件或改用List返回分页查出重复数据多条排序字段不唯一排序加主键兜底ORDER BY create_time DESC, id DESCMySQL锁等待超时事务未提交导致行锁占用检查Service方法上是否漏加Transactional6.3 前端常见的联调Bug处理后端接口全部配好后前端联调还会有几个经典问题图片加载404后端返回的/upload/xxx.jpg这个相对路径前端部署在5173端口解析时会指向前端域名下的/upload自然是404。解决思路是前端在表格渲染时对图片URL做拼接或者后端返回绝对路径http://localhost:8080/upload/xxx.jpg。我倾向于后者因为前端在本地起服务的时候路径拼接逻辑太多太容易乱。日期格式化问题后端返回的Date对象序列化后是时间戳或yyyy-MM-dd HH:mm:ssVue直接显示出来就是一串数字。解决方案是后端实体上加上JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)。接口请求体格式不一致后端用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改掉或关闭控制台输出即可。这个习惯我在多个项目里尝过甜头强烈建议保留。如果你正在打算给本地动物救助组织做一个日常业务管理工具希望这篇内容能让你少走一些弯路。有具体实现的疑问也欢迎交流毕竟这类小而美的系统做出来真的能实打实帮到一些毛孩子。
返回列表