ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

医保结算系统源码拆解:规则引擎、幂等与状态机实践

医保结算系统源码拆解:规则引擎、幂等与状态机实践

简介:这份昌吉州医保结算系统(实施版)资料包面向医保信息化实施人员、医疗机构接口开发与运维学习者,聚焦参保信息管理、费用审核报销、智能审核规则、HIS接口对接及跨区域结算等业务场景,可用于了解地方医保结算流程、系统构成与实施要点。压缩包共3个文件,包含1个htm说明页、1个txt下载说明和1个pbl系统库文件,整体约929KB,便于快速查阅安装指引、功能说明与程序结构线索。已有1837人学习下载,在医保结算实施与入门参考方面具有一定关注度。读者可借助说明文档梳理系统接入与日常结算事务,结合pbl文件观察实施版程序组织,为医保政策落地、费用审核和接口联调提供参考,适合作为地方医保项目资料归档与学习样本。

1. 医保结算系统源码包:一笔门诊结算要过几道关

很多人对医保结算的想象是「总金额乘一个报销比例」,真拿一张门诊处方拆开跑,要在目录匹配、甲乙丙分类、起付线累计、封顶线截断、逐条取整这五道关口各走一遍,任何一道顺序错了,最终金额就差几块到几十块。这份源码包就是把这些关口摊开写清楚的实现:参保人参保信息、药品/诊疗/耗材三大目录、报销政策配置、结算单生成、冲正退费、日终对账,一条链路从接口到落库都能在本地跑起来。适合三类人:要接手计费模块的后端、想给自家产品补一个规则引擎的开发者、需要给团队做技术预研的负责人。它不是只给架构图的样板,规则表、状态机、幂等控制都有对应可执行代码。

2. 目录与数据模型:先把六张核心表和模块边界摸清

拿到压缩包后第一件事不是急着mvn spring-boot:run,而是先看清模块切分和表结构。原因是结算类系统最容易出问题的地方,都藏在模块边界上:规则层和记账层混在一起,改一次政策要动三四个 Service;目录层不区分版本,历史单据重算就会漂移。先花二十分钟把边界摸清,后面省下来的排查时间是以天计的。

2.1 模块边界:规则层为什么必须独立成无状态模块

包里默认给的是 Java 主栈,Spring Boot 加 MyBatis 加 MySQL,前端是 Vue3 加 Element Plus,规则层单独抽了一个module-rule。这个拆法的关键不在于分层好看,而在于规则模块是无状态的:输入一个结算上下文(参保人、就诊类型、业务日期、政策版本、已累计额度),输出一组明细计算结果,中间不查库、不写库、不碰事务。

insurance-settlement/ ├── module-common/ # 枚举、金额工具类、脱敏注解 ├── module-catalog/ # 三大目录与目录版本管理 ├── module-rule/ # 报销规则引擎,纯计算、无状态 ├── module-settle/ # 结算主流程、状态机、幂等控制 ├── module-account/ # 冲正、退费、日终对账 └── web-api/ # 对外接口层,只做参数校验与组装

无状态带来的直接好处是可回放。线上出现金额争议时,把当时的上下文 JSON 存下来,直接喂给规则模块重算,结果必须完全一致;如果规则层里夹着select查当前目录、查当前政策,重算结果一定和当时不一样,因为你查的是「现在」而不是「当时」。我一般会要求规则模块对外只暴露一个SettleCalculator#calc(SettleContext)方法,任何依赖都从上下文传进去,不允许在计算过程里发 SQL。

module-catalog负责目录版本,module-settle负责状态流转和落库,module-account只处理反向业务。三者的调用方向是单向的:settle 依赖 rule 和 catalog,account 依赖 settle,反过来一律不允许。这条约束建议在 CI 里用依赖检查插件卡住,否则半年后一定会有人图省事从 rule 里反向调 settle 的 Mapper。

2.2 六张核心表:从参保人到结算单的完整链路

表不在多,在于每张表的职责不能被侵蚀。包里核心是六张表,加两张辅助表(操作日志、对账批次),下面是关键字段和设计意图。

表名作用关键字段设计意图
member参保人档案member_no、insure_type、annual_cap_used年度已用额度单独存,避免每次结算全量汇总
catalog_item三大目录项catalog_code、charge_class、class_self_ratio甲乙丙分类与乙类先行自付比例挂在目录上
catalog_version目录版本version、effect_start、effect_end按业务日期取生效版本,保证历史可追溯
rule_policy报销政策version、deductible、ratio、annual_cap政策即数据,改政策不改代码
settle_order结算单主表order_no、idem_key、total_amount、fund_pay、status金额全部存分;幂等键加唯一索引
settle_detail结算明细order_id、catalog_code、fund_pay、self_pay逐条留痕,尾差调整也落在这里

