ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java医院急诊系统源码实战:业务表设计、并发与审计

Java医院急诊系统源码实战:业务表设计、并发与审计 简介一套面向Java Web开发者的完整医院急诊系统源码包提供前后端分离实现的Spring BootMyBatisShiro后台与VueBootstrap前端并附带说明文档。资源定位清晰毕业生可用于毕业设计或项目实训创业团队可快速改造成MVP教育机构可作为教学案例开发者也可将其作为稳定模板加速产品开发。压缩包大小30.99MB共828个文件主要类型包括Java源码、Vue组件、JavaScript脚本、SVG图形、CSS样式、HTML页面及SQL数据库脚本等覆盖从数据库设计到前端页面的完整工程结构。资源内还整合了环境安装、项目运行与构建脚本便于本地部署调试已提供MySQL初始化脚本可快速还原数据表。当前已有59人浏览学习适合希望快速理解业务系统分层结构、掌握主流Java Web技术栈整合的初中级学习者。1. 一套 Java 系统源码 医院急诊系统它解决什么问题适合谁上手很多 Java 学习者或刚转行的开发第一次拿到“Java系统源码医院急诊系统”这类项目时第一反应是赶紧启动起来看页面。实际上这类源码真正值钱的不是登录页那个皮而是急诊业务里“先分诊、后挂号、随时留观”这套顺序。急诊系统和普通门诊系统最大的差别在于患者到院时可能没有挂号单但必须先有分诊记录后续的医嘱、收费、留观都围绕这条紧急链路展开。它解决的是一整套从入科到离科的电子化记录问题适合用来做毕业设计、小诊所的信息化改造也适合想搞懂真实业务流的 Java 开发当练手项目。下面我按自己接手这类源码时最常见的落地顺序拆解先看业务和表结构再跑通源码最后处理并发和审计。2. 急诊系统的业务与表结构把挂号、分诊、留观串成 6 张表拿到源码后不要急着调样式先把业务流画出来。急诊和门诊的差异不在“有没有挂号”而在“先判断该不该救、再谈钱和流程”。这一章我们从业务差异讲到核心表结构最后给一条能手动走通的 SQL 流程。2.1 急诊和门诊的流程差异决定了表结构长什么样门诊的典型顺序是挂号 → 候诊 → 就诊 → 开单 → 缴费 → 取药。急诊这个顺序会被打破患者到分诊台时可能意识不清、没有家属、没有身份证系统也要允许先建档分诊护士先测生命体征并打出“I 级、II 级、III 级、IV 级”中的某一级再进入挂号或直接抢救。这种情况如果照搬门诊表就会发现“患者还没挂号医嘱已经写了”数据关系全乱。常见做法是拆出一张独立的急诊登记表把“是否正式挂号”做成状态字段而不是强制先写挂号表。源码里通常会有患者主档patient、急诊登记emergency_register、分诊triage、医嘱doctor_order、收费settlement、留观observation这六张核心表。它们之间通过 visit_id 或 register_id 关联而不是通过挂号单号关联这是判断一套急诊源码靠不靠谱的第一个信号。2.2 核心表设计患者、急诊登记、分诊、医嘱、收费、留观下面是一份简化但可运行的建表 SQL。实际源码里的字段会更多但骨架基本是这个样子CREATE TABLE patient ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, gender TINYINT NOT NULL COMMENT 0-女 1-男, birth_date DATE, id_card VARCHAR(18), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE emergency_register ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id BIGINT NOT NULL, visit_id VARCHAR(32) NOT NULL COMMENT 急诊就诊号, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-待分诊 2-分诊完成 3-就诊中 4-留观 5-离院, arrive_time DATETIME NOT NULL, leave_time DATETIME, INDEX idx_visit_status (status, arrive_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE triage ( id BIGINT PRIMARY KEY AUTO_INCREMENT, register_id BIGINT NOT NULL, level TINYINT NOT NULL COMMENT 分诊等级 1-41级最危重, temperature DECIMAL(4,1), heart_rate INT, systolic INT, diastolic INT, triage_nurse VARCHAR(32), triage_time DATETIME NOT NULL, INDEX idx_register (register_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE doctor_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, register_id BIGINT NOT NULL, order_type TINYINT NOT NULL COMMENT 1-检查 2-检验 3-药品 4-治疗, content VARCHAR(500) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-未执行 2-已执行 3-已停, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE settlement ( id BIGINT PRIMARY KEY AUTO_INCREMENT, register_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_type TINYINT NOT NULL COMMENT 1-医保 2-自费 3-其他, order_id BIGINT, settle_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE observation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, register_id BIGINT NOT NULL, bed_no VARCHAR(16), start_time DATETIME NOT NULL, end_time DATETIME, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-留观中 2-离观, note VARCHAR(500) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 SQL 背后的几个设计点值得对着源码核对。时间字段我用DATETIME对应 Java 的LocalDateTime金额用DECIMAL(10,2)对应BigDecimal避免 double 的精度问题状态字段用TINYINT加注释对应 Java 里的枚举常量而不是让业务代码里散落魔法数字。emergency_register.status上的联合索引idx_visit_status(status, arrive_time)是为了支撑后面最常见的查询按状态拉今天候诊患者并按到达时间排序。这里要提醒一个新手容易忽略的点很多源码为了图省事会把分诊等级直接写进emergency_register表而不是单独一张triage表。一旦出现“护士修改分诊等级”“医生复议分诊”这类需求单表设计就只能覆盖最后一个值没有留痕。真正能二次开发的源码分诊一般独立成表因为分诊是急诊的核心动作后续的排队、叫号、统计都依赖这次记录。2.3 用一条 SQL 流程把最小可行链路走通在跑源码之前我习惯先用 SQL 把主链路手动走一遍验证表之间的关联关系是否符合业务。最常见的验证方式是查询今天所有“已分诊但未离院”的急诊患者并带上他们的分诊等级和当前状态。SELECT r.visit_id, p.name, t.level, r.status, o.order_count, b.bed_no FROM emergency_register r LEFT JOIN patient p ON p.id r.patient_id LEFT JOIN triage t ON t.register_id r.id LEFT JOIN ( SELECT register_id, COUNT(*) AS order_count FROM doctor_order WHERE status 1 GROUP BY register_id ) o ON o.register_id r.id LEFT JOIN observation b ON b.register_id r.id AND b.status 1 WHERE r.arrive_time CURDATE() AND r.status IN (1, 2, 3, 4) ORDER BY t.level ASC, r.arrive_time ASC;这条 SQL 的核心逻辑是以emergency_register为驱动表用LEFT JOIN把患者、分诊、未执行医嘱、在观床位拼出来。ORDER BY t.level ASC让一级危重患者排在最前面这就是最简版的分诊排序规则。如果一个源码里的 join 关系连不上说明它要么缺表要么把关联字段写错了这时候先别启动服务先把模型修完。真正接诊的时候可能有同一个register_id对应多条留观记录的情况比如患者出观后再次留观。上面 SQL 的LEFT JOIN observation ... AND b.status 1就是为了只取当前正在留观的那条记录避免一个人被床号重复匹配。这套写法在报表里也适用但要注意统计口径如果按“当天入观人次”和“当前在观人数”SQL 里的条件过滤位置完全不同后面第五章还会讲到。3. 把源码跑通从环境检查到登录页的完整操作源码拿到手先别急着双击 start.bat。这里说的“环境检查”不是让你重装 JDK而是通过几个文件判断这套源码的“脾气”它依赖什么版本、需要哪些中间件、数据库脚本放哪。这一章按我平时排雷的顺序写。3.1 拿到源码先做的三件事pom.xml、配置文件、SQL 脚本第一件事是看pom.xml里 Spring Boot 的 parent 版本这决定了你要装 JDK 8 还是 JDK 17。新一点的源码常用 Spring Boot 2.7.x JDK 8或者 3.x JDK 17两者在javax.servlet和jakarta.servlet上有明显差异不看清楚会编译报错。第二件事是看src/main/resources下的配置文件。老项目喜欢叫application.properties新项目多半是application.yml加application-dev.yml。重点找spring.datasource.url、spring.redis.host、server.port这些键位判断是否依赖 Redis 或 RabbitMQ。如果依赖了而你本地没装后面启动必然卡在连接超时。第三件事是找 SQL 脚本。一般放在sql/或docs/目录命名像ems_init.sql、init_data.sql之类。如果一个源码包里没有任何 SQL 脚本只有实体类那大概率要依赖自动建表先不急着跑。我一般会在命令行先确认基础环境再打开关键文件java -version mvn -version mysql --version# 用管道看 pom 里的 Spring Boot 版本和依赖组避免人工翻太慢 grep -E spring-boot-starter-parent|spring-boot-starter-data-jpa|mybatis|mysql|redis pom.xml这两个命令的作用是把“环境版本”和“依赖列表”提前暴露出来。grep那行虽然简单但能快速看出这套源码是 JPA 还是 MyBatis 体系、数据库驱动是什么、有没有引入 Redis starter。依赖决定你本地要不要安装对应的中间件。3.2 从空库到登录页初始化数据库与启动命令假设源码里的配置是spring.jpa.hibernate.ddl-autovalidate或者 MyBatis 体系那么数据库必须手动建好。常见做法是先用 MySQL 客户端建库再把项目里的 SQL 脚本导入。mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS ems DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p ems sql/ems_init.sql mysql -uroot -p ems sql/init_data.sql这里的逻辑是建库时指定utf8mb4字符集避免中文乱码导入顺序不能反先建表结构再灌初始数据。有些模块会在init_data.sql里写管理员账号导入后就能直接登录系统。如果你看到的脚本是多个create_table_*.sql文件那就按文件名的数字前缀或字母顺序逐个导入。数据库就绪后启动服务不同构建方式启动命令略有不同mvn spring-boot:run -Dspring-boot.run.profilesdev或者走更传统的打包方式mvn clean package -DskipTests java -jar target/ems.jar --spring.profiles.activedev-Dspring-boot.run.profilesdev是让 Maven 插件把dev环境变量交给 Spring Boot从而加载application-dev.yml。如果你在打包时看到BUILD FAILURE先别怀疑代码多半是依赖下载失败或 JDK 版本不符可以去~/.m2/repository里清掉对应目录后重试。启动日志里看到Started Application in 8.432 seconds后用浏览器访问http://localhost:8080。如果端口被占日志里会出现Port 8080 was already in use这属于最常见的第一个翻车点。3.3 三个必调的启动参数端口、数据库连接、文件上传路径明明启动成功却登录不进后台九成问题出在配置。整理成表更直观配置项常见默认值需要改成什么踩坑点server.port8080按本地可用端口改和本机其他服务冲突时启动直接失败spring.datasource.urljdbc:mysql://localhost:3306/ems数据库 IP、端口、库名没写useSSLfalseserverTimezoneAsia/Shanghai会报时区错误file.upload-dir空或./upload改成项目外绝对路径部署后相对路径失效上传图片打不开下面给一段application-dev.yml的常见形制字段名可能因源码略有差异但思路一致server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ems?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver file: upload-dir: /data/ems/upload logging: level: com.hospital.ems: debug这里每个参数都对应一个“后悔药”。characterEncodingutf8解决插入中文变问号serverTimezoneAsia/Shanghai解决 JDBC 驱动的时区报错file.upload-dir必须是绝对路径不能写./upload因为用java -jar启动时当前工作目录是 jar 所在目录换部署目录就丢文件。日志级别开放debug只是为了前期排错正式环境建议调回info否则一天几 GB 日志还拖慢接口。4. 分诊优先级和排队叫号把“伪需求”做成能上线的 Java 实现急诊系统的灵魂功能不是挂号收费而是分诊和排队叫号。很多源码会把这两块做得很“演示”一个 Controller 里写满 if else患者列表来了就按创建时间倒序显示。真上线时会发现分诊等级没参与排序、叫号并发重复、大屏轮询把接口打崩。这一章按生产标准拆这三个点。4.1 分诊等级计算别把规则写死在 Controller 里一个常见的分诊规则是根据体温、心率、收缩压、舒张压以及患者主诉自动给出 IIV 级。新手最容易写成这样在 Controller 里接收参数然后用一串 if 判断最后 set 到实体保存。这样做在 java 面试题里就是最典型的坏味道——业务规则和接口耦合规则一变就要改 Controller测试也没法写。我一般会建议至少拆成一个枚举加一个计算服务。枚举负责等级和排序权重服务负责接收生命体征参数并返回等级public enum TriageLevel { LEVEL_I(1, 危重, 0), LEVEL_II(2, 急重, 1), LEVEL_III(3, 急症, 2), LEVEL_IV(4, 非急, 3); private final int code; private final String label; private final int priority; TriageLevel(int code, String label, int priority) { this.code code; this.label label; this.priority priority; } public int getCode() { return code; } public int getPriority() { return priority; } }Service public class TriageRuleService { public TriageLevel evaluate(Integer heartRate, Integer systolic, Boolean chestPain) { if (chestPain ! null chestPain) { return TriageLevel.LEVEL_I; } if (heartRate ! null heartRate 130) { return TriageLevel.LEVEL_II; } if (systolic ! null systolic 90) { return TriageLevel.LEVEL_II; } return TriageLevel.LEVEL_III; } }这段代码把“等级”和“排序权重”收口在枚举里计算逻辑收口在TriageRuleService里。TriageRuleService.evaluate只依赖参数不依赖 HttpServletRequest 或数据库连接因此可以直接写单元测试传一组心率血压断言返回 LEVEL_II。以后规则变成“120 就升 II 级”改动只发生在这个类内部Controller 不需要动。这里也回应一下热词“面向对象编程java”面向对象不是把数据塞进实体就完了真正的价值是让“等级”这个状态有行为比如getPriority()参与排序而不是在 Controller 里拿level 1手写比较。4.2 排队列表用 PriorityQueue 还是数据库状态机分诊完成后患者进入候诊队列。常见需求是按等级从高到低同等级按到达先后。有些源码会用PriorityQueueTriagePatient做内存队列写起来很爽但隐患极大应用重启队列就没了多节点部署时两台服务器队列不一致。这个场景我一般不推荐纯内存队列而是用数据库状态机。一个务实做法是emergency_register.status表示“待分诊/分诊完成/就诊中/留观/离院”再加一个queue_no字段或直接用arrive_time。查询时就查status 2分诊完成、等待就诊的记录按triage.level的优先级和arrive_time排序。Java 侧只要负责把它映射成列表public ListQueueItem getWaitingList() { ListEmergencyRegister waiting emergencyRegisterDao .findByStatus(RegisterStatus.TRIAGED); return waiting.stream() .map(reg - new QueueItem( reg.getVisitId(), reg.getPatientName(), triageService.findLevelByRegisterId(reg.getId()), reg.getArriveTime())) .sorted(Comparator .comparingInt((QueueItem q) - q.getLevel().getPriority()) .thenComparing(QueueItem::getArriveTime)) .collect(Collectors.toList()); }这段代码的关键是sorted里的两层 comparator第一层按分诊等级的priority升序也就是 I 级 level 为 1 但 priority 为 0排在最前第二层按arriveTime升序保证先来先看。如果源码里没有现成的排行字段这就是典型的“不要自己手写 java 排序算法”的场景Comparator链式排序已经够了。如果一定要用PriorityQueue我只会把它当临时缓存数据库负责持久化队列负责给叫号屏幕提供秒级刷新同时每次叫号前仍以数据库状态为唯一事实源绝不能在队列里做最终判定。4.3 叫号推送轮询和 WebSocket 的边界叫号屏幕通常是一台挂在候诊区的电视它需要实时显示当前叫到几号、下一位是谁。最稳的落地方式是前端隔几秒拉一次接口。对于日均几百人次的急诊轮询完全够用但要在叫号时给患者手机推送消息轮询就不合适引入 WebSocket 或 SSE 更合理。接口侧不需要写得太复杂保证“查询列表 当前叫号”两个动作原子化就行GetMapping(/api/queue/current) public ResultCurrentQueue currentQueue() { EmergencyRegister current registerService.findCurrentVisiting(); ListQueueItem waiting registerService.getWaitingList(); return Result.ok(new CurrentQueue(current, waiting)); }前端大屏用setInterval(fetchCurrentQueue, 5000)拉这个接口即可。如果源码已经引入了 WebSocket做法通常是在afterConnectionEstablished时推一次全量队列之后由QueueChangeEvent触发增量推送。边界判断标准很简单单台服务器、低并发用轮询多节点、要求消息及时性再加一层消息推送中间件。不建议一上来就给医院急诊系统上 Kafka前期运维成本远超收益。5. 急诊系统二次开发必踩的 5 个坑现象、原因、解决源码可以顺利启动不等于能上线。以下五个问题是我在接诊类项目里反复见过的每条按现象、原因、解决的顺序写方便你直接对号入座。5.1 明明保存了重启后留观数据消失现象护士录入留观信息后界面显示成功但服务一重启那张留观床又空了已录入的备注也跟着没影。原因这套源码的留观状态写在 Redis 缓存里或者用一个静态ListObservation存内存没有真正落库。开发时为了演示方便把数据库层省了。解决检查observation表有没有INSERT日志。如果是内存存储改成数据库写入如果既有 Redis 又有数据库一定要以数据库为准Redis 只做加速。这个坑的本质是数据源唯一性的问题建议顺带把emergency_register.status的更新也放进同一个事务避免界面显示“留观中”数据库里却还是“分诊完成”。5.2 并发叫号时一个患者被叫两次现象两名医生或两个护士站同事点击“呼叫下一个”屏幕上一次显示两个不同的患者甚至同一患者被叫到两个诊室。原因经典并发问题。代码先执行select status待就诊 order by level limit 1然后再update status就诊中两步之间没有加锁两个线程同时读到同一条记录。解决把“取号”和“改状态”合并成一条条件更新语句利用数据库行锁或乐观锁保证只有一个线程成功。MyBatis 下类似这样Update(UPDATE emergency_register SET status #{newStatus} WHERE id #{id} AND status #{expectStatus}) int compareAndSetStatus(Param(id) Long id, Param(expectStatus) Integer expectStatus, Param(newStatus) Integer newStatus);调用时先查一条候选记录再执行compareAndSetStatus(id, STATUS_TRIAGED, STATUS_VISITING)返回行数为 1 才说明叫号成功为 0 就说明这条记录已被别人抢走重新取下一条。这里的核心是“期望状态”参数它把并发控制交给数据库是比 synchronized 更可靠的公司级做法。5.3 报表查询越来越慢时间字段没走索引现象急诊统计报表刚上线时秒开跑了两个月后要十几秒数据库 CPU 直接飙高。原因报表 SQL 大量按arrive_time和status过滤但表里只有主键索引EXPLAIN显示typeALL全表扫描。解决在emergency_register表上补联合索引就像第二章建表脚本里写过的idx_visit_status(status, arrive_time)。这里要注意索引顺序如果查询全是“按状态 时间范围”把状态放前面时间放后面。还需要检查统计类 SQL 是否对triage.level做了分组如果有triage表上也要加idx_register(register_id, level)。排查手法是先用EXPLAIN SELECT ...看扫描行数和key字段没有索引就ALTER TABLE加上。加了索引后如果还慢再看是不是日期函数包裹了索引列比如WHERE DATE(arrive_time) CURDATE()会导致索引失效要改成arrive_time CURDATE() AND arrive_time CURDATE() INTERVAL 1 DAY。5.4 文件上传路径写死导致部署后图片打不开现象本地开发环境下传的医嘱单照片能显示部署到服务器后图片路径 404。原因代码里写死了C:/upload/或./upload/。本地路径到服务器上自然不存在./又依赖启动目录用 systemd 启动和手动启动的目录不同最终文件被写到无法访问的位置。解决把上传路径配置到application-dev.yml如file.upload-dir: /data/ems/upload然后通过配置类注入目录前缀。Spring Boot 里常见的做法是写一个WebMvcConfigurer把本地磁盘目录映射成/files/**虚拟路径这样前端拿到的 URL 仍是http://ip:8080/files/2024/xx.jpg不暴露真实路径也便于日后换对象存储。5.5 事务注解不生效同类调用和私有方法的坑现象保存患者时同时要写分诊记录Transactional标在私有方法上抛异常后患者主档还是插了进去。原因Spring 的声明式事务基于代理同类中的this.method()调用不会经过代理对象私有方法更不会。也就是说调的是原始对象注解形同虚设。解决把事务边界放在外部调用的public方法上或者把写库操作拆到一个独立的Service类中由外部注入后调用。还要顺带确认 MySQL 表引擎是 InnoDBMyISAM 不支持事务即使注解生效也会静默失效。遇到这种问题看启动日志里有没有 “Unable to rollback against JDBC” 之类的痕迹再搜一下表引擎通常能定位。6. 进阶技巧用操作日志注解在不改业务代码的前提下审计急诊全流程最后分享一个我接手任何一套医院系统源码都会做的事给核心接口加操作审计。急诊系统涉及患者隐私和医疗责任谁在什么时间改过分诊等级、谁把患者从待诊改成就诊中这些都必须留下痕迹。与其在每个 Service 里手动写日志不如用一个自定义注解加 AOP 切面统一处理。先定义一个注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OperationLog { String module() default emergency; String action() default ; }再写一个切面在方法执行成功后记录入参、操作人、耗时和结果状态Aspect Component public class OperationLogAspect { AfterReturning( value annotation(operationLog), returning result ) public void log(JoinPoint joinPoint, OperationLog operationLog, Object result) { String username SecurityContextHolder.getContext() .getAuthentication().getName(); String method joinPoint.getSignature().toShortString(); Object[] args joinPoint.getArgs(); AuditLogService.record(username, operationLog.module(), operationLog.action(), method, args, result); } }这段代码的价值在于在已经写好的TriageService.evaluate或RegisterService.dispatch方法上只要加一行OperationLog(action 修改分诊等级)就自动获得审计能力不用侵入原方法内部。新的业务方法照常写切面自动截获。操作人从SecurityContextHolder拿也就是登录用户的上下文不需要把用户名当一个普通参数传来传去。如果系统里还没有统一登录认证这套审计就是空中楼阁。所以下一步验证方法是用两个不同账号分别执行一次分诊和叫号再到日志表里查是否记录了账号、动作、时间、参数。我现在的习惯是拿到一套新源码后第一周先做两件事对所有写数据的接口补审计对叫号接口补并发条件更新。这两件事做完系统才勉强敢放给医生用。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进