
做这套宠物健康顾问系统的时候我身边好几个养猫养狗的朋友都跑来问疫苗到底什么时候打、驱虫多久一次、体检报告散落几张该怎么整理问的人多了我干脆把平时积累的 SpringBoot Vue MyBatis MySQL 这套前后端分离技术栈用上宠物档案、疫苗记录、驱虫管理、体检报告、下次项目提醒、健康顾问建议一起做进一个系统里。它既能帮普通养宠人把健康数据管起来也是一个完整可复现的 Java 全栈练手项目——后端是 SpringBoot 提供 RESTful API前端是 Vue 单页应用持久层走 MyBatis数据落在 MySQL部署教程也一并整理好。这套东西适合谁正在找前后端分离项目实战的同学、准备毕业设计的本科生、想系统走一遍从建表到上线全流程的转行朋友都有参考价值。我尽量不讲空话每个模块都按我实际开发时踩过的坑、验证过的写法来写。1. 项目整体设计与技术选型思路1.1 为什么选前后端分离而不是传统单体 JSP先聊一个最基础的问题宠物健康管理系统这种体量的项目为什么非要前后端分离我见过不少入门项目还在用 JSP 方案页面和后端代码揉在一起改一个按钮样式要重启整个应用前端设计师和后端开发互相等对方效率确实低。前后端分离的核心逻辑是后端只负责出 JSON 数据前端只负责渲染页面和交互。两边通过 HTTP 接口通信谁都不管对方内部怎么实现。放到这个宠物健康系统里好处非常具体开发阶段前端用 Vite 开发服务器后端用 SpringBoot 内嵌 Tomcat两边独立启动并行开发。部署阶段前端打包成纯静态文件交给 Nginx后端打成一个 jar 包独立运行互不干扰。后续想加微信小程序端或者安卓 App直接复用现有 API前端完全重写都不影响后端。用生活化的话说前端是餐厅前台负责点菜上菜后端是后厨负责把菜做好。前后端分离就是把这两个区域彻底隔开前台装修不影响后厨炒菜。1.2 SpringBoot Vue MyBatis MySQL 这套组合的取舍技术选型我比较务实谁成熟用谁谁踩坑少用谁。后端选 SpringBoot理由很简单约定大于配置Starter 机制把大部分样板配置都吞掉了一个注解就能启动 Web 服务。我用的是 SpringBoot 2.7.x JDK 8 这个组合有人可能问为什么不上 3.x这里多说一句——3.x 要求 JDK 17并且很多老 MyBatis 插件、代码生成器还没完全跟上对新手项目而言没必要冒险。网络热词里那句springboot版本太高说的就是这个场景版本追新结果依赖冲突、配置不兼容光折腾环境就耗掉半天。持久层选 MyBatis 而不是 Spring Data JPA是因为宠物健康系统里涉及不少统计查询和动态条件筛选比如查某只宠物所有即将到期的疫苗记录按月统计驱虫记录数量。MyBatis 的 XML 映射文件可以精确控制 SQL动态 SQL 标签拼条件也直观出问题我能直接看到 SQL 本身排查成本低。JPA 虽然省代码但复杂查询的调试成本高小型系统里优势不明显。前端选 Vue因为生态稳定、上手曲线平缓。我具体用的是 Vue 3 Vite Element Plus Pinia这套组合现在资料最多遇到问题搜一下基本都有答案。如果求更加保守Vue 2 Vue CLI 也完全能实现本项目的核心逻辑不依赖 Vue 版本特性。数据库选 MySQL 5.7.44稳定、资料多。如果你机器上已经装了 MySQL 8.0 也没问题项目里没有用到只有 8.0 才支持的特性兼容性很好。1.3 系统模块与数据闭环设计宠物健康顾问系统不像电商系统那样功能庞杂它的核心价值是形成一条完整的健康数据闭环。我规划了四个核心模块加两个辅助模块。核心模块宠物档案管理负责维护宠物基础信息包括品种、生日、体重、绝育状态健康记录管理负责疫苗、驱虫、体检、就诊四类记录的增删改查提醒中心根据健康记录自动算出下次疫苗、驱虫的到期时间按状态展示健康顾问建议根据宠物档案和最近记录生成提示文字比如该打猫三联加强针了体重偏重建议控制喂食量。辅助模块用户登录注册普通用户只能看到自己的宠物数据系统管理后台管理员可以查看所有用户和宠物统计信息用于展示完整的管理端写法。数据闭环是这样的用户建档 → 录入疫苗/驱虫/体检记录 → 系统计算下次到期时间 → 提醒中心展示 → 用户带宠物去完成 → 再录入新记录。每一步都有数据承接不会出现录完就扔在那儿的情况。2. 后端核心实现与数据库设计2.1 SpringBoot 工程搭建与分层结构后端工程我用 Maven 管理包结构按常见分层拆不要图省事把所有类堆在一起。我的目录是这样的com.pet.health ├── controller # 接口层只做参数接收和结果包装 ├── service # 业务层处理业务规则 ├── mapper # MyBatis 数据访问接口 ├── entity # 数据库实体类 ├── dto # 前端传参和返回结果对象 ├── config # 配置类如跨域、定时任务 └── common # 统一返回结果、异常处理、工具类启动类上记得加MapperScan(com.pet.health.mapper)否则 MyBatis 扫描不到 Mapper 接口启动直接报错。这个注解是新手最容易漏的漏了之后 Spring 容器里没有 Mapper 的代理对象Service 注入失败报错信息还不太直白。application.yml里的关键配置我直接列一个可用的版本server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pet_health?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.pet.health.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case必须开这样数据库的record_date能自动映射到实体的recordDate字段省掉一堆Results注解。log-impl配置成 StdOutImpl控制台会直接打印 SQL 和参数调试阶段非常有用上线前再关掉。2.2 MyBatis 持久层XML 映射与动态 SQL 的实战写法MyBatis 的核心玩法是 Mapper 接口配 XML 文件。接口里定义方法XML 里写 SQL两者通过 namespace 和方法 id 绑定。我这个系统里最常用的是动态 SQL举一个真实场景健康记录查询页允许用户按类型过滤还可能按日期范围查这三个条件都可能为空不可能为每种组合写一个 SQL。于是在 XML 里用where加if标签select idselectRecordList resultTypecom.pet.health.entity.HealthRecord SELECT * FROM health_record where if testpetId ! null AND pet_id #{petId} /if if testtype ! null and type ! AND type #{type} /if if teststartDate ! null AND record_date gt; #{startDate} /if if testendDate ! null AND record_date lt; #{endDate} /if /where ORDER BY record_date DESC /selectwhere标签会自动处理掉第一个条件前面的 AND这个机制比手动拼接 WHERE 11 干净得多。这里必须强调一个新手高频踩坑点传参一律用#{param}不要用${param}。#{}是预编译占位符MyBatis 会把它替换成?由 JDBC 的 PreparedStatement 处理天然防 SQL 注入${}是字符串拼接用户输入了什么就拼什么进 SQL等于把数据库直接敞开给攻击者。关于 MyBatis 二级缓存项目里我只给纯字典类查询开了比如查询宠物品种列表这种不常变的数据。健康记录这类频繁增删改的表不建议开缓存因为每次 insert/update 都要手动清理缓存容易出现改完了查出来还是旧数据的诡异问题。默认的开关配置是cacheEnabledfalse保持默认就好。2.3 健康顾问规则不用规则引擎用策略模式就够了健康顾问这个模块听起来高大上其实落地的规则并不复杂。我实现的时候没有引入 Drools 这种重量级规则引擎而是用 Java 的策略模式把不同类型的建议拆成独立策略类每个类只做一件事。举个例子疫苗建议的规则是这样的猫三联一般打两针基础免疫间隔 21 到 28 天之后每年加强一针。当系统查到某只猫的最近一次猫三联记录距今超过 330 天时生成建议近期接种猫三联加强针提示查不到记录且年龄大于 3 个月生成建议尽快完成首针免疫提示。驱虫规则类似体外驱虫每月一次体内驱虫每三个月一次根据到期时间生成不同紧急程度的建议。我把这些规则封装成AdviceStrategy接口每个策略类实现generateAdvice(Pet pet, ListHealthRecord records)方法再用一个AdviceContext按宠物类型分发。这样以后想加新的建议规则写一个新类就行不用改动旧代码。这个设计是我实际项目里反复验证过的小规则集用策略模式完全够用引入规则引擎反而增加部署复杂度和学习成本。2.4 MySQL 表设计五张核心表怎么建数据库表设计是整个系统最容易返工的部分设计错了后面写代码全是泪。我按业务闭环设计了五张核心表这里直接给出关键字段和设计思路。宠物表petCREATE TABLE pet ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) DEFAULT NULL, name varchar(50) NOT NULL, species varchar(20) NOT NULL COMMENT cat/dog, breed varchar(50) DEFAULT NULL, birthday date DEFAULT NULL, weight decimal(5,2) DEFAULT NULL COMMENT 单位kg, avatar varchar(255) DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;健康记录表health_recordCREATE TABLE health_record ( id bigint(20) NOT NULL AUTO_INCREMENT, pet_id bigint(20) NOT NULL, type varchar(20) NOT NULL COMMENT vaccine/deworm/checkup/treatment, title varchar(100) NOT NULL, record_date date NOT NULL, next_due_date date DEFAULT NULL, hospital varchar(100) DEFAULT NULL, note text, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_pet_type_date (pet_id, type, record_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;另外三张表分别是reminder提醒表、health_advice建议表、sys_user用户表字段逻辑相似不再展开。设计上有两个经验值得说说。第一表与表之间我刻意没有加物理外键只用普通索引维护逻辑关系。理由很实际物理外键会在每次 insert/delete 时做额外约束检查影响写入性能而且后续如果要分库分表或做数据迁移物理外键会变成巨大的障碍。业务层面在 Service 里做存在性校验就够了。第二联合索引(pet_id, type, record_date)是故意加的。因为系统的高频查询就是查某只宠物某类型记录并按日期排序这个联合索引能直接命中避免文件排序。我在实际数据量到几万条之后发现这个索引让查询从几十毫秒降到几毫秒收益非常明确。3. 前端 Vue 实现与前后端联调3.1 从零搭建 Vue 工程及环境配置前端我用 Vue 3 Vite。环境配置这里说几个容易出问题的地方都是我在vue安装及环境配置这个环节被卡过的事情。Node.js 版本建议用 18 或 20 LTS 版本太老或太新都可能跟 Vite 有兼容问题。创建项目的命令很简单npm create vitelatest pet-health-web -- --template vue进入目录后安装核心依赖我实际用到的就四个npm install vue-router4 pinia axios element-plusVite 的配置文件vite.config.js里需要配置开发服务器代理这是前后端分离联调的关键。如果不配代理浏览器直接请求http://localhost:8080/api会触发跨域浏览器拦截响应接口调不通。配了代理之后前端开发服务器会把/api开头的请求转发到后端浏览器看到的只是同源请求跨域问题在开发阶段直接消失export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })有人问生产环境怎么办生产环境前端是静态文件不存在开发代理跨域一般用 Nginx 反向代理解决这个放到部署章节细说。3.2 路由与状态管理这套系统怎么做页面组织路由我用 vue-router 4按模块拆分页面。核心路由表就这几个登录页、首页仪表盘、宠物列表、宠物详情、健康记录新增/编辑、提醒中心、健康顾问建议。这里说个判断很多网上项目喜欢宣传动态路由按用户权限动态生成菜单但本系统用户角色就两种普通用户和管理员路由差异很小动态路由纯属给系统加复杂度。我选择的是静态路由 路由守卫的方案。路由守卫负责拦截未登录用户每次跳转前检查 localStorage 里的 token没有就重定向到登录页。这个写法 20 行以内搞定清晰可靠。如果你以后想参考若依框架那种前后端分离版本里的动态路由实现那是大项目里的权限体系小系统不必照搬。状态管理用 Pinia比 Vuex 简洁很多。我只在 store 里维护两个东西用户信息和登录状态。token 存放在 localStorage请求前加到 header。Pinia 的写法非常直观const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null }), actions: { setLogin(data) { this.token data.token this.userInfo data.userInfo localStorage.setItem(token, data.token) }, logout() { this.token this.userInfo null localStorage.removeItem(token) } } })3.3 API 封装与跨域联调前后端分离项目里前端所有请求应该走统一的 API 封装而不是在每个页面里直接调 axios。我的做法是新建src/api/request.js创建一个 axios 实例统一配置 baseURL、超时时间、请求拦截器和响应拦截器import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { return Promise.reject(new Error(res.message || 请求失败)) } return res.data }, error { if (error.response error.response.status 401) { window.location.href /login } return Promise.reject(error) } )后端接口路径统一以/api开头Controller 上我加了一个类级别的RequestMapping(/api/health-record)。这样开发环境的 Vite 代理、生产环境的 Nginx 反代都只处理/api前缀规则统一不容易出错。后端跨域我也配了全局 CORS 配置作为兜底防止有人跳过代理直接访问后端。两种方案同时保留并不冲突前端代理优先生效后端 CORS 作为保障。3.4 核心页面与组件实现页面实现上我分了几个关键组件。宠物档案列表页用卡片式布局每张卡片展示宠物头像、名字、品种、年龄、体重和最近一次健康记录右上角是查看详情和新增记录按钮。宠物详情页用了 Element Plus 的时间线组件把疫苗、驱虫、体检记录按时间倒序展示每条记录后面跟着一个下次到期标签到期时间在一个月内显示黄色已过期显示红色。这个视觉反馈非常直观用户打开页面第一眼就知道该做什么。健康记录表单页是数据准确性最关键的地方表单校验做得很细。日期必填、类型必选、下次到期日期不能早于本次日期这些规则我全部在表单层面强制校验避免脏数据进数据库。体重字段我做了一个联动建议检测到连续两次体重记录增长超过 15% 时在表单下方提示体重增长较快建议关注饮食结构。ECharts 我用来做首页仪表盘的体重趋势折线图展示宠物最近六次体重变化。这个图对用户判断健康状态很有价值实现也不复杂只是把 health_record 里的体重数据取出来按日期画线而已。4. 完整部署流程与打包细节4.1 本地开发环境版本选型先把环境版本统一好避免我电脑上能跑的尴尬。Java 用 8 或 11Maven 用 3.6 以上MySQL 用 5.7.44。MySQL 安装这里单独提一句Windows 用户安装包版一路下一步很容易但最后容易忘记选字符集建议安装时选 utf8mb4或者装完手动执行ALTER DATABASE pet_health CHARACTER SET utf8mb4;否则中文可能乱码。Linux 服务器安装 MySQL 5.7.44 用 rpm 包相对省事但 rpm 安装完后要手动启动服务并设置 root 密码systemctl start mysqld grep temporary password /var/log/mysqld.log mysql -u root -p ALTER USER rootlocalhost IDENTIFIED BY YourPassword123!;这里有个细节经验MySQL 5.7 默认有 validate_password 插件初始密码策略要求包含大小写字母数字和特殊字符。如果想用简单密码需要先执行set global validate_password_policyLOW;再改密码否则一直报错。数据库初始化更直接用 mysql 命令导入项目里准备好的 SQL 脚本mysql -u root -p -e CREATE DATABASE IF NOT EXISTS pet_health DEFAULT CHARACTER SET utf8mb4; mysql -u root -p pet_health pet_health.sql4.2 后端打成 jar 包部署后端部署我最常用的是打可执行 jar 包放服务器跑。打包前先确认pom.xml里引入了 spring-boot-maven-plugin否则打出来的 jar 不能直接运行。打包命令mvn clean package -DskipTests执行完 target 目录下会生成pet-health-server.jar传到服务器后启动java -jar pet-health-server.jar --spring.profiles.activeprod生产环境的数据库连接我放在application-prod.yml里和本地配置隔离。因为前面配置里数据源 URL 带了serverTimezoneAsia/Shanghai和useSSLfalseMySQL 8.0 的认证方式默认是 caching_sha2_password如果你用的驱动版本比较老可能出现连接报错解决方案要么换新驱动要么在 MySQL 里执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY password;。我建议直接用 MySQL Connector/J 8.x 驱动省去老兼容问题。想长期稳定运行我建议写一个 systemd 服务文件开机自启进程挂了自动拉起[Unit] DescriptionPet Health Server Afternetwork.target [Service] Userroot ExecStart/usr/bin/java -jar /opt/pet-health/pet-health-server.jar --spring.profiles.activeprod Restartalways [Install] WantedBymulti-user.target4.3 前端构建产物的两种部署方式前端打包很简单npm run builddist 目录下就是纯静态文件。部署方式有两种我都实际用过各有适用场景这里对比一下。方式一Nginx 托管静态文件加反向代理这是推荐的生产方案。Nginx 配置里做两件事一是把 dist 目录作为网站根目录二是把/api前缀的请求转发到后端服务server { listen 80; server_name your_domain; root /opt/pet-health-web/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }try_files这一行是必须的。因为前端用的是 history 路由直接访问http://your_domain/pet/1时 Nginx 会去找pet/1这个文件找不到就 404。try_files $uri $uri/ /index.html的含义是先找文件找不到就返回 index.html由前端路由接管。方式二把前端打包文件放进 SpringBoot 的src/main/resources/static目录重新打后端 jar前后端一个进程跑。这种方式适合毕设演示、内网工具类项目特点是部署最简单一个 jar 全部搞定。需要注意前端请求的 baseURL 必须是相对路径/api不能写死http://localhost:8080否则生产环境域名变了就要重新打包。4.4 数据库初始化与提醒任务提醒功能依赖定时任务。我在 SpringBoot 启动类加EnableScheduling然后在ReminderTask里每天凌晨扫一次所有健康记录把next_due_date在最近七天内的或者已经过期的记录写入提醒表Component public class ReminderTask { Scheduled(cron 0 30 2 * * ?) public void scanDueReminders() { ListReminder dueList reminderMapper.selectDueReminders(7); for (Reminder r : dueList) { r.setStatus(0); reminderMapper.updateStatus(r); } } }定时任务上线时有一个坑我踩过如果部署了多个后端实例同一个任务会被执行多次。对提醒这种业务来说重复写记录会造成数据污染。我的处理方式很朴素insert 之前先查有没有同宠物同类型的待办提醒存在就跳过。如果要更严格可以引入分布式锁但对本项目来说查重已经够用。5. 常见问题排查与避坑实录5.1 启动阶段问题速查这一节我整理一下反复被问到的问题直接对照排查。端口被占用是最常见的第一道坎。启动报Port 8080 was already in use时Linux 上用netstat -tlnp | grep 8080找到进程 PIDkill -9 PID干掉Windows 上netstat -ano | findstr 8080配合任务管理器结束进程。Mapper 找不到是第二个高频问题。报错Invalid bound statement (not found)时按三个方向排查启动类有没有MapperScanXML 文件的 namespace 是否跟 Mapper 接口全限定名一致application.yml里的mapper-locations是否指向classpath:mapper/*.xml。这个错误一般就是这三个原因我见过 90% 的情况都能命中其中一个。SpringBoot 版本过高引发的坑也值得单独说。比如 SpringBoot 3.x 把javax包改名成jakarta很多老项目里 import 的是javax.servlet升级后直接编译不过。如果你在网上找到的参考代码是 2.x 时代的而你本地装了 3.x建议直接降级到 2.7.x而不是费力改第三方依赖。5.2 数据库连接与 SQL 问题数据库连接报错的排查顺序第一看 MySQL 服务有没有启动第二看application.yml的 URL、用户名密码对不对第三看时区和 SSL 配置。这个系统我推荐统一用这串 URLjdbc:mysql://localhost:3306/pet_health?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai。前两项保证中文不乱码useSSLfalse关掉 MySQL 8.0 默认的 SSL 校验避免本地没配证书时报 SSL 连接错误serverTimezone把服务器时区固定为东八区否则 JDBC 解析时间戳会跟本地时间差 8 小时。还有一个典型报错是Expression #1 of ORDER BY clause is not in GROUP BY clause这是 MySQL 5.7 的only_full_group_by模式导致的。我在统计报表查询里就踩过解决方式有两种一是在查询里确保 select 的字段都在 group by 里出现二是修改 sql_mode 去掉only_full_group_by。正规做法是改 SQL图省事才改配置建议前者。5.3 前端构建与路由问题前端问题主要集中在这几个点。跨域报错 CORS 时确认请求是从浏览器直接发到后端还是走代理。开发环境按 3.1 节配置 Vite proxy 即可生产环境走 Nginx。很多人开发环境跨域通了生产环境又说跨域其实生产环境根本没有跨域因为前端和/api在同一个域名下真正的问题是 Nginx 代理没配好。history 路由刷新 404 是部署中最常见的坑。解决方式就是前面说的try_files $uri $uri/ /index.html。不少项目部署完首页能打开点进去再刷新就白屏或者 404八成是这个配置没写。还有一个细节是打包后接口域名不生效。前端代码里如果写死了http://localhost:8080这种绝对地址打包换环境后必然报错。正确做法是接口 baseURL 统一用/api这种相对路径由部署层的代理决定请求发到哪。5.4 上线后的性能与安全经验数据量上来之后提醒中心按next_due_date查询会变慢。我加了一个单列索引idx_next_due_date配合健康记录的联合索引扫描范围大幅降低。慢 SQL 排查我直接开 MySQL 慢查询日志定位到具体语句后基本都能靠索引解决。安全方面有三件事必须做。一是密码加密存储绝对不能明文我用的是 BCryptSpring Security 里直接有现成的BCryptPasswordEncoder比 MD5 靠谱得多因为自带盐值相同的密码每次加密结果不同。二是预防 SQL 注入MyBatis 里全部用#{}预编译上面已经强调过这里是底线要求。三是接口权限校验登录接口之外的业务接口都要检查 token 是否有效不能只靠前端隐藏按钮因为接口可以被直接调用。系统里我实现了一个简单的拦截器统一校验Authorization头没有 token 直接返回 401。关于安全管理我的原则是能力范围内把能做的基础防御做扎实密码加密、SQL 注入防护、接口鉴权这三道防线守住小系统就够用了。这套系统做完之后我最大的体会是新手做项目重要的不是功能堆得多花哨而是把一条主链路从数据库到页面再到部署完整走通。健康记录、提醒、建议这看似简单的三个环节一旦亲手从建表开始实现到上线你对 SpringBoot、MyBatis 动态 SQL、Vue 路由守卫、Nginx 代理这些技术的理解会完全不一样。最后再分享一个实际经验这套系统做的时候我刻意保持了模块边界清晰后来有朋友想把宠物诊所的预约功能加进来我只需要新增预约表和对应接口原有模块完全不用动。所以如果你也想拿这个项目练手我建议你把它改造成宠物诊所版加上预约、病历、收费这些模块既锻炼了业务设计能力又能当作品集里一个差异化亮点。