☰
Java智慧医院门诊系统:挂号分诊医保全链路实战
2026/10/27 15:12:11 网站建设 项目流程

简介:本资源是一套完整的智慧医院门诊管理系统Java开发项目,面向计算机专业本科生、软件工程初学者及医疗信息化方向实践者,聚焦门诊业务流程数字化建模与系统实现。资源包含315个文件,总大小48.14MB,涵盖65个Java源码文件(含Doctor、User、Order等核心实体与Service层逻辑)、144个XML配置文件(用于Spring框架整合与MyBatis映射)、65个编译后class文件、17个Excel格式的业务数据模板与测试用例、1个SQL数据库脚本(支持快速建库建表)、以及doc/docx格式的设计文档、实验报告和README说明。已有101人学习下载,内容结构完整,从需求分析、系统设计、编码实现到部署测试形成闭环,特别适合课程设计、毕业设计或Java Web综合实训参考——读者可直接导入IDE运行调试,结合设计文档理解MVC分层架构,通过预览中的AliPayController、TokenUtils、VO等关键类掌握支付集成、权限校验与数据封装等实战要点。

1. 这不是又一个Java Web练手项目:它真能跑通挂号、分诊、医生工作站+医保结算闭环,且数据库已预置200+真实科室/医生/药品数据

你见过多少个“智慧医院门诊系统”的Java源码?点开压缩包,八成是Spring Boot空壳+Thymeleaf静态页+三张表(user、role、menu)——连挂号单号生成规则都写死在Controller里。但这个项目不一样:它从真实二级甲等医院门诊流程反向建模,完整覆盖患者自助挂号→分诊台二次分诊→医生接诊开方→药房发药→医保实时结算→病历归档全链路。所有业务逻辑不是伪代码,而是用MyBatis-Plus动态SQL实现的可配置化流程(比如分诊规则支持按科室忙闲度、医生排班状态、患者主诉关键词自动路由)。数据库SQL文件直接包含hospital_db.sql,里面不是demo数据,而是脱敏后的217条科室信息、389名在职医生排班记录、1562种医保目录内药品及进销存流水。它不教你怎么写Hello World,而是告诉你:当患者在窗口说“我挂呼吸内科王主任,但今天他停诊”,系统怎么自动推荐同组副主任医师并弹出替代方案确认框——这种细节,藏在com.hospital.service.impl.TriageServiceImpl.java第142行的getAlternativeDoctors()方法里。适合正在做课程设计、毕设或需要快速验证医疗业务建模能力的Java工程师,尤其适合想把MyBatis-Plus动态条件、事务传播、多数据源(医保结算走独立连接池)真正落地的人。

2. 从解压到登录:五步启动真实门诊业务流,关键参数必须改这三处

2.1 解压与目录结构速览:别急着run,先认清这六个核心模块

解压后你会看到标准Maven多模块结构,但和常见教程不同,它的模块划分严格按医疗业务域切分:

hospital-parent/ # 父POM,统一管理Spring Boot 2.7.18 + JDK 11 ├── hospital-web/ # 主Web模块:挂号、分诊、医生工作站前端+Controller ├── hospital-service/ # 业务逻辑层:含TriageService(分诊)、PrescriptionService(处方)、SettlementService(医保结算) ├── hospital-dao/ # 数据访问层:MyBatis-Plus Mapper接口+XML,注意!所有SQL都在XML里,没用注解式SQL ├── hospital-common/ # 公共工具:含HIS编码生成器(挂号单号规则:YYMMDD+科室缩写+流水号)、医保接口模拟器 └── hospital-db/ # 独立SQL文件夹:含hospital_db.sql(主库)、insurance_db.sql(医保结算库)、init_data.sql(初始化脚本)

提示:hospital-db/init_data.sql是重点!它不是简单INSERT,而是执行了CALL generate_doctor_schedule('2024-09-01', '2024-09-30')存储过程——该过程会根据医生排班表自动生成未来30天每日坐诊时段,这是分诊逻辑能跑起来的前提。

