首页 / 资讯中心 / 文章详情

医保结算系统源码拆解:幂等、对账与规则配置化

医保结算系统源码拆解:幂等、对账与规则配置化 ★ FEATURED ARTICLE
简介面向医保信息化实施与开发人员的医保结算系统实施版资料包聚焦昌吉州地区医保结算业务的落地场景可用于理解参保人员信息管理、医疗费用审核报销、智能审核规则、与医疗机构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 PlusNginx 做静态资源与反向代理。这套组合谈不上新但对医保结算这种改一次要回归三个月的场景反而合适生态成熟、招人好招、出问题能搜到答案。缓存用 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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.profilesmock # 3. 起主应用 cd mi-api mvn spring-boot:run -Dspring-boot.run.profilesdev # 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. 业务字段转医保报文金额由元转分必须是整数 MapString, 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 { ResponseEntityString 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) { ListReconcileRecord 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. 反向扫一遍本地有、文件里没有的属于我方认为成功对方没收到 ListSettleMain 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.000equals直接判不等一整天全是差异单。差异单不是生成完就完事得有处理入口。管理端给差异单配了三种处理动作确认无差异人工核对后核销、发起补记本地缺补一条、发起冲正重复结算退掉。每种动作都写操作日志谁处理的、什么时候、依据什么都要留痕。这部分代码量不大但没有它前面的对账等于白做。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注册启动时扫进一个MapString, SettleRule调度器按配置表里的优先级依次执行命中的规则。这样新增一条规则只需要写一个类加一条配置不用碰主流程回归范围也小得多。改造有风险验证方法是拿历史单子回放。从settle_main和明细表里导出最近三个月已对账成功的结算单写一个回放程序用新规则重算一遍逐单对比self_pay和fund_pay。差异为 0 才允许上线有差异的单子逐条看是规则变了还是 bug。这一步我一般会跑两遍一遍全量一遍只跑金额大于某个阈值的单子因为大额单子出问题影响最大。从那以后我每次动结算核心都强制走一遍导出历史单、回放、逐笔比对这个流程哪怕只改了一行金额舍入。规则外置之后代码是好改了但好改和改对是两件事中间隔着的就是这套回放验证。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站