简介:面向医保信息化实施与开发人员的医保结算系统实施版资料包,聚焦昌吉州地区医保结算业务的落地场景,可用于理解参保人员信息管理、医疗费用审核报销、智能审核规则、与医疗机构HIS接口对接以及跨区域结算等核心流程,也适合作为地区医保政策、报销标准与操作流程的实施参考。压缩包共3个文件,约929KB,以htm说明页、txt使用说明和pbl程序库文件为主,分别承担系统说明、安装操作指引与程序主体承载,体积轻量但结构指向明确。已有1837人学习下载,具备一定参考热度。读者可借助说明文档快速梳理系统接入方式、日常结算事务处理路径与实施注意事项,并结合程序库了解业务模块组织和二次开发线索,为医保结算系统的部署、运维和功能完善提供可复用的思路。
1. 早上八点第一笔结算就卡住,问题往往不在业务代码
收费窗口刚开,队伍已经排到走廊,第一笔医保结算点了确认,页面转圈十几秒后弹出"交易超时"。收费员再点一次,第二笔成功了,可到了下午对账,系统里两笔、医保侧一笔,账不平。这套医保结算系统要解决的正是这类事:把门诊、住院的费用归集、医保试算、正式结算、退费冲正、日终对账串成一条可控的链路。它适合正在做医疗信息化二次开发的团队,也适合想搞清楚"结算"这个动作背后到底有多少状态要维护的开发者。业务规则不难,难的是钱扣了、单子没落库,或者单子落库了、医保侧没收到。下面按源码结构把这条链路拆开讲,能跑起来的部分我都给到命令和参数。
2. 项目定位与技术选型:边界划不清,后面全是返工
2.1 系统边界:谁管临床,谁管钱
拿到这类源码,第一件事不是看代码,是看它把边界划在哪儿。医保结算系统最容易做歪的地方,是试图把医嘱、处方、执行这些临床动作也纳进来,结果模块越滚越大,最后一改收费规则就牵动半个系统。这套代码的划分比较克制,职责大致是三层:
| 层 | 负责什么 | 不该管什么 |
|---|---|---|
| 临床侧(HIS 等) | 医嘱、处方、执行确认、费用产生 | 医保目录匹配、待遇计算 |
| 结算中心(本项目) | 费用归集、目录对照、预结算、正式结算、退费冲正、对账 | 诊断录入、药品库存 |
| 医保平台侧 | 目录下发、待遇试算、基金支付、结算流水 | 本地费用明细的组织方式 |
这个边界带来的直接好处是:结算中心只认"费用明细 + 患者身份 + 险种"三样输入,剩下的都能替换。实际项目里临床系统换供应商是常事,只要费用明细的入参结构不变,结算中心基本不用动。我见过反过来的做法,把医嘱对象直接透传到结算层,结果换一家 HIS,字段名一变,整条链路跟着重写,血泪经验。
代码层面这条边界体现为依赖方向:mi-settlement依赖mi-insurance-adapter的接口,但 adapter 不反向依赖 settlement。adapter 里只有报文、签名、超时、重试这些和具体业务无关的东西,所以联调阶段可以拿一个 Mock 平台顶上,不用等对方环境就绪。
2.2 技术栈与目录结构:八个模块各管一段
技术选型属于这类系统的常规组合,源码里是 Spring Boot 2.7 + MyBatis-Plus + MySQL 8 + Redis,前端 Vue 3 + Element Plus,Nginx 做静态资源与反向代理。这套组合谈不上新,但对医保结算这种"改一次要回归三个月"的场景反而合适:生态成熟、招人好招、出问题能搜到答案。缓存用 Redis 主要干两件事,一是三大目录的本地缓存,二是结算请求的幂等锁;消息队列是可选项,源码里用来异步推送对账结果,没有也能跑。
目录结构大致长这样,我按职责重新排了序:
medical-insurance-settlement/ ├── mi-common/ 金额、日期、身份证脱敏、统一返回体 ├── mi-api/ 对外 REST 接口,窗口、自助机、管理端都调这里 ├── mi-settlement/ 结算核心:预结算、正式结算、冲正、退费 ├── mi-insurance-adapter/ 医保平台适配:报文拼装、签名验签、超时重试、结果查询 ├── mi-catalog/ 三大目录(药品、诊疗项目、医用耗材)对照与缓存 ├── mi-reconcile/ 日终对账文件解析、差异单生成与处理 ├── mi-admin-web/ 管理端前端,目录维护、差异单处理 └── sql/ 建表脚本、初始化数据、对账样例文件值得说的是mi-common里那个金额工具类。这类系统里金额一律走BigDecimal,并且强制setScale(2, RoundingMode.HALF_UP),库表字段用DECIMAL(16,2)。有人图省事用double,单笔看不出来,一天几千笔下来对账差几分钱,查起来能把人逼疯。另外医保报文里的金额经常以"分"为单位的整数传输,适配层负责乘 100 转整、回来再除,这个换算必须收在一处,散在各处迟早翻车。
2.3 从零启动:依赖、配置与第一个可运行环境
先把环境跑起来再谈改造。需要本机具备 JDK 8 或 11、Maven 3.6+、MySQL 8、Redis 5+、Node 16+。数据库建库脚本在sql/下,按文件名顺序执行即可,里面有基础目录数据和一条用于自测的患者信息。
配置文件在mi-api/src/main/resources/application-dev.yml,关键项如下:
server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/mi_settle?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 # 本地开发用,上线走配置中心 redis: host: 127.0.0.1 port: 6379 database: 3 # 单独库,避免和本机其他项目串键 mi: insurance: base-url: http://127.0.0.1:9090/mock # 联调期指向 Mock 平台 timeout-ms: 3000 # 连接+读取总超时,超了必须走结果查询 retry-times: 1 # 只重试一次,且仅限连接异常 sign-key: dev-placeholder-key # 签名密钥,上线由密钥管理下发 org-code: H001 # 机构编码,报文头必填 settle: idempotent-ttl: 300 # 幂等锁 5 分钟,覆盖人工重试窗口启动顺序是先中间件后应用,命令很简单,但顺序错了会看到一堆连接超时的报错,容易被误判成代码问题:
# 1. 建库与初始化数据 mysql -uroot -p < sql/01_schema.sql mysql -uroot -p mi_settle < sql/02_init_data.sql # 2. 起 Mock 医保平台(联调用,独立进程,端口 9090) cd mi-insurance-adapter && mvn spring-boot:run -Dspring-boot.run.profiles=mock # 3. 起主应用 cd mi-api && mvn spring-boot:run -Dspring-boot.run.profiles=dev # 4. 前端 cd mi-admin-web && npm install && npm run dev跑通的标准不是"页面能打开",而是调一次预结算接口能拿到金额、调一次正式结算能落库、调一次冲正能把状态翻回去。这三个动作在mi-api的SettlementController里对应三个端点,Postman 集合放在sql/同级的http/目录下,导入就能用。参数说明放在报文注释里:patientId是本地患者主键,insuranceNo是医保侧人员编号,两者不能混用,混了就会查到别人的待遇。
3. 核心结算链路:一笔费用从录入到回盘的完整走位
3.1 三段式流程:预结算、正式结算、冲正
这套代码把结算拆成三段,而不是一个方法干到底,这是我认为最值得借鉴的设计。预结算只算不落账,给窗口报个"患者自付多少";正式结算才真正扣款并落库;冲正负责撤销,可能是退费,也可能是结算失败后的补偿。三段分开之后,每一段的失败处理策略可以不一样,预结算失败直接返回让收费员重试,正式结算失败必须走查证。
核心方法在SettlementService里,结构大概是这样:
@Service public class SettlementService { @Resource private InsuranceAdapter insuranceAdapter; @Resource private SettleMainMapper settleMainMapper; @Resource private IdempotentLock idempotentLock; /** * 正式结算 * @param req 结算入参,settleNo 由前端生成并保证全局唯一,用于幂等 * @return 结算结果,含医保流水号与基金支付金额 */ public SettleResult settle(SettleRequest req) { // 1. 幂等锁:settleNo 做 key,防止窗口连点与自助机重试产生两笔 if (!idempotentLock.tryLock("settle:" + req.getSettleNo(), 300)) { throw new BizException("结算处理中,请勿重复提交"); } try { // 2. 先查本地是否已存在,存在直接返回上次结果,这是第二道保险 SettleMain exist = settleMainMapper.selectBySettleNo(req.getSettleNo()); if (exist != null && exist.getStatus() == SettleStatus.SUCCESS) { return SettleResult.from(exist); } // 3. 本地先落"处理中",状态机只允许 处理中 -> 成功/失败 SettleMain main = buildMain(req); main.setStatus(SettleStatus.PROCESSING); settleMainMapper.insert(main); // 4. 调医保平台扣款。注意:远程调用不放在事务里 InsureSettleResp resp = insuranceAdapter.settle(req.toInsureRequest()); // 5. 拿到明确结果后才更新本地状态,这一步才开事务 if (resp.isSuccess()) { settleMainMapper.markSuccess(main.getId(), resp.getInsureSerialNo(), resp.getFundPay()); return SettleResult.success(resp); } settleMainMapper.markFail(main.getId(), resp.getCode(), resp.getMsg()); return SettleResult.fail(resp.getCode(), resp.getMsg()); } finally { idempotentLock.unlock("settle:" + req.getSettleNo()); } } }这段代码有三个点要解释清楚。第一,幂等键用的是前端生成的settleNo而不是数据库自增 ID,因为自增 ID 在插入前拿不到,等拿到就已经晚了;前端生成时用"机构码 + 日期 + 序列"的规则,保证跨天不冲突。第二,第 3 步插入"处理中"记录放在远程调用之前,这样即使进程在中途挂掉,留下的一条PROCESSING记录也能被定时任务扫到并触发查证,而不是凭空消失。第三,远程调用刻意不包在@Transactional里,数据库连接不跟着网络超时一起被长时间占用,这是这类系统最容易忽略的一条。
冲正的方法长得像反过来走一遍,但多一个前置校验:只允许对SUCCESS状态的单子发起冲正,并且insureSerialNo必须存在。冲正入参里的reason字段是有含义的,退费用一个码,结算失败补偿用另一个码,报文里必须区分,否则医保侧统计口径会乱。
3.2 医保适配层:报文拼装、签名与超时重试
适配层是这套代码里最"脏"的一块,但也是隔离做得最好的一块。它对外只暴露settle、query、cancel三个方法,内部封装了报文头拼装、字段映射、签名、HTTP 调用、结果解析。上层业务完全看不到input节点里那几十个字段名。
签名和超时处理是重点,看这段:
@Component public class InsuranceAdapterImpl implements InsuranceAdapter { @Resource private RestTemplate restTemplate; @Value("${mi.insurance.sign-key}") private String signKey; @Value("${mi.insurance.org-code}") private String orgCode; @Override public InsureSettleResp settle(InsureSettleRequest req) { // 1. 业务字段转医保报文,金额由元转分,必须是整数 Map<String, Object> body = new LinkedHashMap<>(); body.put("orgCode", orgCode); body.put("psnNo", req.getInsuranceNo()); body.put("medfeeSumamt", toFen(req.getTotalAmount())); // 元 -> 分 body.put("fundPaySumamt", toFen(req.getFundPay())); body.put("setlTime", DateUtil.format(new Date(), "yyyyMMddHHmmss")); // 2. 签名:业务体按 key 字典序拼接后加盐,放报文头,不改业务体 String sign = SignUtil.md5(sortedJoin(body) + signKey); HttpHeaders headers = new HttpHeaders(); headers.set("X-Sign", sign); headers.setContentType(MediaType.APPLICATION_JSON); try { ResponseEntity<String> resp = restTemplate.postForEntity( "/settle", new HttpEntity<>(body, headers), String.class); return InsureSettleResp.parse(resp.getBody()); } catch (ResourceAccessException e) { // 3. 超时/连接异常:状态未知,绝不能直接判失败,走结果查询 return querySettleResult(req.getSettleNo()); } } /** 超时后的查证:同一个 settleNo 查三次,间隔递增 */ private InsureSettleResp querySettleResult(String settleNo) { for (int i = 0; i < 3; i++) { try { Thread.sleep(500L * (i + 1)); InsureSettleResp r = doQuery(settleNo); if (r.isSettled()) { return r; // 查到已结算,按成功处理 } } catch (Exception ignored) { // 查询本身失败不抛,继续下一次 } } return InsureSettleResp.unknown(); // 三次都查不到,交人工 } }toFen这个方法看着不起眼,实际是整个适配层里最容易出问题的地方。BigDecimal乘 100 之后必须setScale(0, RoundingMode.HALF_UP)再转long,直接intValue()会截断,0.29 元变成 28 分,对账时差一分钱,查一天。签名这块,业务体保持原样、签名单独放头,好处是排查问题时可以直接看报文,不用先验一遍签名才能读。
retry-times配成 1 是有讲究的。连接异常可以重试,因为请求根本没发出去;读超时不能重试,因为请求可能已经到对方并扣了款,再发就是重复结算。所以这里的策略是:连接异常重试一次,读超时直接走查询。这个区分在代码里靠捕获的异常类型判断,ConnectException和SocketTimeoutException要分开 catch,合成一个Exception就分不出来了。
3.3 三大目录对照:药品、诊疗、耗材怎么落到医保编码
费用明细里的项目是本地编码,医保结算必须换成医保编码,中间靠一张对照表。这套代码的匹配逻辑有三层优先级:先按本地编码精确匹配,再按本地名称 + 规格匹配,最后才是人工维护的兜底映射。前两层自动,第三层靠管理端维护,这是常见做法,因为目录差异永远存在。
public String matchInsuranceCode(ChargeItem item) { // 1. 精确匹配:本地编码 + 机构编码,命中率最高 CatalogMap m = catalogMapper.selectByLocalCode(item.getOrgCode(), item.getLocalCode()); if (m != null && m.getStatus() == 1) { return m.getInsuranceCode(); } // 2. 名称 + 规格匹配:处理本地编码变更但药品没换的情况 m = catalogMapper.selectByNameAndSpec(item.getOrgCode(), item.getName(), item.getSpec()); if (m != null && m.getStatus() == 1) { return m.getInsuranceCode(); } // 3. 都没有:抛业务异常,让窗口提示"该项目未维护医保对照" // 不要返回 null 让它往下走,否则报错会出现在很深的地方 throw new BizException("目录未对照:" + item.getName() + " / " + item.getSpec()); }status字段是软删除标记,对照关系下线不能物理删,否则历史单子回查会找不到编码。这一步的坑在于:目录匹配发生在结算前,匹配不上就直接中断,别指望"先结算再补对照",医保侧根本不收没有编码的明细。联想、模糊匹配这类做法在这套代码里没有,也不建议加,匹配错了比匹配不上更麻烦,退费冲正的成本高得多。
4. 数据模型与日终对账:钱和账必须能对上
4.1 四张核心表的设计取舍
结算相关的表不用多,四张够用:结算主表、结算明细表、交易流水表、目录对照表。主表和明细表是一对多,流水表记录每一次和医保平台的交互(请求、响应、耗时、结果),目录对照表前面说过。建表语句里几个关键点值得单独拎出来:
CREATE TABLE settle_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, settle_no VARCHAR(40) NOT NULL COMMENT '本地结算号,前端生成,全局唯一', patient_id BIGINT NOT NULL COMMENT '本地患者ID', insurance_no VARCHAR(40) NOT NULL COMMENT '医保人员编号', total_amount DECIMAL(16,2) NOT NULL DEFAULT 0.00 COMMENT '费用总额(元)', fund_pay DECIMAL(16,2) NOT NULL DEFAULT 0.00 COMMENT '基金支付(元)', self_pay DECIMAL(16,2) NOT NULL DEFAULT 0.00 COMMENT '个人自付(元)', insure_serial VARCHAR(64) DEFAULT NULL COMMENT '医保返回流水号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败 3已冲正', settle_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_settle_no (settle_no), UNIQUE KEY uk_insure_serial (insure_serial), KEY idx_patient_time (patient_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算主表';uk_settle_no是幂等的最后一道防线,代码里的锁可能因为 Redis 抖动失效,数据库唯一索引不会。uk_insure_serial允许为空但唯一,作用是防止同一笔医保流水被记录两次,MySQL 的唯一索引对多个 NULL 是放行的,正好符合"处理中的单子还没有流水号"这个状态。idx_patient_time服务于窗口最常做的查询——按患者查最近结算记录,没有这个索引,几千条数据就开始明显卡顿。
明细表除了金额,还要存insurance_code和catalog_source,前者是对照后的医保编码,后者标记这条明细是自动匹配还是人工维护的。出问题时看catalog_source就能判断是目录没维护还是匹配逻辑有 bug,省掉很多来回问。
4.2 对账文件解析与差异单处理
日终对账是这类系统的收尾动作,也是最能体现工程质量的地方。流程是:从医保平台下载当日结算文件,逐条和本地主表比对,一致的标记已对账,不一致的生成差异单。文件格式常见两种,定长和分隔符,这套代码两种都支持,靠配置切换。
public ReconcileResult reconcile(File file, LocalDate bizDate) { List<ReconcileRecord> records = parser.parse(file); // 按配置选定长或分隔解析 int match = 0, diff = 0; for (ReconcileRecord r : records) { // 1. 以医保流水号为主键比对,没有流水号的本地单直接进差异 SettleMain local = settleMainMapper.selectByInsureSerial(r.getInsureSerial()); if (local == null) { diffService.save(DiffType.LOCAL_MISSING, r, bizDate); diff++; continue; } // 2. 金额比对必须用 compareTo,不能用 equals // DECIMAL 读出来的 scale 可能不一致,equals 会误判 if (local.getFundPay().compareTo(r.getFundPay()) != 0) { diffService.save(DiffType.AMOUNT_MISMATCH, r, bizDate); diff++; continue; } settleMainMapper.markReconciled(local.getId()); match++; } // 3. 反向扫一遍:本地有、文件里没有的,属于"我方认为成功对方没收到" List<SettleMain> onlyLocal = settleMainMapper.selectUnreconciled(bizDate); for (SettleMain m : onlyLocal) { diffService.save(DiffType.PLATFORM_MISSING, m, bizDate); diff++; } return new ReconcileResult(match, diff); }第 2 步的compareTo是我踩过的坑:BigDecimal的equals连 scale 一起比,1.0和1.00是不相等的,可金额上它们明明一样。库里是DECIMAL(16,2),解析出来的字符串如果带三位小数,转成BigDecimal就是1.000,equals直接判不等,一整天全是差异单。
差异单不是生成完就完事,得有处理入口。管理端给差异单配了三种处理动作:确认无差异(人工核对后核销)、发起补记(本地缺,补一条)、发起冲正(重复结算,退掉)。每种动作都写操作日志,谁处理的、什么时候、依据什么,都要留痕。这部分代码量不大,但没有它,前面的对账等于白做。
5. 踩坑排查:结算链路上五个真实翻车点
5.1 状态与事务类问题
现象一:窗口提示结算失败,收费员重试后成功,但医保平台显示当天该患者结算了两次。
原因是超时被当成了失败。第一次请求其实已经到了对方,扣款成功,只是响应在回程超时;本地收到异常直接标失败,用户重试又发了一笔。解决方式是把"状态未知"和"确定失败"分开:捕获读超时后不落失败态,落"处理中"并立即触发结果查询,查三次仍无结果才交给人工,同时给这笔单子打上待查证标记,次日对账时优先处理。代码里InsureSettleResp.unknown()就是给这个场景留的口子。
现象二:本地事务回滚了,医保侧却已经扣款。
原因是把远程调用包进了@Transactional。远程调用成功、后续本地更新抛异常,事务一回滚,本地什么都没有,医保侧钱已经扣了。解决方式是把远程调用移到事务外,先落"处理中",拿到结果后再开一个短事务更新状态;如果更新失败,那条"处理中"记录还在,定时任务能扫到并补偿。这条改动看起来只是挪了个注解位置,实际是整个链路稳定性的分水岭。
现象三:患者连点两次确认,产生两笔结算。
原因是幂等只做了前端置灰。网络慢的时候按钮置灰之前就已经发出去了,自助机上更没有置灰这回事。解决方式是服务端三层防护:Redis 锁按settleNo加,数据库settle_no唯一索引兜底,方法入口再查一次已有记录直接返回。三层里任意一层生效都能挡住,只靠一层迟早出事。锁的过期时间别设太短,人工重试窗口一般几十秒,配 300 秒比较稳妥。
5.2 数据与解析类问题
现象四:对账差异单里全是金额不一致,差的全是 0.01。
除了前面说的BigDecimal.equals问题,还有一个来源:明细逐条累加和主表总额对不上。费用明细里每条金额各自四舍五入,累加起来和先加再四舍五入的结果可能差一分。解决方式是统一口径:明细存原始精度,汇总时先在BigDecimal上累加、最后一次性setScale(2, HALF_UP),别在循环里每条都舍入。库表字段给到DECIMAL(16,4)存明细,主表DECIMAL(16,2)存汇总,两边都留了余地。
现象五:对账文件解析出来中文全是乱码,或者金额普遍大了一百倍。
乱码是编码问题,医保侧文件常见 GBK,代码里默认按 UTF-8 读,必乱。解析器要显式指定Charset.forName("GBK"),并且把这个编码做成配置项,不同对接方给的文件编码不一定一样。金额大一百倍是单位问题,有的文件金额字段单位是分,有的是元,光看数字看不出来,得对着文件说明或者拿一笔已知单子反推。我在解析器里加了个启动自检:读第一条记录,如果金额量级明显异常(比如超过一千万),直接抛异常拒绝解析,总比默默导进去一片错误数据强。
6. 二次开发:把结算规则从代码里拔出来,做成可配置的
改这套代码最常见的需求是"结算规则要变"。今天加一个特殊人群的报销比例,明天某个项目要单独计算自付,如果每次都改SettlementService加if,三个月后这个方法会变成八百行,谁都不敢动。我的改造思路是把规则外置成配置加策略,代码只负责调度。
第一步,建一张规则配置表,字段包括规则编码、适用条件(险种、人员类别、项目类型,用 JSON 存)、计算方式、优先级、生效时间。第二步,定义一个策略接口:
public interface SettleRule { /** 规则编码,与配置表对应 */ String code(); /** 是否命中该规则 */ boolean match(SettleContext ctx); /** 计算自付金额,返回值参与后续汇总 */ BigDecimal calculate(SettleContext ctx); }具体规则实现类用@Component注册,启动时扫进一个Map<String, SettleRule>,调度器按配置表里的优先级依次执行命中的规则。这样新增一条规则只需要写一个类加一条配置,不用碰主流程,回归范围也小得多。
改造有风险,验证方法是拿历史单子回放。从settle_main和明细表里导出最近三个月已对账成功的结算单,写一个回放程序,用新规则重算一遍,逐单对比self_pay和fund_pay。差异为 0 才允许上线,有差异的单子逐条看是规则变了还是 bug。这一步我一般会跑两遍,一遍全量,一遍只跑金额大于某个阈值的单子,因为大额单子出问题影响最大。
从那以后我每次动结算核心,都强制走一遍"导出历史单、回放、逐笔比对"这个流程,哪怕只改了一行金额舍入。规则外置之后代码是好改了,但"好改"和"改对"是两件事,中间隔着的就是这套回放验证。希望帮到你。
本文还有配套的精品资源,点击获取