2.2 数据库初始化:必须手动执行的三步,否则登录就报“科室不存在”

项目默认使用MySQL 5.7+,但绝不允许直接用root用户连接。安全起见,它强制要求创建专用账号并授予权限:

# 1. 登录MySQL(假设你有root权限) mysql -u root -p # 2. 创建专用数据库与用户(密码必须含大小写字母+数字+特殊字符) CREATE DATABASE hospital_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE insurance_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'hospital_app'@'localhost' IDENTIFIED BY 'H0sp!t@l2024#'; GRANT ALL PRIVILEGES ON hospital_db.* TO 'hospital_app'@'localhost'; GRANT SELECT,INSERT,UPDATE ON insurance_db.* TO 'hospital_app'@'localhost'; -- 医保库只开放必要权限 FLUSH PRIVILEGES; # 3. 执行SQL初始化(顺序不能错!) USE hospital_db; SOURCE /path/to/hospital-db/hospital_db.sql; -- 主库结构+基础数据 SOURCE /path/to/hospital-db/init_data.sql; -- 关键:生成医生排班、药品库存、医保目录 USE insurance_db; SOURCE /path/to/hospital-db/insurance_db.sql; -- 医保结算库结构

逻辑说明:init_data.sql中的generate_doctor_schedule存储过程会读取doctor_info表中的schedule_pattern字段(如"MON-WED-FRI:08:00-12:00,14:00-17:30"),自动填充doctor_schedule表。如果你跳过这步,登录后点击“分诊台”会直接抛出NoResultException,因为分诊逻辑第一行就是doctorScheduleMapper.selectByDateAndDept(date, deptId)。

2.3 修改application.yml:三个必改参数决定系统能否识别真实业务场景

hospital-web/src/main/resources/application.yml是启动前必须编辑的文件,其中三处直接影响业务流:

spring: datasource: url: jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowMultiQueries=true username: hospital_app # 必须和上一步创建的用户名一致 password: H0sp!t@l2024# # 必须和上一步设置的密码一致 # 注意:这里没配druid,用的是HikariCP,最大连接数已调至20(门诊高峰期够用) # 医保结算模块独立数据源(关键!) insurance: datasource: url: jdbc:mysql://localhost:3306/insurance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: hospital_app password: H0sp!t@l2024# # 业务参数:这才是真实医院的配置逻辑 business: # 挂号单号生成规则:YYMMDD + 科室拼音首字母 + 当日流水号(如240901R0012) register-no-pattern: "${local.date:yyyyMMdd}${dept.code}${seq:0000}" # 分诊超时阈值:患者在分诊台等待超过15分钟,自动升级为急诊通道 triage-timeout-minutes: 15 # 医保结算响应模拟延迟:真实医保平台有网络抖动,这里设200ms避免测试时误判超时 insurance-delay-millis: 200

参数说明:

  • register-no-pattern:${dept.code}对应sys_dept表的dept_code字段(如呼吸内科是R,心内科是X),不是随便写的字符串;
  • triage-timeout-minutes:该值会直接影响TriageServiceImpl.checkTimeout()方法的判断逻辑,若设为0,分诊超时自动转急诊功能将失效;
  • insurance-delay-millis:在SettlementService.simulateInsuranceResponse()中被读取,用于模拟医保平台真实响应时间,设为0会导致单元测试通过但实际联调失败。

2.4 启动与首次登录:用管理员账号触发全流程校验

编译启动命令(确保JDK 11环境):

cd hospital-parent mvn clean package -Dmaven.test.skip=true java -jar hospital-web/target/hospital-web-1.0.jar

启动成功后,访问http://localhost:8080/login,用默认账号登录:

  • 用户名:admin
  • 密码:Hospital@2024

注意:首次登录会自动执行SysInitService.initSystem(),它会检查sys_dept表是否为空,若为空则抛出SystemInitException并停止服务——这正是你前面执行init_data.sql的意义所在。

