资讯动态

Spring Boot+Vue宠物医院管理系统设计与实践

发布时间:2026/9/9 18:47:13 来源:尧图企业网站定制
去年帮一家连锁宠物医院做内部管理系统的时候对方老板娘对我说了一句让我印象很深的话“我们每天最忙的不是看病是找病历和算账。”这句话基本就是这套系统的设计起点。这篇文档想聊的项目标题叫《基于 Spring Boot 的宠物医院管理系统 Vue》听起来很像大学里的结课设计但真正把一个宠物医院的管理支撑起来需要处理的细节远比想象中多挂号和预约怎么排病历和处方怎么关联库存怎么扣收费怎么对账回访和疫苗提醒怎么触发。这篇文章我会把当时的完整设计思路、后端接口的边界、前端页面的组织方式以及联调部署阶段踩过的坑系统性写出来。想用 Spring Boot 和 Vue 做管理系统的人不论课程设计还是实际业务项目都可以直接拿这套思路做对照。1. 项目立项宠物医院信息化的真正痛点和 Spring Boot Vue 的选型理由1.1 先蹲点观察再画功能清单很多人拿到这种题目第一反应是打开后台管理系统模板把用户管理、部门管理、角色管理这些通用模块先铺上。我第一版也是这样做的但等到真正去医院现场跟了一天后发现通用模块只是底座核心的难点在业务边界。宠物医院的信息化痛点集中在几个地方前台要同时处理电话预约、线上预约、到店挂号三种入口很容易出现医生时间冲突宠物不会说话医生问诊完要快速调出历史病历、过往用药记录纸质档案查找非常浪费时间处方和收费是分离的医生开完药前台收费时经常搞混处方状态药品库存和处方没有联动药品出库靠手写月底盘点永远对不上疫苗、驱虫这类项目需要定期回访但回访记录经常散落在微信聊天记录里。所以我在设计功能清单时是按照“预约—接诊—处置—结算—回访”这条主线去拆的而不是按传统增删改查去堆页面。这一点很关键因为后面所有表结构设计都在为这条主线服务而不是反过来被功能清单绑架。1.2 Spring Boot 和 Vue 的组合在这个场景下到底合理在哪先回答一个很多人纠结的问题为什么不用单体模板直接用 Thymeleaf为什么不上微服务我的理由有三个Spring Boot 比较擅长做稳定的数据管理和接口服务生态成熟招人相对容易Vue 在页面交互上的体验比传统模板渲染强太多特别是排班日历、病历时间线、库存盘点这一类需要局部刷新的页面宠物医院管理系统属于典型的“中小型业务系统”数据量和并发量都不至于逼迫你上微服务用模块化的单体项目才是成本更低的答案。我也给当时的选型做了个对比给后来者参考维度Spring Boot 2.7 Vue 2Spring Boot 3.x Vue 3生态成熟度资料多排坑容易部分老框架需要升级适配默认工具链Java 8/11Java 17适合人群第一次做完整项目想直接用新技术长期维护仍在安全维护期新功能更新更快如果你做的是课程设计建议直接 Spring Boot 2.7 Vue 3因为网上现成资料最多真要遇到问题搜索效率高。我当时用的是 Spring Boot 2.7.18 Vue 3.4 Element Plus MySQL 8.0整体稳得很。另外一个踩过的点是 Spring Boot 版本不要随手拉最新尤其是 3.x 刚出来那会儿很多 starter 对 Jakarta 命名空间的支持还不完善用起来容易卡在环境问题上但这些和环境无关纯粹是版本坑。1.3 项目拆成几个端各自负责什么系统最终部署形态是前后端分离但我在工程结构上刻意分成了四个部分pet-serverSpring Boot 后端负责认证、权限校验、业务接口、定时任务pet-webVue 前端负责运营后台的所有交互页面包括前台收银、医生接诊、院长报表pet-mobile面向医生助理的简单 H5 页面主要用于住院部查房记录和疫苗提醒如果时间不够这端可以砍掉pet-sql包括建库脚本、初始化数据、测试数据。前后端分离带来的额外成本主要是跨域和部署但换来的是前端调试效率和界面表现力的提升后面第 5 部分我会具体讲怎么处理。如果项目只需要做本地展示不打算真上云那前后端分离依然值得因为 Vue 开发服务器热更新带来的效率提升太明显了写完代码立刻刷新看到效果比改完模板重启整个 Spring Boot 要舒服得多。2. 从“挂号—接诊—结算”这一条业务主线反推数据模型与模块边界2.1 “预约单”才是系统的核心表不是“宠物表”很多管理系统的数据库设计往往是先做基础资料比如宠物主人表、宠物表、医生表最后才考虑业务表。我在蹲点后调整了顺序把预约单 appointment 放到了最中间位置因为医院里日常流动的每个动作几乎都以预约或挂号为起点。预约单的核心字段我压得比较精简字段说明appointment_no预约编号前台展示和查询用pet_id关联宠物档案owner_id关联宠物主人doctor_id接诊医生appointment_type预约来源电话、线上、到店start_time / end_time预约时间段status已预约、已到店、接诊中、已完成、已取消cancel_reason取消原因一定程度能反映服务质量问题这里最容易犯的错是把预约和挂号混在一起。实际业务里电话预约来的客户可能第二天才到店到店挂号属于即时预约不把两个状态区分清楚排班统计就会乱。我在列表页也单独加了“今日预约”和“今日挂号”两个 tab虽然查的是同一张表但筛选条件不同前台用起来直观很多。2.2 电子病历和处方单要设计成“一次就诊一张主记录”宠物医院的电子病历和人类的 EMR 不完全一样因为宠物没有身份证号但有芯片号和疫苗本编号。我在 pet_medical_record 表上设计了 pet_id、visit_time、temperature、weight、symptom、diagnosis、follow_up_plan 这几个关键列。处方表 medical_prescription 和病历表是一对一关系处方明细表 prescription_item 再和药品表关联。这样设计的好处是收费时只需要根据处方明细汇总不用医生再去手工录入一次收费项目。每次就诊的入口是预约单接诊之后创建病历医生写完诊断如果开了药系统自动生成处方单并把状态置为“待收费”。这里要特别注意处方明细中的药品价格不能实时去查药品表要快照到处方里。因为药品价格会调价等月底对账时处方总价必须能还原到开单当时的金额。我在项目里加了 unit_price、total_amount 两个冗余字段而不是靠前端重新计算就是为了防止精度和价格漂移问题。2.3 库存、收费和报表之间的数据流转库存方面我设计了 inventory_records 流水表每次入库、出库、报损都写流水药品表里的 stock 数量只是方便查询的结果值不是唯一依据。收费方面pay_order 表记录费用来源类型比如处方、住院、美容服务每种来源关联对应的业务单号。到了月底做报表时不要直接去 count 业务表因为状态是动态的。我在项目里使用了按日汇总的快照表每天凌晨把前一天的收入、接诊量、药品消耗量汇总一次这样院长端看报表就很快而且历史汇总值不会被后续订单修改影响。如果没有这张快照表每次报表页面都要连表查询好几张业务表数据量一大页面就卡后期还得回头优化。2.4 模块拆分七个功能域整个系统最终拆成七个功能域预约排班、前台收银、诊疗管理、住院管理、药品库存、会员回访、系统权限。前六个是业务域最后一个是支撑域。每个功能域内部可以独立迭代比如后续要加在线问诊只需要在预约维度增加一个会话类型不需要改其他表结构。功能域之间通过业务单号互相引用比如诊疗管理里产生的处方单号会被收银域引用这样责任边界很清晰不至于一排模块围绕着一张订单表硬凑。3. Spring Boot 后端实现权限边界、预约并发和库存扣减三个硬骨头3.1 先定好“统一返回体”和“全局异常”后续接口才不会乱在写大量接口之前我建议先定义好后端返回 JSON 的结构。我的做法很简单public class ResultT { private Integer code; // 0 表示成功非0表示业务错误 private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.message ok; r.data data; return r; } public static T ResultT fail(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }同时配合 RestControllerAdvice 做全局异常捕获把参数校验异常、业务异常、未知异常都转换成统一结构。前端 axios 在响应拦截器里只需要判断 code 字段不需要每个页面写一遍 try-catch这个约定能省掉很多后期联调时间。我在这个项目里还约定业务错误码从 1000 开始1001 表示预约冲突1002 表示库存不足1003 表示权限不足。这样前端遇到特定错误码时还能做更精确的拦截提示而不是笼统弹一个“请求失败”。3.2 登录认证和角色权限后端校验才是最后一道关这个项目里有三类主要角色管理员院长、医生、前台。Vue 端会用路由守卫控制菜单显隐但后端接口如果不做权限校验被绕过只是时间问题。我在 Spring Boot 里做了基于 JWT 的登录认证用一个 OncePerRequestFilter 解析 token把当前用户信息和角色塞进 SecurityContext。白名单只开放 /api/auth/login其余接口都要认证。针对业务接口再用自定义注解和切面做细粒度权限比如医生端只能访问自己的预约单数据而院长端可以读全部Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }在切面里判断Around(annotation(requireRole)) public Object checkRole(ProceedingJoinPoint pjp, RequireRole requireRole) throws Throwable { UserContext user UserContextHolder.get(); if (user null || !Arrays.asList(requireRole.value()).contains(user.getRole())) { throw new BusinessException(403, 无权限访问); } return pjp.proceed(); }数据权限我建议在 Service 层显式传入当前医生 ID而不是在 SQL 里用全局 ThreadLocal 拼接因为一旦遇到异步任务或定时任务ThreadLocal 很容易丢值。我的做法是 controller 从 token 中解析用户传到 serviceservice 在构造查询条件时统一追加 doctorId 过滤。这个方案看起来啰嗦但好处是每个业务方法都能明确知道自己“以什么身份在操作”排查问题的时候也方便。3.3 预约时间冲突一个接口里最容易翻车的场景预约模块的并发问题非常典型。前台小王和小李同时给同一个医生登记 10:00-10:30 的预约如果不做任何限制数据库里会出现两条重叠记录。最简单的实现是查询重叠数量Override Transactional(rollbackFor Exception.class) public void createAppointment(AppointmentCreateDTO dto) { LocalDateTime start dto.getStartTime(); LocalDateTime end dto.getEndTime(); int conflictCount appointmentMapper.countConflict(dto.getDoctorId(), start, end); if (conflictCount 0) { throw new BusinessException(1001, 该时间段已被预约请选择其他时间); } Appointment appointment new Appointment(); // 属性拷贝、保存 appointmentMapper.insert(appointment); }光是这个逻辑压力测试就会出现两个线程同时查到 conflictCount 0然后同时插入。解决办法有两种一种是给 appointment 表加一个 doctor_id 和 start_time 的组合唯一索引并携带状态字段另一种是对医生的预约操作加分布式锁。单机项目用 synchronized 只能锁到单进程如果后续要部署多实例还是建议用 Redis 的 setNx 或 Redisson。我在这个项目里采用的是数据库唯一索引 事务兜底因为复杂度最低。对应 SQL 大致是Select(SELECT COUNT(*) FROM appointment WHERE doctor_id #{doctorId} AND status ! CANCELLED AND start_time #{end} AND end_time #{start}) int countConflict(Param(doctorId) Long doctorId, Param(start) LocalDateTime start, Param(end) LocalDateTime end);注意并集判断条件老预约开始时间小于新预约结束时间并且老预约结束时间大于新预约开始时间就是交集。这个条件我见过太多次写反了。如果只是判断 start_time 是否在范围内会漏掉“新预约从医生上一个预约中间开始”的情况。3.4 药品库存扣减别用“先查库存再 update”的写法又是个经典并发问题。处方提交后要扣库存如果先 select stock 再 update stockstock-5多个并发窗口会把库存扣成负数。最简单的兜底是使用带条件的更新语句让数据库来保证Update(UPDATE drug SET stock stock - #{num} WHERE id #{drugId} AND stock #{num}) int reduceStock(Param(drugId) Long drugId, Param(num) Integer num);如果返回影响行数为 0说明库存不足直接抛业务异常回滚事务即可。这个写法不需要显式加悲观锁对中小项目来说性能也更友好。要注意每次库存流水都必须记录操作人、业务单号和变化量否则后续追溯库存差会非常痛苦。我见过只更新 drug 表不写流水月底盘点对不上账时完全找不到原因。4. Vue 前端实现角色化路由、axios 封装和 Element Plus 使用中的坑4.1 路由按角色拆权限判断放在动态路由里前端如果只是把导航栏隐藏而路由表仍然全部注册那么懂点前端的人直接在地址栏输入路径就能看到页面数据接口虽然后端有校验但体验很差。我在 Vue 里采用动态路由方案登录成功后根据用户角色返回可访问的路由配置用 router.addRoute 动态添加。const buildRoutes (role) { const common [ { path: /dashboard, component: Layout, children: [Home] }, { path: /profile, component: Layout, children: [Profile] }, ]; const roleMap { ADMIN: [...common, appointmentRoutes, drugRoutes, reportRoutes, userRoutes], VET: [...common, appointmentRoutes, medicalRecordRoutes], RECEPTIONIST: [...common, appointmentRoutes, payRoutes, petRoutes], }; return roleMap[role] || common; };这里有个容易被忽略的问题addRoute 添加的路由在刷新页面后会丢失需要把当前用户的角色存到 localStorage 或 pinia 中刷新时重新读取并重新 addRoute。否则用户按 F5 后直接变成空白页。我当时排查了很久最后发现是刷新后 pinia 里的状态被清空了路由还没来得及恢复所以我在应用初始化时先统一调用 initAuth 方法再挂载路由。4.2 axios 拦截器code、message、401 的处理要统一axios 封装是我的习惯性动作。请求拦截器加 token响应拦截器做统一处理service.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res.data; }, (error) { if (error.response?.status 401) { localStorage.removeItem(token); router.push(/login); } else { ElMessage.error(error.response?.data?.message || 网络异常); } return Promise.reject(error); } );注意我在成功时返回的是 res.data 而不是整个 response这样页面里可以直接拿业务数据少套一层 .data.data。这个小决定能让几十个页面写起来都轻松一点。如果后端的 Result 里 code 用 200 表示成功那这里的判断条件也要改成 res.code ! 200前后端约定必须写好注释否则后来接手的人很容易改出 bug。4.3 Element Plus 按需引入导致 ElMessage 未定义这是高频问题用 Element Plus 时如果开启按需自动导入unplugin-auto-import 和 unplugin-vue-components有时候会遇到页面里明明写了 ElMessage控制台却提示 ElMessage is not defined。原因很简单ElMessage 不是组件而是方法自动组件导入主要解析的是模板里的组件不会主动把方法导入到 setup 作用域里。这时候要么手动在文件里 import { ElMessage } from element-plus要么在 vite.config.ts 里配置自动导入 APIAutoImport({ imports: [vue, vue-router], resolvers: [ElementPlusResolver()], })即使配置了 resolver也建议在用到 ElMessage、ElMessageBox、ElNotification 的地方显式 import。这类“看似配置没问题但运行时找不到”的坑排查起来最耗时间因为报错信息不会直接指向 import 配置。我在登录页面当时整整卡了一个小时后来发现只是 ElMessage 少了一次手动导入。4.4 日期时间选择器和 LocalDateTime 的交互格式后端实体用 LocalDateTime前端组件默认传给后端的是 “2025-01-08T10:00:00” 这样的 ISO 字符串而 Java 默认反序列化是可以接受的但如果前端展示要 “2025-01-08 10:00”就必须在格式化上保持一致。我在后端全局配置了 Jackson 的 LocalDateTime 序列化格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8同时在前端给时间选择器加上 value-format“YYYY-MM-DD HH:mm:ss”保证传参格式统一。否则预约列表里看到的时区时间会比实际晚 8 小时这种 bug 在部署到云服务器后尤其明显因为服务器时区可能默认是 UTC。预约时间和病历时间一旦错乱整个系统的可信度都会下降医生的排班记录也没法看了。5. 联调和部署阶段最容易翻车的四个细节5.1 本地开发用 Vite 代理线上用 Nginx 反向代理前后端分离以后跨域是最先遇到的问题。本地开发时我一般不打开后端 CORS而是使用 Vite 的代理把前端请求转发到后端这样最接近线上的同源效果server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }线上环境用 Nginx 接管静态文件并反向代理接口请求server { listen 80; server_name your-domain.com; root /opt/pet-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; } }这里有一个容易犯错的地方proxy_pass 的 URL 结尾有没有斜杠代表的是“保留 /api 前缀”还是“去掉 /api 前缀”。我习惯后端 context-path 设为 /api前端请求 /api/xxxNginx 这里写 proxy_pass http://127.0.0.1:8080;不带斜杠这样后端接口路径才能正确解到 /api/xxx。如果写了带斜杠的请求会被转发到 /xxx后端没有对应路由直接 404。5.2 数据库时区和金额精度这类基础配置影响所有统计MySQL 8.0 连接串里如果不指定 serverTimezoneAsia/Shanghai当服务器时区不是本地时间时查询出来的时间和保存出去的时间差 8 小时是常见故障。我的连接串里会固定加上jdbc:mysql://localhost:3306/pet_hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue金额字段用 BigDecimal不要用 double。double 在折扣计算、退款均摊这种场景下会出现 0.10.2 不精确的问题测试期可能没事月底对账一定出事。数据库字段也统一用 decimal(10,2)避免 ORM 与数据库类型出现精度截断。处方明细里如果存了 double几个订单加起来差几分钱报表对不上查起来非常难受。5.3 初始化数据比功能开发更值得花时间给系统做初始化脚本是我个人很推荐的一步。很多项目交付后使用者第一次打开系统面对空白页面根本不知道怎么开始操作。我会写一个>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价