结算单主表的建表语句值得单独看一眼,几个细节后面几章都会用到。

CREATE TABLE settle_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '结算单号,业务唯一', idem_key VARCHAR(64) NOT NULL COMMENT '幂等键:就诊号+结算类型+业务日期', member_id BIGINT NOT NULL, visit_type TINYINT NOT NULL COMMENT '1门诊 2住院', biz_date DATE NOT NULL COMMENT '业务日期,不是创建时间', total_amount BIGINT NOT NULL COMMENT '总金额,单位:分', fund_pay BIGINT NOT NULL COMMENT '基金支付,单位:分', self_pay BIGINT NOT NULL COMMENT '个人自付,单位:分', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待结算 1已结算 2已冲正 3已退费', policy_ver VARCHAR(20) NOT NULL COMMENT '政策版本,出账后必须可追溯', catalog_ver VARCHAR(20) NOT NULL COMMENT '目录版本,同上', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_idem (idem_key), UNIQUE KEY uk_order_no (order_no), KEY idx_member_biz (member_id, biz_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

金额字段统一BIGINT存分,这条如果一开始不定死,后面全是血泪。biz_date和created_at是两个概念:业务日期由就诊时间决定,创建时间是落库时间,跨零点结算时两者可能差一天,政策和目录版本必须按biz_date取。policy_ver和catalog_ver冗余在主表上是刻意的,出账之后的单据要想说清楚「当时按什么算的」,只能靠这两列。uk_idem是幂等的最后一道防线,第 4 章会细说。

2.3 本地跑通:三步冒烟测试与参数含义

环境要求不复杂:JDK 17、Maven 3.8 以上、MySQL 8.0、Redis 6 以上(只用做幂等前置缓存和字典缓存,不做持久化)。下面三步走完,你会得到一笔完整的门诊结算结果。

# 1. 建库并导入表结构与样例数据 mysql -uroot -p123456 -e "CREATE DATABASE IF NOT EXISTS insurance DEFAULT CHARSET utf8mb4" mysql -uroot -p123456 insurance < sql/schema.sql mysql -uroot -p123456 insurance < sql/init_catalog.sql # 三大目录样例,含甲乙丙各若干条 mysql -uroot -p123456 insurance < sql/init_policy.sql # 门诊/住院两套示例政策

导入顺序不能反,init_catalog.sql里带了外键约束会校验catalog_version是否存在。样例目录数据量不大,三十来条,但特意覆盖了甲类、乙类(带先行自付比例)、丙类三种情况,方便你验证分类逻辑。

# 2. application-local.yml 里必须对齐的三处配置 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/insurance?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 settle: policy-version: P20240101 # 必须与 rule_policy.version 完全一致,否则规则加载为空 catalog-version: C20240101 # 同上,对不上会在目录匹配阶段抛 CatalogNotFoundException rounding-mode: HALF_UP # 取整方式全链路只允许在这里改 amount-unit: CENT # 分;切成 YUAN 只影响出参展示,不动库

policy-version和catalog-version这两项是新手上手时最常翻车的地方,配置文件和库里的版本号差一个字符,启动不报错,一调接口才抛异常,而且异常信息里往往只有编码没有版本,看着很懵。建议在启动时加一个ApplicationRunner校验这两项在库里存在,早失败远比晚失败省事。

# 3. 起服务并打一发门诊结算 mvn -pl web-api -am spring-boot:run curl -X POST http://127.0.0.1:8080/api/settle/outpatient \ -H 'Content-Type: application/json' \ -H 'X-Idem-Key: OP20240115000001-OUT-20240115' \ -d '{ "memberId": 1001, "visitNo": "OP20240115000001", "visitType": 1, "bizDate": "2024-01-15", "items": [ {"catalogCode": "DRUG001", "qty": 2, "unitPrice": 1250}, {"catalogCode": "TREAT007", "qty": 1, "unitPrice": 8000} ] }'

几个参数单独说明。unitPrice单位是分,1250 表示 12.50 元,前端传元、后端转分这一步建议放在接口层的 DTO 转换里,别让规则模块去猜单位。X-Idem-Key是幂等键,格式建议「就诊号-结算类型-业务日期」,同一个键重复提交必须返回第一次的结果而不是新建一张单。bizDate不传会默认取服务器当天,跨零点场景下这就是个坑,所以样例里强制要求显式传。