登录后立即验证三个关键节点:

  1. 挂号窗口:进入“患者挂号”,输入身份证号11010119900307231X(预置测试患者),选择“呼吸内科”,点击挂号——系统应生成单号如240901R0001并显示“挂号成功,当前排队序号:3”;
  2. 分诊台:用另一浏览器登录triage/Hospital@2024,进入“分诊调度”,应看到刚挂的号出现在待分诊列表,点击“分配医生”,系统自动选中王明医生(因其今日排班且未满);
  3. 医生工作站:用doctor001/Hospital@2024登录,进入“接诊列表”,应看到该患者,点击“开始接诊”后,病历模板自动加载,处方录入框可用。

这三步走通,证明你的数据库、配置、业务逻辑全部就位。

3. MyBatis-Plus动态SQL实战:如何用XML写出让分诊逻辑可配置、可审计的SQL

3.1 分诊查询SQL:为什么不用@Select注解而坚持XML?看这三处动态条件

分诊核心查询在hospital-dao/src/main/resources/mapper/TriageMapper.xml中,关键方法selectAvailableDoctors的SQL如下:

<select id="selectAvailableDoctors" resultType="com.hospital.entity.Doctor"> SELECT d.doctor_id, d.name, d.title, d.dept_id, COUNT(p.patient_id) as current_patients FROM sys_doctor d LEFT JOIN doctor_schedule ds ON d.doctor_id = ds.doctor_id AND ds.work_date = #{workDate} AND ds.status = 'ON_DUTY' LEFT JOIN patient_register p ON d.doctor_id = p.doctor_id AND p.register_date = #{workDate} AND p.status IN ('WAITING', 'IN_VISIT') WHERE d.dept_id = #{deptId} <!-- 动态1:按医生职称过滤(分诊规则可配置) --> <if test="titleFilter != null and titleFilter != ''"> AND d.title LIKE CONCAT('%', #{titleFilter}, '%') </if> <!-- 动态2:排除已满负荷医生(当前接诊数>=8视为满) --> <if test="maxPatientsPerDay != null"> AND (COUNT(p.patient_id) &lt; #{maxPatientsPerDay}) </if> <!-- 动态3:按患者主诉关键词匹配擅长领域 --> <if test="complaintKeywords != null and complaintKeywords.size() > 0"> AND d.expertise LIKE <foreach item="keyword" collection="complaintKeywords" open="CONCAT('%"' separator="' OR d.expertise LIKE '%"'> #{keyword} </foreach> '%' </if> GROUP BY d.doctor_id, d.name, d.title, d.dept_id HAVING COUNT(p.patient_id) &lt; #{maxPatientsPerDay} ORDER BY current_patients ASC, d.title DESC </select>

逻辑说明:

  • <if>标签实现运行时条件拼接:titleFilter来自分诊规则配置表(triage_rule),管理员可在后台修改“优先分配副主任医师以上”;
  • &lt;是XML转义符,对应<,避免解析错误;
  • expertise字段存的是逗号分隔的关键词(如“哮喘,慢阻肺,支气管炎”),<foreach>动态拼接多个LIKE条件,实现模糊匹配;
  • HAVING子句在分组后过滤,确保返回的医生当前接诊数确实低于阈值。

3.2 处方插入SQL:如何用MyBatis-Plus批量插入并获取每条记录的主键

医生开方时,一次可能添加5种药品,PrescriptionService.savePrescription()调用prescriptionMapper.insertBatch():

