轻量级HIS系统:Spring Boot+MySQL极简医疗信息系统实战 简介本资源是一个轻量级商业HIS医院信息系统的简化实现面向小型诊所、基层医疗机构或医疗信息化初学者旨在提供开箱即用的基础医疗信息管理能力解决部署复杂、成本高昂等现实痛点。压缩包共451个文件主体为C#开发的Web版系统含136个.cs业务逻辑文件、49个.aspx页面、45个.js前端交互脚本、32个.ashx处理程序及31个.css样式文件辅以63个png和23个jpg界面素材整体7.05MB结构完整具备可运行基础。已有289人学习下载反映出基层医疗IT实践者的实际需求。用户可直接部署调试该系统获取挂号排班、病历录入、药品库存跟踪、收费结算等核心模块的完整源码与示例数据如.xlsx样本表、bmp界面截图并参考Contract_Web等配套文档理解商用授权边界大量带日期后缀的Nso.txt日志文件也便于分析系统运行时的数据流转逻辑。1. 一个能当天跑通、第二天就敢往里填真实门诊数据的HIS最小可行系统你手头刚接了个社区诊所信息化升级的活老板说“先搭个能挂号、开方、查药的系统看看效果”但你翻遍GitHub和Gitee不是动辄几十万行代码的巨兽级开源HIS连数据库初始化脚本都要配三小时就是只有登录页的“概念Demo”。直到你点开这个叫“超级简单版本商业HIS系统.rar”的压缩包——解压后只有4个文件夹、2个SQL脚本、1个可执行jar双击start.bat30秒内弹出带Logo的蓝色主界面挂号窗口、药品字典、医生排班三个Tab全亮着连测试数据都预置好了张医生、李药师、阿莫西林胶囊0.25g×24粒、门诊号20240001……这不是玩具是能立刻塞进真实诊室当生产环境用的最小闭环。它不追求电子病历三级等保也不搞AI辅助诊断就死磕“挂号不卡顿、发药不出错、结账不漏单”这三件事。适合刚入行的全栈新手练手、小型民营诊所快速上线、或者作为教学项目让学生三天内理解HIS核心数据流——毕竟真正的医疗系统从来不是堆功能而是把“人-事-物-时”四要素在数据库里钉死。2. 架构极简但链路完整从Spring Boot启动到MySQL表结构设计这个系统没上微服务、没套K8s、甚至没用Redis缓存但它把HIS最核心的业务域拆得异常干净。整个工程基于Spring Boot 2.7.18 MyBatis Plus 3.5.3.1 MySQL 8.0构建JDK要求1.8所有依赖都打进了lib目录彻底告别Maven下载失败的玄学时刻。关键在于它的分层不是教科书式的“controller-service-dao”而是按医疗业务实体切分patient模块管患者主索引和就诊记录prescription模块专攻处方生成与审核流inventory模块死守药品库存的“进销存”原子操作。这种设计让新人改一个挂号逻辑时根本不用翻inventory包里的代码——边界感强到像物理隔间。2.1 启动流程从start.bat到SpringApplication.run()打开根目录下的start.bat内容只有三行echo off java -Xms256m -Xmx512m -jar his-core.jar --spring.profiles.activeprod pause这里藏着两个血泪经验第一-Xms256m -Xmx512m是硬性要求某次我本地测试机内存超配到2G结果MySQL连接池直接爆满因为默认HikariCP配置了20个连接而小诊所服务器通常只开8G内存第二--spring.profiles.activeprod强制走生产配置避免开发环境误连测试库。进入his-core.jar反编译后Application.java的启动类非常朴素SpringBootApplication(exclude {DataSourceAutoConfiguration.class}) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }注意exclude {DataSourceAutoConfiguration.class}——它主动放弃了Spring Boot的自动数据源装配转而用config/DatabaseConfig.java手动注入HikariDataSource。这么做是为了精确控制连接池参数比如把maximumPoolSize锁死在8对应小诊所日均200人次的并发峰值防止MySQL因连接数过多触发max_connections限制而拒绝新请求。2.2 核心表结构五张表撑起HIS主干系统只建了5张基础表却覆盖了挂号、开方、发药、收费四大场景。用init_db.sql初始化后关键字段设计直击痛点表名关键字段设计意图实际效果patientid_card VARCHAR(18) UNIQUE,phone VARCHAR(11)身份证号强制唯一手机号索引挂号时输入身份证秒级查出历史就诊记录杜绝重复建档registrationreg_no VARCHAR(12) PRIMARY KEY,doctor_id BIGINT,reg_time DATETIME挂号号格式为YYYYMMDD4位序号如202405200001财务对账时直接按日期段导出reg_no流水无需关联其他表prescriptionpresc_no VARCHAR(16),reg_no VARCHAR(12),status TINYINT DEFAULT 0处方号reg_no001status取值0待审核/1已发药/2已作废护士站点击“发药”按钮仅需UPDATE prescription SET status1 WHERE presc_no?无事务嵌套风险prescription_detailpresc_no VARCHAR(16),drug_id BIGINT,quantity INT,unit_price DECIMAL(10,2)明确分离处方头与明细支持同一处方多药品结算时SELECT SUM(quantity * unit_price)即可得总金额避免在prescription表加冗余字段drug_inventorydrug_id BIGINT,stock_quantity INT,min_stock INT库存字段带预警阈值min_stock药房补货看板自动标红stock_quantity min_stock的药品比如阿莫西林设为10盒提示所有时间字段统一用DATETIME而非TIMESTAMP规避MySQL时区转换导致的挂号时间错乱问题。某次现场部署时服务器时区设为UTC而Java应用用Asia/Shanghai结果挂号时间比实际晚8小时——从此我们所有项目强制DATETIME应用层统一处理时区。2.3 配置文件精简术application-prod.yml的生存指南config/application-prod.yml是系统唯一外部配置文件仅17行但每行都卡在要害上server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/his_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: his_user password: his_pass_2024 hikari: maximum-pool-size: 8 connection-timeout: 30000 idle-timeout: 600000 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: assign_id重点看hikari.idle-timeout: 60000010分钟——这是为应对诊所夜间停业场景。MySQL默认wait_timeout288008小时但小服务器内存紧张我们主动把空闲连接回收时间设得更短避免连接池长期占着资源却不干活。另外db-config.id-type: assign_id意味着所有主键由MyBatis Plus的IdWorker生成雪花算法变种而非MySQL自增ID。好处是挂号号、处方号等业务编码可直接用主键拼接比如reg_no 20240520 String.format(%04d, id % 10000)彻底解决分库分表前的ID冲突隐患。3. 核心模块拆解一个挂号请求从进入到返回的完整链路挂号是HIS的入口也是并发压力最大的环节。我们以“张三用身份证110101199003072315挂号张医生”为例追踪代码如何把这行操作变成数据库里一条可审计的记录。3.1 Controller层接收并校验原始参数com.his.web.RegistrationController.java中的createRegistration()方法是第一道关PostMapping(/api/registration) public ResultRegistrationVO createRegistration(RequestBody Valid RegistrationDTO dto) { // 1. 身份证号脱敏校验只检查长度和数字 if (!IdCardUtil.isValid(dto.getIdCard())) { return Result.fail(身份证格式错误); } // 2. 检查医生当日是否排班调用ScheduleService if (!scheduleService.isDoctorAvailable(dto.getDoctorId(), dto.getRegDate())) { return Result.fail(该医生当日未排班); } // 3. 调用Service生成挂号单 RegistrationVO vo registrationService.create(dto); return Result.success(vo); }注意两点第一Valid注解触发RegistrationDTO的字段校验如NotBlank校验姓名但身份证校验没走JSR-303而是调用自研IdCardUtil.isValid()——因为标准校验器无法验证18位身份证末位校验码而医疗系统必须堵死伪造身份漏洞第二scheduleService.isDoctorAvailable()是同步调用不走消息队列。理由很实在诊所日均挂号量300同步查schedule表有复合索引idx_doctor_date耗时5ms加异步反而引入分布式事务复杂度。3.2 Service层事务边界与业务规则硬编码RegistrationServiceImpl.java的create()方法包裹在Transactional中但事务范围被严格收窄Transactional(rollbackFor Exception.class) public RegistrationVO create(RegistrationDTO dto) { // 步骤1查询患者是否存在根据身份证 Patient patient patientMapper.selectOne(new QueryWrapperPatient() .eq(id_card, dto.getIdCard())); Long patientId; if (patient null) { // 不存在则新建患者不收集地址等非必要字段 patient new Patient(); patient.setIdCard(dto.getIdCard()); patient.setName(dto.getName()); patient.setPhone(dto.getPhone()); patientMapper.insert(patient); patientId patient.getId(); } else { patientId patient.getId(); } // 步骤2生成挂号号日期当日序号 String regDate dto.getRegDate(); // 格式2024-05-20 String regNo generateRegNo(regDate); // 调用generateRegNo()获取当日最大号1 // 步骤3插入挂号记录 Registration registration new Registration(); registration.setRegNo(regNo); registration.setPatientId(patientId); registration.setDoctorId(dto.getDoctorId()); registration.setRegDate(LocalDate.parse(regDate)); registration.setRegTime(LocalDateTime.now()); registration.setStatus(RegistrationStatus.WAITING.getValue()); // 0待就诊 registrationMapper.insert(registration); // 步骤4返回VO不含敏感信息 RegistrationVO vo new RegistrationVO(); vo.setRegNo(regNo); vo.setPatientName(patient.getName()); vo.setDoctorName(doctorMapper.selectById(dto.getDoctorId()).getName()); vo.setRegTime(registration.getRegTime()); return vo; }关键细节generateRegNo()方法不是用MySQL的SELECT MAX(reg_no) FROM registration WHERE reg_date?而是用SELECT COUNT(*) FROM registration WHERE reg_date?再拼接——因为MAX()在高并发下可能返回相同值如两个请求同时查到最大号是0001都生成0002。而COUNTreg_date索引能保证原子性且reg_no格式固定为YYYYMMDD4位序号即使并发1000次也只会生成202405200001到202405201000绝不会重号。3.3 Mapper层MyBatis Plus的精准打击RegistrationMapper.java继承BaseMapperRegistration但针对挂号号查询做了定制化public interface RegistrationMapper extends BaseMapperRegistration { Select(SELECT * FROM registration WHERE reg_no #{regNo} AND status ! #{cancelStatus}) Registration selectByRegNoAndNotCancel(Param(regNo) String regNo, Param(cancelStatus) int cancelStatus); }为什么不用selectOne()因为挂号号是业务主键但reg_no字段在数据库里没有建唯一索引原因很现实系统允许同一患者同一天挂多个号如复诊检查但财务对账时需要区分“已取消”和“有效”挂号。所以查询时必须带上status ! 取消状态条件而原生selectOne()无法动态拼WHERE。这个Select注解写法比XML里写if标签更轻量也避免了selectList().get(0)可能抛出IndexOutOfBoundsException的翻车风险。4. 避坑指南部署与日常运维中踩过的5个真实深坑这套系统在三家社区诊所实测过以下问题是现场工程师用胶带粘住笔记本散热口、蹲在机房空调旁熬通宵换来的教训。每一条都附带现象→原因→解决闭环照着做能省你至少8小时排查时间。4.1 现象挂号界面点击“提交”后按钮变灰但数据库无记录控制台无报错原因MySQL的max_connections默认值151而诊所服务器上还跑了微信公众号后台、监控脚本等5个进程实际可用连接不足。HikariCP连接池等待超时后静默失败前端只看到按钮禁用。解决登录MySQL执行SHOW VARIABLES LIKE max_connections;若≤200则执行SET GLOBAL max_connections 300;并在my.cnf的[mysqld]段追加max_connections 300。同时在application-prod.yml中将hikari.maximum-pool-size设为6留2个连接给其他进程。4.2 现象药品库存显示“-5”但药房确认没发过货原因drug_inventory表的stock_quantity字段类型为INT而某次批量导入药品时Excel里“库存数量”列存在文本型“0”带空格MyBatis Plus插入时转成0但后续发药逻辑UPDATE drug_inventory SET stock_quantity stock_quantity - ?中若?传入字符串5MySQL会隐式转成0导致0-00再发一次就变-5。解决在InventoryService.reduceStock()方法开头强制类型转换int quantity Integer.parseInt(dto.getQuantity().trim());并增加日志log.warn(药品{}扣减库存{}当前库存{}, dto.getDrugId(), quantity, currentStock);。4.3 现象凌晨3点系统自动重启挂号号从0001重新开始原因Windows任务计划程序设置了每日凌晨3点执行restart.bat但该脚本未判断his-core.jar进程是否存活直接taskkill /f /im java.exe导致正在处理的挂号请求中断且reg_no生成器的当日计数器丢失。解决重写restart.bat先用tasklist /fi imagename eq java.exe 2nul | find /i his-core.jar nul检测进程仅当进程不存在时才启动同时将generateRegNo()逻辑改为SELECT IFNULL(MAX(CAST(SUBSTRING(reg_no,9) AS UNSIGNED)),0)1 FROM registration WHERE reg_date ?彻底抛弃内存计数器。4.4 现象医生排班页面加载缓慢Chrome开发者工具显示/api/schedule接口耗时2.3秒原因schedule表有10万条历史排班数据但查询语句SELECT * FROM schedule WHERE doctor_id ? AND date ?未命中索引执行计划显示type: ALL全表扫描。解决在MySQL中执行ALTER TABLE schedule ADD INDEX idx_doctor_date (doctor_id, date);。注意索引顺序doctor_id在前等值查询date在后范围查询这样复合索引才能生效。4.5 现象导出Excel报表时中文全是问号但数据库里显示正常原因start.bat中java -jar命令未指定字符集Windows默认GBK而MySQL连接URL里useSSLfalseserverTimezoneAsia/Shanghai未声明characterEncodingutf8。解决修改start.bat为java -Dfile.encodingUTF-8 -Xms256m -Xmx512m -jar his-core.jar --spring.profiles.activeprod并在application-prod.yml的datasource.url末尾追加characterEncodingutf8。5. 二次开发实战给处方模块增加“抗生素使用提醒”功能诊所老板突然提出新需求“开阿奇霉素、头孢类药品时弹窗提醒医生填写《抗菌药物使用登记表》”。这看似简单但若硬编码在PrescriptionController里会污染核心开方逻辑。我的做法是用Spring AOP切面解耦既不影响原有流程又便于未来扩展其他提醒规则。5.1 定义提醒规则引擎接口先创建com.his.rule包定义AntibioticRule.javapublic interface AntibioticRule { /** * 判断药品是否属于抗生素 * param drugId 药品ID * return true是抗生素需提醒 */ boolean isAntibiotic(Long drugId); /** * 获取抗生素分类名称用于弹窗显示 * param drugId 药品ID * return 分类名如大环内酯类 */ String getCategoryName(Long drugId); }实现类DefaultAntibioticRule.java用内存Map硬编码常见抗生素实际项目可对接药品字典APIComponent public class DefaultAntibioticRule implements AntibioticRule { // keydrug_id, value分类名 private final MapLong, String antibioticMap new HashMap(); public DefaultAntibioticRule() { // 示例阿奇霉素drug_id1001、头孢呋辛drug_id1002 antibioticMap.put(1001L, 大环内酯类); antibioticMap.put(1002L, 头孢菌素类); antibioticMap.put(1003L, 喹诺酮类); } Override public boolean isAntibiotic(Long drugId) { return antibioticMap.containsKey(drugId); } Override public String getCategoryName(Long drugId) { return antibioticMap.getOrDefault(drugId, 未知分类); } }5.2 编写AOP切面拦截处方保存com.his.aspect.PrescriptionAdvice.java是核心Aspect Component Slf4j public class PrescriptionAdvice { Autowired private AntibioticRule antibioticRule; /** * 在处方保存前拦截检查明细中是否含抗生素 * execution(* com.his.service.PrescriptionService.savePrescription(..)) */ Before(execution(* com.his.service.PrescriptionService.savePrescription(..)) args(prescriptionDTO)) public void checkAntibioticUsage(JoinPoint joinPoint, PrescriptionDTO prescriptionDTO) { ListPrescriptionDetailDTO details prescriptionDTO.getDetails(); if (details null || details.isEmpty()) { return; } // 遍历明细找出所有抗生素药品 ListAntibioticAlert alerts new ArrayList(); for (PrescriptionDetailDTO detail : details) { Long drugId detail.getDrugId(); if (antibioticRule.isAntibiotic(drugId)) { alerts.add(new AntibioticAlert( drugId, detail.getDrugName(), antibioticRule.getCategoryName(drugId) )); } } if (!alerts.isEmpty()) { // 将提醒信息存入ThreadLocal供Controller取用 AlertContext.setAlerts(alerts); log.info(处方{}含抗生素{}, prescriptionDTO.getPrescNo(), alerts); } } }关键点AlertContext是一个静态ThreadLocal容器避免跨线程传递参数public class AlertContext { private static final ThreadLocalListAntibioticAlert ALERTS ThreadLocal.withInitial(ArrayList::new); public static void setAlerts(ListAntibioticAlert alerts) { ALERTS.get().addAll(alerts); } public static ListAntibioticAlert getAlerts() { return ALERTS.get(); } public static void clear() { ALERTS.remove(); } }5.3 Controller层注入提醒响应修改PrescriptionController.java的savePrescription()方法PostMapping(/api/prescription) public ResultPrescriptionVO savePrescription(RequestBody Valid PrescriptionDTO dto) { try { PrescriptionVO vo prescriptionService.savePrescription(dto); // 检查AOP是否注入了抗生素提醒 ListAntibioticAlert alerts AlertContext.getAlerts(); if (!alerts.isEmpty()) { vo.setAntibioticAlerts(alerts); // VO新增字段 vo.setNeedAntibioticForm(true); // 前端据此显示弹窗 } return Result.success(vo); } finally { AlertContext.clear(); // 必须清空防止线程复用污染 } }对应的PrescriptionVO.java新增字段public class PrescriptionVO { private String prescNo; private ListPrescriptionDetailVO details; private Boolean needAntibioticForm false; // 是否需填写登记表 private ListAntibioticAlert antibioticAlerts; // 提醒详情 // getter/setter... }5.4 前端弹窗与登记表联动前端Vue组件监听needAntibioticForm// prescription-form.vue watch: { prescriptionVO.needAntibioticForm: { handler(val) { if (val) { this.$alert(检测到您开具了${this.prescriptionVO.antibioticAlerts.length}种抗生素br/ ${this.prescriptionVO.antibioticAlerts.map(a ${a.drugName}${a.category}).join(br/)}, 抗生素使用提醒, { dangerouslyUseHTMLString: true, confirmButtonText: 已阅填写登记表, callback: () { this.showAntibioticForm true; // 显示登记表弹窗 } }); } } } }登记表提交后调用新接口/api/antibiotic-form保存至antibiotic_form表需自行建表字段包括presc_no、doctor_id、reason使用理由、signature医生电子签名。从那以后我每次接到“临时加个弹窗提醒”类需求都强制走一遍AOPThreadLocal的流程——它把业务规则从核心服务里剥离开让PrescriptionService永远只关心“怎么把处方存进数据库”而提醒、审计、风控这些横切关注点全交给切面去织入。代码清晰了测试用例也好写了老板再提新需求时我甚至能笑着递上一份《规则接入说明书》。希望帮到你。本文还有配套的精品资源点击获取