
“宠物医院管理系统”这套东西我在接手之前也觉得不就是CRUD堆一起吗真做起来才发现预约、病历、药房、收费这几条线串起来之后业务逻辑的复杂度远超预期。这套基于Spring Boot的宠物医院管理系统配套完整可运行源码正好适合作为Java后端入门到进阶的练手项目拿来写课程设计或者毕业设计都够用。Spring Boot的作用在这里体现得淋漓尽致自动装配帮我把项目从零跑起来的时间压缩到了几分钟不再需要折腾XML配置、Tomcat部署写好业务代码直接就能启动。这也是为什么现在绝大多数Java新人项目都选它。下面这篇拆解我会从需求设计、技术选型、数据库建模、核心模块实现到问题排查把整个项目的思路和实操心法完整讲一遍源码怎么改、怎么跑、哪里容易翻车都给你交代清楚。1. 项目概述与需求拆解1.1 宠物医院管理系统到底是做什么的宠物医院管理系统本质上是一套面向宠物诊所内部运营的信息化管理工具。它的核心服务对象是前台接待人员、宠物医生、药房管理员和院长这类角色。和人类医院的信息化系统相比宠物医院规模通常更小但业务闭环一点不少主人带着宠物进门先建档再预约或者挂号由医生接诊后写病历、开处方然后主人去药房取药、去收费台结账整个过程涉及的信息都要能留痕、可追溯。所以这个系统从功能模块上看至少需要覆盖这几个核心内容宠物主人的信息管理、宠物档案管理、医生排班和预约管理、病例处方记录、药品库存管理、收费管理、系统用户与权限管理。别看它叫“宠物医院管理”这套模块拆开揉碎了跟一个简化版的门诊HIS系统没有本质区别。我见过很多同学拿到这种项目源码第一反应是直接启动、到处点点看结果被一堆从未见过的表结构和接口搞得一头雾水。正确的做法是先读需求想清楚每个页面和按钮背后到底在解决什么业务问题再去看代码一下子就通了。1.2 核心用户与使用场景拆解用户角色通常分为三种管理员院长/运维、医生接诊开方、前台/药房。这三个角色对应的场景完全不同权限设计也得跟着拆开。前台最关心的是预约和挂号效率。主人在微信或电话里预约某个时间段前台要在系统里快速查到医生排班情况能约就约不能约就给出下一个可约时间。宠物到店后前台需要快速调出宠物档案确认主人身份创建本次就诊记录。医生关心的则是“这个宠物之前得过什么病、对什么药过敏、上次做的检查结果是什么”。所以病历模块必须支持按宠物ID查询历史就诊记录处方必须能关联具体药品并控制库存扣减。药房和收费关注的是药品数量与金钱账目。药品入库、出库、库存预警以及每笔收费的记录、退费处理这些都属于财务敏感操作必须留下完整日志。正因为角色和场景足够清晰这套系统在权限设计上采用了典型的RBAC模型给不同角色分配不同菜单和按钮权限。这也是Spring Boot项目里比较标准的做法用拦截器或者Shiro/Spring Security都能实现具体用哪种取决于项目初始设计。2. 技术选型与架构设计2.1 核心技术栈说明这套系统的后端选择Spring Boot 2.x作为基础框架搭配MyBatis Plus进行数据库操作数据库采用MySQL缓存用Redis权限控制使用Spring Security或原生拦截器方案前端则采用Thymeleaf服务端渲染或者Vue前后端分离取决于源码实际版本。我先横向对比一下各个技术组件的作用。技术组件核心作用为什么这么选Spring Boot 2.x项目基础框架自动装配、快速启动社区生态最成熟MyBatis PlusORM持久层框架单表CRUD无需写SQL复杂查询仍能自定义XMLMySQL关系型数据库免费、稳定、部署门槛低满足中小型系统Redis缓存/分布式锁存储验证码、登录会话处理并发预约时做锁Spring Security / 拦截器接口鉴权保护后台接口不裸奔区分角色权限Maven依赖管理和构建市面上最主流的Java构建工具这套组合的合理性在于它没有引入任何“秀肌肉”的重型组件。宠物医院管理系统的并发量很低常规情况下同时在线也就几十个人不需要微服务、不需要消息队列更不需要分库分表。技术选型讲究够用就好Spring Boot加MyBatis Plus的组合既保证了开发效率又让新手能看懂每一行代码的来龙去脉。2.2 项目分层架构解析拿到源码后第一件事一定是看目录结构。标准的Spring Boot项目分包规则直接决定了代码后续好不好维护。这套系统采用了经典的四层架构从上到下分别是Controller层、Service层、Mapper层、实体层。Controller层负责接收前端请求、参数校验、调用Service、返回统一格式结果。Service层承载业务逻辑比如预约时间的冲突校验、处方的金额计算、库存的扣减操作这些都必须写在这里绝不能一股脑堆到Controller里。Mapper层通过MyBatis Plus提供的BaseMapper接口实现单表CRUD复杂查询才额外写XML或注解SQL。实体层则是与数据库表一一对应的POJO类。我见过太多新手项目把业务逻辑写在Controller里一个方法几百行后期想加个权限判断得顺着代码翻半天。项目源码里只要严格遵守分层规范每个类的职责就会特别清晰Controller薄、Service厚、Mapper只做数据访问这是评判一个Spring Boot项目质量最直观的标准。另外项目里通常还会有一个config包专门放配置类比如跨域配置、拦截器注册、Redis序列化配置。这部分容易被忽略但实际运行中问题最多后面我会单独讲。3. 数据库设计与核心实体建模3.1 核心数据表梳理数据库设计是整个系统的地基。宠物医院管理系统涉及的数据表我从源码中归纳出的核心表大概有这些数据表核心字段与作用sys_user用户表包含用户名、密码、角色、状态pet_owner宠物主人表包含姓名、电话、地址pet_info宠物档案表包含宠物名、种类、品种、生日、主人IDdoctor_schedule医生排班表包含医生ID、日期、时段、剩余号源appointment预约表包含宠物ID、医生ID、预约日期、时间段、状态medical_record就诊病历表包含宠物ID、医生ID、主诉、诊断、处理意见prescription处方表包含病历ID、药品ID、数量、用法drug_info药品表包含药品名、库存量、单价、预警线charge_record收费记录表包含关联业务单号、金额、支付状态这里最容易踩的坑是“业务单与台账分不清”。比如处方和收费处方是医疗行为记录属于业务单据收费是财务行为记录属于账单。两者通过一个业务单号关联而不是把金额字段直接写到处方表里。这样设计的好处是后续扩展退费、发票、统计报表都更方便不会因为业务变化去改表结构。3.2 表字段设计中的关键细节设计这些表的时候有几个字段细节是必须注意的也是这套源码里做得比较规范的地方。首先是时间字段。预约表、病历表这些涉及时间记录的一定要区分“业务时间”和“创建时间”。业务时间指的是预约的日期时段创建时间指的是这条记录在系统里生成的那一刻。两者都用datetime类型存储但语义完全不同统计报表时最容易因为混用这两类时间而算错数据。其次是金额字段。药品价格、收费金额这类财务数据MySQL里建议用DECIMAL(10,2)而不是float或double。浮点数在计算机里是近似存储计算0.1加0.2都可能出现精度误差用在钱上是要出大事的。这点在Java里同样适用实体类的金额属性要用BigDecimal不要用Double。还有一个隐藏较深的设计宠物档案与主人档案是分开的两张表。从业务上看同一个主人可能带两只猫来看病主人信息可以复用反过来一只宠物也可能在被送养后换了主人。所以宠物表和主人表用外键关联但宠物表中同时保存主人ID的快照字段这样即使主人信息变更历史病历依然能对应到当时的联系人。3.3 状态字段的枚举化管理状态字段是业务系统里最容易写出脏数据的地方。预约状态可能有“待就诊、已完成、已取消、已过期”挂号单状态有“待支付、已支付、已退费”药品库存状态有“正常、预警、缺货”。源码里比较好的做法是设置一个状态字段用int类型存储同时配合常量类或者枚举类把每种状态的数值含义固定下来。比如预约状态定义常量APPOINTMENT_STATUS_WAIT 0、APPOINTMENT_STATUS_DONE 1这样在代码里判断状态时写的就是语义清晰的常量而不是满屏的魔法数字。我实际操作中强烈建议把状态流转的逻辑收敛到Service层做一个统一的状态变更方法不要让每个Controller都去改状态字段否则后期改一个状态规则得满项目找哪里动了这个字段。4. 核心功能模块的实现细节4.1 预约排班模块并发冲突处理预约排班是宠物医院系统里业务逻辑最复杂的模块也是面试官最喜欢追问的点。核心需求很简单医生在某个时间段只能接一个患者前台登记某个时段预约后其他宠物不能再预约同一时段。最初的实现方案通常是先查再插查预约表里有没有该时段记录没有就插入。但在并发场景下两个请求同时查到“没有记录”然后同时插入就会造成超约。这个问题的经典解法有三种数据库唯一约束、乐观锁、Redis分布式锁。这套项目里采用的是Redis锁加上数据库唯一索引双重保障。在预约时先尝试获取一个以医生ID和时段拼接的Redis锁拿到锁后再执行业务逻辑执行完释放锁。同时预约表在医生ID和时间段的联合字段上加了唯一索引这样即使极端情况下Redis锁失效数据库也会挡住重复的预约并抛给上层一个DuplicateKey异常。具体代码思路大致是这样的public Appointment createAppointment(Appointment appointment) { String lockKey appointment:lock: appointment.getDoctorId() : appointment.getSlotTime(); boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!locked) { throw new ServiceException(该时段正在被预约请稍后重试); } try { // 再查一次数据库确认没有冲突 Integer count appointmentMapper.selectCountByDoctorAndTime(...); if (count 0) { throw new ServiceException(该时段已被预约); } appointment.setStatus(AppointmentStatus.WAIT); appointmentMapper.insert(appointment); return appointment; } finally { redisTemplate.delete(lockKey); } }写这段代码时有三个容易被忽略的细节第一Redis锁必须设置过期时间否则服务异常时锁永远不释放系统直接卡死第二加锁后还要查一次数据库因为锁只能保证请求串行不能保证库里真的没有数据这两个检查缺一不可第三finally里删除锁的时候要小心如果业务执行时间超过锁的过期时间前一个请求的锁可能已被自动删除后一个请求拿到新锁前一个请求的finally就会把后一个请求的锁误删。严谨的做法是使用带请求标识的锁删除前判断value是否匹配。4.2 就诊病历与处方流程病历模块的核心是一个主从结构一次就诊对应一条medical_record主记录而一条处方可以包含多个药品明细。这里数据库设计上要建立处方主表和处方明细表明细表通过外键指向主表。典型的做法是主表存储病历ID、开方医生、处方日期、总金额明细表存储药品ID、药品名称、单价、数量、用法用量。写处方时系统需要同时做两件关联操作一是把处方明细写入数据库二是扣减药品库存。这两件事必须放在同一个事务里否则会出现处方开了但是库存没扣或者库存扣了但处方没保存成功的尴尬情况。在Spring Boot中实现事务管理最简单的方式就是在Service层方法上加Transactional注解事务边界直接由方法划分。我在这里特别提醒一个实际业务中的细节处方保存时一定要保存“单价快照”。药品价格可能会调整如果处方明细只存了药品ID而不存价格那么三个月后打历史处方的时候价格就会变成当前价格而不是当时的开药价格。这在财务审计上是致命的。很多新手做设计时会忽略这一点只关联药品ID留下了价格追溯的隐患。Transactional(rollbackFor Exception.class) public Prescription createPrescription(PrescriptionRequest request) { // 1. 获取病历并校验状态 MedicalRecord record medicalRecordMapper.selectById(request.getRecordId()); if (record null || !record.getStatus().equals(RecordStatus.DIAGNOSING)) { throw new ServiceException(病历不存在或不在诊疗中); } // 2. 构造处方主表 Prescription pres new Prescription(); pres.setRecordId(request.getRecordId()); pres.setDoctorId(request.getDoctorId()); pres.setTotalAmount(BigDecimal.ZERO); prescriptionMapper.insert(pres); // 3. 逐条处理明细并扣库存 BigDecimal total BigDecimal.ZERO; for (PrescriptionItemDTO item : request.getItems()) { DrugInfo drug drugInfoMapper.selectById(item.getDrugId()); if (drug null) { throw new ServiceException(药品不存在); } if (drug.getStock() item.getQuantity()) { throw new ServiceException(药品【 drug.getName() 】库存不足); } PrescriptionDetail detail new PrescriptionDetail(); detail.setPrescriptionId(pres.getId()); detail.setDrugId(drug.getId()); detail.setDrugName(drug.getName()); detail.setPrice(drug.getPrice()); // 关键保存单价快照 detail.setQuantity(item.getQuantity()); detail.setSubtotal(drug.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); prescriptionDetailMapper.insert(detail); // 扣减库存 drugInfoMapper.decreaseStock(drug.getId(), item.getQuantity()); total total.add(detail.getSubtotal()); } pres.setTotalAmount(total); prescriptionMapper.updateById(pres); return pres; }这个过程中库存扣减最好使用UPDATE语句直接减类似UPDATE drug_info SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}而不是先查出来再算好值去更新。后者容易产生并发问题前者用SQL条件判断保证原子性更安全可靠。4.3 收费模块与支付状态闭环收费记录是整个系统中数据敏感度最高的部分。这里要做的事情看起来简单实际坑不少。前台在收费页面看到待支付列表后点击收款系统应当将对应业务单号下的所有待支付项目汇总生成一条或几条收费记录状态置为已支付同时更新关联单据的状态。这个模块的难点不在“收款”这个动作而在“退费”和“对账”。宠物医院时有退款发生比如检查没做、药拿错了。退费时要求必须关联原收费记录不能直接删除而是新建一条退款记录金额为负数并备注原单号。这样做的好处是账目始终有完整流水月底对账时总收入等于收费记录之和不会因为删除数据而导致账实不符。我的做法是给收费记录增加一个payment_type字段取值是pay/refund通过同一个charge_record表管理关联订单号相同。这样查询某笔业务的总收支时直接sum金额字段就能得到净收入报表统计非常干净。另外收费模块必须打印或保存收款小票编号。这个编号通常是基于日期加序号生成的比如20240115-001保证每天从001重新编号方便物理单据和电子记录对得上。序列号生成要防止并发重复一般用数据库的计数器表或者Redis自增实现放在一个事务里避免两个线程同时拿到002。5. 实操中容易踩的坑与排查方法5.1 金额精度导致的差一分钱问题宠物医院药品价格经常出现小数点后两位比如23.50元。如果你在Java里用了Double类型在MySQL里用了float那么算总价时经常会出现23.500000000004这种鬼数字。系统一切正常时你察觉不到一旦订单量大了对账就会差几分钱查都查不出来。解决方案我之前说过了Java侧用BigDecimal数据库侧用DECIMAL(10,2)。BigDecimal的构造一定要用字符串不能直接用double构建否则精度问题照样存在。比如new BigDecimal(0.1)打印出来是0.1000000000000000055511151231257827021181583404541015625而new BigDecimal(0.1)才是标准的0.1。金额计算时还要注意scale一致性两个BigDecimal相乘后结果的scale可能超过两位需要调用setScale(2, RoundingMode.HALF_UP)做统一的四舍五入。5.2 MyBatis Plus自动填充时间字段失效很多源码项目都会用MyBatis Plus的自动填充功能来管理create_time、update_time字段在实体字段上标注TableField(fill FieldFill.INSERT)然后写一个MetaObjectHandler实现类。这个方法很方便但也有失效的时候。最常见的失效原因是实体类字段名与数据库列名的映射对不上。比如Java字段叫createTime数据库列名是create_time如果你在MyBatis Plus里没有开启驼峰映射或者实体上缺少TableField注解那么自动填充的字段根本找不到目标列插入的数据永远是null。排查这个问题的套路很固定第一步看启动日志有没有关于MetaObjectHandler的报错第二步看数据库里这条记录的create_time是不是null第三步检查实体字段的RequestMapping映射。绝大多数情况下问题都出在字段映射上跟自动填充逻辑本身无关。5.3 逻辑删除与唯一索引的冲突很多Spring Boot项目为了提高数据安全性会采用逻辑删除方案也就是删数据时不真正DELETE而是将is_deleted字段置为1。这个方案在宠物医院系统里有个非常经典的问题当我们需要给某个手机号建立唯一索引防止重复创建主人档案时逻辑删除的记录仍然占据着这个唯一索引的位置。比如主人A使用手机号13800000000注册后离职了管理员删除了这条主人记录其实是is_deleted1过段时间新的主人B用同一个手机号建档插入时就会发现唯一索引冲突导致建档失败。这个问题的常规解法有两种。第一种是建一个组合唯一索引包含业务键和is_deleted类似UNIQUE KEY uk_phone_deleted(owner_phone, is_deleted)但这样只能允许一条历史删除记录删除两次同样会冲突。更标准的做法是增加一个单独的字段delete_token删除时将其设置为一个UUID唯一索引改为UNIQUE KEY uk_phone_delete(owner_phone, delete_token)。未删除记录的delete_token设为固定值空字符串删除后更新为随机UUID这样历史记录就不会抢占唯一键了。5.4 事务失效的几种典型场景Transactional是不是只要加上就万事大吉当然不是。事务失效的案例我在项目里见过太多次了归纳起来主要有三种。第一种是事务方法被同类内部方法调用。Spring的事务是通过AOP代理实现的同类内部调用不会经过代理对象所以事务注解不生效。解决方法有两种把事务方法拆到另一个Service类或者注入自身的代理对象后调用。第二种是方法不是public的。Spring默认只对public方法做代理增强如果你把Transactional加在private方法上它会安静地不生效没有任何报错。第三种异常被吞掉了。默认情况下Spring只在RuntimeException和Error上回滚如果你抛的是检查型异常或者在catch里把异常吃掉了没往上抛事务同样不会回滚。我通常会指定rollbackFor Exception.class强制要求所有异常都触发回滚这样更省心。6. 项目部署与常见问题速查6.1 本地三步跑通源码很多同学拿到源码的第一个问题是“这玩意儿怎么跑起来”。我总结一套标准流程按顺序执行几乎不会出错。第一步准备环境。确认JDK版本是8或11Maven版本3.6以上MySQL版本5.7以上Redis版本随意但必须启动。创建一个名为pet_hospital的数据库编码设置为utf8mb4然后导入项目里database目录下的SQL文件。第二步修改配置。打开application.yml把数据库用户名密码改成你自己的。如果Redis设置了密码也要在配置里填上。特别注意Redis的host不要写localhost要在云服务器上部署的更得记得改成公网或内网IP。第三步启动项目。用IDEA打开项目后等待Maven下载依赖找到主类PetHospitalApplication右键运行。启动成功后命令行会输出Tomcat started on port(s): 8080这时候就能通过浏览器访问了。启动报错的话优先看端口是否被占用。在Windows下执行netstat -ano | findstr 8080找到占用进程后kill掉重新启动。这种问题95%都是因为本机装了别的Web服务。6.2 常见问题排查速查表现象可能原因排查方向启动后立即退出没报错数据库连接失败检查URL、账号、密码、依赖是否引入页面能打开但接口全报404前端请求路径和后端不匹配查看浏览器Network里实际请求路径登录成功后仍然跳回登录页Session失效或拦截器放行路径错误检查拦截器排除列表中的路径配置列表数据中文乱码数据库连接字符集配置错误设置useUnicodetruecharacterEncodingutf8端口占用无法启动8080被其他程序占用netstat查找并kill进程MyBatis Plus分页不生效缺少PaginationInnerInterceptor插件检查MybatisPlusConfig里是否注册分页插件Redis连接超时本地没启动Redis服务双击redis-server.exe启动后再运行项目以上这些坑每一个都是我实际调试过程中踩过的不是理论推演。如果你也是刚开始接触这类项目建议拿到源码后先别急着改功能把上面这套流程走一遍权当热身。项目本身能跑通后面改代码才会心里有底。7. 说说我对这套源码的真实体会做这套宠物医院管理系统最大的收获不是说学会了Spring Boot怎么用而是搞懂了一个真实的业务系统从0到1需要经历哪些设计决策。很多人写代码只盯着页面和接口忽略了底层的数据建模、事务边界、权限设计、状态管理这些关键环节最后写出来的东西能跑但没法维护。对于准备拿这个项目做毕业设计或者面试作品的同学我的建议是在读懂源码的基础上至少动手改造两个地方。一个是给预约模块增加一个“取消预约释放号源”的逻辑把状态流转彻底打通另一个是给收费模块加一个简单的日报表导出功能用Excel或PDF把当天收入汇总出来。这样的改动不需要太多代码但能体现出你对业务的理解深度面试时聊起来会非常有说服力。最后分享一个我的小习惯每改一个功能前先在数据库里把对应的表结构、字段含义过一遍再顺着Mapper到Service到Controller的调用链读一遍代码最后才动手。别嫌慢这套流程走熟了之后你看项目源码的效率会是直线上升的。