轻量级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.bat,30秒内弹出带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.active=prod pause这里藏着两个血泪经验:第一,-Xms256m -Xmx512m是硬性要求,某次我本地测试机内存超配到2G,结果MySQL连接池直接爆满(因为默认HikariCP配置了20个连接,而小诊所服务器通常只开8G内存);第二,--spring.profiles.active=prod强制走生产配置,避免开发环境误连测试库。进入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初始化后,关键字段设计直击痛点:
| 表名 | 关键字段 | 设计意图 | 实际效果 |
|---|---|---|---|
patient | id_card VARCHAR(18) UNIQUE,phone VARCHAR(11) | 身份证号强制唯一+手机号索引 | 挂号时输入身份证,秒级查出历史就诊记录,杜绝重复建档 |
registration | reg_no VARCHAR(12) PRIMARY KEY,doctor_id BIGINT,reg_time DATETIME | 挂号号格式为YYYYMMDD+4位序号(如202405200001) | 财务对账时直接按日期段导出reg_no流水,无需关联其他表 |
prescription | presc_no VARCHAR(16),reg_no VARCHAR(12),status TINYINT DEFAULT 0 | 处方号=reg_no+001,status取值0(待审核)/1(已发药)/2(已作废) | 护士站点击“发药”按钮,仅需UPDATE prescription SET status=1 WHERE presc_no=?,无事务嵌套风险 |
prescription_detail | presc_no VARCHAR(16),drug_id BIGINT,quantity INT,unit_price DECIMAL(10,2) | 明确分离处方头与明细,支持同一处方多药品 | 结算时SELECT SUM(quantity * unit_price)即可得总金额,避免在prescription表加冗余字段 |
drug_inventory | drug_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?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true 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: 600000(10分钟)——这是为应对诊所夜间停业场景。MySQL默认wait_timeout=28800(8小时),但小服务器内存紧张,我们主动把空闲连接回收时间设得更短,避免连接池长期占着资源却不干活。另外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 Result<RegistrationVO> 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 QueryWrapper<Patient>() .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)。而COUNT+reg_date索引能保证原子性,且reg_no格式固定为YYYYMMDD+4位序号,即使并发1000次也只会生成202405200001到202405201000,绝不会重号。
3.3 Mapper层:MyBatis Plus的精准打击
RegistrationMapper.java继承BaseMapper<Registration>,但针对挂号号查询做了定制化:
public interface RegistrationMapper extends BaseMapper<Registration> { @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 - ?中,若?传入字符串"5",MySQL会隐式转成0,导致0-0=0,再发一次就变-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" 2>nul | 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里useSSL=false&serverTimezone=Asia/Shanghai未声明characterEncoding=utf8。
解决:修改start.bat为java -Dfile.encoding=UTF-8 -Xms256m -Xmx512m -jar his-core.jar --spring.profiles.active=prod,并在application-prod.yml的datasource.url末尾追加&characterEncoding=utf8。
5. 二次开发实战:给处方模块增加“抗生素使用提醒”功能
诊所老板突然提出新需求:“开阿奇霉素、头孢类药品时,弹窗提醒医生填写《抗菌药物使用登记表》”。这看似简单,但若硬编码在PrescriptionController里,会污染核心开方逻辑。我的做法是用Spring AOP切面解耦,既不影响原有流程,又便于未来扩展其他提醒规则。
5.1 定义提醒规则引擎接口
先创建com.his.rule包,定义AntibioticRule.java:
public interface AntibioticRule { /** * 判断药品是否属于抗生素 * @param drugId 药品ID * @return true=是抗生素,需提醒 */ boolean isAntibiotic(Long drugId); /** * 获取抗生素分类名称(用于弹窗显示) * @param drugId 药品ID * @return 分类名,如"大环内酯类" */ String getCategoryName(Long drugId); }实现类DefaultAntibioticRule.java用内存Map硬编码常见抗生素(实际项目可对接药品字典API):
@Component public class DefaultAntibioticRule implements AntibioticRule { // key=drug_id, value=分类名 private final Map<Long, String> antibioticMap = new HashMap<>(); public DefaultAntibioticRule() { // 示例:阿奇霉素(drug_id=1001)、头孢呋辛(drug_id=1002) 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) { List<PrescriptionDetailDTO> details = prescriptionDTO.getDetails(); if (details == null || details.isEmpty()) { return; } // 遍历明细,找出所有抗生素药品 List<AntibioticAlert> 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 ThreadLocal<List<AntibioticAlert>> ALERTS = ThreadLocal.withInitial(ArrayList::new); public static void setAlerts(List<AntibioticAlert> alerts) { ALERTS.get().addAll(alerts); } public static List<AntibioticAlert> getAlerts() { return ALERTS.get(); } public static void clear() { ALERTS.remove(); } }5.3 Controller层注入提醒响应
修改PrescriptionController.java的savePrescription()方法:
@PostMapping("/api/prescription") public Result<PrescriptionVO> savePrescription(@RequestBody @Valid PrescriptionDTO dto) { try { PrescriptionVO vo = prescriptionService.savePrescription(dto); // 检查AOP是否注入了抗生素提醒 List<AntibioticAlert> 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 List<PrescriptionDetailVO> details; private Boolean needAntibioticForm = false; // 是否需填写登记表 private List<AntibioticAlert> 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(医生电子签名)。
从那以后我每次接到“临时加个弹窗提醒”类需求,都强制走一遍AOP+ThreadLocal的流程——它把业务规则从核心服务里剥离开,让PrescriptionService永远只关心“怎么把处方存进数据库”,而提醒、审计、风控这些横切关注点,全交给切面去织入。代码清晰了,测试用例也好写了,老板再提新需求时,我甚至能笑着递上一份《规则接入说明书》。希望帮到你。
本文还有配套的精品资源,点击获取