《大营销平台系统设计实现》 - 营销服务 第8节:抽奖规则树模型结构设计

《大营销平台系统设计实现》 - 营销服务 第8节:抽奖规则树模型结构设计 一、本章诉求本章节需要引入新的设计模式结构解决先阶段中抽奖策略规则的中、后两部分执行问题。通过组合模式的规则引擎让过滤节点可以满足一颗二叉树的结构自由的组合和多分支链路的方式完成流程的处理。二、流程设计这里有一个矛盾点需要解决。对于抽奖策略的前置规则过滤是顺序一条链的有一个成功就可以返回。比如黑名单抽奖、权重人群抽奖、默认抽奖总之它只能有一种情况所以这样的流程是适合责任链的那么对于抽奖中到抽奖后的规则它是一个非多分支情况的规则过滤。单独的责任链是不能满足的如果是拆分开抽奖中规则和抽奖后规则分阶段处理中间单独写逻辑处理库存操作。那么是可以实现的。但这样的方式始终不够优雅配置化的内容较低后续的规则开发仍需要在代码上改造。所以这里小傅哥会带着大家实现一版组合模式的决策树模型设计三、功能实现1. 工程结构在策略领域模型下rule 规则部分添加 tree 规则树模型。「后续 filte 就会过删掉了只保存一个chain链路一个tree组合」责任链的链路执行有它本身的优势自身的实现就可以从一个链转入到下一个。那么对于普通策略规则的过滤一种是for循环顺序执行另外一种借助组合模式的思想创建出二叉树结构的调用链路关系。本节就是这种方式实现规则树模型。2.为什么从责任链继续演进到规则树第七节的责任链模式已经非常适合处理“线性流程”的前置规则黑名单命中就接管权重命中就接管否则继续往下传递最后走默认抽奖这个模型的特点是链路是单线的。但到了抽奖中和抽奖后规则关系开始变复杂。比如先判断次数锁是否放行如果不放行直接走兜底奖励如果放行再去做库存判断库存不足还要继续走兜底奖励库存充足才真正返回目标奖品这类规则已经不是“一个接一个顺序往下传”就能表达清楚了因为它开始出现分叉路径。责任链适合“顺着一条线走”而规则树更适合“根据不同判断结果走不同分支”。所以第八节并不是推翻责任链而是在责任链之后继续为更复杂的规则组合补上一种新的结构表达方式。3.新增规则树的基础值对象这一节先补的是一整套规则树的数据模型它们的作用不是直接执行业务而是把“树长什么样”描述清楚/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则树对象【注意不具有唯一ID不需要改变数据库结果的对象可以被定义为值对象】 * create 2024-01-27 10:45 */ Data Builder AllArgsConstructor NoArgsConstructor public class RuleTreeVO { /** 规则树ID */ private Integer treeId; /** 规则树名称 */ private String treeName; /** 规则树描述 */ private String treeDesc; /** 规则根节点 */ private String treeRootRuleNode; /** 规则节点 */ private MapString, RuleTreeNodeVO treeNodeMap; }这是整棵树的根对象里面描述了树 ID树名称树描述根节点 key整棵树的节点集合 treeNodeMap/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则树节点对象 * create 2024-01-27 10:48 */ Data Builder AllArgsConstructor NoArgsConstructor public class RuleTreeNodeVO { /** 规则树ID */ private Integer treeId; /** 规则Key */ private String ruleKey; /** 规则描述 */ private String ruleDesc; /** 规则比值 */ private String ruleValue; /** 规则连线 */ private ListRuleTreeNodeLineVO treeNodeLineVOList; }这个类描述的是单个节点包含当前节点属于哪棵树节点的 ruleKey节点描述节点自身配置值 ruleValue从这个节点出发能走到哪些线 treeNodeLineVOList例子ruleKey rule_stockruleValue award:107ruleKey rule_lockruleValue 1/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则树节点指向线对象。用于衔接 from-to 节点链路关系 * create 2024-01-27 10:49 */ Data Builder AllArgsConstructor NoArgsConstructor public class RuleTreeNodeLineVO { /** 规则树ID */ private Integer treeId; /** 规则Key节点 From */ private String ruleNodeFrom; /** 规则Key节点 To */ private String ruleNodeTo; /** 限定类型1:;2:;3:;4:;5;6:enum[枚举范围] */ private RuleLimitTypeVO ruleLimitType; /** 限定值到下个节点 */ private RuleLogicCheckTypeVO ruleLimitValue; }这是树里的“边”也就是节点和节点之间的连接关系。里面描述了从哪个节点来到哪个节点去用什么条件判断这条边能不能走命中的条件值是什么这是规则树比责任链更强的地方。责任链里“下一个是谁”是固定的规则树里“下一个是谁”要看当前判断结果。/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则限定枚举值 * create 2024-01-27 12:27 */ Getter AllArgsConstructor public enum RuleLimitTypeVO { EQUAL(1, 等于), GT(2, 大于), LT(3, 小于), GE(4, 大于等于), LE(5, 小于等于), ENUM(6, 枚举), ; private final Integer code; private final String info; }这个枚举是给树的边做条件类型定义的支持等于大于小于大于等于小于等于枚举/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则过滤校验类型值对象 * create 2024-01-06 11:10 */ Getter AllArgsConstructor public enum RuleLogicCheckTypeVO { ALLOW(0000, 放行执行后续的流程不受规则引擎影响), TAKE_OVER(0001,接管后续的流程受规则引擎执行结果影响), ; private final String code; private final String info; }RuleLogicCheckTypeVO 本质上是在统一表达一件事当前规则执行完之后流程下一步该怎么走。它现在只有两个值ALLOWTAKE_OVERRuleLogicCheckTypeVO 虽然只是一个包含 ALLOW 和 TAKE_OVER 的简单枚举但它在整个规则体系中承担了非常关键的统一语义作用。在过滤器模式下它用来表达当前规则是否放行在责任链模式下它的语义被内化为“继续传递”与“当前节点接管”而到了规则树模型中它又进一步承担了节点分支流转条件的角色。也正是因为有了这样一套统一的规则结果标识前置规则、中置规则以及树形决策流程才能在不同模式下保持一致的执行语义。4.新增规则树节点接口/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则树接口 * create 2024-01-27 11:14 */ public interface ILogicTreeNode { DefaultTreeFactory.TreeActionEntity logic(String userId, Long strategyId, Integer awardId); }和责任链节点相比这里的输入更像“抽奖中的上下文”userIdstrategyIdawardId返回值也不再是单纯的 awardId而是一个更完整的动作对象 TreeActionEntity这说明树节点的职责不是简单地“产出一个奖品”而是“做一次判断并告诉引擎本次判断结果是什么同时可附带奖品处理数据”。5. 新增规则树工厂/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则树工厂 * create 2024-01-27 11:28 */ Service public class DefaultTreeFactory { private final MapString, ILogicTreeNode logicTreeNodeGroup; public DefaultTreeFactory(MapString, ILogicTreeNode logicTreeNodeGroup) { this.logicTreeNodeGroup logicTreeNodeGroup; } public IDecisionTreeEngine openLogicTree(RuleTreeVO ruleTreeVO) { return new DecisionTreeEngine(logicTreeNodeGroup, ruleTreeVO); } /** * 决策树个动作实习 */ Data Builder AllArgsConstructor NoArgsConstructor public static class TreeActionEntity { private RuleLogicCheckTypeVO ruleLogicCheckType; private StrategyAwardData strategyAwardData; } Data Builder AllArgsConstructor NoArgsConstructor public static class StrategyAwardData { /** 抽奖奖品ID - 内部流转使用 */ private Integer awardId; /** 抽奖奖品规则 */ private String awardRuleValue; } }这个类是规则树体系的入口工厂。它和前面的责任链工厂很像也使用了private final MapString, ILogicTreeNode logicTreeNodeGroup;也就是说这里的树节点 Bean 也是由 Spring 自动收集的和责任链那套方式一致。不同点在于责任链工厂负责“按顺序拼链”规则树工厂负责“给定一棵树配置创建一个决策引擎”。它的核心方法是public IDecisionTreeEngine openLogicTree(RuleTreeVO ruleTreeVO)也就是说树不是在工厂里写死的而是由外部传入 RuleTreeVO工厂只负责把“节点集合 树配置”组装成一台可执行的引擎。这个类里还定义了两个内部数据对象TreeActionEntityStrategyAwardData这两个对象很关键TreeActionEntity 表达“节点执行结果”包括本次规则判断类型 ruleLogicCheckType可选的奖品处理数据 strategyAwardDataStrategyAwardData 表达的是更贴近业务的结果数据awardIdawardRuleValue这说明已经开始把“规则执行结果”和“奖品流转数据”分开包装为后续更复杂的树节点交互做准备。6.新增决策树引擎/** * author Fuzhengwei bugstack.cn 小傅哥 * description 决策树引擎 * create 2024-01-27 11:34 */ Slf4j public class DecisionTreeEngine implements IDecisionTreeEngine { private final MapString, ILogicTreeNode logicTreeNodeGroup; private final RuleTreeVO ruleTreeVO; public DecisionTreeEngine(MapString, ILogicTreeNode logicTreeNodeGroup, RuleTreeVO ruleTreeVO) { this.logicTreeNodeGroup logicTreeNodeGroup; this.ruleTreeVO ruleTreeVO; } Override public DefaultTreeFactory.StrategyAwardData process(String userId, Long strategyId, Integer awardId) { DefaultTreeFactory.StrategyAwardData strategyAwardData null; // 获取基础信息 String nextNode ruleTreeVO.getTreeRootRuleNode(); MapString, RuleTreeNodeVO treeNodeMap ruleTreeVO.getTreeNodeMap(); // 获取起始节点「根节点记录了第一个要执行的规则」 RuleTreeNodeVO ruleTreeNode treeNodeMap.get(nextNode); while (null ! nextNode) { // 获取决策节点 ILogicTreeNode logicTreeNode logicTreeNodeGroup.get(ruleTreeNode.getRuleKey()); // 决策节点计算 DefaultTreeFactory.TreeActionEntity logicEntity logicTreeNode.logic(userId, strategyId, awardId); RuleLogicCheckTypeVO ruleLogicCheckTypeVO logicEntity.getRuleLogicCheckType(); strategyAwardData logicEntity.getStrategyAwardData(); log.info(决策树引擎【{}】treeId:{} node:{} code:{}, ruleTreeVO.getTreeName(), ruleTreeVO.getTreeId(), nextNode, ruleLogicCheckTypeVO.getCode()); // 获取下个节点 nextNode nextNode(ruleLogicCheckTypeVO.getCode(), ruleTreeNode.getTreeNodeLineVOList()); ruleTreeNode treeNodeMap.get(nextNode); } // 返回最终结果 return strategyAwardData; } public String nextNode(String matterValue, ListRuleTreeNodeLineVO treeNodeLineVOList) { if (null treeNodeLineVOList || treeNodeLineVOList.isEmpty()) return null; for (RuleTreeNodeLineVO nodeLine : treeNodeLineVOList) { if (decisionLogic(matterValue, nodeLine)) { return nodeLine.getRuleNodeTo(); } } throw new RuntimeException(决策树引擎nextNode 计算失败未找到可执行节点); } public boolean decisionLogic(String matterValue, RuleTreeNodeLineVO nodeLine) { switch (nodeLine.getRuleLimitType()) { case EQUAL: return matterValue.equals(nodeLine.getRuleLimitValue().getCode()); // 以下规则暂时不需要实现 case GT: case LT: case GE: case LE: default: return false; } } }/** * author Fuzhengwei bugstack.cn 小傅哥 * description 规则树组合接口 * create 2024-01-27 11:33 */ public interface IDecisionTreeEngine { DefaultTreeFactory.StrategyAwardData process(String userId, Long strategyId, Integer awardId); }这是这一节最核心的代码。责任链解决的是“节点顺序怎么传”规则树引擎解决的是“从根节点开始怎么根据每次节点判断结果决定下一步走向”。process(...) 的执行流程是先从 RuleTreeVO 里拿到根节点 key根据根节点 key 从 treeNodeMap 里拿到节点配置根据节点的 ruleKey 去 Spring 收集好的 logicTreeNodeGroup 里找到真正的节点实现类调用节点的 logic(...) 计算出当前动作结果根据这个结果和当前节点的连线配置决定下一个节点是谁循环往下执行直到没有下一个节点为止最后返回最终的 StrategyAwardData这就是一个典型的树型决策流程。它比责任链多出来的关键能力在于责任链的下一步是固定的 next()决策树的下一步是通过 nextNode(...) 动态算出来的这里的 nextNode(...) 又依赖 decisionLogic(...) 去判断当前规则结果是否满足某条边的条件。当前实现里只支持 EQUAL也就是“当前节点返回的结果 code 是否等于某条边要求的值”。这套结构虽然现在还很初级但骨架已经完整了树配置节点接口节点实现引擎执行分支跳转都已经搭起来了7.新增三个规则树节点实现/** * author Fuzhengwei bugstack.cn 小傅哥 * description 次数锁节点 * create 2024-01-27 11:22 */ Slf4j Component(rule_lock) public class RuleLockLogicTreeNode implements ILogicTreeNode { Override public DefaultTreeFactory.TreeActionEntity logic(String userId, Long strategyId, Integer awardId) { return DefaultTreeFactory.TreeActionEntity.builder() .ruleLogicCheckType(RuleLogicCheckTypeVO.ALLOW) .build(); } }这个节点表示“次数锁判断节点”。当前实现非常简单直接返回 ALLOW。也就是说它现在更像一个占位实现用来验证“树能不能从锁节点继续往下走”而不是完整实现实际次数锁逻辑。这个设计能看出这一节的重点先把树模型和引擎跑通再逐步往节点里填业务。/** * author Fuzhengwei bugstack.cn 小傅哥 * description 库存扣减节点 * create 2024-01-27 11:25 */ Slf4j Component(rule_stock) public class RuleStockLogicTreeNode implements ILogicTreeNode { Override public DefaultTreeFactory.TreeActionEntity logic(String userId, Long strategyId, Integer awardId) { return DefaultTreeFactory.TreeActionEntity.builder() .ruleLogicCheckType(RuleLogicCheckTypeVO.TAKE_OVER) .build(); } }这个节点表示“库存处理节点”。它当前直接返回 TAKE_OVER也没有真正去扣库存。这也是一个典型的骨架节点说明此时树的目标主要还是验证路径流转而不是把库存能力彻底做完。/** * author Fuzhengwei bugstack.cn 小傅哥 * description 兜底奖励节点 * create 2024-01-27 11:23 */ Slf4j Component(rule_luck_award) public class RuleLuckAwardLogicTreeNode implements ILogicTreeNode { Override public DefaultTreeFactory.TreeActionEntity logic(String userId, Long strategyId, Integer awardId) { return DefaultTreeFactory.TreeActionEntity.builder() .ruleLogicCheckType(RuleLogicCheckTypeVO.TAKE_OVER) .strategyAwardData(DefaultTreeFactory.StrategyAwardData.builder() .awardId(101) .awardRuleValue(1,100) .build()) .build(); } }这个节点表示“兜底奖励节点”。它会返回TAKE_OVER一个固定的 StrategyAwardDataawardId 101awardRuleValue 1,100这个节点是三个里面最有业务意味的因为它已经开始返回“实际奖品数据”了。这说明在规则树语境下兜底奖励会成为某些分支路径的叶子节点一旦走到这里就直接产出最终奖励数据。8.抽奖装配算法修复之前遗留/** * author Fuzhengwei bugstack.cn 小傅哥 * description 策略装配库(兵工厂)负责初始化策略计算 * create 2023-12-23 10:02 */ Slf4j Service public class StrategyArmoryDispatch implements IStrategyArmory, IStrategyDispatch { Resource private IStrategyRepository repository; Override public boolean assembleLotteryStrategy(Long strategyId) { // 1. 查询策略配置 ListStrategyAwardEntity strategyAwardEntities repository.queryStrategyAwardList(strategyId); assembleLotteryStrategy(String.valueOf(strategyId), strategyAwardEntities); // 2. 权重策略配置 - 适用于 rule_weight 权重规则配置 StrategyEntity strategyEntity repository.queryStrategyEntityByStrategyId(strategyId); String ruleWeight strategyEntity.getRuleWeight(); if (null ruleWeight) return true; // TODO queryStrategyRule 方法名称限定只查询一个对象。目前可能造成别人调用查询list返回 StrategyRuleEntity strategyRuleEntity repository.queryStrategyRule(strategyId, ruleWeight); if (null strategyRuleEntity) { throw new AppException(ResponseCode.STRATEGY_RULE_WEIGHT_IS_NULL.getCode(), ResponseCode.STRATEGY_RULE_WEIGHT_IS_NULL.getInfo()); } MapString, ListInteger ruleWeightValueMap strategyRuleEntity.getRuleWeightValues(); SetString keys ruleWeightValueMap.keySet(); for (String key : keys) { ListInteger ruleWeightValues ruleWeightValueMap.get(key); ArrayListStrategyAwardEntity strategyAwardEntitiesClone new ArrayList(strategyAwardEntities); strategyAwardEntitiesClone.removeIf(entity - !ruleWeightValues.contains(entity.getAwardId())); assembleLotteryStrategy(String.valueOf(strategyId).concat(Constants.UNDERLINE).concat(key), strategyAwardEntitiesClone); } return true; } /** * 计算公式 * 1. 找到范围内最小的概率值比如 0.1、0.02、0.003需要找到的值是 0.003 * 2. 基于1找到的最小值0.003 就可以计算出百分比、千分比的整数值。这里就是1000 * 3. 那么「概率 * 1000」分别占比100个、20个、3个总计是123个 * 4. 后续的抽奖就用123作为随机数的范围值生成的值100个都是0.1概率的奖品、20个是概率0.02的奖品、最后是3个是0.003的奖品。 */ private void assembleLotteryStrategy(String key, ListStrategyAwardEntity strategyAwardEntities) { // 1. 获取最小概率值 BigDecimal minAwardRate strategyAwardEntities.stream() .map(StrategyAwardEntity::getAwardRate) .min(BigDecimal::compareTo) .orElse(BigDecimal.ZERO); // 2. 循环计算找到概率范围值 BigDecimal rateRange BigDecimal.valueOf(convert(minAwardRate.doubleValue())); // 3. 生成策略奖品概率查找表「这里指需要在list集合中存放上对应的奖品占位即可占位越多等于概率越高」 ListInteger strategyAwardSearchRateTables new ArrayList(rateRange.intValue()); for (StrategyAwardEntity strategyAward : strategyAwardEntities) { Integer awardId strategyAward.getAwardId(); BigDecimal awardRate strategyAward.getAwardRate(); // 计算出每个概率值需要存放到查找表的数量循环填充 for (int i 0; i rateRange.multiply(awardRate).intValue(); i) { strategyAwardSearchRateTables.add(awardId); } } // 4. 对存储的奖品进行乱序操作 Collections.shuffle(strategyAwardSearchRateTables); // 5. 生成出Map集合key值对应的就是后续的概率值。通过概率来获得对应的奖品ID MapInteger, Integer shuffleStrategyAwardSearchRateTable new LinkedHashMap(); for (int i 0; i strategyAwardSearchRateTables.size(); i) { shuffleStrategyAwardSearchRateTable.put(i, strategyAwardSearchRateTables.get(i)); } // 6. 存放到 Redis repository.storeStrategyAwardSearchRateTable(key, shuffleStrategyAwardSearchRateTable.size(), shuffleStrategyAwardSearchRateTable); } /** * 转换计算只根据小数位来计算。如【0.01返回100】、【0.009返回1000】、【0.0018返回10000】 */ private double convert(double min){ double current min; double max 1; while (current 1){ current current * 10; max max * 10; } return max; } Override public Integer getRandomAwardId(Long strategyId) { // 分布式部署下不一定为当前应用做的策略装配。也就是值不一定会保存到本应用而是分布式应用所以需要从 Redis 中获取。 int rateRange repository.getRateRange(strategyId); // 通过生成的随机值获取概率值奖品查找表的结果 return repository.getStrategyAwardAssemble(String.valueOf(strategyId), new SecureRandom().nextInt(rateRange)); } Override public Integer getRandomAwardId(Long strategyId, String ruleWeightValue) { String key String.valueOf(strategyId).concat(Constants.UNDERLINE).concat(ruleWeightValue); return getRandomAwardId(key); } Override public Integer getRandomAwardId(String key) { // 分布式部署下不一定为当前应用做的策略装配。也就是值不一定会保存到本应用而是分布式应用所以需要从 Redis 中获取。 int rateRange repository.getRateRange(key); // 通过生成的随机值获取概率值奖品查找表的结果 return repository.getStrategyAwardAssemble(key, new SecureRandom().nextInt(rateRange)); } }这个文件的改动虽然不属于规则树主线但很重要因为它修的是概率装配算法。旧逻辑是先算总概率和最小概率再用 totalAwardRate / minAwardRate 得到范围值然后用 setScale(..., CEILING) 去扩展每个奖品的占位数新逻辑改成单纯根据最小概率的小数位数算出一个合适的放大倍数比如 0.01 - 1000.009 - 10000.0018 - 10000然后再用这个倍数去生成概率表。新增的 convert(...) 方法本质上是在做“把最小概率转换成整数精度范围”的事。这个修正的意义是原来的算法在某些概率组合下可能会出现装配偏差现在的方式更直接按小数位精度来构建概率表思路更稳定。