资讯动态

Java医院急诊系统源码实战:分诊调度与队列设计全解析

发布时间:2026/10/9 7:14:03 来源:尧图企业网站定制
简介一套医院急诊系统的Java前后端完整源码后端基于Spring Boot、Shiro、MyBatis及MySQL前端采用Vue、Bootstrap和jQuery适合毕业生快速启动Web项目、团队搭建MVP、院校教学或自学练习。压缩包共828个文件包含125个Java后端类、70个Vue组件、158个JavaScript脚本以及CSS样式、HTML页面、SQL数据库脚本、XML配置、运维批处理和说明文档等整体约30.99MB目录按功能模块划分便于定位和二次开发。目前已有59人学习可用于理解前后端分离架构、权限控制及数据库交互的完整实现。资源内附一键安装部署脚本和演示文件能帮助读者从配置环境到启动系统快速跑通并可根据实际场景扩展急诊挂号、分诊管理等业务模块。1. Java系统源码医院急诊系统这套代码解决的是调度问题不是看病问题凌晨两点的急诊护士台一台老旧的PC上同时开着分诊登记、候诊队列和留观床位三个界面走廊里的加床已经排到了电梯口。如果你接手过这类系统的源码你会明白Java系统源码医院急诊系统真正要处理的不是诊断逻辑而是一堆现实约束下的调度问题谁优先、谁先进诊室、床位怎么腾、药房备了什么药。把这个问题想清楚再看这套源码的模块、表结构和接口脉络就非常清晰。它能直接解决的是从零起步的急诊信息化的基线问题。不用从空目录开始搭项目源码把分诊记录、叫号队列、电子病历、处方流转、留观管理、药房库存都放到了你能改动的Java工程里。适合正在做医疗信息化交付的Java后端工程师也适合准备用完整业务做毕设或练手项目的学生。前者拿它做二次开发的起点后者拿它学真实业务流程落到表字段上的方式。下面我按自己复现这类系统的经验把架构、核心表、关键接口和真实环境里的坑一条条拆开讲。2. 拆解急诊系统源码业务模块与Java技术栈的对应关系2.1 急诊业务和门诊的差异决定了源码的模块划分第一次做急诊系统的人最容易犯的错是把门诊挂号的逻辑直接搬过来。门诊的节奏是“预约 - 候诊 - 医生按号叫”而急诊的节奏是“随机到达 - 分诊定级 - 分级排队 - 随时抢救”。分诊台把患者分成I级濒危、II级危重、III级急症、IV级非急症四个档位级别不同候诊队列的权重完全不同甚至I级患者根本不排队直接送抢救室。这套Java源码的模块边界应该和上述流程一一对应。分诊台模块负责登记和定级候诊大厅模块负责叫号与队列展示医生工作站负责接诊、写电子病历、开处方护士工作站负责执行医嘱、管理留观床位和输液记录药房模块负责库存与发药管理员端负责排班、权限和统计报表。每个模块在后端工程里对应一个独立的包互相之间通过接口调用而不是共用一个巨大的Service类。如果你拿到的源码把这六个场景的代码搅在一个类里建议先按这个边界拆开再动手改。模块之间最重要的接口是“分诊 - 候诊队列”这一段。分诊台登记完患者前端页面要立刻在候诊大屏上显示排队位置医生点“下一号”候诊队列要立刻弹出对应级别的患者。这个联动如果做成前端轮询数据库系统还没上线就会把数据库查垮。我见过一个交付项目候诊大屏每5秒刷一次分诊记录表高峰期30个护士站同时开着页面数据库的CPU直接打满。所以源码里这段队列逻辑一般不会直接查表而是走内存队列或独立缓存。2.2 为什么用Spring Boot MyBatis Plus而不是更重的架构这套源码的技术栈选择直接决定了你接手后改代码的成本。急诊系统的并发量比电商低一个量级三甲医院分诊台高峰也就每秒几条写入候诊队列同时在线也就一两百个终端。这个量级下Spring Boot单体应用是最务实的选择——启动快、调试直观、部署简单出问题了一个进程的事不用在微服务链路里猜是哪一环超时。持久层用MyBatis Plus的理由不是为了省那几行XML而是因为它让实体类和表结构之间的映射变得可维护。急诊系统的字段很碎患者姓名、证件号、到达时间、分诊级别、主诉、过敏史、生命体征每次查询还要带各种条件组合。MyBatis Plus的条件构造器把这些组合写在Java代码里比在XML里拼字符串SQL清晰得多而且改字段名时有编译期提示不会出现SQL里字段写错、运行到半夜才爆出来的问题。说句题外话也常有人问为什么不直接上Python写FastAPI。开发快是快但医院机房里的部署环境五花八门有的还在用老旧的Windows Server和JDK版本。Java生态在这类环境里的兼容性以及后续接医保接口、对接检验设备时现成的SDK数量都明显占优。这不是语言优劣问题是交付环境的现实选择。2.3 从源码目录结构看一条请求的完整路径拿到源码第一件事看包结构。常规分层如下com.yiyuan.emergency ├── controller # HTTP 入口只做参数校验和路由 ├── service # 业务逻辑事务边界都在这一层 ├── mapper # MyBatis Plus 的 Mapper 接口 ├── entity # 数据库实体类和表字段一一对应 ├── dto # 接口出入参对象不对应数据库表 ├── common # 统一返回体、异常处理、常量 └── config # Redis、MyBatis Plus、定时任务的配置我一般先挑一个最完整的接口走读比如分诊登记。Controller层收到POST请求只负责把JSON转成DTO调用Service后把结果包成统一的Result对象返回。Service层是重点它做了三件事校验分诊级别合法性、把数据落库、把患者信息推进候诊队列。如果事务边界画对了你会发现Service层不会出现“先写库再调别的Service”这种跨事务的裸操作。实体类里藏着一个面试常考的点——序列化。患者信息要传到前端展示要写日志要存Redis所以实体类上的MyBatis Plus注解和Jackson注解经常一起出现。TableField指定数据库列名JsonFormat指定时间返回格式JsonIgnore把不该暴露的字段挡在接口外面。理解实体类上的每个注解比背八股文有用得多因为这就是线上代码的真实写法。3. 用MyBatis Plus从实体类生成建表SQL急诊核心表怎么设计3.1 分诊记录表把“时间”和“级别”作为一等公民急诊业务里最核心的不是诊断结论而是“这患者什么时候到的、现在是什么级别、排队排了多久”。所以分诊记录表的设计字段可以缺时间不能缺。我的做法是实体类上把每个时间点写成明确的字段然后用MyBatis Plus的表信息解析机制写一个通用工具类遍历实体类自动生成建表SQL。这样表结构和实体类始终一致永远不会出现“实体类加了字段但数据库没加列”的尴尬。Component public class DdlGenerator { public String buildCreateTableSql(Class? entityClass) { TableInfo tableInfo TableInfoHelper.getTableInfo(entityClass); if (tableInfo null) { // 初始化表信息MyBatis Plus 在实体类未加载时会返回 null tableInfo TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), ), entityClass ); } StringBuilder sql new StringBuilder(CREATE TABLE ) .append(tableInfo.getTableName()).append( (\n); for (TableFieldInfo field : tableInfo.getFieldList()) { sql.append( ).append(field.getColumn()) .append( ).append(resolveType(field.getPropertyType())) .append( COMMENT ).append(field.getProperty()) .append(,\n); } // 主键单独处理MyBatis Plus 的 TableId 信息不包含在 fieldList 里 sql.append( PRIMARY KEY (id)\n) ENGINEInnoDB DEFAULT CHARSETutf8mb4;); return sql.toString(); } }这段代码的核心是TableInfoHelper它会解析实体类上的TableName、TableField注解把Java字段映射成列名和类型。resolveType方法就是把Java类型翻译成MySQL类型——String对应VARCHARLocalDateTime对应DATETIMEBigDecimal对应DECIMAL。实际跑的时候只对核心业务实体生成一次SQL然后DBA手工审核不会在生产环境直接执行。分诊记录表的核心字段我按这个清单设计字段类型说明idBIGINT雪花ID不用自增避免分库后冲突arrive_timeDATETIME到达急诊科的时间统计滞留时长用triage_timeDATETIME护士完成分诊的时间triage_levelTINYINT1-4对应I-IV级chief_complaintVARCHAR(255)主诉方便医生快速扫一眼vital_signs_contentVARCHAR(500)体温、血压、心率JSON串存triage_nurse_idVARCHAR(32)分诊护士工号visit_statusTINYINT1排队中2接诊中3留观4离院triage_level不要用字符串“I级、II级”排序和查询都不方便。用TINYINT存数字查询条件写WHERE triage_level 2语义清晰索引效率也高。3.2 药房库存表为什么急诊库存要单独拆分急诊药房和门诊药房虽然在一个系统里但库存表最好分开。原因有三个急诊药房24小时有人值班急诊用药的消耗速度和门诊完全不同急救药品如肾上腺素、阿托品有严格的近效期管理批次信息要单独盯独立库存表让药剂科能单独盘点不会和门诊药品的月结数据搅在一起。库存表的关键设计是必须带version字段做乐观锁以及expire_date字段控制近效期。每次扣减库存时不先查再改而是直接用条件UPDATE影响行数为0说明库存不足或版本冲突事务回滚重新读一次库存给前端提示。这个表还有一个容易忽略的点——库存单位。急诊药品有的按盒、有的按支、有的按瓶字段里要加unit和spec不然发药界面显示“数量1”患者和护士都不知道是一支还是一盒。3.3 留观床位表状态机贯穿整个源码留观床位是急诊系统的另一个核心资源。一张床的状态流转是空闲 - 已占用 - 离院/转科 - 空闲中间还可能插入“暂时离开去做检查”。状态不要只用一个空闲标志位要有一个current_visit_id关联当前占用它的就诊记录防止两个患者同时被分配到同一张床。床位的状态字段我建议叫bed_status而不是is_available。因为is_available只能表达空闲和占用两种状态但真实场景里还有“床上的人去做CT了床位上东西还在”这种中间态护士需要在界面上看到区别。整个急诊系统里类似的状态流转到处都有——处方状态、检查单状态、发药状态。把状态机写清楚后续接大屏展示和报表统计会省很多事。4. 把分诊到叫号跑通三个核心接口的实现与参数说明4.1 分诊接口写库与入队绝对不能分两步提交分诊登记是急诊系统的第一个入口也是最容易出并发问题的接口。护士操作高峰期几秒内会有多名患者同时到达如果分诊记录插入数据库和患者信息写入候诊队列是分开两步的就会出现记录在库、队列没进去或者反过来的情况。患者在大屏上看不到自己的排队号护士台就得多一批吵架单。我的方案是在同一个事务里先写MySQL分诊表再操作Redis的候诊队列两件事包在一个Transactional方法里。Transactional(rollbackFor Exception.class) public Long triage(TriageCreateDTO dto) { // 1. 构造分诊记录实体并落库 TriageRecord record new TriageRecord(); record.setPatientName(dto.getPatientName()); record.setArriveTime(LocalDateTime.now()); record.setTriageLevel(dto.getTriageLevel()); record.setVisitStatus(1); triageRecordMapper.insert(record); // 2. 入候诊队列score 越小越优先 long score calcQueueScore(dto.getTriageLevel(), LocalDateTime.now()); redisTemplate.opsForZSet().add( emergency:queue, record.getId().toString(), score ); // 3. 返回到大屏展示的排队号直接用记录ID的尾号拼出 return record.getId(); }其中calcQueueScore是优先级的核心算法。规则很简单级别越高的患者score越小同级别的患者到达越早越靠前。具体实现是把级别权重和到达时间绑在一个数值里private long calcQueueScore(int triageLevel, LocalDateTime arriveTime) { long epochMilli arriveTime.atZone(ZoneId.systemDefault()) .toInstant().toEpochMilli(); // 级别权重1级(濒危)权重最大score 会最小 return (5L - triageLevel) * 1000000L epochMilli / 1000L; }注意epochMilli除以1000降到秒这样score的整数部分由级别主导同一个级别内再按时间排序。秒都用毫秒会溢出Long的范围吗不会但会让同级别内的排序意义变小因为毫秒部分占比太高没必要。级别差一档score差一百万足够覆盖秒级的时间差。老版本的实现是用Redis的List做队列左进右出。但List做不了按优先级插队III级患者到了只能在尾部排队有生命危险的也要按顺序等这在急诊场景是绝对不允许的。换成ZSet以后I级的患者可以随时插到队首这才是急诊的本质。4.2 叫号接口医生叫号必须锁医生维度否则并发顶号叫号逻辑看似简单医生点一下按钮弹出一个患者。但两个医生同时点“下一号”有可能把同一个患者弹出来两次。根源在于“取下一个号”是一个读改写操作Redis的ZSet本身是单线程原子操作但“取号前段端展示状态更新”组合起来不是原子的。我的做法是在叫号接口里对医生ID加一把分布式锁锁的粒度是“医生”不是“队列”。同一时刻一个医生只能叫一次号不同医生可以并行叫号不会互相阻塞。public PatientVO callNext(String doctorId) { String lockKey emergency:lock:doctor: doctorId; // 拿锁防止同一个医生端重复点击造成顶号 boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (!locked) { throw new BizException(操作太快了请稍候再试); } try { // 从 ZSet 里弹出 score 最小最优先的患者 SetString set redisTemplate.opsForZSet() .range(emergency:queue, 0, 0); if (set null || set.isEmpty()) { return null; // 队列已空 } String recordId set.iterator().next(); redisTemplate.opsForZSet().remove(emergency:queue, recordId); TriageRecord record triageRecordMapper.selectById(recordId); record.setDoctorId(doctorId); record.setBeginTime(LocalDateTime.now()); record.setVisitStatus(2); triageRecordMapper.updateById(record); return convertToVO(record); } finally { // 必须释放锁不然后续叫号全部失败 redisTemplate.delete(lockKey); } }锁的过期时间设3秒。正常情况下取号、改库、返回结果在几百毫秒内完成3秒完全够。如果出现网络抖动导致锁没释放3秒后锁自动失效医生再点一次就能恢复不会造成永久阻塞。这里有一个细节锁的value必须是一个唯一标识或者固定的“1”也行因为锁的用途只是防同一医生重复点击不涉及跨线程互斥如果用的是“拿到锁才执行”那么finally里删锁时要判断是不是自己拿到的锁防止误删别人的锁。医生维度锁场景简单不会出现这个问题但如果是库存这类全局锁就必须用UUID做value再校验删除。这个接口还有一个边界场景医生点叫号取到患者后门口的患者没听到叫号过了时。系统应该允许医生手动把该患者改为“过号”并重新插入队列。我在源码里通常会有对应的callBack独立接口单独处理而不是复用callNext避免把过号逻辑和正常叫号逻辑搅在一起。4.3 处方开单与扣库存乐观锁挡不住的前提是SQL要写对医生开完处方患者去药房领药药房发药的瞬间扣减库存。扣库存的SQL只要写错一行库存就会变成负数。最经典的错误是“先查库存看够不够够就减”。两条并发请求同时查到库存是1同时执行减1结果库存变成-1超卖了。正确做法是让数据库自己判断public void deductStock(Long drugId, Integer quantity) { int rows drugStockMapper.deductWithCondition(drugId, quantity); if (rows 0) { // 影响行数为 0说明库存不足或并发冲突抛异常回滚 throw new BizException(药品库存不足请联系药房补货); } }对应的Mapper SQLupdate iddeductWithCondition update drug_stock set stock stock - #{quantity}, update_time now() where drug_id #{drugId} and stock gt; #{quantity} /update逻辑说明stock stock - #{quantity}让数据库判断库存会不会减成负数where里的stock #{quantity}保证只有库存够才更新。两条并发请求同时执行只有一条能影响1行另一条影响0行从而抛出业务异常。这个方案比“先查后改”效率高不需要锁表也不需要事务包裹多次查询。但乐观锁的前提是SQL条件严格。我遇到过一个团队把where条件写成stock 0而不是stock #{quantity}结果是库存有1支一次买5支也扣成功了库存变成-4。这个问题用DB约束其实拦不住只能在SQL条件上把关。所以你在改源码时看到扣库存的SQL第一件事就检查where条件里的数量比较是不是还是。4.4 时间统计字段给医院管理报表预留的数据钩子急诊系统的报表核心只有一张患者从到达、分诊、候诊、接诊、离院的全链路耗时。这套源码里如果每张业务表都记录了对应的操作时间报表就是一条简单的SQL。如果没记那就只能靠中间件日志去猜患者什么时候进诊室的这个数据往往不准。所以在分诊记录表里除了arrive_time和triage_time还要有enter_room_time进诊室时间、finish_time就诊完成时间和depart_time离开急诊科时间。每一个字段在对应的操作接口里更新不能用“覆盖式更新”把之前的节点时间冲掉。源码设计的时候这些时间字段会被单独建一张visit_timeline表一条就诊记录一行时间节点一列一个值更新时用UPDATE ... SET enter_room_time IFNULL(enter_room_time, now())防止重复更新覆盖。这个表是后续做“急诊滞留超4小时预警”的基础医院管理方对这个指标非常敏感。5. 排查急诊系统在真实医院跑的5个高频翻车点5.1 现象夜间叫号分钟级延迟查Redis发现队列全是过期数据现象晚上10点到12点护士反映叫号反应慢大屏上显示有人排队但医生端一直叫不到人。原因这台服务器上Redis没有配置持久化策略默认RDB快照夜间高峰数据变更频繁RDB保存失败重启后数据丢了。但MySQL里有分诊记录前端页面又从MySQL查出“排队中”的状态看起来队列有人实际上Redis的ZSet是空的。解决给Redis开启AOF持久化appendonly yes并且保证ORM框架里删队列和更新数据库状态在同一个事务/同一个补偿机制内。最稳妥的是用定时任务做对账每5分钟扫描一次数据库里visit_status1排队中的记录检查对应ID是否在Redis ZSet里不在则重新入队。5.2 现象库存显示还有8盒药房发药时却提示无效现象药房窗口发药时系统提示“药品库存不足”。药师手工去药房货架数货架上确实还有8盒。原因drug_stock表里库存被一个“锁定库存”字段搞乱了。源码里设计了“医生开处方时预占库存”预占后库存减少但处方被医生取消了预占的库存没有回滚导致可用库存和实物库存对不上。解决预占库存和释放库存必须成对出现放进同一个事务里。医生取消处方时要调用releaseStock接口把预占数量加回去。排查的时候先用一条SQL把所有“预占状态”的处方列出来和库存表对一下差额再批量释放。还有一个兜底方案——每天凌晨定时任务自动释放超过24小时未发药的预占库存。5.3 现象凌晨新开服务器时系统日志疯狂报“时钟回拨”现象用雪花算法生成ID的服务凌晨上线时报“Clock moved backwards”导致患者建档失败。原因实体类主键用的雪花ID算法服务器NTP时间同步时短暂回拨了几毫秒正好赶上ID生成器的序列号判断直接抛异常。解决要么换一个弱依赖时钟的ID生成算法要么给源码里的ID生成器加一个“回拨容忍”配置允许最大10毫秒的回拨等待。还有更简单的办法——ID生成器实例在进程启动后提前初始化一个“最后生成时间”每次生成前判断if (currentTime lastTime)不是直接抛异常而是休眠lastTime - currentTime毫秒后重试。这个改动小但对凌晨的稳定性提升非常明显。5.4 现象同一条分诊记录在护士台和医生工作站看到的状态不一样现象护士台显示患者“排队中”医生工作站显示该患者“已过号”。患者本人站在医生门口医生说没叫到号。原因triage_record表的状态字段被两个接口并发更新。护士台“更新主诉补充登记”的接口用了updateById把整个实体类的所有字段都更新了一遍医生工作站“叫号”接口同时更新了visit_status2后提交的接口覆盖了先提交的状态回到了排队中。解决所有状态变更不要用全字段更新要写独立的更新SQL。我这里的要求是状态变更只能通过UPDATE triage_record SET visit_status #{targetStatus} WHERE id #{id} AND visit_status #{expectedStatus}完成两个条件缺一不可。这样即使并发请求同时到达也只有一个能成功另一个会抛乐观锁异常前端提示“操作冲突请刷新页面”。5.5 现象Java进程启动就报OutOfMemoryError无论怎么调堆大小都崩现象拿到源码后在服务器上打包启动命令照抄网上的java -jar -Xmx8000m结果启动时报java.lang.OutOfMemoryError: GC overhead limit exceeded。原因服务器物理内存只有2GB堆设8GB操作系统拒绝分配。或者不是堆的问题而是Metaspace太小加载了大量Spring Boot和MyBatis Plus的类后直接撑爆。解决先看服务器物理内存再定堆大小。我习惯的起步参数是-Xms256m -Xmx512m -XX:MetaspaceSize128m -XX:MaxMetaspaceSize256m绝不在没确认内存的情况下直接写大数值。启动失败时先看/var/log/messages或dmesg有没有OOM Killer记录Java堆调大反而可能触发系统杀进程这是交学费换来的经验。这个场景属于玄学但排查路径一点都不玄一步都不能跳过。6. 进阶验证用并发脚本把“能跑”变成“敢上线”6.1 自测脚本模拟30个患者同时到达源码跑通第一遍要验证的不是“有没有Bug”而是“并发下会不会乱”。最简单的方法是写一段Python脚本30个线程同时调用分诊接口然后立刻读一遍每个患者的排队顺序。import threading import requests def triage(level, name): payload { patientName: name, triageLevel: level, chiefComplaint: 自测并发入队 } r requests.post(http://localhost:8080/api/triage, jsonpayload) print(f{name} - {r.status_code}, queueId{r.json().get(data)}) threads [] for i in range(30): t threading.Thread(targettriage, args((i % 4) 1, f患者{i:02d})) threads.append(t) t.start() for t in threads: t.join()注意triageLevel按1到4循环分配这样写能同时测试优先级排序和并发插入两个场景。跑完以后去Redis里执行ZRANGE emergency:queue 0 -1 WITHSCORES手动检查I级患者是不是全部排在II级前面。如果发现顺序乱了优先检查calcQueueScore里的权重值是否有溢出或者Redis的ZSet操作是否被RedisTemplate的序列化器干扰。6.2 上线前的两个必看仪表队列长度与滞留时长第一个仪表是Redis ZSet的实时长度。用命令ZCARD emergency:queue就能看到此刻还有多少人在排队。配合监控告警当队列长度连续10分钟超过阈值比如30人就需要通知护士台加开诊室。这个阈值每个医院不一样要跑一段历史数据才能定。第二个仪表是滞留时长。SQL统计arrive_time到当前时间超过4小时且visit_status还在1或2的记录这是急诊管理的核心指标。我在实际项目里搭了一个简单的定时任务每5分钟跑一次这条SQL结果写入日志表第二天早上看前一天的峰值。如果每天都有人滞留超4小时就要和院方讨论是分诊级别判断过严还是医生排班不够。6.3 我自己收尾前的一个习惯核对时间戳字段上线前最后一晚我会把核心流转表triage_record、prescription、bed_record的时间字段全部导出来检查时间戳是不是按预期的顺序递增。比如分诊记录里arrive_time一定小于等于triage_timetriage_time一定小于等于enter_room_time。如果发现某条记录的arrive_time比triage_time还晚那就是前端传参时把默认时间传错了。这个检查用一条SQL就能完成但很少有人做因为大家都默认“时间字段不会错”。实际在急诊现场时间错了意味着监护仪、系统日志、护士手工记录对不上定性纠纷的时候非常被动。这也是我做医疗系统养成的一个习惯别人的系统上线前问有什么功能我上线前先问哪几个时间字段必须保证先后顺序。有一次线上突然出现一批患者叫号后接诊时间早于分诊时间原因就是护士补录历史数据时把“到诊时间”填成了当前时间而triage_time填的是补录的真实时间。后来我要求所有补录接口必须做时间戳顺序校验补录数据虽然入门槛低但绝不能成为脏数据的重灾区。急诊系统是个细节决定成败的工程你花在字段校验上的时间永远会在某个深夜的情境下成倍还给你。做医疗信息化这几年这套系统我前后交付过两版踩过的坑比写过的接口多希望这次的拆解能帮你在复现和二次开发的路上少走几步弯路也希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