510 lines
20 KiB
Java
510 lines
20 KiB
Java
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.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<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 太小无空间调整)
|
||
}
|
||
}
|