正常返回会包含orderNo、totalAmount、fundPay、selfPay以及一组明细。拿样例数据算一遍:甲类药品 25.00 元全额进范围,乙类诊疗 80.00 元按 10% 先行自付,剩下 72.00 元进范围,再扣起付线、乘比例、封顶截断。如果你算出来的数和返回对不上,先别怀疑代码,回头检查init_policy.sql里的起付线和比例是不是被你改过。

3. 报销规则引擎:起付线、封顶线与三大目录怎么写成代码

结算系统的复杂度不在并发量,在规则。同样一笔费用,换个参保类型、换个就诊类型、换个目录版本,结果就完全不同,而这些组合在真实业务里是成百上千的。把规则写成一堆if-else的结局是:政策一调整就要改代码、发版、回归,改一次错一次。这一章讲清楚包里是怎么把政策变成数据、再把数据变成计算的。

3.1 规则表设计:政策即数据,代码只负责执行顺序

rule_policy表把可变部分全部外置。核心字段不多,但每个字段的含义必须在团队内对齐,否则后面每个人理解都不一样。

字段含义单位备注
version政策版本号字符串与配置项、结算单冗余字段一致
visit_type就诊类型枚举1 门诊、2 住院,同一版本下各一行
deductible起付线分按年度累计,不是每次重新扣
ratio报销比例万分比存 8500 表示 85%,避免浮点
annual_cap年度封顶线分基金支付累计上限
class_b_self_ratio乙类先行自付比例万分比也可放目录表,包里放目录表更灵活

比例用万分比存整数,是个小决定但很关键。用0.85这种小数存库,读出来再乘,一不小心就是精度问题;存8500,计算时先乘后除,全程整数运算,只在最后一步做取整。起付线按年度累计这一点也常被写错:不是每笔结算重新扣一次起付线,而是要把当年已进入范围的金额累加起来,只有当累计额跨过起付线之后的部分才开始报销。

3.2 计算顺序:一条明细从原价到个人自付的七个动作

顺序是这个模块里唯一不能商量的事情。换任何一个步骤的先后,结果都会变。包里固化的顺序是:目录匹配 → 金额计算 → 甲乙丙分类 → 乙类先行自付 → 起付线抵扣 → 比例报销 → 封顶线截断 → 尾差调整。

