轻量化HIS系统落地实践:Spring Boot单体架构如何支撑门诊全流程 简介一份面向小型诊所与医疗信息化初学者的轻量级HIS系统源码包简化了传统医院信息系统的复杂度围绕病患管理、挂号、药品、收费、排班等常见业务模块组织代码与页面适合快速部署试用或作为二次开发蓝本。压缩包共451个文件约7.05MB其中包含C#工程源码cs、csproj、ASP.NET页面与处理程序aspx、ashx、前端样式与脚本css、js、图片资源png、jpg、gif以及编译后的exe、dll和配置文件config并有Excel数据样例与文档辅助理解目录结构清晰便于定位业务模块。已有285人学习下载。通过阅读源码和示例数据可以了解基础HIS的表单交互、业务逻辑与接口调用方式进而扩展预约、统计报表、患者追踪等模块对想要快速搭建医院信息系统原型或学习ASP.NET Web开发的人来说是一份实用性很强的起步资料。1. 商业HIS系统“超级简单版”落地记解压就能复现的门诊业务闭环如果你印象中的HIS医院信息系统还停在“大厂级项目、几十张表、部署半年”的体量这个资源会颠覆你的预期。所谓超级简单的商业HIS就是一个保留了挂号、医生工作站、收费、药房库存与基础统计的核心闭环没有引入微服务、消息队列这类重组件解压后初始化数据库就能在浏览器里走通一个患者从进院到取药的完整路径。我拆这套工程时最大的感受是它没有为了简单而阉割业务反而把退费冲正、日结报表这些最容易写翻车的逻辑都留了样例代码。适合中小规模门诊快速搭业务也适合想进HIS二次开发的工程师拿来做底稿研究。2. 技术栈与工程结构拆开RAR之后先看清四层设计拿到压缩包第一件事不是急着跑mvn spring-boot:run而是先把工程里的技术栈和目录结构摸清楚。这套HIS不走微服务后端是一个标准的 Spring Boot 单体应用前端是独立的 Vue 工程数据库脚本单独放在sql/目录里。前后端分离、代码分层清晰是它敢叫“简单版”的底气。2.1 后端技术选型与分层Spring Boot MyBatis MySQL后端是基于 Spring Boot 2.x 搭建的 Maven 工程Java 版本 8持久层用 MyBatis数据库用 MySQL。没有引入 Redis 做缓存也没用 RabbitMQ 这类中间件登录态用 JWT 生成 token请求拦截在过滤器中完成鉴权。这种组合的好处是部署成本低一台 2G 内存的云主机就能跑出现问题也好排查。代码分层很规矩controller - service - mapper三层。控制层只负责参数接收和结果封装不写业务业务层用Transactional管理事务数据层通过 MyBatis 的 XML 文件写 SQL。我一般会在拿到这类 HIS 源码后先看pom.xml里依赖了哪些包。这个项目里值得注意的有spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、jjwt和lombok基本就是一套没有多余依赖的清爽组合。2.2 前端独立工程Vue Element UI 的构建产物与后端集成前端在frontend/目录下用的是 Vue 2 Element UI。页面包括登录、挂号收银台、医生工作台、收费窗口、药房发药、报表中心这几个核心视图。日常开发的时候前端通过 devServer 代理请求到后端如果不想开两个服务也可以直接npm run build构建出的dist目录会被复制到后端的static目录下由 Spring Boot 统一托管。判断一个工程是不是这种做法看后端resources/static下有没有 index.html 和js/css目录就行。有的话生产环境打出 jar 包就能直接跑不用再配 Nginx。前端路由用的 Vue Router 的 history 模式但生产环境如果直接打包进后端注意路由模式改成 hash否则刷新页面会出现 404。这套系统的默认配置我印象中是用的 hash 模式省了很多部署时的血泪麻烦。2.3 目录结构与配置约定拿到RAR先看这几个文件解开 RAR 后整个工程的主干结构大致长这样his-super-simple/ ├── sql/ │ ├── init_his.sql # 库表结构初始化 │ └── demo_data.sql # 演示数据与初始账号 ├── src/main/java/com/his/ │ ├── controller/ # 控制层按模块分包 │ ├── service/ # 业务层 │ ├── mapper/ # MyBatis接口 │ └── entity/ # 数据实体 ├── src/main/resources/ │ ├── application.yml # 数据源与端口配置 │ └── mapper/ # MyBatis XML文件 ├── frontend/ │ ├── src/ │ │ ├── views/ # 页面级组件 │ │ ├── api/ # 请求封装 │ │ └── router/ # 路由表 │ └── dist/ # 已构建好的前端产物 └── README.mdapplication.yml是启动前必须改的文件。数据库连接、端口、JWT 密钥都在里面我把常用配置整理成一张表配置项示例值说明server.port8080后端HTTP端口server.servlet.context-path/his接口统一前缀spring.datasource.urljdbc:mysql://localhost:3306/his_db数据库地址spring.datasource.usernameroot数据库账号mybatis.mapper-locationsclasspath:mapper/*.xmlSQL映射位置jwt.secretyour-secret-keytoken签名密钥生产必须改这里有个容易忽略的细节context-path配了/his前端所有请求就会带上/his前缀。如果自己二次开发时把前缀去掉记得同步修改前端request.js里的 baseURL不然接口全部 404。3. 核心模块拆解一个门诊患者从挂号到收费的完整链路这一章是最值得细读的部分。HIS 的核心不是界面有多好看而是数据在挂号、医生站、收费这三个节点之间的流转是否正确。我从这套源码里挑三条主线逐段拆给你看。3.1 挂号模块排队号生成与号源状态判断挂号是整个门诊流程的入口也是号源状态最敏感的地方。这套系统的挂号表设计得很直接一张his_register表记录一条挂号记录字段包括register_no、patient_id、dept_id、doctor_id、status。status用整型表示状态机0是待就诊1是已就诊2是已退号。挂号核心逻辑在RegisterService里关键代码是这段Service public class RegisterService { Resource private RegisterMapper registerMapper; Resource private SeqGenerator seqGenerator; Transactional(rollbackFor Exception.class) public RegisterInfo register(RegisterRequest req) { // 1. 判断该医生当天号源是否已满 int usedCount registerMapper.countTodayByDoctor( req.getDoctorId(), DateUtil.today()); if (usedCount req.getMaxNumber()) { throw new BizException(该医生今日号源已约满); } // 2. 生成当天排队号例如 20240520-001 String registerNo seqGenerator.nextDailySeq( REG, DateUtil.today()); RegisterInfo info new RegisterInfo(); info.setRegisterNo(registerNo); info.setPatientId(req.getPatientId()); info.setDeptId(req.getDeptId()); info.setDoctorId(req.getDoctorId()); info.setStatus(0); registerMapper.insert(info); return info; } }这段代码有两个值得关注的参数maxNumber是医生当日放号上限在科室排班表里维护nextDailySeq生成器则负责把REG前缀、日期和一个三位流水号拼成可读的挂号单号。流水号用数据库表加synchronized关键字控制并发在生产环境如果挂号量很大这一步需要换成 Redis 自增但作为“简单版”这个实现够用。为什么register方法要加Transactional因为号源判断和插入挂号记录必须是原子操作。如果先判断再插入、中间不做事务控制两个患者同时挂最后一个号时就会超卖。这个坑在日活不高的系统里不容易暴露但在做压力测试时一定会翻车。3.2 医生工作站处方如何拆成可收费的费用明细患者挂号后进入医生工作站。医生选择患者、录入诊断、开出药品或检查项目系统会把每条医嘱转成一条待收费明细。这里的核心表是his_prescription和his_prescription_item一张处方对多条明细明细里记录项目、数量、单价、金额和状态。开立处方的核心代码在PrescriptionServiceTransactional(rollbackFor Exception.class) public String createPrescription(PrescriptionDTO dto) { String rxNo seqGenerator.nextDailySeq(RX, DateUtil.today()); Prescription rx new Prescription(); rx.setRxNo(rxNo); rx.setRegisterNo(dto.getRegisterNo()); rx.setDoctorId(dto.getDoctorId()); rx.setStatus(0); // 0-未收费 prescriptionMapper.insert(rx); ListFeeItem items new ArrayList(); for (PrescriptionItemDTO itemDto : dto.getItems()) { FeeItem item new FeeItem(); item.setRxNo(rxNo); item.setItemCode(itemDto.getItemCode()); item.setItemName(itemDto.getItemName()); item.setPrice(itemDto.getPrice()); item.setQuantity(itemDto.getQuantity()); // 金额 单价 * 数量保留两位小数 item.setAmount(itemDto.getPrice() .multiply(BigDecimal.valueOf(itemDto.getQuantity()))); item.setStatus(0); items.add(item); feeItemMapper.insert(item); } return rxNo; }代码里有一个在商业 H IS 开发里常见但新手容易写错的地方金额计算必须用BigDecimal绝不能用double。药品单价和数量做乘法0.1 元乘以 3 在 double 下会得到0.30000000000000004一旦累计到整单收费一分钱对不上账就麻烦了。这里status又出现了含义变成费用的支付状态0未收费1已收费2已退费。一套系统里多个业务表各自维护自己的status这是 HIS 源码里很常见的约定。二次开发时看到status字段第一反应应该是去查它对应的状态机注释而不是猜。3.3 收费与退费金额汇总、发票号回填与冲正逻辑收费窗口拿着处方号去收费ChargeService负责汇总所有待收费明细并写入收费记录。这个模块的代码是整个工程里事务边界最值得学习的部分Transactional(rollbackFor Exception.class) public ChargeResult charge(String rxNo, String cashierId) { ListFeeItem items feeItemMapper.selectUnpaidByRxNo(rxNo); if (items.isEmpty()) { throw new BizException(没有待收费明细请勿重复收费); } // 汇总明细金额 BigDecimal total items.stream() .map(FeeItem::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); // 生成收费流水与发票号 String chargeNo seqGenerator.nextDailySeq(CH, DateUtil.today()); ChargeRecord record new ChargeRecord(); record.setChargeNo(chargeNo); record.setRxNo(rxNo); record.setCashierId(cashierId); record.setTotalAmount(total); record.setInvoiceNo(FP chargeNo); // 简单版把流水号当发票号 chargeMapper.insert(record); // 把明细状态改为已收费 feeItemMapper.updateStatusByRxNo(rxNo, 0, 1); ChargeResult result new ChargeResult(); result.setChargeNo(chargeNo); result.setInvoiceNo(record.getInvoiceNo()); result.setTotalAmount(total); return result; }selectUnpaidByRxNo有个隐藏的防重设计它只选出status 0的明细。如果收费请求被前端重复提交第二次进来时items为空直接抛异常天然挡住了重复收费。这种用业务状态而不是用锁来控制并发的方式在低并发场景下是足够可靠的。退费逻辑就更有意思了。它不是直接删除收费记录而是插入一条金额为负数的冲正流水。这样设计的好处是财务对账时每一笔流水都有迹可循正数和负数自然抵消不会因为物理删除造成账实不符。你在源码里搜索refund关键字会看到它调用了chargeMapper.insert但金额取负数同时把对应明细状态改回0。4. 本地跑通全流程从初始化数据库到完成首笔挂号收费静态看代码是一种理解方式但真正让这套 HIS “活”起来必须把服务跑起来走一遍业务。下面是我实际操作时的完整路径照着做基本一次能通。4.1 环境准备与数据库初始化本地需要 JDK 8、Maven 3.6、MySQL 5.7 或 8.0、Node.js 12。MySQL 准备好之后用命令行导入两个脚本mysql -uroot -p sql/init_his.sql mysql -uroot -p sql/demo_data.sqlinit_his.sql建库建表demo_data.sql插入演示数据。演示数据里包含了四个科室、三名医生、两个挂号窗口账号、一批药品字典和患者档案。这里提醒一句MySQL 8 默认的认证插件是caching_sha2_password如果连不上库先检查驱动版本是不是 8.x项目里如果用老版本mysql-connector-java会报认证协议错误。我一般会把pom.xml里的驱动升到 8.0.20 以上同时把application.yml时区参数配上避免后面启动时报时区错误。4.2 启动后端并验证接口数据库就绪后改application.yml里的数据库密码然后启动mvn spring-boot:run启动日志里看到Tomcat started on port 8080就算成功。先用 curl 验证登录接口确认 JWT 能正常签发curl -X POST http://localhost:8080/his/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}返回值里拿到token字段后续请求在 Header 里带Authorization: Bearer token。如果返回 401多半是数据库里的账号初始密码不是 123456或者demo_data.sql没有导入成功去his_user表里查一下账号是否存在。4.3 启动前端并用演示账号走通业务流程后端在跑前端我用开发模式启动这样改页面代码能实时生效cd frontend npm install npm run serve前端默认跑在 8081 端口开发环境的代理配置会把/his开头的请求转发到 8080 后端。访问http://localhost:8081用内置演示账号登录角色账号主要权限系统管理员admin基础数据、报表、账号管理挂号收费员reg挂号、退号、收费门诊医生doctor接诊、开处方药房操作员pharmacy发药、库存查询完整走一遍业务的路径是用reg账号登录在挂号页选择“内科-张医生”为演示患者李某某挂一个号拿到挂号单号然后退出登录用doctor账号登录在医生工作台找到这个挂号记录填写诊断并开两张药品处方保存再次退出用reg登录进入收费窗口输入处方号点击收费回显发票号最后用pharmacy登录药房发药界面确认发药。这五步走通说明系统的表关系和状态流转没有配置问题。5. 避坑手册这套商业HIS最常见的问题与排查记录我拆这套工程时在本地跑了三轮样例过程中踩了几个典型的坑按场景分两组记录下来。这些都是真实会遇到的启动与业务问题提前知道能省不少排查时间。5.1 数据库初始化与启动阶段的高发问题坑一MySQL 8 时区导致启动直接报错现象后端启动时抛The server time zone value Öйú±ê׼ʱ¼ä is unrecognized服务起不来。原因MySQL 8 驱动对时区参数更敏感application.yml里没有指定serverTimezone。解决在数据库连接串上追加参数?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai。顺手把连接串里的useSSLfalse也加上避免 SSL 告警。坑二导入 SQL 脚本时提示DEFINER权限不足现象用 Navicat 或命令行导入init_his.sql时报ERROR 1227 (42000): Access denied; you need (at least one of) the SUPER privilege(s) for this operation。原因脚本里可能带有创建视图或存储过程时的DEFINERrootlocalhost信息当前导入账号不是管理员无权指定 DEFINER。解决用 root 导入或者把脚本里的DEFINER段落去掉。我一般直接用命令行导入避开图形工具对脚本执行权限的差异。坑三8080 端口被占用启动日志却只报“端口被占用”后退出现象mvn spring-boot:run跑起来几秒钟又停了日志只给一句Port 8080 was already in use。原因本地有其他 Java 服务或 Docker 容器占用了 8080。解决快速找出占用进程并清理lsof -i:8080Mac/Linux或netstat -ano | findstr 8080Windows。也可以直接用配置逃生把application.yml的server.port改成 8082前端vue.config.js里代理目标同步改成 8082省到连杀进程都免了。5.2 业务逻辑与并发场景下容易翻车的地方坑四退费请求被双击提交造成两条负数流水现象收费员点击退费按钮两次费用明细状态还没刷新第二次又成功插入一条冲正记录最终账目多冲了一笔。原因退费接口没有做幂等校验前端重复提交后后端把“插入负数流水”当成独立业务执行了两次。解决在退费接口入口先查询原始收费记录的status如果已经是“已冲正”直接抛BizException(该收费单已退费)。这也是这套系统charge_record表里设计了status字段的缘故。坑五挂号高峰时段排队号出现重复现象并发挂号时日志里出现两条相同的register_no患者取号时窗口“撞号”。原因nextDailySeq生成器的实现是先查当天最大值再加一但多个请求并发执行查询时读到同一个最大值。解决常见做法是在生成器的mapper方法上加synchronized或者用数据库行锁SELECT ... FOR UPDATE锁住日期流水行。如果项目后续要加大并发我一般会把这个生成器替换成 Redis incr。坑六前端页面能打开但列表数据全部 404现象登录页显示正常登录后每张列表都请求失败Network 面板看到请求 URL 是http://localhost:8081/api/...而后端配置了/his前缀。原因前端request.js里的 baseURL 没有和context-path统一。开发环境代理只转发了/his开头的地址/api开头的直接请求到了前端 devServer 自己返回 404。解决把request.js的 baseURL 改为/his/api或者修改代理规则将/api也转发到 8080。核心原则是前端实际请求路径 context-path controller 路由两边必须保持一致。6. 二次开发手记不破坏原有数据结构地扩展业务模块拿到这套 HIS 源码最值钱的不是跑通而是基于它扩展出自己的业务模块。下面是我会采取的两种落地路径分别对应“加字段”和“加功能”。6.1 新增业务字段的标准操作路径假设要给挂号表增加一个“医保类型”字段。最稳的做法是沿着数据层往上层逐级加而不是直接在 controller 里写死第一步在his_register表增加insurance_type字段第二步在RegisterInfo实体类增加对应属性第三步在RegisterMapper.xml的insert语句里增加字段映射第四步在挂号请求入参 DTO 里接收前端传值第五步前端挂号页的弹窗表单增加一个下拉框。每一步只动一个文件出问题用git diff一眼能定位。我一般会特意先改数据库、再改后端、最后改前端避免出现“前端已经在传新字段后端还没接住”的中间态。6.2 新增一个日结算统计报表的实现示例原项目带了基础报表如果想按自己的需求加一个“按医生统计日收费金额”的报表可以直接复用现有的fee_item和charge_record两张表不用改数据结构。Mapper XML 里加一段 SQLselect idsumDailyByDoctor resultTypejava.util.Map SELECT d.doctor_name AS doctorName, COUNT(DISTINCT cr.charge_no) AS chargeCount, SUM(fi.amount) AS totalAmount FROM charge_record cr INNER JOIN fee_item fi ON cr.rx_no fi.rx_no INNER JOIN prescription rx ON cr.rx_no rx.rx_no INNER JOIN doctor d ON rx.doctor_id d.doctor_id WHERE cr.create_time gt; #{startTime} AND cr.create_time lt; DATE_ADD(#{endTime}, INTERVAL 1 DAY) AND fi.status 1 GROUP BY d.doctor_name, rx.doctor_id /select三个参数的含义分别是startTime统计开始日期、endTime统计结束日期为了把 “某天” 扩成 “某天 00:00:00 到次日 00:00:00”我在 SQL 里对endTime加了一天。fi.status 1这条条件必须保留否则会把已退费的明细也算进收入。改造完成后验证方式不是打开页面看一眼就收工而是先在数据库手工构造一张测试处方一条收费明细、一条退费明细然后调用报表接口核对统计出来的金额是否一致。这套系统里最容易被忽视的验证死角是日期边界——跨天挂号、跨天收费时统计口径很容易差一笔。从那以后我每次给 HIS 加报表第一件事必定是拿真实业务数据跑一遍日结对账而不是拿演示数据糊弄自己。希望这份拆解能帮你更快地把这套系统跑起来也让你在后面积累 HIS 业务代码时少走一点弯路。本文还有配套的精品资源点击获取