简介:这是一套面向Java开发者与医疗信息化学习者的医院管理系统完整源码,围绕挂号、门诊、药房、住院等核心业务场景,提供了一套可运行、可二次开发的信息化解决方案。系统采用Java技术栈构建,涵盖后端服务、数据访问与权限控制等典型企业级设计,适合用于课程设计、毕业设计或医疗业务逻辑的学习研究。资源包共1236个文件,约8.91MB,其中791个java源文件构成主要业务代码,106个xml与17个yml负责配置与映射,84个js、37个html及18个css支撑前端页面,另有sql脚本、dockerfile、properties等部署与数据文件,目录结构完整。目前已有681人学习下载。通过研读这套源码,读者可以掌握挂号并发控制、电子病历、药品库存与住院费用统计等模块的实现思路,理解医疗数据的表结构设计与接口集成方式,并借鉴其权限控制与系统测试经验,为自身项目开发提供可复用的参考。
1. 从一份 Java 医院管理系统源码说起:挂号、门诊、药房、住院到底怎么串起来
很多同学拿到“医院管理系统源码+数据库”这类资源,第一反应是先把项目跑起来看界面,结果卡在数据库连不上、挂号编号重复、药房扣库存对不上账。问题不在代码写得多烂,而在于没先想清楚业务链路:一个患者从挂号开始,到门诊开方、药房发药、必要时转住院,这条链上每一步都在改同一批数据,谁先改、谁锁行、谁回滚,决定了系统能不能用。
这套系统本质是一个典型的 Java Web 单体应用,常见做法是 Spring Boot + MyBatis + MySQL,前端用 Thymeleaf 或 Vue 都行。它要解决的核心不是“增删改查”,而是状态流转:挂号单从“待就诊”到“已就诊”,处方从“待发药”到“已发药”,床位从“空闲”到“占用”再到“出院释放”。适合拿来做课程设计、毕业设计,也适合工作两三年的人拿来练手事务和并发。
2. 医院管理系统数据库表结构设计与挂号主键生成
2.1 挂号、门诊、药房、住院四张核心表怎么拆
先把表拆清楚,后面写代码才不会乱。我一般会保留这几张主表,字段只列关键的:
| 表名 | 作用 | 关键字段 |
|---|---|---|
patient | 患者档案 | id、name、id_card、phone |
registration | 挂号单 | id、patient_id、dept_id、doctor_id、status、reg_no |
prescription | 处方主表 | id、reg_id、doctor_id、status、total_fee |
prescription_item | 处方明细 | id、prescription_id、drug_id、qty |
drug | 药品库存 | id、name、stock、price、version |
admission | 住院单 | id、patient_id、bed_id、status、in_time、out_time |
bed | 床位 | id、ward_id、bed_no、status |
拆表的原则是:挂号单和处方一对多,处方和明细一对多,住院单和床位一对一。不要把药品库存直接塞进处方明细里,否则退药时库存回滚会非常难写。
2.2 挂号编号生成:别用自增 ID 当业务号
挂号单对外要展示一个reg_no,常见格式是GH202405200001。很多人图省事直接用自增主键,结果换库或分表就崩。我一般用“日期 + 当日序号”的方式,配合数据库唯一索引兜底:
CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reg_no VARCHAR(32) NOT NULL, patient_id BIGINT NOT NULL, dept_id INT NOT NULL, doctor_id BIGINT, status TINYINT DEFAULT 0 COMMENT '0待就诊 1已就诊 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_reg_no (reg_no), KEY idx_patient (patient_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_reg_no这个唯一索引是关键,它保证即使并发下生成了相同编号,数据库也会拒绝第二条,应用层捕获异常后重试即可。status用 TINYINT 而不是字符串,查询和索引都更省。
2.3 用 MyBatis 写挂号插入并处理并发冲突
生成编号的 Java 代码,我一般放在 Service 层,用“查当日最大序号 + 1”再插入,失败重试三次:
public String createRegistration(Long patientId, Integer deptId) { for (int i = 0; i < 3; i++) { String regNo = buildRegNo(); // GH + yyyyMMdd + 4位序号 try { Registration reg = new Registration(); reg.setRegNo(regNo); reg.setPatientId(patientId); reg.setDeptId(deptId); reg.setStatus(0); registrationMapper.insert(reg); return regNo; } catch (DuplicateKeyException e) { // 唯一索引冲突,说明序号被抢,重试 } } throw new RuntimeException("挂号编号生成失败,请重试"); }buildRegNo()里查SELECT MAX(reg_no) FROM registration WHERE reg_no LIKE 'GH20240520%',取后四位加一。注意这里不要加锁,靠唯一索引重试比锁表更轻。DuplicateKeyException是 Spring 对SQLIntegrityConstraintViolationException的封装,捕获它比捕获Exception精准。
3. 门诊开方与药房扣库存的事务控制
3.1 处方提交为什么要放在一个事务里
门诊医生开完处方点“提交”,背后要做三件事:写prescription主表、写prescription_item明细、扣drug库存。这三步必须原子,否则会出现“处方有了但库存没扣”或者“库存扣了但处方没存”。Spring 的@Transactional直接标在 Service 方法上:
@Transactional(rollbackFor = Exception.class) public void submitPrescription(Long regId, List<PrescriptionItem> items) { Prescription p = new Prescription(); p.setRegId(regId); p.setStatus(0); // 待发药 prescriptionMapper.insert(p); for (PrescriptionItem item : items) { item.setPrescriptionId(p.getId()); prescriptionItemMapper.insert(item); // 扣库存,带乐观锁 int rows = drugMapper.reduceStock(item.getDrugId(), item.getQty()); if (rows == 0) { throw new RuntimeException("药品库存不足: " + item.getDrugId()); } } }rollbackFor = Exception.class很重要,默认只回滚运行时异常,检查异常不会回滚。reduceStock用乐观锁版本号:
UPDATE drug SET stock = stock - #{qty}, version = version + 1 WHERE id = #{drugId} AND stock >= #{qty} AND version = #{version}stock >= #{qty}这个条件防止超卖,version防止并发覆盖。返回影响行数为 0 就抛异常,整个事务回滚。
3.2 药房发药时怎么保证不重复扣
药房发药是另一个状态流转:处方从“待发药”变“已发药”。这里最容易出的 bug 是重复发药,患者拿着同一张处方跑两次药房。解决办法是在更新状态时加条件:
UPDATE prescription SET status = 1, dispense_time = NOW() WHERE id = #{id} AND status = 0只有status = 0时才更新,返回 0 行说明已经被发过了,直接提示“该处方已发药”。这个技巧叫状态机更新,比先查再改安全得多,因为查和改之间可能有并发。
3.3 药品库存的 version 字段怎么用
version字段不是必须的,如果并发量不大,直接用stock >= qty条件更新就够了。但如果有多个药房窗口同时扣同一批药,乐观锁能避免“两个事务都读到 stock=10,各扣 8,最后 stock=-6”的情况。代价是冲突时要重试,我一般让前端提示“请重试”,而不是在后台无限循环。
注意:
@Transactional方法内部调用同类方法不会触发事务,因为 Spring AOP 代理的是外部调用。如果submitPrescription里调了本类的另一个@Transactional方法,事务不会生效。
4. 住院床位分配与出院结算的落地细节
4.1 床位占用用状态机还是行锁
住院模块的核心是床位分配。常见做法是bed表加status字段,0 空闲、1 占用。分配时:
UPDATE bed SET status = 1 WHERE id = #{bedId} AND status = 0返回 1 行说明抢到了,返回 0 行说明被别人占了。这比SELECT ... FOR UPDATE轻,因为不需要锁等待。如果医院床位紧张、并发高,可以再加一个admission表的唯一索引uk_bed_active,保证一个床位同时只有一条未出院记录。
4.2 出院结算的费用汇总 SQL
出院时要算总费用:挂号费 + 处方费 + 床位费。床位费按天算,用DATEDIFF:
SELECT (SELECT reg_fee FROM registration WHERE id = #{regId}) AS reg_fee, (SELECT IFNULL(SUM(total_fee),0) FROM prescription WHERE reg_id = #{regId} AND status = 1) AS drug_fee, (SELECT DATEDIFF(IFNULL(out_time, NOW()), in_time) * bed_price FROM admission a JOIN bed b ON a.bed_id = b.id WHERE a.id = #{admissionId}) AS bed_fee三个子查询分别取挂号费、已发药处方总费、床位费。IFNULL(out_time, NOW())处理还没点出院的情况。这个 SQL 可以直接映射到一个BillVO对象,不用在 Java 里拼。
4.3 出院时释放床位的顺序
出院操作要按顺序:先更新admission的out_time和status,再更新bed的status = 0。顺序反了会出现“床位空了但住院单还在”的脏数据。放在同一个事务里:
@Transactional public void discharge(Long admissionId) { admissionMapper.discharge(admissionId); // status=2, out_time=NOW() Admission a = admissionMapper.selectById(admissionId); bedMapper.release(a.getBedId()); // status=0 }discharge的 SQL 也要带条件WHERE id = #{id} AND status = 1,防止重复出院。
5. 源码跑起来之后:三个必调的参数和排错清单
5.1 数据库连接池和时区参数
源码里application.yml的 JDBC URL 经常少参数,导致中文乱码或时间差 8 小时。我一般改成:
spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password hikari: maximum-pool-size: 10 connection-timeout: 30000serverTimezone=Asia/Shanghai解决时间差,characterEncoding=utf8解决中文,allowPublicKeyRetrieval=true解决 MySQL 8 的认证报错。maximum-pool-size别设太大,课程设计环境 10 够用,设 100 反而容易把数据库连接打满。
5.2 挂号并发测试怎么做
想验证挂号编号会不会重复,可以用ab或 JMeter 压一下。简单点用 Java 起 20 个线程:
ExecutorService pool = Executors.newFixedThreadPool(20); CountDownLatch latch = new CountDownLatch(20); for (int i = 0; i < 20; i++) { pool.submit(() -> { try { registrationService.createRegistration(1L, 1); } finally { latch.countDown(); } }); } latch.await();跑完查SELECT reg_no, COUNT(*) FROM registration GROUP BY reg_no HAVING COUNT(*) > 1,如果没有结果,说明唯一索引和重试逻辑生效了。这个测试比看代码有用得多。
5.3 药房扣库存为负的排查路径
如果发现drug.stock出现负数,按这个顺序查:先看reduceStock的 SQL 有没有stock >= #{qty}条件;再看@Transactional有没有生效,可以在方法里故意抛异常看库存回不回滚;最后看是不是有别的入口直接UPDATE drug SET stock = stock - 1绕过了 Service。常见坑是定时任务或数据初始化脚本直接改库存,没走事务。
提示:MySQL 的
SELECT ... FOR UPDATE必须在事务里才生效,自动提交模式下加了等于没加。如果非要用行锁,记得先SET autocommit = 0或开事务。
5.4 住院模块的日期计算边界
DATEDIFF算床位费时,同一天入院同一天出院会返回 0,实际应该收一天。我一般改成DATEDIFF(out_time, in_time) + 1,或者用TIMESTAMPDIFF(DAY, in_time, out_time)再判断。这个细节在课程设计答辩时经常被问,提前处理掉能省很多解释。
本文还有配套的精品资源,点击获取