public SettleDetailResult calc(SettleContext ctx, OrderItem item) { // 1. 目录匹配:同一编码可能有多版本,按业务日期取生效版本,不是取最新版本 CatalogItem catalog = catalogService.match(item.getCatalogCode(), ctx.getBizDate()); // 2. 原始金额,全程整数分 long amount = item.getUnitPrice() * item.getQty(); // 3. 甲乙丙分类:丙类全额自付,不进基金,也不参与起付线累计 if (catalog.getChargeClass() == ChargeClass.C) { return new SettleDetailResult(amount, 0L, amount, amount); } // 4. 乙类先行自付:先按比例切出一块个人自付,剩下的才进报销范围 long preSelf = Math.round(amount * catalog.getClassSelfRatio() / 10000.0); long inScope = amount - preSelf; // 5. 起付线:只用进入范围的金额去抵扣剩余起付线额度 Policy p = policyService.get(ctx.getPolicyVersion(), ctx.getVisitType()); long remainDeductible = Math.max(0L, p.getDeductible() - ctx.getAccumulatedInScope()); long afterDeductible = Math.max(0L, inScope - remainDeductible); // 6. 比例报销 long fundPay = Math.round(afterDeductible * p.getRatio() / 10000.0); // 7. 封顶线截断:按年度已用额度算剩余,别用本次结算金额去比封顶线 long capLeft = Math.max(0L, p.getAnnualCap() - ctx.getAnnualFundUsed()); fundPay = Math.min(fundPay, capLeft); return new SettleDetailResult(amount, fundPay, amount - fundPay, preSelf); }

几个容易读漏的点。第 3 步丙类直接返回,是因为丙类既不能报销,也不应该占用起付线累计额度,很多实现把它算进inScope再靠比例 0 兜底,结果起付线被提前吃掉。第 4 步的除数是10000.0,因为比例按万分比存,这里用 double 只做一次性中间运算,最终Math.round回到整数分,不会累积误差;如果你介意,可以改成amount * ratio / 10000的整数除法加余数处理,包的注释里给了这种写法。

第 5 步的ctx.getAccumulatedInScope()是这一年度的累计进入范围金额,必须在结算主流程里查出来放进上下文,不能在规则模块里查库。第 7 步的封顶线判断要用「年度已用基金额度」而不是「本次基金支付」,这两个搞混是新人最常见的错误之一,会导致封顶线形同虚设。

3.3 取整与尾差:为什么明细合计必须等于结算单合计

逐条取整必然产生尾差。两条明细各 0.5 分,各自四舍五入上去,汇总就比真实值多 1 分;如果结算单合计是「先汇总再取整」算出来的,那明细加起来就对不上主表。财务侧看到这种差异会直接打回,所以必须在生成明细之后做一次强制对齐。

// 明细逐条取整后,把差额调整到基金支付金额最大的一条上 long detailSum = details.stream().mapToLong(SettleDetailResult::getFundPay).sum(); long diff = order.getFundPay() - detailSum; if (diff != 0L) { SettleDetailResult target = details.stream() .max(Comparator.comparingLong(SettleDetailResult::getFundPay)) .orElseThrow(() -> new IllegalStateException("明细为空")); // 差额可能是负数,直接对冲,同时同步调整该条的个人自付 target.setFundPay(target.getFundPay() + diff); target.setSelfPay(target.getSelfPay() - diff); }

为什么调到金额最大的一条,而不是最后一条?因为尾差通常是几分钱,调到最大的一条上,相对误差最小,也不会出现某条明细基金支付变成负数的尴尬场面。调整完之后必须再断言一次sum(detail.fundPay) == order.fundPay且sum(detail.selfPay) == order.selfPay,断言失败直接抛异常回滚,别让脏数据落库。

还有一条经验:取整方式(HALF_UP/HALF_EVEN/ 向下取整)全链路只允许配置一处。我见过一个项目,规则模块用HALF_UP、对账模块用向下取整,跑了一个月才发现每天的差异汇总里都有几毛钱的缺口,查了两天才定位到。这种问题不难修,难的是发现。

4. 结算事务与状态机:幂等、冲正、退费三件事怎么做扎实

结算写库这段是整个系统里最容易出生产事故的地方。用户手抖点两下、网关超时重试、定时任务重跑,任何一个都能造出重复结算单;退费流程写错,账目对不上,查起来要翻好几天的日志。这一章把包里这三块的实现摊开讲。

4.1 状态机:四个状态与五条合法迁移

状态不能随手update,必须收敛到一张迁移表里,任何不在表里的迁移一律拒绝。包里定义的状态是:0 待结算、1 已结算、2 已冲正、3 已退费。

当前状态允许迁移到触发动作说明
0 待结算1 已结算结算成功唯一入口,写主表与明细同事务
1 已结算2 已冲正冲正原单不再参与对账
1 已结算3 已退费退费部分退费另生成退费单,不改原单金额
2 已冲正1 已结算重新结算仅限当日冲正重结,跨日走新单
3 已退费——终态,不允许再变
public enum SettleStatus { PENDING(0), SETTLED(1), REVERSED(2), REFUNDED(3); private final int code; // 合法迁移白名单,新增迁移必须显式加进来并补单测 private static final Map<SettleStatus, Set<SettleStatus>> ALLOWED = Map.of( PENDING, Set.of(SETTLED), SETTLED, Set.of(REVERSED, REFUNDED), REVERSED, Set.of(SETTLED), REFUNDED, Set.of() ); public void checkTransition(SettleStatus target) { if (!ALLOWED.getOrDefault(this, Set.of()).contains(target)) { throw new IllegalStateException("非法状态迁移: " + this + " -> " + target); } } }

白名单写法比一堆if好维护的地方在于,评审时一眼能看完所有迁移路径,谁想偷偷加一条「已退费 → 已结算」的捷径,代码 review 时必然会被看到。状态更新语句本身也要带条件,update settle_order set status = 1 where id = ? and status = 0,靠影响行数判断是否抢到了这次迁移,影响行数为 0 说明状态已经被别人改过,直接走失败分支。

4.2 幂等:唯一索引兜底,前置查询只是优化

幂等要做三层,缺一层都不算稳。第一层是接口层的X-Idem-Key,进服务先查 Redis 有没有处理中的标记;第二层是落库前的select ... where idem_key = ?;第三层是数据库唯一索引uk_idem,这层是唯一可信的兜底,前两层只是减少无效写入。

@Transactional(rollbackFor = Exception.class) public SettleResponse settle(SettleRequest req, String idemKey) { // 第一层:Redis 占位,SETNX 失败说明有并发请求正在处理同一个键 Boolean locked = redis.opsForValue().setIfAbsent("settle:idem:" + idemKey, "1", Duration.ofMinutes(10)); if (Boolean.FALSE.equals(locked)) { return queryAndReplay(idemKey); // 回放已有结果,不是抛错 } try { // 第二层:查库,命中直接回放,避免重复计算 SettleOrder exist = orderMapper.selectByIdemKey(idemKey); if (exist != null) { return SettleResponse.from(exist); } SettleOrder order = doSettle(req, idemKey); return SettleResponse.from(order); } catch (DuplicateKeyException e) { // 第三层:并发下唯一索引冲突,回查后回放,绝不重试插入 SettleOrder exist = orderMapper.selectByIdemKey(idemKey); if (exist == null) throw e; return SettleResponse.from(exist); } finally { redis.delete("settle:idem:" + idemKey); } }

这段的关键在DuplicateKeyException的处理方式:捕获后必须回查并回放结果,而不是重试。重试会再撞一次唯一索引,日志里刷一片异常,业务侧还是一次失败。另外finally里删 Redis 键要小心,如果执行时间超过 10 分钟锁已经自然过期,删的是别人的锁,生产上建议把 value 存成请求唯一标识,删除前比对。

还有一种情况要考虑:同一个幂等键,第一次请求还在事务里没提交,第二次请求进来查库查不到,插入时撞唯一索引 —— 这时候回查可能也查不到(未提交不可见)。包里对这个的处理是回查加重试三次、每次间隔 200ms,超过就返回「处理中,请稍后查询」,让前端轮询。这个妥协比死等事务锁要好得多。

4.3 冲正与退费:只做反向记账,永不改历史

冲正和退费是两个概念,代码里必须分开。冲正是指原结算单整体作废(算错了、重复收的),退费是指患者实际退掉了部分或全部服务。两者的共同点是:原单的金额字段一个都不改,靠新增反向单据来对冲。

-- 冲正:原单置为已冲正,新增一张负数结算单,两单永远成对出现 UPDATE settle_order SET status = 2 WHERE id = #{originId} AND status = 1; INSERT INTO settle_order (order_no, idem_key, member_id, visit_type, biz_date, total_amount, fund_pay, self_pay, status, policy_ver, catalog_ver) SELECT CONCAT('REV', order_no), CONCAT('REV-', idem_key), member_id, visit_type, biz_date, -total_amount, -fund_pay, -self_pay, 1, policy_ver, catalog_ver FROM settle_order WHERE id = #{originId};

反向金额用负数存,好处是日终对账时直接sum(fund_pay)就自动对冲了,不需要在报表逻辑里区分正负单据。退费则更细:按明细退,生成一张退费单并把status置为 3,退费金额累加到参保人的年度已用额度上要做减法,但不能超过已用额度,这点必须在校验里卡住,否则退费退多了会把年度额度算成负数,后面所有结算全乱。

事务边界上,冲正和退费都必须在一个事务里完成「置原单状态 + 插反向单 + 回滚参保人额度」三件事,锁的顺序统一按member → settle_order来,避免两个并发冲正互相死锁。我一般会在member表上加一个版本号字段乐观锁,冲正时带上where version = ?,冲突了就让业务重试,比悲观锁省心。

5. 避坑排查:上线前必须自己踩一遍的五个坑

这一章的五个坑,都是真跑起来才会暴露的,光看代码看不出来。建议在做预研或交付前,按顺序手动复现一遍,比读十遍文档有用。

5.1 一分钱对不上:BigDecimal 和 double 混着用

现象:单条明细单独算是对的,多条明细汇总后结算单总额比明细合计多 1 分或 2 分,财务对账每天都有零星差异。

原因:规则层用的是整数分,但某个中间环节为了「精确」换成了BigDecimal或者double,两次取整叠加。更隐蔽的是new BigDecimal(0.1)这种构造方式,本身带进来的就是二进制误差。

解决:全链路只允许一种数值载体。包里定的是long存分,比例用万分比整数,只在最后一步Math.round。如果业务确实需要BigDecimal,构造必须用字符串new BigDecimal("0.1"),并且setScale(0, RoundingMode.HALF_UP)只能在出明细时调用一次。上线前加一条断言:sum(detail) == order,不成立直接抛异常。

5.2 目录版本切换当天,历史单据金额变了

现象:目录版本更新后的第二天,查询上个月的结算单重新打印,金额和当时不一样了。

原因:查询或重算时直接取「当前生效版本」,没有用结算单上冗余的catalog_ver。这在只读展示时通常无害,但只要涉及重新计算或对账,结果一定漂移。

解决:一切重算入口都从settle_order.catalog_ver和policy_ver读版本,作为参数传给规则模块,不允许在规则模块内部查「最新版本」。同时给catalog_version加上effect_start/effect_end双端区间,匹配时用biz_date between effect_start and effect_end,别只判<= now(),那样会出现两个版本同时生效的窗口。

5.3 同一笔结算被点了两次,生成两张单

现象:用户反馈「我就点了一次」,库里同一就诊号有两张已结算单,金额一样,基金被重复记账。

原因:前端防重复提交做了,但接口被网关重试绕过了;或者 Redis 锁的 key 里拼了 UUID,每次请求都不一样,等于没锁。

解决:幂等键必须来自业务,不能来自请求本身。格式统一为「就诊号-结算类型-业务日期」,由调用方传或者服务端按就诊号生成。三层防护要全上,尤其是数据库唯一索引这一层,没有它前面两层都是纸糊的。补充一点:DuplicateKeyException必须显式捕获并回放,否则用户看到的是 500,然后他会再点一次。

5.4 跨零点结算,政策日期取错

现象:晚上 23:58 挂号的急诊,结算发生在次日 00:03,结果套用了次日的政策版本,或者年度额度被算进了新的一年。

原因:代码里用了LocalDate.now()或者new Date()作为业务日期。业务日期应该由就诊时间决定,而不是处理器执行时间。

解决:所有涉及日期的地方统一从入参bizDate取,服务端只做格式校验不做补全,缺省值只在测试环境的配置里给。年度额度归属也按bizDate的年份算,不按创建时间。这条在年初年尾特别容易炸,12 月 31 日晚上那几笔跨零点的单子,是每年必出的经典故障。

5.5 日志里打了完整证件号,出事才发现

现象:排查问题翻日志,发现入参日志把参保人证件号、手机号全打出来了。

原因:用了log.info("req = {}", req)这种整对象打印,DTO 上没有脱敏注解。

解决:在module-common里定义@Sensitive注解加自定义JsonSerializer,对证件号、手机号、姓名做打码,序列化时自动生效。同时把所有打印整个请求对象的日志改成打印关键字段。这件事平时看着多余,等真出事的时候,它就是后悔药。

6. 回归验证:用构造数据集把结算结果钉死

规则类系统最怕的不是写错,是改错。今天调了一下起付线逻辑,明天加了一个乙类比例,如果没有一套固定的回归数据集,你永远不知道自己动坏了哪条链路。包里带了src/test/resources/cases目录,一份 YAML 描述一个场景,配一个期望结果,跑单测就是全量回归。

我的做法是先自己造五条覆盖边界的用例,把期望值手算出来写死在 YAML 里,然后再改代码。这五条是:正常门诊(跨越起付线)、乙类先行自付、起付线未达(基金支付应为 0)、封顶线截断(基金支付到顶)、冲正后退费额度回滚。

用例关键输入期望基金支付验证点
正常门诊甲类 200 元,起付线 50 元,比例 85%12750 分起付线抵扣正确
乙类先行自付乙类 100 元,先行自付 10%按 90 元进范围计算自付切分正确
起付线未达甲类 30 元,起付线 50 元0不越线不报销
封顶线截断年度已用接近封顶剩余额度不会超年度上限
冲正退费原单 12750 分全额冲正0反向单对冲干净

跑回归的方式很简单,mvn -pl module-rule test就能跑完所有用例,规则模块无状态的设计在这里体现价值:不需要起 Spring 上下文,不需要连库,纯计算几秒钟跑完,可以塞进每次提交前的钩子里。有条件的团队建议再加一条:把线上近三个月已结算单的上下文导出来做回放,重算结果与库中金额逐笔比对,差异为 0 才算通过。这条比任何单测都有说服力,因为它验的是真实数据分布。

最后说个教训。早些年我改一个取整规则,本地单测全绿,因为用例都是整数金额;上线后第二天对账发现几百笔差一分钱。从那以后我每次动金额相关的代码,都强制走一遍「构造五条边界用例 + 线上数据回放 + 明细合计断言」这三步,一步都不省。规则引擎这东西,快是快,但它不会提醒你哪里疼,只能靠你自己提前扎针。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进