ARTICLE DETAIL

资讯详情

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

宠物医院管理系统全栈开发:从Spring Boot到Vue的完整实战指南

宠物医院管理系统全栈开发:从Spring Boot到Vue的完整实战指南 1. 为什么选宠物医院管理系统它的复杂度刚好卡在全栈练习的甜点上每年毕业设计或者简历项目选题的时候总有一批人扎堆做XX管理系统但很多人做完之后只有一个感觉空。CRUD写了一大堆页面也像模像样可面试官一问业务难点什么都说不出来。宠物医院管理系统这个题目的妙处在于它不是一个普通的增删改查而是天然带着预约、开方、扣库存、收费、档案跟踪这一整条业务链。宠物医院的管理逻辑和普通进销存系统有一个本质区别它的核心对象不是商品而是活体病患。先理清楚这个系统到底要管什么。一家正常的宠物医院每天的业务流程大概是这样的主人带着宠物到店 → 前台登记或者查询档案 → 挂号分诊 → 医生接诊 → 诊断后开处方 → 药房按处方发药并扣减库存 → 收费台结算 → 治疗或者手术 → 后续疫苗提醒和复诊跟踪。这中间牵扯到的角色至少有四类前台接待、兽医、药房管理员、院长或者店长。如果系统只有宠物信息管理和药品信息管理两张表你做完会发现这东西根本没法用因为业务流程没串起来。这个系统的价值就在于你能用它完整跑通一条医疗业务闭环。设计得当的话它比单纯的商品管理系统多了一层业务深度比如预约时间冲突、处方与库存联动、收费金额汇总这些全是真实系统里每天都会遇到的问题。而它又比电商系统简单因为不涉及复杂的订单状态机、支付回调、物流拆分这些环节非常适合一个人独立完成。所以这篇内容不是手把手教你敲每一行代码而是从设计和实现的角度把整套系统的骨架、关键模块的技术方案、以及我在实际开发和调试过程中踩过的坑完整过一遍。无论你是准备拿它当毕业设计还是想写进简历作为全栈项目按这个思路走下来你得到的是一个能讲的清楚、能演示的完整体验不是一个只会在答辩现场点几下页面就词穷的玩具。2. 技术栈选型和架构划分这套组合的实际取舍逻辑技术选型这部分很多人上来就照搬Spring Boot Vue这个标签但并不知道为什么是它俩。你得先搞清楚这个组合之所以成为主流是因为它把职责划分得很干净前端只管渲染和交互后端只管业务逻辑和数据持久化双方通过JSON接口通信。这样带来的好处是你改前端不用动后端后端加接口前端只管对接而且前端可以单独部署也可以打包后放进后端一起部署部署方式非常灵活。2.1 后端版本的坑Spring Boot 2.x还是3.x现在网上搜Spring Boot教程经常能搜到2.3.x、2.6.x、2.7.x也有直接上3.x的。这里给出我的建议如果你用的是JDK 8老老实实选Spring Boot 2.7.x这是2.x系列的最后一个维护版本坑最少资料最多。如果你已经用了JDK 17可以直接上Spring Boot 3.x但是要注意3.x版本中javax.servlet包换成了jakarta.servlet很多老教程里的代码直接复制会报错这个问题当年我帮别人排查的时候经常遇到。版本选完之后还有一个常用的配套组件需要确认持久层框架。这里大多数人会在MyBatis-Plus和Spring Data JPA之间纠结。我的倾向很明确这个项目用MyBatis-Plus。原因就两条第一宠物医院管理系统里有大量单表查询和分页列表MyBatis-Plus的QueryWrapper能极大减少手写SQL的量第二它的IService接口自带增删改查和分页方法项目后期维护起来思路清晰。JPA虽然写起来也很爽但遇到一些复杂统计查询比如统计某时间段内的就诊量JPQL或者原生SQL混用的时候反而别扭。2.2 前端框架怎么选Vue 3 Element Plus是当前最优解前端部分我用的是Vue 3 Vite Element Plus的组合。这里提醒一句网上还有大量Vue 2 Element UI的教程和组件封装代码如果你照着抄大概率会碰到Vue.use、this.$set这类老写法。Vue 2已经停止维护新项目没必要再用。Vite启动项目的速度比webpack快很多而且Vite 5.x对Vue 3支持已经非常稳定。前端的大致结构是这样的src/ ├── api/ // 按模块封装的接口请求 ├── router/ // 路由配置含动态路由和守卫 ├── store/ // Pinia状态管理存用户信息和权限标记 ├── views/ // 页面组件按业务模块划分 ├── components/ // 通用组件图片上传、分页、富文本等 ├── utils/ // axios实例封装、工具函数 └── layout/ // 后台管理布局侧边栏顶栏Element Plus组件库提供了一套现成的后台管理组件像el-table、el-form、el-dialog、el-tabs这些拿来就能拼出一个后台管理界面。你不需要会复杂的CSS布局重点精力放在业务交互逻辑上。2.3 前后端接口约定一次定义好后面少吵架前后端分离的项目最怕的就是接口定义不统一。后端返回data字段前端有的地方取值res.data有的地方取res.result这种混乱会直接拖垮联调效率。我在项目开始前就固定了一套统一返回结构所有接口都用这个格式{ code: 200, message: 操作成功, data: { } }错误时的结构也是一样的code换掉message换成具体错误描述data为null。前端封装一个统一的响应拦截器只要code不是200直接弹出message内容不用每个页面单独写错误处理逻辑。另外分页接口的结构也要提前定好通常是这样{ code: 200, message: 成功, data: { records: [...], // 当前页数据 total: 153, // 总记录数 current: 1, // 当前页码 size: 10 // 每页大小 } }这对应的是MyBatis-Plus分页插件默认的返回结构前端分页组件直接对接几乎不需要转换。3. 数据库设计从宠物档案到诊疗记录的完整表结构数据库是整个系统的地基。很多人的表设计思路是页面长什么样我就建什么表这是错误的方向一定是先有业务流程再反推表结构。宠物医院管理系统的核心数据流向可以画成一条线客户主人→ 宠物档案 → 预约单 → 就诊记录 → 处方 → 收费单同时旁边挂着药品库存表和员工表。3.1 核心表的划分和关系我用下面这几张核心表把整个系统串起来表名作用关键外键/关联sys_user系统用户员工含账号密码角色关联角色pet_owner宠物主人信息一人可养多只宠物pet_profile宠物档案所属主人类型、品种、绝育状态appointment预约记录宠物ID、医生ID、时间段medical_record就诊记录宠物ID、医生ID、诊断结果prescription处方单主表就诊记录ID总金额prescription_item处方明细处方单ID、药品ID、数量medicine药品信息存储药品规格、单价、预警阈值medicine_stock药品库存药品ID当前库存量billing收费单处方ID、实收金额、支付方式这里面的核心关系是一个主人可以有多只宠物一只宠物可以有多条就诊记录一条就诊记录可以开一张处方一张处方包含多个药品明细处方审核完成后收费。按照这个关系去建表后面写代码的时候思路非常顺畅——因为每一个操作步骤对应的表都是清晰的。3.2 关键表字段设计不要忽略这些细节拿pet_profile来举例字段设计上很多人会把品种直接存成一个字符串比如金毛英短。这样设置看似没问题但一旦要统计本院接诊的猫咪中英短占比多少字符串匹配的效率就很差。正确的做法是品种ID单独存一个breed_id字段或者至少把品种和宠物类型作为固定字典表比如pet_type字段1-狗 2-猫 3-异宠避免全表都用字符串描述。appointment预约表需要特别设计的是时间段字段。我建议不要用开始时间结束时间两个字段来标记一个时段而是用预约日期 时间槽编号的方式比如appointment_date存日期2025-06-10time_slot存时间段编号1表示09:00-09:302表示09:30-10:00。这样查某天某个时间槽是否被占用只需要一条记录匹配即可不用做时间范围比较。prescription处方表要记录的不只是开药明细还需要标记处方状态0-待收费1-已收费2-已作废。如果没有状态字段收费模块和药房发药模块完全没法协作。药房和收费处的操作顺序通常是这样的医生开完处方之后前台打印处方单客户去缴费缴费成功之后药房看到状态变为已收费再进行发药操作并扣减库存。这条状态机链路是整个系统的核心流转逻辑设计表的时候一定要提前想清楚。3.3 一个容易忽略的点逻辑删除和唯一索引的冲突系统里几乎所有表我都会加上deleted字段用MyBatis-Plus的逻辑删除功能代替物理删除。数据安全性只是其中一个原因更重要的原因是宠物档案、就诊记录这些是医疗数据法规和实际操作上都要求保留痕迹不能直接DELETE。但这里有个隐蔽的坑如果给某张表的业务字段加了唯一索引比如pet_owner的phone字段设为唯一索引一旦执行逻辑删除下次再录入同一个手机号的客户时插入会因为唯一索引冲突而失败。解决办法有两个一是唯一索引改成(phone, deleted)的组合索引二是在业务上做如果已存在逻辑删除的客户则直接恢复该记录第一个方案相对更省事。4. 后端核心功能实现预约冲突、处方与扣库存的事务边界后端部分我挑三个最值得展开的功能点来讲它们不是简单的CRUD而是真正有业务逻辑复杂度的地方。4.1 预约挂号怎么避免同一个时间槽被重复预约预约模块最核心的校验只有一个医生在某天的某时间槽是否已有预约。这种场景用数据库唯一索引是最高效的解决方式。给appointment表设置唯一索引uk_doctor_date_slot(doctor_id, appointment_date, time_slot)数据库层面直接保证同一个医生同一天同一时间槽只能有一条记录。然后代码里写好业务校验插入前先查一次插入时再捕获重复键异常兜底。因为并发量不大双重校验基本够用。接口代码大致是这样的// 先校验该时间段是否已被预约 long count appointmentMapper.selectCount(new LambdaQueryWrapperAppointment() .eq(Appointment::getDoctorId, dto.getDoctorId()) .eq(Appointment::getAppointmentDate, dto.getAppointmentDate()) .eq(Appointment::getTimeSlot, dto.getTimeSlot()) .eq(Appointment::getStatus, 1)); // 状态1表示有效预约 if (count 0) { return Result.error(该医生的预约时段已被占用请选择其他时间); } try { appointmentService.save(appointment); } catch (DuplicateKeyException e) { return Result.error(该时段刚被预约请刷新后重试); }4.2 开处方和扣库存事务要放在正确的位置医生提交处方时后端要同时做三件事保存处方主表、保存处方明细表、扣减药品库存。这三件事必须在一个事务里完成否则可能出现处方建成功了但库存没扣这种没法收场的数据错误。Spring的事务处理有一个经典的坑事务不生效。在做这个项目的时候我一开始把处方保存和扣库存写在了不同Service里然后在Controller层逐个调用。Controller方法加了Transactional但是因为Spring AOP代理是基于接口或者子类的Controller本身没有被事务代理管理所以最终事务根本没生效。正确做法是把整个操作放到一个独立的Service方法中比如PrescriptionService.submitPrescription(PrescriptionDTO dto)方法上加Transactional里面按顺序完成保存主表、遍历明细、逐条扣库存这三件事任何一步抛出异常都会整体回滚Transactional(rollbackFor Exception.class) public Long submitPrescription(PrescriptionDTO dto) { Prescription prescription new Prescription(); BeanUtils.copyProperties(dto, prescription); prescription.setStatus(0); prescriptionMapper.insert(prescription); BigDecimal totalAmount BigDecimal.ZERO; for (PrescriptionItemDTO itemDto : dto.getItems()) { PrescriptionItem item new PrescriptionItem(); BeanUtils.copyProperties(itemDto, item); item.setPrescriptionId(prescription.getId()); prescriptionItemMapper.insert(item); // 扣库存这个方法里要加悲观锁或者乐观锁 medicineStockMapper.deductStock(itemDto.getMedicineId(), itemDto.getQuantity()); // 累加金额 Medicine medicine medicineMapper.selectById(itemDto.getMedicineId()); totalAmount totalAmount.add(medicine.getPrice().multiply(BigDecimal.valueOf(itemDto.getQuantity()))); } prescription.setTotalAmount(totalAmount); prescriptionMapper.updateById(prescription); return prescription.getId(); }扣库存这里如果直接用UPDATE medicine_stock SET stock stock - #{quantity} WHERE id #{medicineId}这种SQLMySQL的行锁会保证在并发情况下不会出现超卖。这里不需要用Java层加锁的方式因为数据库行锁性能更好而且实现简单。这就是为什么我不建议用先select再update的普通Java代码扣库存那两步之间存在时间窗口并发下会出问题。4.3 药品库存预警把常理写进业务逻辑里库存模块除了基础的出入库还需要做库存预警。做法其实很简单medicine表上加一个alert_threshold字段比如某个药品的阈值是10当库存低于10时列表页的库存数字标红仪表盘上显示预警数量。这个功能本身不复杂但有一个问题值得注意扣完库存之后要及时判断是否低于阈值如果低于阈值需要返回给前端一个标记。所谓及时意思是最好在扣库存的方法里直接返回当前最新库存量由前端判断是否显示预警而不是让前端再发一次库存查询请求。少一次请求交互上就是快那么100毫秒代码逻辑上也更清晰。4.4 JWT登录与权限控制角色不同看到的菜单不同系统里分了三种角色管理员、医生、前台。管理员能看所有模块医生主要看接诊和处方前台主要负责挂号、收费和收费记录。前后端分离项目里登录认证通常用JWT来做。思路很清晰sys_user表里存账号密码登录成功后后端生成一个JWT返回给前端前端每次请求在header里带上Authorization: Bearer token。后端用一个拦截器解析token然后把用户信息放到ThreadLocal里后续业务代码可以直接获取当前用户ID和角色。权限校验用Spring Security或者自己写一个HandlerInterceptor都行考虑到你不需要太复杂的安全机制自己写拦截器加注解就够了省去一堆Spring Security配置的学习成本。这里有一个实际开发中的坑JWT的密钥不能硬编码在代码里。虽然是小项目但也要养成好习惯写在application.yml里面并且用ConfigurationProperties绑定到一个配置类上。密钥至少要32个字符HS256算法对长度有要求。5. 前端实现细节动态菜单、权限拦截和关键页面设计前端如果不做权限控制每个用户打开系统看到的都是一样的菜单那角色划分就没有存在意义了。这里要用到动态路由的思路也就是用户登录成功后根据角色返回给前端的菜单列表是动态生成的。5.1 动态菜单和路由守卫实现方式是登录成功后后端返回当前用户角色和权限标识列表前端根据权限标识过滤掉无权限的路由再通过router.addRoute动态添加。这样实现之后前端只有一个空壳路由用户没登录前router.beforeEach守卫直接拦到登录页登录后再把可用路由注册进去。简化版的思路前端先定义一份完整的路由表每条路由带上meta.roles{ path: /prescription, name: Prescription, component: () import(../views/prescription/index.vue), meta: { title: 处方管理, roles: [admin, doctor] } }登录后拿到的用户角色是doctor前端路由守卫里做一次过滤router.beforeEach((to, from, next) { const userInfo useUserStore() if (!userInfo.token) { next(/login) return } if (!userInfo.routesLoaded) { // 动态添加当前角色拥有的路由 const accessibleRoutes filterRoutes(userInfo.routes, userInfo.roles) accessibleRoutes.forEach(route router.addRoute(route)) userInfo.setRoutesLoaded(true) next({ ...to, replace: true }) return } next() })5.2 Axios拦截器的统一封装前端和后端联调时最头疼的问题是接口调用失败后的重复处理。我用axios实例封装了两层拦截器。请求拦截器主要做一件事自动带上token。响应拦截器做两件事统一处理业务错误码和HTTP错误状态。service.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, (error) { if (error.response?.status 401) { // token过期清空用户信息并跳转登录页 userStore.reset() router.push(/login) } else { ElMessage.error(error.response?.data?.message || 网络异常) } return Promise.reject(error) } )注意这里返回的分页接口结构是{ records, total }所以页面里拿res.data.records的时候Element Plus的el-table数据源就直接绑定了el-pagination的total也直接绑定到res.data.total整个过程不用再做一次数据格式转换。5.3 关键页面预约看板、处方开单和库存预警页面设计上我觉得最出效果的是预约管理页。用一个周视图日历做展示按医生维度显示一周里每个时间槽是否被预约。这个页面用Element Plus的el-calendar其实能做但是定制性差一些更推荐直接用el-table做时间段矩阵行是时间槽列是医生表格的单元格里显示忙/闲状态。点击空闲单元格弹出预约对话框选择宠物和主人信息提交完成。处方开单页面则是把宠物信息、诊断信息、药品明细分三个区域展示。药品明细区用el-table动态增删行选择药品时自动带出单价和规格数量改变时自动计算小计和合计金额。这个页面直接对接的是prescriptionService.submitPrescription接口前端组装好Dto对象一次性提交。库存预警页面是用来给药房管理员看的核心是一张列表库存低于预警阈值的药品名称、当前数量、阈值、最近入库时间后面跟一个生成采购单的按钮点击后把该药品信息带入新增采购单的弹窗里。这个页面可以说是整个系统里最简单的一个但也是最容易体现系统可用性的页面因为它是给实际业务场景提供决策支持的。6. 打包部署和联调阶段容易被拖垮的几个点代码写完了联调和部署阶段才是最考验耐心的时候。我把当初实际踩过的坑列举一下你如果遇到类似问题可以少花几天时间排查。6.1 前端打包后路由刷新404的问题前端开发模式下启动是正常的但打包以后如果直接双击dist/index.html运行会发现页面空白或者刷新直接404。这是因为项目用的是vue-router的history模式它需要服务器端配合所有未知的路由都回退到index.html。如果前端是单独部署在Nginx上配置里加一行就行location / { try_files $uri $uri/ /index.html; }如果你图方便把前端打包后的文件直接放到Spring Boot的static目录下一起部署靠Spring Boot自己当静态服务器那要处理的问题会更多。建议能分开部署就分开部署前后端分离的项目Nginx部署还是主流。6.2 时间字段的时区问题这个项目里涉及大量时间字段比如预约日期、就诊时间、收费时间。前后端传输时间如果后端是LocalDateTime并直接序列化为JSON字符串而前端拿到后用了本地时区去格式化往往会出现8小时左右的偏差。解决办法在后端统一配置Jackson的日期格式和时区spring.jackson.date-formatyyyy-MM-dd HH:mm:ss spring.jackson.time-zoneGMT8如果用了LocalDateTime还需要引入jackson-datatype-jsr310依赖并且对LocalDateTime字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解。前端axios拿到的时间就是一个固定格式字符串展示的时候不用再做时区转换。6.3 跨域问题的正确排查姿势开发模式下前端跑在http://localhost:5173后端跑在http://localhost:8080跨域几乎是必然的。后端的跨域配置要区分开发环境和生产环境。开发环境最简单的方式就是给后端加一个全局CORS配置Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }但注意生产环境如果前后端用Nginx做反向代理通常是同一域名的不同路径根本不存在跨域问题反而是你后端如果保留addAllowedOriginPattern(*)配合allowCredentials(true)在部分浏览器下会触发安全限制。生产环境建议把跨域组件关闭或者改为精确的域名白名单。6.4 上线前的自测清单项目做完到答辩或者演示之前建议按下面这份清单过一遍业务流程任何一个环节断掉都说明系统还没闭环新客户登记创建宠物档案检查老客户手机号能否正确关联到已有档案。用医生账号登录尝试预约一个已被占用的时间槽看会不会被正确拒绝。开一张包含三种药品的处方验证处方金额计算是否正确提交后去数据库确认库存已被扣减。使用一个库存接近预警阈值的药品来开方确认扣减后列表页展示预警状态。收费完成后查看收费记录列表确认状态流转和金额汇总。退出登录用另一个角色账号登录确认菜单展示和页面访问权限有所区分。如果你的系统能把这条链路完整跑通那它在功能完整度上已经超过绝大多数同类型项目了。7. 我的几点实操体会最后说几句个人的感受。这类管理系统项目最大的价值不是用了Spring Boot和Vue这个组合标签而是你通过它理解了真实业务系统是如何被拆解和数据驱动的。在做的过程中我最大的体会是表结构设计决定了你后面写代码是享受还是受罪。一开始贪图省事把宠物品种、医生科室全部塞成字符串字段到了写统计功能的时候才发现需要一个个去匹配解析为时已晚。另外一个让我印象深刻的经验是接口的命名和返回结构一定要在项目开始前就固定下来。这个项目的前端接口请求方法我是从第一个页面就严格统一为post方法、统一返回Result对象后面新增了几十个接口也没有出现过一次前端取错字段的情况。还有个小技巧想分享给做答辩或者演示场景的朋友在预约管理页面提前准备好几条演示数据包括一个处于已预约状态的时间槽和一个空闲的时间槽演示时先展示列表再实际操作预约一个空闲槽位然后刷新页面展示预约成功并已锁定的效果。带状态变化的数据演示比纯静态页面有说服力得多。这套系统的可扩展方向其实也挺多的比如接入微信小程序让宠物主人自己预约查看报告或者加一个短信提醒模块在预约日前一天发通知。核心的预约、处方、库存、收费这条链跑通之后往上叠加新模块就跟搭积木一样顺手了。
返回列表