Files
xiu_b/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java
T
2026-08-09 14:52:22 +08:00

510 lines
20 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.tailbet.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tailbet.event.RedPacketFinishedEvent;
import com.tailbet.model.entity.GameRound;
import com.tailbet.model.entity.RedPacket;
import com.tailbet.model.entity.RedPacketRecv;
import com.tailbet.model.entity.User;
import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.mapper.RedPacketMapper;
import com.tailbet.mapper.RedPacketRecvMapper;
import com.tailbet.mapper.UserMapper;
import com.tailbet.service.IAuditService;
import com.tailbet.service.IPointsService;
import com.tailbet.service.IRedPacketService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
/**
* 红包Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class RedPacketServiceImpl implements IRedPacketService {
private final RedPacketMapper redPacketMapper;
private final RedPacketRecvMapper redPacketRecvMapper;
private final UserMapper userMapper;
private final GameRoundMapper gameRoundMapper;
private final IPointsService pointsService;
private final IAuditService auditService;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 发红包
*/
@Override
@Transactional
public RedPacket sendRp(Long userId, Long groupId, BigDecimal totalAmount,
Integer totalCount, Long roundId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 前置校验:总金额必须够每个包至少 1 分,避免产生 0 元红包
BigDecimal minRequired = BigDecimal.valueOf(totalCount == null ? 0 : totalCount)
.multiply(new BigDecimal("0.01"));
if (totalAmount == null || totalAmount.compareTo(minRequired) < 0) {
throw new RuntimeException("红包总金额不能低于 " + minRequired.toPlainString() + " 元");
}
// 扣积分
pointsService.subPoints(userId, totalAmount,
IPointsService.TYPE_RP_SEND, null, null, "发送红包");
// 创建红包
RedPacket rp = new RedPacket();
rp.setUserId(userId);
rp.setGroupId(groupId);
rp.setRoundId(roundId);
rp.setTotalAmount(totalAmount);
rp.setTotalCount(totalCount);
rp.setRemainAmount(totalAmount);
rp.setRemainCount(totalCount);
rp.setStatus(STATUS_ONGOING);
rp.setCreateTime(LocalDateTime.now());
// 判断红包类型
Integer targetDigit = null;
if (roundId != null) {
rp.setType(TYPE_GAME);
} else {
rp.setType(TYPE_RANDOM);
}
redPacketMapper.insert(rp);
// 如果是游戏红包,检查并绑定第一个红包
if (roundId != null) {
GameRound round = gameRoundMapper.selectById(roundId);
if (round == null) {
throw new RuntimeException("局不存在");
}
if (round.getFirstRpId() == null) {
// 第一个红包,绑定到局
round.setFirstRpId(rp.getId());
gameRoundMapper.updateById(round);
// 获取局的目标尾数(可能为null)
targetDigit = round.getTargetDigit();
} else {
// 已有红包,不再绑定本局,作为普通游戏红包发放(不计算尾数)
log.info("本局已有红包,当前红包不绑定: rpId={}, roundId={}", rp.getId(), roundId);
}
}
// 无论红包类型(TYPE_GAME/TYPE_RANDOM),都预先生成份额金额
// 游戏红包有targetDigit时控制手气王尾数,无targetDigit或非游戏红包按纯随机份额
List<BigDecimal> shareAmounts = preCalculateShareAmounts(rp, targetDigit);
try {
rp.setShareAmounts(objectMapper.writeValueAsString(shareAmounts));
} catch (JsonProcessingException e) {
log.error("序列化shareAmounts失败: {}", shareAmounts, e);
throw new RuntimeException("系统错误");
}
rp.setShareTakenIndex(0);
redPacketMapper.updateById(rp);
log.info("预分配红包份额: rpId={}, type={}, targetDigit={}, amounts={}",
rp.getId(), rp.getType(), targetDigit, shareAmounts);
// 审计日志
auditService.log(groupId, roundId, userId, "RP_SEND",
String.format("{\"rpId\":%d,\"amount\":%s,\"count\":%d}",
rp.getId(), totalAmount, totalCount));
return rp;
}
/**
* 领红包
*/
@Override
@Transactional
public RedPacketRecv receiveRp(Long rpId, Long userId) {
RedPacket rp = redPacketMapper.selectById(rpId);
if (rp == null) {
throw new RuntimeException("红包不存在");
}
if (rp.getStatus() != STATUS_ONGOING) {
throw new RuntimeException("红包已领完");
}
// 检查是否已领取
RedPacketRecv existing = redPacketRecvMapper.selectOne(new LambdaQueryWrapper<RedPacketRecv>()
.eq(RedPacketRecv::getRpId, rpId)
.eq(RedPacketRecv::getUserId, userId));
if (existing != null) {
throw new RuntimeException("已领取过该红包");
}
// 计算领取金额(从预分配中取或随机)
BigDecimal amount = drawFromPreAllocatedAmounts(rp);
// 更新红包
rp.setRemainAmount(rp.getRemainAmount().subtract(amount));
rp.setRemainCount(rp.getRemainCount() - 1);
boolean isFinished = rp.getRemainCount() <= 0;
if (isFinished) {
rp.setStatus(STATUS_FINISHED);
}
// 更新手气王信息(领取金额最大的用户为手气王)
Integer takenIndex = rp.getShareTakenIndex();
BigDecimal currentLucky = rp.getLuckyAmount();
if (currentLucky == null || amount.compareTo(currentLucky) > 0) {
rp.setLuckyAmount(amount);
rp.setLuckyUserId(userId);
rp.setLuckyDigit(digitOf(amount));
}
rp.setShareTakenIndex(takenIndex != null ? takenIndex + 1 : 1);
redPacketMapper.updateById(rp);
// 如果是游戏红包且已领完,标记手气王并发布开奖事件
if (isFinished && TYPE_GAME.equals(rp.getType()) && rp.getRoundId() != null) {
// 标记手气王的领取记录 is_lucky=1
if (rp.getLuckyUserId() != null) {
redPacketRecvMapper.update(null, new LambdaUpdateWrapper<RedPacketRecv>()
.eq(RedPacketRecv::getRpId, rpId)
.eq(RedPacketRecv::getUserId, rp.getLuckyUserId())
.set(RedPacketRecv::getIsLucky, 1));
}
eventPublisher.publishEvent(new RedPacketFinishedEvent(
this, rp.getRoundId(), rp.getGroupId(), rp.getId(), rp.getLuckyDigit()));
log.info("游戏红包领完,标记手气王并发布开奖事件: roundId={}, luckyDigit={}, luckyUserId={}",
rp.getRoundId(), rp.getLuckyDigit(), rp.getLuckyUserId());
}
// 加积分
pointsService.addPoints(userId, amount,
IPointsService.TYPE_RP_RECV, String.valueOf(rpId), null, "领取红包");
// 记录领取
RedPacketRecv recv = new RedPacketRecv();
recv.setRpId(rpId);
recv.setUserId(userId);
recv.setAmount(amount);
recv.setCreateTime(LocalDateTime.now());
redPacketRecvMapper.insert(recv);
// 审计日志
auditService.log(rp.getGroupId(), rp.getRoundId(), userId, "RP_RECV",
String.format("{\"rpId\":%d,\"amount\":%s}", rpId, amount));
return recv;
}
/**
* 计算领取金额
*/
@Override
public BigDecimal calculateReceiveAmount(RedPacket rp) {
if (rp.getRemainCount() <= 0) {
return BigDecimal.ZERO;
}
// 如果是游戏红包且有目标尾数,则需要控制手气王
// 简化实现:随机分配
Random random = new Random();
if (rp.getRemainCount() == 1) {
// 最后一个,拿剩余全部
return rp.getRemainAmount();
}
// 随机金额,最小0.01
double min = 0.01;
double max = rp.getRemainAmount().divide(BigDecimal.valueOf(rp.getRemainCount()), 2, RoundingMode.HALF_UP).doubleValue() * 2;
double amount = min + (max - min) * random.nextDouble();
return BigDecimal.valueOf(amount).setScale(2, RoundingMode.HALF_UP);
}
/**
* 获取红包详情
*/
@Override
public RedPacket getById(Long rpId) {
return redPacketMapper.selectById(rpId);
}
/**
* 获取红包领取列表
*/
@Override
public List<RedPacketRecv> getReceives(Long rpId) {
return redPacketRecvMapper.selectList(new LambdaQueryWrapper<RedPacketRecv>()
.eq(RedPacketRecv::getRpId, rpId)
.orderByAsc(RedPacketRecv::getCreateTime));
}
/**
* 查询红包记录
*/
@Override
public Page<RedPacket> getRecords(Long userId, Integer pageNum, Integer pageSize) {
Page<RedPacket> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<RedPacket> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RedPacket::getUserId, userId)
.orderByDesc(RedPacket::getCreateTime);
return redPacketMapper.selectPage(page, wrapper);
}
/**
* 查询已收红包记录
*/
@Override
public Page<RedPacketRecv> getReceivedRecords(Long userId, Integer pageNum, Integer pageSize) {
Page<RedPacketRecv> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<RedPacketRecv> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RedPacketRecv::getUserId, userId)
.orderByDesc(RedPacketRecv::getCreateTime);
return redPacketRecvMapper.selectPage(page, wrapper);
}
/**
* 查找进行中的游戏红包
*/
@Override
public RedPacket getOngoingGameRp(Long groupId) {
return redPacketMapper.selectOne(new LambdaQueryWrapper<RedPacket>()
.eq(RedPacket::getGroupId, groupId)
.eq(RedPacket::getType, TYPE_GAME)
.eq(RedPacket::getStatus, STATUS_ONGOING)
.isNotNull(RedPacket::getRoundId));
}
/**
* 从预分配金额中按索引顺序取值(不再修改 shareAmounts JSON
* 兼容旧数据:如果没有预分配金额,则走随机算法
*/
private BigDecimal drawFromPreAllocatedAmounts(RedPacket rp) {
if (rp.getShareAmounts() == null || rp.getShareAmounts().isEmpty()) {
// 兼容旧数据fallback到随机
return calculateReceiveAmount(rp);
}
try {
List<BigDecimal> amounts = objectMapper.readValue(rp.getShareAmounts(),
new TypeReference<List<BigDecimal>>() {
});
if (amounts.isEmpty()) {
return calculateReceiveAmount(rp);
}
// 按 shareTakenIndex 索引取值,shareAmounts 不再被修改
Integer index = rp.getShareTakenIndex();
if (index == null || index >= amounts.size()) {
// 越界:兼容旧数据或异常情况,走随机
return calculateReceiveAmount(rp);
}
return amounts.get(index);
} catch (Exception e) {
log.error("解析shareAmounts失败: {}, error={}", rp.getShareAmounts(), e.getMessage());
return calculateReceiveAmount(rp);
}
}
/**
* 预计算红包份额金额
* targetDigit 非空时确保手气王金额尾数=targetDigit
* targetDigit 为空时按纯随机份额处理
*/
private List<BigDecimal> preCalculateShareAmounts(RedPacket rp, Integer targetDigit) {
List<BigDecimal> amounts = new ArrayList<>();
int count = rp.getTotalCount();
BigDecimal total = rp.getTotalAmount();
BigDecimal minUnit = new BigDecimal("0.01"); // 最小份额:1 分
if (count <= 1) {
amounts.add(total);
return amounts;
}
Random random = new Random();
// 1. 计算手气王金额(已保证不超过 luckyMax 且尾数=targetDigit
BigDecimal luckyAmount = calculateLuckyAmount(total, targetDigit, count, random);
// 2. 计算其他人总额(保证每人至少 minUnit)
BigDecimal minForOthers = minUnit.multiply(BigDecimal.valueOf(count - 1));
BigDecimal remaining = total.subtract(luckyAmount);
if (remaining.compareTo(minForOthers) < 0) {
// 手气王挤占了他人最小份额,回收
luckyAmount = total.subtract(minForOthers);
if (targetDigit != null) {
luckyAmount = adjustToTargetDigit(luckyAmount, luckyAmount.add(minUnit), targetDigit);
}
remaining = minForOthers;
}
// 3. 其他人金额(带小随机波动)
// 计算 avgAmount 后,确保 luckyAmount ≥ avgAmount × 1.2maxFactor
// 这样循环里的 amount 自然 ≤ luckyAmount,无需钳制,金额严格守恒
double maxFactor = 1.2;
BigDecimal avgAmount = remaining.divide(BigDecimal.valueOf(count - 1), 2, RoundingMode.HALF_UP);
BigDecimal needed = avgAmount.multiply(BigDecimal.valueOf(maxFactor))
.setScale(2, RoundingMode.HALF_UP);
if (luckyAmount.compareTo(needed) < 0) {
// 手气王不够大,抬高到 needed(满足"分位尾数"=targetDigit 优先)
BigDecimal lifted = needed;
if (targetDigit != null) {
lifted = adjustToTargetDigit(lifted, total, targetDigit);
}
// 若抬高后超过 luckyMaxtotal - minForOthers),回退取 luckyMax 并再调尾数
BigDecimal cap = total.subtract(minForOthers);
if (lifted.compareTo(cap) > 0) {
lifted = cap;
if (targetDigit != null) {
lifted = adjustToTargetDigit(lifted, cap, targetDigit);
}
}
luckyAmount = lifted;
remaining = total.subtract(luckyAmount);
// 重新计算 avgAmount,确保循环里的金额仍 ≥ minUnit
avgAmount = remaining.divide(BigDecimal.valueOf(count - 1), 2, RoundingMode.HALF_UP);
}
amounts.add(luckyAmount);
BigDecimal remainingForOthers = remaining;
for (int i = 1; i < count - 1; i++) {
double factor = 0.8 + random.nextDouble() * 0.4;
BigDecimal amount = avgAmount.multiply(BigDecimal.valueOf(factor))
.setScale(2, RoundingMode.HALF_UP);
// 下限保护:每个份额至少 minUnit
if (amount.compareTo(minUnit) < 0) {
amount = minUnit;
}
// 累加上限:不超过 remainingForOthers
if (amount.compareTo(remainingForOthers) > 0) {
amount = remainingForOthers;
}
amounts.add(amount);
remainingForOthers = remainingForOthers.subtract(amount);
}
// 最后一个拿剩余
BigDecimal last = remainingForOthers;
// 下限保护:累减后可能 < minUnit
if (last.compareTo(minUnit) < 0) {
last = minUnit;
}
amounts.add(last.setScale(2, RoundingMode.HALF_UP));
// 打乱顺序(让领取顺序随机)
Collections.shuffle(amounts);
// 如果指定了目标尾数,确保列表中至少有1个金额的尾数=targetDigit
if (targetDigit != null) {
ensureLuckyDigit(amounts, targetDigit);
}
return amounts;
}
/**
* 计算手气王金额
* - 比例为总额的 [1/count, 50%],下限定为 1/count 以保证 luckyAmount ≥ avgAmount
* (手气王必须 ≥ 其他人的平均金额,否则随机波动后有人会比手气王还大)
* - 有目标尾数时确保"分位尾数"=targetDigit
* - 不超过 luckyMaxtotal - minUnit * (count-1)),留出最小份额给其他人
*/
private BigDecimal calculateLuckyAmount(BigDecimal total, Integer targetDigit, int totalCount, Random random) {
BigDecimal minUnit = new BigDecimal("0.01");
BigDecimal minForOthers = minUnit.multiply(BigDecimal.valueOf(totalCount - 1));
BigDecimal luckyMax = total.subtract(minForOthers);
// 比例下限 1/count(保证 luckyAmount ≥ avgAmount),上限 50%
double minRatio = 1.0 / totalCount;
double ratio = minRatio + random.nextDouble() * (0.5 - minRatio);
BigDecimal luckyAmount = total.multiply(BigDecimal.valueOf(ratio))
.setScale(2, RoundingMode.HALF_UP);
// 调整尾数(如果指定)
if (targetDigit != null) {
luckyAmount = adjustToTargetDigit(luckyAmount, luckyMax, targetDigit);
}
// 上限保护:不超过 luckyMax
if (luckyAmount.compareTo(luckyMax) > 0) {
luckyAmount = luckyMax;
if (targetDigit != null) {
luckyAmount = adjustToTargetDigit(luckyAmount, luckyMax, targetDigit);
}
}
return luckyAmount;
}
/**
* 调整金额让"分位尾数"=targetDigit,且不超过 maxAllowed
* 优先向上调整(保持金额更大),如会超出则向下调整
*/
private BigDecimal adjustToTargetDigit(BigDecimal amount, BigDecimal maxAllowed, int targetDigit) {
BigDecimal minUnit = new BigDecimal("0.01");
int lastDigit = digitOf(amount);
if (lastDigit == targetDigit) {
return amount;
}
int upDiff = (targetDigit - lastDigit + 10) % 10; // 向上调(单位:分)
int downDiff = (lastDigit - targetDigit + 10) % 10; // 向下调
BigDecimal up = amount.add(BigDecimal.valueOf(upDiff).movePointLeft(2))
.setScale(2, RoundingMode.HALF_UP);
if (up.compareTo(maxAllowed) <= 0 && up.compareTo(minUnit) >= 0) {
return up;
}
BigDecimal down = amount.subtract(BigDecimal.valueOf(downDiff).movePointLeft(2))
.setScale(2, RoundingMode.HALF_UP);
if (down.compareTo(minUnit) >= 0) {
return down;
}
return amount; // 实在调不动,保持原值(兜底)
}
/**
* 取金额的"分位尾数":金额×100 后取个位(如 0.45→51.05→50.40→0
* BigDecimal.remainder(BigDecimal.TEN) 对小数返回自身+intValue 截断会丢精度,
* 这里按国内红包/微信红包标准按"分位"算尾数
*/
private static int digitOf(BigDecimal amount) {
if (amount == null) return 0;
return amount.movePointRight(2).remainder(BigDecimal.TEN).intValue();
}
/**
* 确保 amounts[0] 的"分位尾数"=targetDigit(手气王必须在首位)
* 如果首位已经命中则不操作;否则找一个命中项 swap 到首位;都没有则兜底不动
*/
private void ensureLuckyDigit(List<BigDecimal> amounts, int targetDigit) {
if (digitOf(amounts.get(0)) == targetDigit) {
return;
}
// 首位不命中,找一个命中项 swap 到首位
for (int i = 1; i < amounts.size(); i++) {
if (digitOf(amounts.get(i)) == targetDigit) {
Collections.swap(amounts, 0, i);
return;
}
}
// 没有任何命中项,无法保证(这种情况很罕见:total/count 太小无空间调整)
}
}