资讯详情

资讯详情

Spring Boot+MyBatis医院管理系统:从挂号到住院的并发控制与事务设计

简介这是一套基于Java开发的医院管理系统完整源码与数据库面向Java学习者、医疗信息化开发人员及需要课设/毕设参考的开发者覆盖挂号、门诊、药房、住院等核心业务流程。压缩包共1236个文件大小约8.91MB其中791个java文件构成业务逻辑主体106个xml与17个yml等配置文件支撑Spring Boot等框架的集成部署另有84个js、37个html、18个css等前端资源实现管理界面7个sql脚本用于初始化数据库表结构。系统包含患者、医生、科室、预约、诊疗、药品库存等数据模型并提供在线排班预约、电子病历、处方配药、病房分配、费用统计等功能模块同时设计有基于角色的权限控制、预约并发处理及RESTful接口便于扩展对接第三方系统。已有681人学习下载适合希望通过完整项目案例掌握企业级Java开发流程、理解医疗业务数据建模与模块化架构的开发者可从中获得源码级排错思路和工程化目录组织参考。 每到毕设季或者中小机构想上一套内部管理系统时总能碰到同一个问题网上能看到的 java 开发的医院管理系统源码不少可真能把挂号、门诊、药房、住院全流程跑通的并不多。多数版本拿到手第一件事就是数据库导入报错、登录页白屏、去挂号发现号源数量对不上。这套系统并不只是“患者表 药品表”的增删改查而是挂号占用号源门诊开出处方收费通过才允许药房发药住院又是一条独立的医嘱与费用链路。把这条链路吃透比背任何一个页面模板都重要。这篇笔记就带你从源码结构、数据库设计一路走到部署排错照着做能少走不少弯路。2. 技术栈与数据库设计先定事务边界再设计表结构拿到任何一套源码第一件事不是急着启动而是先看技术栈和表结构。如果这两个位置没理清楚后面改功能、调 Bug 都会很痛苦。医院管理系统这类项目最常见的组合就是 Spring Boot MyBatis MySQL前端套一个管理后台模板或 JSP。这个组合之所以普及是因为每一层都便于排查Spring Boot 减少了大量 XML 配置MyBatis 把每条 SQL 都放在 mapper 里遇到数据问题可以直接把 SQL 拉出来执行改起来效率最高。用 JPA 写简单 CRUD 很爽但写带统计、多条件关联查询时生成的 SQL 经常像个黑匣子医院系统这种对数据准确性要求高的场景我一般不太推荐。2.1 技术选型为什么 Spring Boot MyBatis 是默认选择如果你拿到的源码是 SSMSpring SpringMVC MyBatis老结构也不用急着换。SSM 和 Spring Boot 在业务代码层面差异不大差异主要在装配方式上。Spring Boot 内嵌 Tomcat打一个 jar 包就能跑适合大多数中小项目的部署方式老 SSM 结构则需要外置 Tomcat部署步骤多两步。从维护角度说Spring Boot 的项目结构更清晰依赖冲突也更少。你可以先检查源码里是javax.servlet还是jakarta.servlet前者多半对应 Spring Boot 2.x JDK 8后者对应 3.x JDK 17。这个区分决定了你能不能直接在当前环境启动是最容易踩的第一个坑。MyBatis 的好处是 SQL 完全由你控制。比如后面要写的号源扣减、库存扣减都是靠一条带条件的 update 语句保证正确性这种 SQL 在 MyBatis 里可以直接写、直接调试。换成 JPA你得研究它的锁机制和 Repository 方法命名反而多一层学习成本。因此在这类系统里MyBatis 几乎是默认选择。前端方面JSP、Thymeleaf 或者模板后台都无所谓它们不影响核心业务真正决定系统能不能用的是数据库设计和事务边界。2.2 核心表结构挂号、处方、药房、住院的数据到底怎么关联理解了技术栈接下来要把表结构理清楚。医院管理系统虽然页面多但核心表其实就那么十来张。下面这张表把四个业务模块的落库方式列出来照着这张表去比对你手里源码的数据库就能看出它的设计是否完整。业务模块核心表关键字段作用基础资料patientid, name, id_card, phone患者主数据基础资料department / doctordoctor_id, dept_id科室与医生信息挂号scheduledoctor_id, schedule_date, shift, total_quota, remain_quota出诊号源挂号registerschedule_id, patient_id, visit_status挂号记录门诊prescriptionregister_id, doctor_id, status处方主表门诊prescription_itemprescription_id, drug_id, quantity处方明细药房drug / drug_stockdrug_id, stock_quantity, warning_quantity药品字典与库存药房drug_stock_flowdrug_id, flow_type, related_no出入库流水住院admissionpatient_id, ward_id, bed_id, status住院登记住院bed / medical_order / inpatient_feeadmission_id, fee_amount, status床位、医嘱、费用这十张表基本覆盖了标题里“挂号、门诊、药房、住院”四个功能。核心关系一句话能说清挂号记录从排班号源里扣号门诊根据挂号记录开处方处方明细指向药品库存药房按已收费处方发药并扣库存住院则是从入院登记开始由医嘱产生费用明细出院时统一结算。下面这张建表 SQL 是整套系统的关键直接决定业务能不能跑通。-- 号源表一个医生一天一个班次一条记录 CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL COMMENT 医生ID, dept_id INT NOT NULL COMMENT 科室ID, schedule_date DATE NOT NULL COMMENT 出诊日期, shift TINYINT NOT NULL COMMENT 1上午 2下午, total_quota INT NOT NULL DEFAULT 0 COMMENT 总号数, remain_quota INT NOT NULL DEFAULT 0 COMMENT 剩余号数, UNIQUE KEY uk_doc_time (doctor_id, schedule_date, shift) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT出诊排班表; -- 挂号记录关联号源和患者 CREATE TABLE register ( id INT PRIMARY KEY AUTO_INCREMENT, register_no VARCHAR(32) NOT NULL COMMENT 挂号单号, schedule_id INT NOT NULL COMMENT 号源ID, patient_id INT NOT NULL, doctor_id INT NOT NULL, visit_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待诊 1已接诊 2已退号, register_time DATETIME NOT NULL, UNIQUE KEY uk_register_no (register_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT挂号记录; -- 处方主表处方和挂号一对一 CREATE TABLE prescription ( id INT PRIMARY KEY AUTO_INCREMENT, prescription_no VARCHAR(32) NOT NULL, register_id INT NOT NULL COMMENT 关联挂号记录, doctor_id INT NOT NULL, patient_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未收费 1已收费 2已发药 3已退费, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, create_time DATETIME NOT NULL, UNIQUE KEY uk_prescription_no (prescription_no), UNIQUE KEY uk_register_id (register_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT处方表;注意几个细节。schedule 表加了(doctor_id, schedule_date, shift)唯一索引这是防超卖的第二道防线register 表用register_no做业务单号而不是直接用自增 id 对外暴露prescription 表对register_id加唯一索引避免同一张挂号重复开处方。这些细节是“能用”和“好用”之间的差别。后面的住院相关表可以在此基础上增量添加不影响已跑通的流程。2.3 项目骨架与启动配置application.yml 里必调的 4 个参数表结构有了接下来是让源码在本地启动。大多数启动失败都集中在数据源配置上。下面是我常用的最小配置可以直接对照修改你手里的源码。server: port: 8080 servlet: context-path: /his spring: datasource: url: jdbc:mysql://localhost:3306/hospital_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: your_user password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.his.entitycharacterEncodingutf8解决中文写入变问号的问题serverTimezoneAsia/Shanghai解决 MySQL 8.x 的时区报错context-path决定了登录地址是localhost:8080/his/login而不是根路径mapper-locations告诉 MyBatis 去哪里找 XML 文件。这四个参数是启动阶段最容易出问题的点。如果启动后 SQL 打印不出来可以在配置文件里加一行logging.level.com.example.his.mapperdebug把 SQL 输出到控制台排查效率会高很多。3. 挂号与门诊模块从号源扣减到处方收费的完整链路挂号是门诊和住院的入口所有业务都从一条挂号记录开始。很多教学 Demo 在挂号功能上只做了 insert号源扣减用前端 JavaScript 控制这种设计在单机演示时没问题一旦多人同时操作就会出现超卖。正确做法是把扣号逻辑放到数据库层面用一条带条件的 update 解决并发问题。3.1 号源扣减一行 update 顶掉 synchronized很多同学第一反应是给挂号方法加synchronized关键字这在单机单实例下确实有效但一旦部署多实例锁就失效了。我常用的方案是直接在 SQL 层面做条件更新让数据库保证原子性。先看 Mapper XML 里的扣号语句update iddeductQuota UPDATE schedule SET remain_quota remain_quota - #{num} WHERE id #{scheduleId} AND remain_quota #{num} /update对应的挂号 Service 长这样Service public class RegisterService { Transactional(rollbackFor Exception.class) public String register(RegisterDTO dto) { int rows scheduleMapper.deductQuota(dto.getScheduleId(), 1); if (rows 0) { throw new BusinessException(号源不足或已被抢完); } Register record new Register(); record.setRegisterNo(generateRegisterNo()); record.setScheduleId(dto.getScheduleId()); record.setPatientId(dto.getPatientId()); record.setDoctorId(dto.getDoctorId()); record.setVisitStatus(0); registerMapper.insert(record); return record.getRegisterNo(); } }这段代码的逻辑是deductQuota的执行结果只有 0 和 1 两种0 表示remain_quota已经不够这时直接抛出业务异常不再走后面的插入逻辑。因为Transactional把扣号和插入挂号记录绑在同一个事务里任何一步失败都会回滚不会出现号扣了但记录没生成的情况。退号操作则相反把visit_status置为 2同时把号源回补 1这里注意不能物理删除挂号记录否则后续门诊和收费链路会失去关联。3.2 门诊接诊与处方保存状态机防止重复收费门诊模块的核心是接诊和开处方。医生端登录后看到待诊列表点击接诊把register.visit_status从 0 改为 1然后进入开处方页面。处方保存时要把握一个原则状态不能合并。我见过不少 Demo 把“已收费”和“已发药”合并成一个状态这样做药房就没办法区分哪些单子可以发药、哪些单子还有退费机会全凭人工判断。Transactional(rollbackFor Exception.class) public Long createPrescription(PrescriptionDTO dto) { // 1. 校验挂号状态必须是待诊或已接诊 Register reg registerMapper.selectById(dto.getRegisterId()); if (reg null || reg.getVisitStatus() 2) { throw new BusinessException(挂号状态不允许开处方); } // 2. 创建处方主表状态为未收费 Prescription p new Prescription(); p.setPrescriptionNo(genNo(RX)); p.setRegisterId(dto.getRegisterId()); p.setDoctorId(dto.getDoctorId()); p.setPatientId(dto.getPatientId()); p.setStatus(0); prescriptionMapper.insert(p); // 3. 批量插入明细 for (ItemDTO item : dto.getItems()) { Drug drug drugMapper.selectById(item.getDrugId()); prescriptionItemMapper.insertDetail(p.getId(), drug, item.getQuantity()); } // 4. 金额从明细汇总回填不信任前端传值 BigDecimal total prescriptionItemMapper.sumAmount(p.getId()); prescriptionMapper.updateTotalAmount(p.getId(), total); return p.getId(); }这里值得一提是最后一步总金额不是前端传过来的而是从明细表里汇总出来的。这样做防止了篡改金额的可能性。开处方本身不改变挂号状态等到收费完成后再变更处方状态。如果你发现源码里处方保存时就把挂号状态改成了已完成那这里的链路是有问题的建议调整逻辑。3.3 收费结算用 BigDecimal不用 Double用流水号不物理删单收费模块的坑主要在数据精度和单据追溯上。金额计算必须用BigDecimal而且要用字符串构造。直接写new BigDecimal(14.5)和new BigDecimal(14.5)的结果不一样前者可能带二进制误差。正确的写法如下BigDecimal unitPrice new BigDecimal(14.50); BigDecimal quantity new BigDecimal(2); BigDecimal amount unitPrice.multiply(quantity).setScale(2, RoundingMode.HALF_UP);退费时也要走“正向单 冲正单”的模式不要直接 delete 收费记录。比如收了一笔 100 元的处方费退费时新增一条 -100 的流水再关联原收费单号。这样对账的时候每一笔钱都能从流水里追溯。另外收费操作完成前要再次校验处方状态必须为“未收费”防止同一个处方被收两次费。这一整套流程下来挂号、门诊、收费三个模块的状态流转就是闭环了。4. 药房与住院模块库存怎么扣住院费用怎么算药房和住院这两个模块看似独立实际上对数据一致性的要求比门诊更高。药房发药扣库存时如果和处方状态更新分在两个事务就会出现库存扣了、处方还是未发药的错乱状态住院模块的床位分配和费用结算也是同样的道理必须把状态变更和业务操作放在同一个事务里。4.1 发药扣库存三件事必须在一个事务里药房看到一条已收费的处方点击发药时系统要做三件事把处方状态从“已收费”改成“已发药”扣减药品库存写入一条出库流水。这三件事缺一不可而且要在一个事务里完成。下面是发药的核心逻辑Transactional(rollbackFor Exception.class) public void dispense(Long prescriptionId) { Prescription p prescriptionMapper.selectById(prescriptionId); if (p null || p.getStatus() ! 1) { throw new BusinessException(处方不存在或未收费); } ListItemDTO items prescriptionItemMapper.listByPrescriptionId(prescriptionId); for (ItemDTO item : items) { int rows drugStockMapper.deductStock(item.getDrugId(), item.getQuantity()); if (rows 0) { throw new BusinessException(库存不足 item.getDrugName()); } drugStockFlowMapper.insertFlow(item.getDrugId(), OUT, item.getQuantity(), p.getPrescriptionNo()); } prescriptionMapper.updateStatus(prescriptionId, 2); }扣库存的 SQL 同样要带条件UPDATE drug_stock SET stock_quantity stock_quantity - #{quantity}, update_time NOW() WHERE drug_id #{drugId} AND stock_quantity #{quantity}如果某项药品库存不够整张处方都会被回滚不会出现发了一半药的情况。这符合药房“整单出库”的业务规则。如果确实需要部分发药应在处方表上新增“部分发药”状态而不是通过多条无关联的记录去拼凑否则对账时会很痛苦。库存流水表每次出入库都写入这条流水是后续盘点和对账的重要依据。4.2 住院登记与床位分配条件更新抢占床位住院模块的第一个关键操作是分配床位。多人同时办理入院时一张空闲床可能被分配两次。和挂号扣号源同理这里也用条件更新来抢床位UPDATE bed SET status 1 WHERE id #{bedId} AND status 0受影响行数为 1 才表示床位占用成功。随后在同一事务里插入住院登记记录。出院时顺序反过来先做费用结算和医保结算然后把admission.status置为 1最后释放床位。床位释放太早可能出现新患者已经住进去旧患者的结算单还没归档的情况后续追溯会非常麻烦。我一般建议把“更新住院状态、释放床位、生成结算单”三个操作放在同一个事务方法里保证出院流程的原子性。4.3 医嘱与费用只有已执行的医嘱才进入账单住院费用是另一个容易出问题的地方。医嘱不是一开出来就计费的而是执行后产生费用。比如一条“每日两次、连用三天”的药品医嘱执行六次就产生六条费用记录。教学 Demo 里常见两个极端要么医嘱一开就计费要么费用由护士手工录入跟医嘱完全没有关联。正确的是给医嘱增加执行状态执行完成时写入一条费用明细SELECT admission_id, SUM(fee_amount) AS total_fee FROM inpatient_fee WHERE status 1 AND admission_id #{admissionId} GROUP BY admission_id;这里加了status 1的条件意味着费用明细有“作废”和“有效”两个状态。医嘱如果被医生作废对应费用行也应该标记为作废而不是物理删除。物理删除会让后续对账失去依据用状态标记才能保证“为什么这笔钱没了”可追溯。5. 排查数据不一致的 4 个高频坑这部分内容是帮 A 同学调一套某高校的 hospital_demo 时反复踩过的几乎每一条都能在真实项目里碰到。按现象到原因再到解决排查能省下很多时间。5.1 现象导入 SQL 报错或中文全部变成问号原因通常是 MySQL 连接串没有带characterEncodingutf8或者建表语句里用的还是latin1字符集。更进一步MySQL 8 推荐的是utf8mb4它才能覆盖全部中文字符和生僻字。解决方式分三步先把数据库和表统一改成utf8mb4再把连接串加上useUnicodetruecharacterEncodingutf8最后在导入 SQL 文件前先执行SET NAMES utf8mb4;。改完这三处中文乱码基本不会再出现。5.2 现象挂号数量超出号源总数这是并发导致的超卖问题。如果源码里是用synchronized锁挂号方法换一台服务器或者多实例部署就失效如果先查remain_quota再在 Java 里判断大于 0 再去更新也会在并发时出现偏差。解决方式是改成前面说的条件更新UPDATE schedule SET remain_quota remain_quota - 1 WHERE id ? AND remain_quota 1。并且检查一下Transactional注解是不是加在了 public 方法上事务没生效也会导致扣号和插记录不在同一原子操作里。5.3 现象药房库存变成负数库存出现负数说明扣库存的操作和处方状态更新没有放在同一个事务里。比如先扣了库存再更新处方状态时抛异常扣库存的操作回滚了但库存已经减掉了或者反过来扣库存成功但没写流水查询时看起来数量就对不上。解决方式是按 4.1 的写法把扣库存、写流水、更新处方状态放在同一个带Transactional的方法里同时给扣库存 SQL 加上stock_quantity #{quantity}条件。库存不足时直接抛异常回滚而不是扣成负数再报错。5.4 现象出院结算金额比每日清单少了几百住院期间每天护士站都会汇总当天的费用但最后出院结算的总金额却比每日清单加起来少这类问题的原因通常是查询条件不一致。每日清单查询时没有过滤作废费用把已作废的医嘱费也统计进去了出院结算查询时却只统计有效状态。两边口径不一致金额自然对不上。解决方式是统一两条查询的过滤条件都只统计status 1的有效费用行。作废的医嘱费用行用状态标记不删除。这样每日清单和出院结算的金额永远可以互相印证。6. 把源码变成可维护的医院系统备份、检查、下一步扩展源码能跑通只是第一步真正要投入使用时还得把备份和上线检查做扎实。先说备份。开发阶段改表结构之前一定先用命令行把当前库导出一份。我习惯用 mysqldump 定时备份保留最近 7 天的文件mysqldump -u root -p hospital_db /backup/hospital_$(date %Y%m%d_%H%M%S).sql恢复时先建空库再把 SQL 导进去。这句命令看起来简单但关键时刻能救命。我接某机构源码时最后悔的一回就是没备份就改了字段结构三小时的数据对账全部白做。在那之后我给自己定了个规矩改表结构之前必须先导出一份完整 SQL这算是给自己留的后悔药。上线前还有三个检查项要落实第一数据库连接串必须带serverTimezoneAsia/Shanghai否则每晚跨时区跑批对账会时不时出一次偏差第二检查所有涉及多表更新的 service 方法确认Transactional(rollbackFor Exception.class)是齐的而且至少有一个事务管理器第三把 MyBatis 的 SQL 日志打开观察两天确认没有慢查询。这三项落实了系统才敢交出去。如果后面想把系统接到预约挂号平台扩展方向建议新增预约表和履约记录而不是直接往register表里写。因为预约只是占号就诊前可能取消和现场挂号的“即占即得”逻辑不同。把预约和挂号拆成两层既不影响现有流程又方便以后扩展。项目做到这个程度再回头看你手里的源码就能明白每个表每个字段为什么存在了。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →