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 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() .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() .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 getReceives(Long rpId) { return redPacketRecvMapper.selectList(new LambdaQueryWrapper() .eq(RedPacketRecv::getRpId, rpId) .orderByAsc(RedPacketRecv::getCreateTime)); } /** * 查询红包记录 */ @Override public Page getRecords(Long userId, Integer pageNum, Integer pageSize) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(RedPacket::getUserId, userId) .orderByDesc(RedPacket::getCreateTime); return redPacketMapper.selectPage(page, wrapper); } /** * 查询已收红包记录 */ @Override public Page getReceivedRecords(Long userId, Integer pageNum, Integer pageSize) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper 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() .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 amounts = objectMapper.readValue(rp.getShareAmounts(), new TypeReference>() { }); 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 preCalculateShareAmounts(RedPacket rp, Integer targetDigit) { List 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.2(maxFactor) // 这样循环里的 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); } // 若抬高后超过 luckyMax(total - 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 * - 不超过 luckyMax(total - 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→5,1.05→5,0.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 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 太小无空间调整) } }