微信抢红包模拟实现
1、抢红包介绍
微信抢红包基本流程:
- 发红包(拼手气红包)
需要发红包用户输入红包总个数、总金额,然后发红包。
2.抢红包
需要满足规则:
- 所有人抢到金额之和要等于红包总金额
- 每个人至少抢到一分钱
- 要保证所有人抢到金额的几率相等
2、二倍均值法
目前市面上主流实现是二倍均值算法(听说微信的红包实现是用的这个,应该是改良过的)
设剩余红包金额为 M,剩余人数为 N,每次抢到的金额 = 随机区间(0,M / N * 2)
分析:这样保证了每个随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平。
假设有 10 个人,红包总金额 100,第一个人的随机范围是(0,100/10 * 2)=(0,20),平均金额 = 10;
假设第一个人随机到 10 元,第二个人的随机范围就是(0,90/9 * 2)=(0,20),平均金额 = 10;
假设第二个人随机到 10 元,第三个人的随机范围就是(0,80/8 * 2)=(0,20),平均金额 = 10。
以此类推,每一次的随机范围都相同,平均值也相同。二倍均值法保证了抢红包的公平性,但不能保证真正的随机性。因为除了最后一个人,前面任何一个人抢到的金额都一定小于当前人均金额的两倍,并不是真正的随机。
算法核心逻辑实现(拆分红包)
代码语言:javascript复制 /**
* 红包分割方法
*
* @param amount 总金额
* @param min 每个红包最小值
* @param num 红包数
*/
public static List<BigDecimal> splitRedPackage(BigDecimal amount, BigDecimal min, BigDecimal num){
List<BigDecimal> split = new ArrayList<>();
BigDecimal remain = amount.subtract(min.multiply(num));
final Random random = new Random();
final BigDecimal hundred = new BigDecimal("100");
final BigDecimal two = new BigDecimal("2");
BigDecimal sum = BigDecimal.ZERO;
BigDecimal redpeck;
for (int i = 0; i < num.intValue(); i ) {
final int nextInt = random.nextInt(100);
if(i == num.intValue() -1){
redpeck = remain;
}else{
//RoundingMode.CEILING:取右边最近的整数
//RoundingMode.FLOOR:取左边最近的正数
redpeck = new BigDecimal(nextInt).multiply(remain.multiply(two).divide(num.subtract(new BigDecimal(i)),2, RoundingMode.CEILING)).divide(hundred,2, RoundingMode.FLOOR);
}
if(remain.compareTo(redpeck) > 0){
remain = remain.subtract(redpeck);
}else{
remain = BigDecimal.ZERO;
}
sum = sum.add(min.add(redpeck));
// 添加到List
split.add(min.add(redpeck));
}
Assert.isTrue(compare(amount, sum), "切分红包出现异常");
return split;
}
private static boolean compare(BigDecimal a, BigDecimal b){
if(a.compareTo(b) == 0){
return true;
}
return false;
}
3、流程模拟实现
3.1 发红包接口
SendRedPackageDto
/**
* @program: red-package
* @description: 发红包dto
* @author: yuanshuai
* @create: 2023-07-19 18:46
**/
@Data
public class SendRedPackageDto {
/**
* 总金额
*/
@NotBlank(message = "总金额不能为空")
private String totalAmount;
/**
* 拆分红包个数
*/
@NotBlank(message = "拆红包个数不能为空")
private String redPackageNums;
/**
* 每个红包最小金额
*/
@NotBlank(message = "红包最小金额不能为空")
private String minAmount;
}
RedPackageV1Controller
/**
* 发红包
*
* @param dto DTO 发红包参数
* @return {@link Result}<{@link String}>
*/
@PostMapping(value = "/send")
public Result sendRedPackage(@Valid @RequestBody SendRedPackageDto dto) {
try {
// 验证红包个数不能小于1
int redNums = Integer.parseInt(dto.getRedPackageNums());
if (redNums <= 0) {
return Result.build(null, ResultCodeEnum.PARAMS_ERROR);
}
// 验证总金额 和 最小金额 不能小于0
BigDecimal total = new BigDecimal(dto.getTotalAmount());
BigDecimal min = new BigDecimal(dto.getMinAmount());
if (total.compareTo(BigDecimal.ZERO) < 0 || min.compareTo(BigDecimal.ZERO) < 0) {
return Result.build(null, ResultCodeEnum.PARAMS_ERROR);
}
log.info("开始发红包, redNums:{}, total:{}, min:{}", redNums, total, min);
return redPackageV1Service.sendRedPackage(redNums, total, min);
}catch (Exception e) {
log.error("请求发红包接口发生异常, e:{}", e.toString());
return Result.build(null, ResultCodeEnum.PARAMS_ERROR);
}
}
RedPackageV1Service
/**
* 发红包
*
* @param redNums 红包个数
* @param total 总金额
* @param min 每个红包最小金额
* @return {@link Result}
*/
public Result sendRedPackage(int redNums, BigDecimal total, BigDecimal min) {
//1 拆红包,将总金额total拆分为redNums个子红包
List<BigDecimal> splitRedPackages = RedPackageUtil.splitRedPackage(total, min, new BigDecimal(String.valueOf(redNums)));
log.info("拆红包结果: {}", new Gson().toJson(splitRedPackages));
//2 发红包并保存进list结构里面且设置过期时间
String key = RedPackageUtil.generateUUID();
// 设置红包拆分缓存 由于这里是发红包不存在并发 理论上可以写成两行
redisTemplate.opsForList().leftPushAll(Constant.RED_PACKAGE_KEY key, splitRedPackages);
// 设置过期时间 默认24小时退回
redisTemplate.expire(Constant.RED_PACKAGE_KEY key, 1, TimeUnit.DAYS);
//3 发红包OK,返回前台显示
return Result.build(key, ResultCodeEnum.SUCCESS);
}
3.2 抢红包
RedPackageV1Controller
/**
* 抢红包
*
* @param redPackageKey 红包密钥
* @param token token
* @return {@link Result}
*/
@GetMapping(value = "/rob")
public Result robRedPackage(@RequestParam("redPackageKey") String redPackageKey, @RequestHeader("token") String token) {
log.info("开始抢红包, redPackageKey:{}, token:{}", redPackageKey, token);
return redPackageV1Service.robRedPackage(redPackageKey, token);
}
RedPackageV1Service
/**
* 抢红包
*
* @param redPackageKey 红包密钥
* @param token token
* @return {@link Result}
*/
public Result robRedPackage(String redPackageKey, String token) {
//1 验证某个用户是否抢过红包,不可以多抢
Object redPackage = redisTemplate.opsForHash().get(Constant.RED_PACKAGE_CONSUME_KEY redPackageKey, token);
//2 没有抢过可以去抢红包,否则返回-2表示该用户抢过红包了
if (null == redPackage) {
//2.1 从红包池(list)里面出队一个作为该客户抢的红包,抢到了一个红包
Object partRedPackage = redisTemplate.opsForList().leftPop(Constant.RED_PACKAGE_KEY redPackageKey);
if (partRedPackage != null) {
//2.2 抢到红包后需要记录进入hash结构,表示谁抢到了多少钱的某个子红包
redisTemplate.opsForHash().put(Constant.RED_PACKAGE_CONSUME_KEY redPackageKey, token, partRedPackage);
log.info("用户:{} 抢到了多少钱的红包:{}", token, partRedPackage);
//TODO 后续异步进mysql或者MQ进一步做统计处理,每一年你发出多少红包,抢到了多少红包,年度总结
return Result.build(partRedPackage, ResultCodeEnum.SUCCESS);
}
// 抢完了
log.info("红包池已抢空,红包标识:{}", redPackageKey);
return Result.build(null, ResultCodeEnum.RED_PACKAGE_FINISHED);
}
//3 某个用户抢过了,不可以作弊抢多次
return Result.build(null, ResultCodeEnum.RED_PACKAGE_REAPT);
}
3.3 模拟测试
3.3.1 先发红包 获取到一个uuid 作为本次红包的一个标识
发100块,分成三个包,每个包最低金额为0.01元
获取到uuid:f4860538-1bd6-4f83-bc4a-d0af8cf1ee9d
3.3.2 抢红包 注意一个用户是一个token 且只能抢一次
第一次抢(header的token一致就认为是一个用户)
同一个用户第二次抢
提示不能重复抢
当红包抢完后
这样基本模拟了简单的抢红包流程。