一、本章诉求
在大营销平台的抽奖子模块中,需要满足用户抽奖N积分后,可中奖范围的设定。也就是说你总共消耗了6000积分抽奖了,那么接下来的抽奖就会有圈定到固定的奖品范围,不会让用户再抽到过低价值的奖品。那么这就需要我们在设计系统实现的时候,处理下不同策略规则权重的概率装配。
二、流程设计
在流程实现中,我们需要结合上一节中的整体概率装配,按照接口单一原则进行拆分出装配和使用。之后在装配接口中重构装配操作,满足对权重策略的装配处理。—— 这里会在实体对象中填充充血方法。
- 集合着梳理的系统设计流程,将后续需要用到的权重抽奖规则,进行提前装配处理。
- 所有装配的数据都会存放到 Redis Map 数据结构下。对于权重的策略装配为策略ID+权重值组合。
- 最终用户在从装配的工厂中执行抽奖的时候,则可以通过策略ID抽奖和策略ID+权重值组合的方式抽奖。
三、功能实现
1. 工程结构
- 首先,拆分装配接口,一个是装配接口,一个是调度接口。这样可以保持接口的但一直职责,避免使用方调用装配操作。装配是活动在创建或者审核的时候初始化的装配动作
- 本节会在实体对象中添加属于实体本身的功能,这样会让代码更加干净,也会体现出充血的作用
2.充血实体
/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略实体 * @create 2023-12-31 15:24 */ @Data @Builder @AllArgsConstructor @NoArgsConstructor public class StrategyEntity { /** 抽奖策略ID */ private Long strategyId; /** 抽奖策略描述 */ private String strategyDesc; /** 抽奖规则模型 rule_weight,rule_blacklist */ private String ruleModels; //获取全部规则 public String[] ruleModels() { if (StringUtils.isBlank(ruleModels)) return null; return ruleModels.split(Constants.SPLIT); } //获得规则权重那个规则 public String getRuleWeight() { String[] ruleModels = this.ruleModels(); for (String ruleModel : ruleModels) { if ("rule_weight".equals(ruleModel)) return ruleModel; } return null; } }package cn.bugstack.domain.strategy.model.entity; import cn.bugstack.types.common.Constants; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略规则实体 * @create 2023-12-31 15:32 */ @Data @Builder @AllArgsConstructor @NoArgsConstructor public class StrategyRuleEntity { /** 抽奖策略ID */ private Long strategyId; /** 抽奖奖品ID【规则类型为策略,则不需要奖品ID】 */ private Integer awardId; /** 抽象规则类型;1-策略规则、2-奖品规则 */ private Integer ruleType; /** 抽奖规则类型【rule_random - 随机值计算、rule_lock - 抽奖几次后解锁、rule_luck_award - 幸运奖(兜底奖品)】 */ private String ruleModel; /** 抽奖规则比值 */ private String ruleValue; /** 抽奖规则描述 */ private String ruleDesc; /** * 获取权重值 * 数据案例;4000:102,103,104,105 5000:102,103,104,105,106,107 6000:102,103,104,105,106,107,108,109 */ public Map<String, List<Integer>> getRuleWeightValues() { if (!"rule_weight".equals(ruleModel)) return null; String[] ruleValueGroups = ruleValue.split(Constants.SPACE); Map<String, List<Integer>> resultMap = new HashMap<>(); for (String ruleValueGroup : ruleValueGroups) { // 检查输入是否为空 if (ruleValueGroup == null || ruleValueGroup.isEmpty()) { return resultMap; } // 分割字符串以获取键和值 String[] parts = ruleValueGroup.split(Constants.COLON); if (parts.length != 2) { throw new IllegalArgumentException("rule_weight rule_rule invalid input format" + ruleValueGroup); } // 解析值 String[] valueStrings = parts[1].split(Constants.SPLIT); List<Integer> values = new ArrayList<>(); for (String valueString : valueStrings) { values.add(Integer.parseInt(valueString)); } // 将键和值放入Map中 resultMap.put(ruleValueGroup, values); } return resultMap; } }获得权重值map(
ruleValue(抽奖规则比值) 对应: 4000:102,103,104,105 5000:102,103,104,105,106,107 6000:102,103,104,105,106,107,108,109
ruleModel 对应:rule_weight"
)
3.接口拆分
将原来的一个接口拆分为俩个接口(一个是装配接口,一个是调度接口)实现单一职责
/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略抽奖调度 * @create 2023-12-31 15:15 */ public interface IStrategyDispatch { /** * 获取抽奖策略装配的随机结果 * * @param strategyId 策略ID * @return 抽奖结果 */ Integer getRandomAwardId(Long strategyId); Integer getRandomAwardId(Long strategyId, String ruleWeightValue); }增加了按照权重的抽奖策略
/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略装配库(兵工厂),负责初始化策略计算 * @create 2023-12-23 09:44 */ public interface IStrategyArmory { /** * 装配抽奖策略配置「触发的时机可以为活动审核通过后进行调用」 * * @param strategyId 策略ID * @return 装配结果 */ boolean assembleLotteryStrategy(Long strategyId); }然后这里实现俩个接口
之前是这样的,职责没有划分,不好管理
4.仓储功能新增
package cn.bugstack.infrastructure.persistent.repository; import cn.bugstack.domain.strategy.model.entity.StrategyAwardEntity; import cn.bugstack.domain.strategy.model.entity.StrategyEntity; import cn.bugstack.domain.strategy.model.entity.StrategyRuleEntity; import cn.bugstack.domain.strategy.repository.IStrategyRepository; import cn.bugstack.infrastructure.persistent.dao.IStrategyAwardDao; import cn.bugstack.infrastructure.persistent.dao.IStrategyDao; import cn.bugstack.infrastructure.persistent.dao.IStrategyRuleDao; import cn.bugstack.infrastructure.persistent.po.Strategy; import cn.bugstack.infrastructure.persistent.po.StrategyAward; import cn.bugstack.infrastructure.persistent.po.StrategyRule; import cn.bugstack.infrastructure.persistent.redis.IRedisService; import cn.bugstack.types.common.Constants; import org.springframework.stereotype.Repository; import javax.annotation.Resource; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略服务仓储实现 * @create 2023-12-23 10:33 */ @Repository public class StrategyRepository implements IStrategyRepository { @Resource private IStrategyDao strategyDao; @Resource private IStrategyRuleDao strategyRuleDao; @Resource private IStrategyAwardDao strategyAwardDao; @Resource private IRedisService redisService; @Override public List<StrategyAwardEntity> queryStrategyAwardList(Long strategyId) { // 优先从缓存获取 String cacheKey = Constants.RedisKey.STRATEGY_AWARD_KEY + strategyId; List<StrategyAwardEntity> strategyAwardEntities = redisService.getValue(cacheKey); if (null != strategyAwardEntities && !strategyAwardEntities.isEmpty()) return strategyAwardEntities; // 从库中获取数据 List<StrategyAward> strategyAwards = strategyAwardDao.queryStrategyAwardListByStrategyId(strategyId); strategyAwardEntities = new ArrayList<>(strategyAwards.size()); for (StrategyAward strategyAward : strategyAwards) { StrategyAwardEntity strategyAwardEntity = StrategyAwardEntity.builder() .strategyId(strategyAward.getStrategyId()) .awardId(strategyAward.getAwardId()) .awardCount(strategyAward.getAwardCount()) .awardCountSurplus(strategyAward.getAwardCountSurplus()) .awardRate(strategyAward.getAwardRate()) .build(); strategyAwardEntities.add(strategyAwardEntity); } redisService.setValue(cacheKey, strategyAwardEntities); return strategyAwardEntities; } @Override public void storeStrategyAwardSearchRateTable(String key, Integer rateRange, Map<Integer, Integer> strategyAwardSearchRateTable) { // 1. 存储抽奖策略范围值,如10000,用于生成1000以内的随机数 redisService.setValue(Constants.RedisKey.STRATEGY_RATE_RANGE_KEY + key, rateRange); // 2. 存储概率查找表 Map<Integer, Integer> cacheRateTable = redisService.getMap(Constants.RedisKey.STRATEGY_RATE_TABLE_KEY + key); cacheRateTable.putAll(strategyAwardSearchRateTable); } @Override public Integer getStrategyAwardAssemble(String key, Integer rateKey) { return redisService.getFromMap(Constants.RedisKey.STRATEGY_RATE_TABLE_KEY + key, rateKey); } @Override public int getRateRange(Long strategyId) { return getRateRange(String.valueOf(strategyId)); } @Override public int getRateRange(String key) { return redisService.getValue(Constants.RedisKey.STRATEGY_RATE_RANGE_KEY + key); } @Override public StrategyEntity queryStrategyEntityByStrategyId(Long strategyId) { // 优先从缓存获取 String cacheKey = Constants.RedisKey.STRATEGY_KEY + strategyId; StrategyEntity strategyEntity = redisService.getValue(cacheKey); if (null != strategyEntity) return strategyEntity; Strategy strategy = strategyDao.queryStrategyByStrategyId(strategyId); strategyEntity = StrategyEntity.builder() .strategyId(strategy.getStrategyId()) .strategyDesc(strategy.getStrategyDesc()) .ruleModels(strategy.getRuleModels()) .build(); redisService.setValue(cacheKey, strategyEntity); return strategyEntity; } @Override public StrategyRuleEntity queryStrategyRule(Long strategyId, String ruleModel) { StrategyRule strategyRuleReq = new StrategyRule(); strategyRuleReq.setStrategyId(strategyId); strategyRuleReq.setRuleModel(ruleModel); StrategyRule strategyRuleRes = strategyRuleDao.queryStrategyRule(strategyRuleReq); return StrategyRuleEntity.builder() .strategyId(strategyRuleRes.getStrategyId()) .awardId(strategyRuleRes.getAwardId()) .ruleType(strategyRuleRes.getRuleType()) .ruleModel(strategyRuleRes.getRuleModel()) .ruleValue(strategyRuleRes.getRuleValue()) .ruleDesc(strategyRuleRes.getRuleDesc()) .build(); } }1. int getRateRange(String key);
作用:按字符串 key 读取某张概率表的范围值
之前只有这种用法:
getRateRange(Long strategyId)只能拿默认策略表的范围,比如:
100001但加了权重规则后,Redis 里不止一张表了,还会有这种 key:
100001_4000:102,103,104,105 100001_5000:102,103,104,105,106,107所以就需要支持按字符串 key 取范围,而不是只按 strategyId。
它服务的是这种场景:
- 默认抽奖:查默认概率表范围
- 权重抽奖:查某个权重子表范围
2. StrategyEntity queryStrategyEntityByStrategyId(Long strategyId);
作用:查询策略主配置
这个方法是为了拿到策略本身配置了哪些规则,比如:
rule_weight,rule_blacklistStrategyArmoryDispatch会先查策略:
StrategyEntity strategyEntity = repository.queryStrategyEntityByStrategyId(strategyId); String ruleWeight = strategyEntity.getRuleWeight();也就是说,它的用途是:
- 先判断这个策略有没有启用 rule_weight
- 如果没有,就只装默认概率表
- 如果有,再继续查规则明细做权重装配
3. StrategyRuleEntity queryStrategyRule(Long strategyId, String ruleModel);
作用:查询某个策略下某种规则的详细配置
比如:
- strategyId = 100001
- ruleModel = "rule_weight"
查出来的可能就是:
4000:102,103,104,105 5000:102,103,104,105,106,107然后交给 StrategyRuleEntity 去解析成:
- 哪些权重档
- 每档包含哪些奖品 ID
这个方法的意义就是:
拿到权重规则的原始配置明细,没有它,就不知道每个权重档对应哪些奖品。
5.主要装配和抽奖逻辑功能
assembleLotteryStrategy 逻辑变了
旧版流程很简单:
- 查策略奖品列表
- 算概率表
- 存 Redis
- 结束
新版流程变成:
- 先查奖品列表
- 先装配一份默认概率表
- 再查策略主配置 StrategyEntity
- 判断有没有 rule_weight
- 如果有,再查 StrategyRuleEntity
- 解析出每个权重档位
- 每个档位筛出自己的奖品集合
- 为每个档位再单独装配一张概率表
对应装配和抽奖代码实现
package cn.bugstack.domain.strategy.service.armory; import cn.bugstack.domain.strategy.model.entity.StrategyAwardEntity; import cn.bugstack.domain.strategy.model.entity.StrategyEntity; import cn.bugstack.domain.strategy.model.entity.StrategyRuleEntity; import cn.bugstack.domain.strategy.repository.IStrategyRepository; import cn.bugstack.types.enums.ResponseCode; import cn.bugstack.types.exception.AppException; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.math.BigDecimal; import java.math.RoundingMode; import java.security.SecureRandom; import java.util.*; /** * @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. 查询策略配置 List<StrategyAwardEntity> 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; 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()); } Map<String, List<Integer>> ruleWeightValueMap = strategyRuleEntity.getRuleWeightValues(); Set<String> keys = ruleWeightValueMap.keySet(); for (String key : keys) { List<Integer> ruleWeightValues = ruleWeightValueMap.get(key); ArrayList<StrategyAwardEntity> strategyAwardEntitiesClone = new ArrayList<>(strategyAwardEntities); strategyAwardEntitiesClone.removeIf(entity -> !ruleWeightValues.contains(entity.getAwardId())); assembleLotteryStrategy(String.valueOf(strategyId).concat("_").concat(key), strategyAwardEntitiesClone); } return true; } private void assembleLotteryStrategy(String key, List<StrategyAwardEntity> strategyAwardEntities) { // 1. 获取最小概率值 BigDecimal minAwardRate = strategyAwardEntities.stream() .map(StrategyAwardEntity::getAwardRate) .min(BigDecimal::compareTo) .orElse(BigDecimal.ZERO); // 2. 获取概率值总和 BigDecimal totalAwardRate = strategyAwardEntities.stream() .map(StrategyAwardEntity::getAwardRate) .reduce(BigDecimal.ZERO, BigDecimal::add); // 3. 用 1 % 0.0001 获得概率范围,百分位、千分位、万分位 BigDecimal rateRange = totalAwardRate.divide(minAwardRate, 0, RoundingMode.CEILING); // 4. 生成策略奖品概率查找表「这里指需要在list集合中,存放上对应的奖品占位即可,占位越多等于概率越高」 List<Integer> 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).setScale(0, RoundingMode.CEILING).intValue(); i++) { strategyAwardSearchRateTables.add(awardId); } } // 5. 对存储的奖品进行乱序操作 Collections.shuffle(strategyAwardSearchRateTables); // 6. 生成出Map集合,key值,对应的就是后续的概率值。通过概率来获得对应的奖品ID Map<Integer, Integer> shuffleStrategyAwardSearchRateTable = new LinkedHashMap<>(); for (int i = 0; i < strategyAwardSearchRateTables.size(); i++) { shuffleStrategyAwardSearchRateTable.put(i, strategyAwardSearchRateTables.get(i)); } // 7. 存放到 Redis repository.storeStrategyAwardSearchRateTable(key, shuffleStrategyAwardSearchRateTable.size(), shuffleStrategyAwardSearchRateTable); } @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("_").concat(ruleWeightValue); // 分布式部署下,不一定为当前应用做的策略装配。也就是值不一定会保存到本应用,而是分布式应用,所以需要从 Redis 中获取。 int rateRange = repository.getRateRange(key); // 通过生成的随机值,获取概率值奖品查找表的结果 return repository.getStrategyAwardAssemble(key, new SecureRandom().nextInt(rateRange)); } }抽奖逻辑也分为权重抽奖和普通抽奖
对应测试方法
抽奖测试
装配测试
所以这里@Before本质是在做:测试初始化 / 测试前准备数据
注意,不是整个类只跑一次,而是每个测试方法前都跑一次
@RunWith(SpringRunner.class)的意思是:
让这个测试类不要用默认 JUnit 方式跑,而是交给 Spring 提供的测试运行器来跑。