最近我刚忙完一个定价与条件技术的代码适配改造,连续两周都在客户现场对价格逻辑,整个人都快被价目表淹没了。做这套东西之前,新来的同事一脸懵地问我:“条件技术是个啥?是数据库的索引吗?”我笑着说不是,它是把“什么条件下给什么价格”这件事变成一套可配置的规则引擎。接触过ERP和电商中台的朋友应该不陌生,商品的价格、折扣、附加费、运费,这些业务规则往往散落在成百上千个if-else里,改一处崩三处,这就是这次代码适配要解决的核心问题。
这篇文章围绕“Code adaption for Pricing and Condition Technique”这个项目展开,讲的不是某个具体产品的操作手册,而是把一套旧的、硬编码的价格逻辑,改造成统一的、条件驱动的定价与条件技术框架时,需要想清楚的设计思路、实操步骤、踩坑经验和排查技巧。适合正在做定价系统重构、规则引擎落地、或者准备把老报价逻辑收编成配置化体系的开发同学参考,也适合刚接手这类改造项目、需要快速建立全局认识的人。
1. 项目整体设计与适配思路拆解
1.1 条件技术到底在解决什么问题
很多人一听“条件技术”就觉得是SAP专属词汇,实际上它描述的是一套通用的规则匹配思想。你回想一下网购的场景:同一个商品,普通用户看是一个价,会员看是另一个价;买一件是一个价,买十件可能触发满减;再加上限时促销、优惠券、免运费,七七八八叠下来,这个价格到底怎么算出来的?传统做法是在代码里写死:
if (user.isMember()) { price = price * 0.9; } if (quantity >= 10) { price = price - 30; }前期规则少的时候这么写没问题,可随着渠道越来越多、促销活动越来越频繁,代码就会膨胀到没法维护。条件技术的思路是,把“判断条件”和“定价结果”拆开:条件放在数据表里,由运营或业务人员配置,代码只负责按规则查询和计算。这样改价格不用发版,不用动代码,项目经理高兴,开发也省心。
1.2 为什么要做代码适配,而不是直接推倒重写
当时内部也争论过,要不要干脆把老系统推翻,用一套全新的定价微服务替代。我投了反对票,理由很简单:老系统虽然代码烂,但它承载着真实的业务数据、历史订单和大量边界逻辑,直接重写风险极高,而且业务方不会给你三个月时间慢慢重构。代码适配的本质,是给老系统加一层“转换器”,让旧逻辑能平滑迁移到新的条件引擎上,中间还能随时回退。
这个思路放在代码层面就是典型的适配器模式加策略模式。对外暴露一个统一的定价接口,内部先走新的条件引擎;针对旧的硬编码逻辑,写一个适配器按老渠道的参数把它包装成同一个接口实现。新系统可以理解成“翻译官”,老系统还是说自己的方言,但对外交流时统一用普通话。这样改造过程就不需要一刀切,可以一个业务线一条业务线地灰度切换,切坏了也能立刻切回老的实现。
1.3 自研轻量条件引擎,而不是直接引入重量级规则引擎
做方案选型时,也考虑过引入Drools这类开源规则引擎。它们的表达能力确实很强,能写复杂的规则脚本,但引入后问题也不少:团队要学习新的DSL语法,规则文件的调试方式跟普通代码完全不同,而且性能调优需要专门的功夫。我们的场景没有复杂到需要推理机,核心诉求是“按条件查表、算价格”,用自研的轻量条件引擎反而更可控。
我把这个方案取了个朴素的模块名:PricingCore。它只做四件事:根据请求参数,按照配置好的访问顺序去查条件表,命中后拿到条件记录,再按条件类型做金额计算。整个链路一目了然,出问题也好定位。设计原则就一句话:“用简单的数据结构和清晰的分层,扛住复杂的业务规则。”这比引入一个你控制不住的庞然大物要实用得多。
2. 条件模型拆解:把业务规则翻译成可检索的数据结构
2.1 条件表设计:所有定价参数的“存身之所”
条件技术最核心的载体是条件表。你可以把它理解成一张巨大的价目表,每一行代表一条定价规则,包含:在什么范围内(客户、物料、组织、渠道)、在什么时间范围内(有效期)、给什么价格(金额或比例)、按什么方式计算(条件类型)。刚开始设计时,我差点把条件表做成一张“万能大宽表”,所有查询条件都塞进去,后来发现这会导致索引失效和查询缓慢,被性能压测打回了。
实际落地时,我采用了“拆表”的思路。基础条件表只存公共字段:条件类型、租户ID、有效期起止、状态、创建人。业务字段根据查询维度拆成独立的扩展表,例如客户价格条件表、客户物料价格条件表、数量折扣条件表,每张表都用条件类型ID关联回公共表。查询时先定位条件类型,再根据业务参数去对应的扩展表里精确匹配。这样设计的好处是索引很瘦,查询很快,而且新增一种业务维度不影响已有表结构。
2.2 访问顺序:价格的“查字典”路径
如果你查过纸质字典,就应该能理解访问顺序。字典不会一上来就给你页码,而是先按拼音找到声母、再找韵母、然后才定位到具体那一页。定价也一样,系统不是上来就直接查最终价格,而是按照预设的访问顺序,一级一级地缩小范围。比如渠道价格表排在前面,客户等级折扣排在后面,系统先查出渠道价格,再用这个结果作为下一步计算的基础。
在数据库里,访问顺序用两张表描述:定价过程表和访问顺序表。定价过程表定义了这个业务场景使用哪一套顺序,访问顺序表记录了每一步对应的条件类型和先后序号。计算时核心逻辑是循环访问顺序表的每一行,用当前上下文去匹配对应的条件记录,命中了就记录结果,然后继续下一步。这个优先级顺序一旦配错,表面上看不出问题,算出来的价格却可能天差地别,所以我在设计时特意加了一个顺序调整的审核日志,每次变更都有记录。
2.3 条件类型与计算结果:单价、折扣、附加费怎么落袋
条件类型决定了命中的条件记录如何参与价格计算。我把条件类型粗略分成四类:固定金额型(直接作为基础价或替代价)、百分比型(按百分比折扣)、加减金额型(附加费或减价)、阶梯型(根据数量区间取不同值)。别小看这个分类,它直接影响后端的计算引擎设计。比如固定金额型要覆盖之前的算出的金额,百分比型要在现有金额上做乘法,加减金额型则做加法或减法。
计算模式的差异,决定了条件类型不能只存一个数字,还要存计算方式标识和正负标识。很多项目搞混淆,把折扣用负金额存、附加费用正金额存,然后代码里写一堆if判断。我在这个项目里的做法是:每个条件记录带一个CalculationType枚举(BASE, PERCENT, SURCHARGE, DISCOUNT)和一个值,计算引擎根据枚举统一处理,不加任何业务判断。这样以后新增条件类型,只需要加枚举值和计算分支,不会动到主流程。
3. 代码适配落地:从旧价格逻辑平滑迁移到条件引擎
3.1 迁移前的盘点:把现有报价逻辑做成一张清单
动代码之前,我先花了两整天把老系统里的价格逻辑全翻出来,做成了一张Excel清单。清单包含几列:触发场景、当前代码位置、涉及的业务参数、计算规则描述、是否被其他模块调用。这一步看着笨,实际价值极大。不把现有规则盘清楚,后面设计条件表就是空中楼阁。
盘点后发现,老系统的价格规则大致能分成五类,就按这个清单映射到了条件类型:
| 原规则 | 触发场景 | 映射条件类型 | 计算方式 |
|---|---|---|---|
| 渠道标准价 | 所有订单 | 基础价BasePrice | 固定金额 |
| 客户等级折扣 | 按客户等级 | CustomerLevelDiscount | 百分比 |
| 数量满减 | 按订购数量 | QuantityRebate | 加减金额 |
| 限时促销 | 按活动时间 | PromotionDiscount | 百分比/金额 |
| 运费 | 按配送方式 | ShippingCharge | 固定金额 |
这个映射表就是整个适配改造的“施工图”,后面建表、写代码基本都对着它来。我建议每个做类似改造的人都先干这一步,别急着写代码。
3.2 定价服务核心实现:用一段可控的代码串起条件引擎
条件引擎的代码实现,我尽量保持简洁。对外暴露一个定价服务接口,输入是定价请求对象,输出是定价结果对象。核心计算过程是一个for循环,遍历访问顺序,调用条件查询器的通用方法,命中后交给计算器处理。下面是一段简化后的核心代码,去掉了缓存、日志等细节:
public PriceResult calculate(PriceRequest request) { PriceResult result = PriceResult.create(request.getBaseAmount()); // 1. 获取当前场景使用的定价过程号 String procedureId = pricingProcedureResolver.resolve(request.getScene()); // 2. 获取该过程的访问顺序列表 List<AccessSequenceItem> accessItems = accessSequenceLoader.load(procedureId); // 3. 按顺序依次匹配条件 for (AccessSequenceItem item : accessItems) { ConditionRecord record = conditionMatcher.match(request, item.getConditionType()); if (record == null) { continue; // 未命中,继续下一步 } // 4. 根据条件类型计算当前金额 PriceUpdater.update(result, record); } return result; }这段代码看起来平平无奇,但它把最复杂的业务规则都隔离到了数据配置层。一旦某个价格不对,我不需要翻几百行业务代码,只要看访问顺序配置和条件记录就行了。这才是条件技术的价值所在:用工程复杂度换取业务灵活度。
3.3 参数计算示例:叠加优惠时优先级怎么排
光讲概念不好懂,我拿一个真实场景演示计算过程。假设一个订单,商品基础价500元,渠道标准折扣10%,客户等级折扣再打95折,数量满5件减50,运费30元。访问顺序配置如下:
| 顺序号 | 条件类型 | 说明 |
|---|---|---|
| 0010 | 渠道标准折扣 | 百分比,在基础价上打折 |
| 0020 | 客户等级折扣 | 百分比,在前一步基础上打折 |
| 0030 | 数量满减 | 金额,直接减 |
| 0040 | 运费 | 金额,直接加 |
计算过程就是:基础价500乘以0.9得到450,再乘以0.95得到427.5,再减50得到377.5,再加30得到407.5。注意这里的顺序很重要:如果客户等级折扣放在渠道折扣前面,结果是一样的,因为乘法满足交换律;但一旦涉及金额减免,顺序不同结果就完全不同。尤其是满减和百分比叠加时,要先满减再打折,还是先打折再满减,业务上可能都有讲究,这就是访问顺序存在的意义。
所以我在代码里严禁直接在计算分支中做“凑数”式的调整,所有金额计算都严格按访问顺序迭代,每步结果都记录在PriceResult的分步明细里,方便事后核对。
3.4 老接口兼容与数据迁移:灰度切换的完整策略
即使有了条件引擎,老系统的存量订单、历史数据也不能丢。我的处理方式是老接口保留,但内部逻辑改成双模式:影子模式和切量模式。影子模式下,新引擎计算的结果只记录在日志里,不实际影响业务,用来和旧逻辑做对比验证;校验通过后,再把流量按比例切到新引擎。
双跑校验是我自己写的一段小工具,会把新旧两个计算结果都打出来,不一致时就报警。刚开始跑的时候,一天能报出几十条差异,后来一条条排查,基本都是边界数据的问题,比如老逻辑里“客户等级为空时默认按最低折扣”这种事,新引擎里忘了处理。把这些边界规则补全后,差异慢慢趋近于零,这时候才敢把流量真正切过去。这套灰度策略推荐给大家,比一次性大切换稳妥太多。
4. 常见问题与排障实录:那些踩过的坑和防坑技巧
4.1 缓存与性能:条件命中慢,先怀疑条件表查询
条件引擎上线后第一次压测,性能就崩了。原因是条件记录表数据量到了上百万行,直接在数据库里实时查询,每个订单查询几十次,数据库连接池直接被打满。优化思路分两层:第一层是给条件记录表加了复合索引,以条件类型、租户ID、业务维度、有效期为联合索引,查询效率提升了十几倍;第二层是把高频访问的条件记录放到Redis缓存里,设置过期时间自动刷新。
缓存设计也有个容易踩的坑:全量缓存初始化。我第一次做的时候,想着把整张条件表一次性加载到Redis,结果启动时直接卡死,花了二十多分钟还没加载完。后来改成“懒加载+版本号失效”策略:查询时先查缓存,缓存没有再去数据库加载单条或单批次条件记录,同时用条件表的数据版本号作为缓存key的一部分,版本号一变说明有更新,自然生成新的缓存,旧数据等过期自动淘汰。这个方案实测下来既能保证实时性,又不会出现缓存击穿。
4.2 金额精度:一分钱都不能差,别用浮点数
定价系统最敏感的就是金额精度。虽然代码里没多少人会用double算钱,但我在设计条件记录表时还是踩了一个隐形坑:折扣比例字段用了Decimal(5,4),结果遇到打98折这种比例,算出的金额带很多位小数。比如500乘以0.98等于490.0000000001,四舍五入到哪一位很关键。
我的建议是金额一律用BigDecimal,而且用字符串构造,不要直接用double构造,否则会出现“0.1+0.2不等于0.3”的经典问题。折扣比例统一按百分比整数存储,比如98折存成98,计算时先乘以98再除以100,避开二进制浮点数误差。金额舍入规则也要全系统统一,我用的是“四舍五入,保留两位小数”,并且所有金额计算都通过一个公共的MoneyCalculator工具类,不允许在其他地方自己做舍入。否则各写各的,线上的金额差异会折磨死你。
4.3 优先级配置混乱:配置表看不出错,价格就是不对
条件引擎上线后,你一定会遇到这种问题:明明条件表里的数据都正确,访问顺序也配了,算出来的价格却不对。排查下来往往发现是访问顺序序号重复,或者某两个条件类型的匹配范围有重叠,导致同一条订单命中了多条条件记录,而系统又不知道该选哪一条。
我的对策是写了一个配置自检脚本,每次修改完定价过程或访问顺序,就自动跑一遍自检:检查序号是否有重复、条件类型是否被重复配置、如果同一维度配置了多条条件记录是否存在交叉有效期的冲突。同时维护了一批业务测试用例,覆盖典型的价格场景,每次配置变更后都跑一遍,确保历史场景不回归。这套机制上线之后,因为价格配置错误导致的工单数量直线下降。
4.4 多租户与多环境隔离:一套代码,多套规则,千万别串
我们这套系统是SaaS化的,多租户共用一套代码,但每个租户的定价规则完全独立。最开始的实现里,条件表没有租户维度,导致A租户改价格,B租户的价格也跟着变,差点酿成事故。后来我把所有条件表和访问顺序表都加上了租户ID,并强制要求在查询条件的第一个条件就是租户ID。
不只数据库要隔离,Redis缓存的key也必须包含租户ID,否则同样会串。这里有个细节容易漏:条件引擎加载访问顺序时,第一次加载会把结果放在本地内存缓存中,如果不带租户ID,不同租户相同场景下会拿到同一份配置。我在应用启动时会打印一份当前生效的租户配置指纹,部署时一眼就能看出是否串了配置。多租户环境下的定价改造,建议在一开始设计模型时就把租户维度放进去,后面再加代价会非常大。
这里把实际排查中遇到过的典型问题整理成一张速查表,方便对照定位:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 价格完全没变化 | 条件引擎未启用或配置未生效 | 检查场景对应的定价过程ID和环境开关 |
| 某些客户价格错误 | 条件表匹配范围重叠 | 检查该客户命中了哪些条件记录 |
| 金额差几分钱 | 浮点运算或舍入规则不统一 | 查金额计算是否走了公共MoneyCalculator |
| 缓存刷新后价格不变 | 缓存key或版本号设计问题 | 检查缓存key是否包含租户ID和条件类型 |
| 新配置生效很慢 | 缓存过期时间过长 | 调整缓存失效策略为版本号驱动 |
说实话,条件技术的代码适配做下来,技术难点真没有想象中高,真正难的是把散落的业务规则一个个梳理清楚,再转化成合理的数据模型和计算顺序。我个人的体会是:不要急着写代码,先在纸上把现有的定价逻辑画成流程图,标出所有分支和边界条件,这个功夫花得越足,后面的改造就越顺。最后再分享一个小技巧,上线前把过去三个月的真实订单拉出来跑一遍回归,拿新旧两版计算结果做全量比对,差异清零了再切流量,能帮你省掉无数半夜被叫醒的麻烦。