
简介这是一套基于Java开发的医院管理系统完整源码与数据库面向医疗信息化方向的Java开发者、课程设计或毕业设计学生以及需要了解医疗业务逻辑的技术人员。系统覆盖挂号、门诊、药房、住院等核心模块并涉及权限控制、接口集成与系统测试等工程实践可作为学习企业级项目架构的参考。压缩包共1236个文件约8.91MB以791个java源码为主体辅以106个xml配置、84个js与37个html前端页面另有sql脚本、yml与properties配置、dockerfile部署文件及md说明文档结构完整、层次清晰。目前已有681人学习下载。通过阅读源码读者可掌握Spring Boot与MyBatis等框架的落地方式、关系型数据库表结构设计、挂号并发控制、电子病历与药品库存管理等实现思路并借鉴其分层目录与接口设计快速搭建自己的医疗信息化项目或完成二次开发。1. 从一份 Java 医院管理系统源码说起挂号、门诊、药房、住院到底怎么串起来很多做 Java 课程设计或毕业设计的同学拿到「医院管理系统」这个题目时第一反应是建几张表、写几个增删改查就交差。但真正跑过一套完整源码的人会发现挂号、门诊、药房、住院这四条业务线并不是四个独立模块而是围绕「患者一次就诊」这条主线互相咬合的。挂号产生就诊记录门诊医生开处方处方流转到药房扣库存需要住院则从门诊转住院登记出院时又要回写费用和床位状态。任何一环的状态没对齐整个流程就会卡死。这份 Java 医院管理系统源码加数据库覆盖的正是这条完整链路。技术栈是典型的 Java Web 组合后端用 Servlet/JSP 或 Spring 系框架数据库用 MySQL前端是 JSP 加少量 JavaScript。它适合三类人一是要做课程设计、需要一套能跑通全流程参考实现的学生二是想拿一个真实业务系统练手数据库设计、事务和状态机的开发者三是面试前想找一个能讲清楚「业务闭环」的项目来充实简历的人。下面从数据库设计讲到接口实现再到并发挂号和排错把这份源码拆开看。2. 医院管理系统数据库表结构与挂号门诊核心表设计一套医院管理系统的质量八成取决于数据库设计。源码里表不少但真正决定业务能否跑通的是挂号、门诊、药房、住院这四组核心表以及它们之间的外键和状态字段。2.1 患者、医生、科室三张基础表先看基础主数据。患者表存的是就诊人信息医生表存的是坐诊医生科室表是挂号时的分类维度。常见做法是把医生和科室用外键关联挂号时先选科室再选医生。-- 患者表一次建档多次就诊复用 CREATE TABLE patient ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT 姓名, id_card VARCHAR(18) NOT NULL UNIQUE COMMENT 身份证号唯一, phone VARCHAR(15) COMMENT 联系电话, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 科室表 CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT 科室名称如内科、外科 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 医生表一个医生归属一个科室 CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, dept_id INT NOT NULL COMMENT 所属科室, title VARCHAR(16) COMMENT 职称, register_fee DECIMAL(8,2) DEFAULT 0 COMMENT 挂号费, FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;id_card上加唯一索引是关键它保证同一个患者不会因为重复建档产生多条记录后续查历史就诊才有依据。register_fee放在医生表而不是科室表是因为同一科室不同职称的挂号费不同这是真实医院的常见规则。2.2 挂号表与门诊记录表的状态字段挂号表和门诊表是整条主线的起点。挂号表记录「谁挂了哪个医生的号、什么状态」门诊表记录「这次就诊医生写了什么」。-- 挂号表 CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, reg_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 0 COMMENT 0待就诊 1已就诊 2已取消, visit_no VARCHAR(32) UNIQUE COMMENT 就诊流水号, FOREIGN KEY (patient_id) REFERENCES patient(id), FOREIGN KEY (doctor_id) REFERENCES doctor(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 门诊记录表一次挂号对应一条门诊记录 CREATE TABLE outpatient_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, registration_id BIGINT NOT NULL UNIQUE COMMENT 关联挂号, diagnosis VARCHAR(255) COMMENT 诊断结果, advice TEXT COMMENT 医嘱, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (registration_id) REFERENCES registration(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status字段是这套系统的灵魂。挂号后是 0医生接诊后置 1患者取消置 2。门诊记录表用registration_id做唯一外键保证一次挂号只能有一条门诊记录避免重复开单。visit_no用唯一约束生成就诊流水号住院、药房都靠它串联。2.3 药房库存表与处方明细表门诊开完处方药房要扣库存。这里最容易出问题的是「处方明细」和「药品库存」的对应关系。表名作用关键字段关联medicine药品字典id, name, stock, price被处方明细引用prescription处方主表id, record_id, status关联门诊记录prescription_item处方明细prescription_id, medicine_id, num关联药品CREATE TABLE medicine ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, stock INT DEFAULT 0 COMMENT 库存数量, price DECIMAL(8,2) DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE prescription_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, prescription_id BIGINT NOT NULL, medicine_id BIGINT NOT NULL, num INT NOT NULL COMMENT 开药数量, FOREIGN KEY (prescription_id) REFERENCES prescription(id), FOREIGN KEY (medicine_id) REFERENCES medicine(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;库存扣减必须和处方写入放在同一个事务里否则会出现「处方开了但库存没扣」或「库存扣了但处方没存」的脏数据。这是后面第 4 章要重点讲的并发问题。2.4 住院表与床位表住院模块比门诊多一层「床位占用」的状态管理。床位表记录每个床位的占用情况住院登记表记录患者从入院到出院的全过程。CREATE TABLE bed ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id INT NOT NULL, bed_no VARCHAR(16) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0空闲 1占用, UNIQUE KEY uk_dept_bed (dept_id, bed_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE admission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id BIGINT NOT NULL, bed_id BIGINT NOT NULL, in_time DATETIME DEFAULT CURRENT_TIMESTAMP, out_time DATETIME COMMENT 出院时间为空表示在院, status TINYINT DEFAULT 0 COMMENT 0在院 1已出院 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;bed表的status和admission表的status要同步更新入院时床位置 1出院时床位回 0。out_time为空是判断「当前在院」的常用方式比单独加一个字段更省事。3. 挂号与门诊模块的 Java 接口实现数据库建好只是骨架真正让业务跑起来的是 Java 层的接口实现。这一章按「挂号 → 接诊 → 开方」的顺序把核心接口和事务边界讲清楚。3.1 挂号接口生成就诊流水号与防重复挂号挂号接口要做三件事校验患者和医生是否存在、生成唯一就诊流水号、写入挂号记录。防重复挂号是重点同一个患者同一天同一科室不应该挂两次。Service public class RegistrationService { Autowired private RegistrationMapper registrationMapper; Transactional(rollbackFor Exception.class) public String register(Long patientId, Long doctorId) { // 1. 校验当天是否已挂过该医生的号 int count registrationMapper.countToday(patientId, doctorId); if (count 0) { throw new BizException(今日已挂过该医生的号); } // 2. 生成就诊流水号日期 6位随机 String visitNo new SimpleDateFormat(yyyyMMdd).format(new Date()) String.format(%06d, new Random().nextInt(1000000)); // 3. 写入挂号记录状态默认 0 待就诊 Registration reg new Registration(); reg.setPatientId(patientId); reg.setDoctorId(doctorId); reg.setVisitNo(visitNo); reg.setStatus(0); registrationMapper.insert(reg); return visitNo; } }Transactional保证校验和写入在同一事务里避免并发下两个请求都通过校验。countToday对应的 SQL 用DATE(reg_time) CURDATE()过滤当天。流水号用日期加随机数简单够用如果要求绝对不重复可以换成数据库自增序列或雪花算法。3.2 门诊接诊接口状态流转与门诊记录写入医生接诊时要把挂号状态从 0 改成 1同时写入门诊记录。这两步必须原子完成。Transactional(rollbackFor Exception.class) public void seeDoctor(Long registrationId, String diagnosis, String advice) { Registration reg registrationMapper.selectById(registrationId); if (reg null || reg.getStatus() ! 0) { throw new BizException(挂号状态异常无法接诊); } // 1. 更新挂号状态为已就诊 registrationMapper.updateStatus(registrationId, 1); // 2. 写入门诊记录 OutpatientRecord record new OutpatientRecord(); record.setRegistrationId(registrationId); record.setDiagnosis(diagnosis); record.setAdvice(advice); recordMapper.insert(record); }先查状态再更新是为了防止对已取消或已就诊的挂号重复接诊。updateStatus的 SQL 里最好再加一个AND status 0条件做成乐观锁这样即使并发也不会重复更新。3.3 处方开立与药房扣库存的事务处理处方开立是门诊和药房的交界点。医生选药、填数量系统写处方明细并扣库存整个过程必须在一个事务里。Transactional(rollbackFor Exception.class) public void createPrescription(Long recordId, ListPrescriptionItem items) { Prescription pres new Prescription(); pres.setRecordId(recordId); pres.setStatus(0); prescriptionMapper.insert(pres); for (PrescriptionItem item : items) { item.setPrescriptionId(pres.getId()); prescriptionItemMapper.insert(item); // 扣库存带库存充足校验 int affected medicineMapper.reduceStock(item.getMedicineId(), item.getNum()); if (affected 0) { throw new BizException(药品库存不足 item.getMedicineId()); } } }reduceStock的 SQL 是UPDATE medicine SET stock stock - #{num} WHERE id #{id} AND stock #{num}。用stock num做条件更新是防止超卖最简单有效的方式比先查再改更安全。任何一条扣减失败整个事务回滚处方也不会留下半截数据。4. 住院管理与并发挂号的排错实战前面把主流程跑通了但真实系统里最容易翻车的是住院状态同步和挂号并发。这一章讲几个实际会遇到的坑和排查方法。4.1 住院登记与床位状态同步住院登记时如果只写 admission 表而忘了更新 bed 表就会出现「床位显示空闲但实际有人住」的情况。正确做法是把两步放进一个事务并且用条件更新防止重复占床。Transactional(rollbackFor Exception.class) public void admit(Long patientId, Long bedId) { // 条件更新只有空闲床位才能被占用 int affected bedMapper.occupy(bedId); if (affected 0) { throw new BizException(床位已被占用); } Admission adm new Admission(); adm.setPatientId(patientId); adm.setBedId(bedId); adm.setStatus(0); admissionMapper.insert(adm); }occupy的 SQL 是UPDATE bed SET status 1 WHERE id #{id} AND status 0。返回 0 说明床位已被别人抢走直接抛异常。出院时反向操作UPDATE bed SET status 0 WHERE id #{id}同时把 admission 的 status 置 1、out_time 填当前时间。4.2 挂号并发下的重复流水号排查高并发挂号时如果流水号生成逻辑不够严谨可能出现重复。排查思路是先看数据库唯一索引有没有报错再看生成逻辑。现象可能原因排查方法插入报 Duplicate entry流水号重复查visit_no唯一索引看生成逻辑挂号成功但查不到事务未提交检查Transactional是否生效同一患者挂两次校验未加锁看 countToday 是否在事务内常见做法是把流水号生成改成数据库自增或 Redis 原子自增避免随机数碰撞。如果坚持用随机数至少把随机位数加到 8 位以上并保留唯一索引兜底。4.3 药房库存扣减失败的定位库存扣减失败通常有三种库存不足、药品不存在、并发超卖。定位时先看reduceStock返回的 affected 值。int affected medicineMapper.reduceStock(medicineId, num); if (affected 0) { // 区分是库存不足还是药品不存在 Medicine m medicineMapper.selectById(medicineId); if (m null) { throw new BizException(药品不存在); } throw new BizException(库存不足当前库存 m.getStock()); }这样报错信息更明确前端能直接提示用户。如果日志里频繁出现库存不足但实际库存充足就要检查是不是有并发事务在扣减考虑给药品行加行锁或改用队列削峰。提示所有涉及状态流转的更新都建议带上原状态作为 WHERE 条件做成乐观锁这是排查并发问题最省事的习惯。5. 用 EXPLAIN 和慢查询日志验证医院管理系统的数据库性能系统能跑通之后下一步是看它跑得快不快。医院管理系统的查询热点集中在「按患者查就诊历史」「按医生查当日挂号」「按科室查空闲床位」这几条 SQL 值得用 EXPLAIN 过一遍。5.1 用 EXPLAIN 看挂号查询的索引命中先看一条典型查询查某患者的所有挂号记录。EXPLAIN SELECT r.id, r.visit_no, d.name AS doctor_name, r.status FROM registration r JOIN doctor d ON r.doctor_id d.id WHERE r.patient_id 1001 ORDER BY r.reg_time DESC;如果type是 ALL说明 registration 表走了全表扫描需要在patient_id上建索引。执行ALTER TABLE registration ADD INDEX idx_patient (patient_id, reg_time);后type应该变成 refkey显示 idx_patient。联合索引把reg_time也带上是因为排序也走这个索引能省一次 filesort。5.2 慢查询日志定位门诊统计接口门诊统计接口通常要按日期聚合数据量大时容易慢。开启慢查询日志# my.cnf 中配置 slow_query_log 1 slow_query_log_file /var/log/mysql/slow.log long_query_time 1重启后观察 slow.log找出执行超过 1 秒的 SQL。常见问题是统计接口用了SELECT *加多层子查询改成先按日期范围过滤再聚合配合create_time上的索引通常能快一个数量级。5.3 住院床位查询的覆盖索引优化查空闲床位是高频操作SQL 是SELECT id, bed_no FROM bed WHERE dept_id ? AND status 0。建一个(dept_id, status, bed_no)的联合索引让查询直接走覆盖索引不用回表。ALTER TABLE bed ADD INDEX idx_dept_status (dept_id, status, bed_no);建完后用 EXPLAIN 确认Extra列出现Using index说明覆盖索引生效。这一步对住院模块的响应速度提升最明显因为床位查询在入院登记页面会被频繁调用。5.4 一个容易被忽略的技巧用状态字段做分区键如果挂号表数据量增长很快可以考虑按status或reg_time做分区。但更实用的技巧是把「待就诊」和「已就诊」的查询分开待就诊数据量小单独建一张热表或加缓存历史数据留在主表。这样既不用改表结构又能让高频查询始终命中小数据集。我一般会先加索引观察一周确认瓶颈确实在挂号表再考虑分区避免过度设计。本文还有配套的精品资源点击获取