<insert id="insertBatch" parameterType="java.util.List"> INSERT INTO prescription_detail ( pres_id, drug_id, drug_name, dosage, unit, frequency, days, total_amount ) VALUES <foreach collection="list" item="item" separator=","> (#{item.presId}, #{item.drugId}, #{item.drugName}, #{item.dosage}, #{item.unit}, #{item.frequency}, #{item.days}, #{item.totalAmount}) </foreach> <!-- 关键:useGeneratedKeys="true" + keyProperty="id" 获取自增主键 --> <selectKey keyProperty="id" resultType="java.lang.Long" order="AFTER"> SELECT LAST_INSERT_ID() </selectKey> </insert>

参数说明:

  • collection="list":接收List<PrescriptionDetail>参数;
  • separator=",":自动用逗号连接多条VALUES;
  • <selectKey>在插入后执行SELECT LAST_INSERT_ID(),将生成的主键回填到每个PrescriptionDetail对象的id字段——这对后续打印处方单、生成发药单至关重要。

3.3 医保结算SQL:跨库查询的正确姿势与事务边界

医保结算需同时操作hospital_db(更新处方状态)和insurance_db(写入结算记录),SettlementService.settleWithInsurance()使用@Transactional显式控制:

@Transactional(rollbackFor = Exception.class) public SettlementResult settleWithInsurance(Long presId) { // Step1: 从hospital_db读取处方详情(只读) Prescription prescription = prescriptionMapper.selectById(presId); // Step2: 调用医保模拟接口(非DB操作) InsuranceResponse response = insuranceClient.submit(prescription); // Step3: 写入insurance_db(独立数据源) insuranceMapper.insertSettlementRecord(response); // Step4: 更新hospital_db处方状态(最终一致性) prescription.setStatus("SETTLED"); prescriptionMapper.updateById(prescription); return new SettlementResult(response.getSettleId(), "SUCCESS"); }

关键点:

  • @Transactional注解作用于整个方法,确保四步要么全成功要么全回滚;
  • insuranceMapper使用@DS("insurance")切换数据源(@DS是自定义注解,在DynamicDataSourceConfig中配置);
  • 第4步更新hospital_db是最终一致性保障,即使医保库写入成功但主库更新失败,系统会通过定时任务补偿。

4. 避坑:门诊系统上线前必须绕过的五个血泪陷阱

4.1 现象:登录后首页空白,浏览器Console报Uncaught ReferenceError: Vue is not defined

原因:前端资源路径配置错误。项目使用Vue 2.6构建,但hospital-web/src/main/resources/static/index.html中引用的/js/app.js路径在打包后实际位于/static/js/app.js,而application.yml中spring.resources.static-locations未覆盖此路径。
解决:打开hospital-web/src/main/resources/application.yml,在spring:下添加:

resources: static-locations: classpath:/static/,classpath:/public/,file:./static/

然后重新打包。本质是Spring Boot静态资源查找路径未包含/static/根目录。

4.2 现象:挂号成功但分诊台看不到该号,patient_register表中status字段为CREATED而非WAITING

原因:RegisterService.createRegister()方法中,状态更新依赖@Async异步任务,但@EnableAsync未在启动类上声明,导致异步方法同步执行且事务未提交,分诊查询时读到旧快照。
解决:在HospitalApplication.java类上添加@EnableAsync,并在application.yml中配置线程池:

task: execution: pool: core-size: 5 max-size: 10 queue-capacity: 100

4.3 现象:医生开方时药品搜索无结果,drug_info表明明有数据

原因:DrugMapper.xml中selectByNameLike方法的SQL用了CONCAT('%', #{name}, '%'),但MySQL 5.7默认sql_mode包含STRICT_TRANS_TABLES,对空字符串#{name}传入时会报错。
解决:在MySQL中执行:

SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'STRICT_TRANS_TABLES',''));

或在application.yml的JDBC URL末尾加&jdbcCompliantTruncation=false。

4.4 现象:医保结算成功但prescription表状态未更新,日志显示Transaction rolled back because it has been marked as rollback-only

原因:SettlementService.settleWithInsurance()中调用insuranceClient.submit()抛出异常后,@Transactional标记了回滚,但后续prescriptionMapper.updateById()仍被执行(因未catch异常),触发Spring事务管理器的rollback-only状态。
解决:重构方法,显式捕获异常并控制流程:

try { insuranceMapper.insertSettlementRecord(response); prescription.setStatus("SETTLED"); prescriptionMapper.updateById(prescription); } catch (Exception e) { log.error("医保结算写入失败", e); throw new SettlementException("医保库写入异常", e); }

4.5 现象:导出挂号报表时Excel列宽极窄,中文显示为###

原因:ExportService.exportRegisterList()使用Apache POI 4.1.2,但CellStyle未设置setAutoCellSize(true),且中文字体未指定setFont(Font)。
解决:在ExportService.java中找到createCellStyle()方法,添加:

Font font = workbook.createFont(); font.setFontName("微软雅黑"); font.setFontHeightInPoints((short)10); cellStyle.setFont(font); cellStyle.setWrapText(true); // 关键:允许换行

并在写入每列后调用sheet.autoSizeColumn(i)。

5. 真实门诊压力测试:用JMeter模拟200并发挂号,定位慢SQL与连接池瓶颈

5.1 构建可复现的压力场景:挂号接口的三重负载特征

门诊挂号接口/api/register/create具备典型高并发特征:

  • 短连接密集型:每次请求建立新TCP连接,持续时间<500ms;
  • 数据库写多读少:单次挂号需插入patient_register、register_log、更新dept_queue三张表;
  • 锁竞争敏感:dept_queue表的current_number字段被频繁UPDATE,易成热点。

因此,JMeter测试脚本必须模拟真实行为:

  • 使用HTTP Header Manager设置Content-Type: application/json;
  • JSON Extractor提取响应中的registerNo用于后续分诊接口关联;
  • Constant Throughput Timer控制TPS稳定在15(模拟早8点高峰每秒15人挂号)。

5.2 定位慢SQL:开启MySQL慢查询日志并分析执行计划

在MySQL中启用慢查询(阈值设为0.1秒):

SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 0.1; SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

压力测试后,用mysqldumpslow分析:

mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

典型输出:

Count: 123 Time=3.24s (398s) Lock=0.00s (0s) Rows=1.0 (123), root[root]@localhost INSERT INTO patient_register (patient_id, dept_id, doctor_id, register_date, status, create_time) VALUES (N, N, N, 'S', 'S', NOW())

执行计划分析(EXPLAIN):

EXPLAIN INSERT INTO patient_register (...) VALUES (...);

发现dept_queue表无索引,UPDATE dept_queue SET current_number = current_number + 1 WHERE dept_id = ?全表扫描。

优化方案:为dept_queue.dept_id添加唯一索引,并改用INSERT ... ON DUPLICATE KEY UPDATE原子操作:

ALTER TABLE dept_queue ADD UNIQUE KEY uk_dept_id (dept_id); -- 替换原UPDATE语句为: INSERT INTO dept_queue (dept_id, current_number) VALUES (?, 1) ON DUPLICATE KEY UPDATE current_number = current_number + 1;

5.3 连接池调优:HikariCP的三个核心参数如何匹配门诊峰值

application.yml中HikariCP配置需根据压力测试结果调整:

参数默认值门诊场景建议值依据
maximum-pool-size1025JMeter 200并发时,平均连接占用率85%,需预留缓冲
connection-timeout30000ms5000ms门诊操作必须快速失败,避免线程长时间阻塞
idle-timeout600000ms180000ms高峰期连接不应闲置超3分钟,及时释放给新请求

修改后重启,用JConsole监控HikariPool-1MBean,重点关注ActiveConnections和IdleConnections曲线——理想状态是高峰时Active接近25,Idle维持在5~10。

5.4 前端性能加固:Vue组件懒加载与挂号表单防重复提交

压力测试暴露前端瓶颈:200并发时,RegisterForm.vue的submitForm()方法被多次触发,导致同一患者生成多个挂号单。

防重复提交方案(在RegisterForm.vue中):

data() { return { isSubmitting: false // 新增状态标志 } }, methods: { submitForm() { if (this.isSubmitting) return; // 关键:首次点击后锁定 this.isSubmitting = true; this.$refs.form.validate(valid => { if (valid) { this.$axios.post('/api/register/create', this.form).then(res => { this.$message.success('挂号成功'); this.resetForm(); }).finally(() => { this.isSubmitting = false; // 恢复按钮状态 }); } }); } }

组件懒加载(优化首屏加载):

// router/index.js const routes = [ { path: '/register', name: 'Register', component: () => import(/* webpackChunkName: "register" */ '@/views/Register.vue') } ]

这样,挂号页面JS仅在用户访问时加载,首屏体积减少320KB。

6. 从那以后我每次部署门诊系统,都强制走一遍“三查一测”清单

6.1 三查:数据库、配置、权限的不可跳过核验

我给自己定的铁律:任何环境部署(开发/测试/生产),必须手敲三行命令验证,缺一不可:

# 查1:数据库基础数据完整性(科室、医生、药品必须>0) mysql -uhospital_app -pH0sp!t@l2024# -e "SELECT COUNT(*) FROM hospital_db.sys_dept;" | grep -q "^[[:space:]]*[1-9]" || echo "❌ 科室表为空!" # 查2:配置文件敏感信息(密码不能是默认值) grep -r "H0sp!t@l2024#" hospital-web/src/main/resources/ >/dev/null 2>&1 || echo "❌ 密码未修改!" # 查3:Linux文件权限(.jar必须可执行,SQL文件不能有写权限) [ -x hospital-web/target/hospital-web-1.0.jar ] && [ ! -w hospital-db/hospital_db.sql ] || echo "❌ 权限风险!"

这三查耗时不到10秒,却挡住了我80%的部署翻车——去年有次测试环境漏查,hospital_db.sql被误删,整个分诊逻辑瘫痪3小时。

6.2 一测:挂号-分诊-接诊-结算的端到端冒烟测试脚本

我写了个Python脚本(smoke_test.py),每次部署后自动执行核心链路:

import requests import time def smoke_test(): base_url = "http://localhost:8080" # 步骤1:挂号 reg_resp = requests.post(f"{base_url}/api/register/create", json={"idCard": "11010119900307231X", "deptId": 1}) assert reg_resp.status_code == 200, "挂号失败" reg_no = reg_resp.json()["registerNo"] # 步骤2:分诊(需等待2秒让异步任务生效) time.sleep(2) triage_resp = requests.get(f"{base_url}/api/triage/waiting-list") assert any(r["registerNo"] == reg_no for r in triage_resp.json()), "分诊未就绪" # 步骤3:医生接诊(用预置doctor001账号) login_resp = requests.post(f"{base_url}/login", data={"username": "doctor001", "password": "Hospital@2024"}) cookies = login_resp.cookies visit_resp = requests.post(f"{base_url}/api/visit/start", json={"registerNo": reg_no}, cookies=cookies) assert visit_resp.status_code == 200, "接诊失败" print("✅ 端到端冒烟测试通过") if __name__ == "__main__": smoke_test()

这个脚本不追求覆盖率,只验证四个环节的HTTP状态码和关键字段存在性。它跑完只需12秒,但比人工点五次界面可靠十倍。

6.3 一个习惯:所有SQL变更必须附带EXPLAIN分析报告

无论是优化慢查询还是新增索引,我坚持在Git Commit Message里附上EXPLAIN对比:

commit abc1234 Author: engineer Date: Mon Sep 2 10:30:00 2024 optimize: add index on dept_queue.dept_id Before: EXPLAIN shows type=ALL, rows=1245 After: EXPLAIN shows type=ref, rows=1, Extra=Using where

没有EXPLAIN报告的SQL变更,CI流水线会直接拒绝合并。这逼着我每次改SQL前,先问自己:“这个WHERE条件真的能用上索引吗?”

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询