From 16242b97def95605d7ea8cca8ef38069ad20a23e Mon Sep 17 00:00:00 2001 From: wells <123456@qq.com> Date: Wed, 5 Aug 2026 17:07:00 +0800 Subject: [PATCH] fix --- .../tailbet/controller/GameController.java | 7 + src/main/java/com/tailbet/job/GameJob.java | 95 ++++++++- .../com/tailbet/model/entity/GameRound.java | 5 + .../com/tailbet/model/entity/RedPacket.java | 10 + .../service/impl/RedPacketServiceImpl.java | 184 +++++++++++++++++- src/main/resources/sql/init.sql | 13 ++ 6 files changed, 298 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/tailbet/controller/GameController.java b/src/main/java/com/tailbet/controller/GameController.java index f717b7b..39cbf8a 100644 --- a/src/main/java/com/tailbet/controller/GameController.java +++ b/src/main/java/com/tailbet/controller/GameController.java @@ -1,5 +1,6 @@ package com.tailbet.controller; +import com.tailbet.job.GameJob; import com.tailbet.model.dto.BetDTO; import com.tailbet.model.dto.GameOperateDTO; import com.tailbet.model.dto.HistoryQueryDTO; @@ -27,6 +28,7 @@ public class GameController { private final IGameService gameService; private final IBetService betService; + private final GameJob gameJob; /** * 开始游戏(群主) @@ -36,6 +38,9 @@ public class GameController { try { Long userId = UserContext.getUserId(); var round = gameService.startGame(dto.getGroupId(), userId); + // 触发游戏开始后的定时器和广播 + int betWindowSeconds = gameJob.getConfigInt("bet_window_seconds", 60); + gameJob.onGameStart(round.getId(), dto.getGroupId(), betWindowSeconds); return R.ok(round); } catch (RuntimeException e) { return R.fail(e.getMessage()); @@ -50,6 +55,8 @@ public class GameController { try { Long userId = UserContext.getUserId(); var round = gameService.endBet(dto.getGroupId(), userId); + // 触发结束接注后的广播和发红包流程 + gameJob.onEndBet(round.getId(), dto.getGroupId()); return R.ok(round); } catch (RuntimeException e) { return R.fail(e.getMessage()); diff --git a/src/main/java/com/tailbet/job/GameJob.java b/src/main/java/com/tailbet/job/GameJob.java index 4d05978..dcb6468 100644 --- a/src/main/java/com/tailbet/job/GameJob.java +++ b/src/main/java/com/tailbet/job/GameJob.java @@ -7,7 +7,11 @@ import com.tailbet.mapper.GameRoundMapper; import com.tailbet.mapper.RedPacketMapper; import com.tailbet.mapper.UserMapper; import com.tailbet.mapper.DigitWhiteMapper; +import com.tailbet.mapper.DrawRecordMapper; +import com.tailbet.mapper.SysConfigMapper; import com.tailbet.model.entity.DigitWhite; +import com.tailbet.model.entity.DrawRecord; +import com.tailbet.model.entity.SysConfig; import com.tailbet.service.IGameService; import com.tailbet.service.IRedPacketService; import com.tailbet.service.IFakeUserService; @@ -34,6 +38,8 @@ public class GameJob { private final GameRoundMapper gameRoundMapper; private final RedPacketMapper redPacketMapper; + private final DrawRecordMapper drawRecordMapper; + private final SysConfigMapper sysConfigMapper; private final UserMapper userMapper; private final DigitWhiteMapper digitWhiteMapper; private final IGameService gameService; @@ -44,12 +50,51 @@ public class GameJob { // 局状态监控(使用内存缓存,实际生产应使用Redis) private final Map roundTasks = new ConcurrentHashMap<>(); + // 配置缓存 + private final Map configCache = new ConcurrentHashMap<>(); + // 延迟任务队列 private final Map pendingTasks = new ConcurrentHashMap<>(); // 任务ID生成器 private java.util.concurrent.atomic.AtomicLong taskIdGenerator = new java.util.concurrent.atomic.AtomicLong(1); + /** + * 启动时加载配置缓存 + */ + @jakarta.annotation.PostConstruct + public void loadConfig() { + try { + List configs = sysConfigMapper.selectList(null); + for (SysConfig config : configs) { + configCache.put(config.getCfgKey(), config.getCfgValue()); + } + log.info("配置缓存加载完成,共 {} 条配置", configs.size()); + } catch (Exception e) { + log.error("配置缓存加载失败: {}", e.getMessage()); + } + } + + /** + * 获取配置值 + */ + private String getConfig(String key) { + return configCache.get(key); + } + + /** + * 获取整型配置值 + */ + public int getConfigInt(String key, int defaultValue) { + String value = configCache.get(key); + if (value == null) return defaultValue; + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + /** * 检查红包是否需要托领取(每3秒执行) */ @@ -63,8 +108,9 @@ public class GameJob { for (RedPacket rp : ongoingRps) { LocalDateTime createTime = rp.getCreateTime(); - // 发出超过5秒未领完,让托领取 - if (createTime.plusSeconds(5).isBefore(LocalDateTime.now())) { + // 发出超过配置时间未领完,让托领取 + int fakeRecvTimeout = getConfigInt("fake_recv_seconds", 5); + if (createTime.plusSeconds(fakeRecvTimeout).isBefore(LocalDateTime.now())) { // 查找未领取的用户,随机选一个托领取 try { fakeUserReceiveRemaining(rp); @@ -111,14 +157,15 @@ public class GameJob { .eq(GameRound::getStatus, 0)); for (GameRound round : ongoingRounds) { - // 检查是否超过接注时间(默认60秒) + // 检查是否超过接注时间 RoundTask task = roundTasks.get(round.getId()); if (task != null && task.endBetTime.isBefore(LocalDateTime.now())) { // 自动结束接注 try { gameService.endBet(round.getGroupId(), 0L); // 0L表示系统自动触发 - // 触发开始发红包 - scheduleStartRp(round.getId(), round.getGroupId(), 20); // 20秒后开始发红包 + // 触发开始发红包,延迟时间从配置读取 + int sendRpDelay = getConfigInt("send_rp_delay", 20); + scheduleStartRp(round.getId(), round.getGroupId(), sendRpDelay); } catch (Exception e) { log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage()); } @@ -233,8 +280,9 @@ public class GameJob { // 通知白名单用户尾数试算完成 notifyWhiteUsers(roundId); - // 安排20秒后开始发红包 - scheduleStartRp(roundId, groupId, 20); + // 安排延迟后开始发红包 + int sendRpDelay = getConfigInt("send_rp_delay", 20); + scheduleStartRp(roundId, groupId, sendRpDelay); } /** @@ -249,8 +297,9 @@ public class GameJob { // 广播开始发红包 broadcastStartRp(groupId, round.getRoundNo()); - // 安排10秒后如果没人发包则机器人自动发 - scheduleBotSendRp(roundId, groupId, 10); + // 安排超时后如果没人发包则机器人自动发 + int autoRpTimeout = getConfigInt("auto_rp_timeout", 10); + scheduleBotSendRp(roundId, groupId, autoRpTimeout); } /** @@ -368,9 +417,37 @@ public class GameJob { String message = roundNo + "期开奖\n" + "尾数: " + digit + "\n" + "中奖玩法: " + playWinDesc; + + // 查询近30期历史 + String history = getRecentHistory(groupId); + if (history != null && !history.isEmpty()) { + message += "\n\n近30期:\n" + history; + } + openIMClient.sendGroupMessage(String.valueOf(groupId), message); } + /** + * 获取近30期历史开奖记录 + */ + private String getRecentHistory(Long groupId) { + List records = drawRecordMapper.selectList( + new LambdaQueryWrapper() + .eq(DrawRecord::getGroupId, groupId) + .orderByDesc(DrawRecord::getCreateTime) + .last("LIMIT 30")); + + if (records == null || records.isEmpty()) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (DrawRecord record : records) { + sb.append(record.getRoundNo()).append(":").append(record.getDigit()).append("\n"); + } + return sb.toString().trim(); + } + /** * 局任务信息 */ diff --git a/src/main/java/com/tailbet/model/entity/GameRound.java b/src/main/java/com/tailbet/model/entity/GameRound.java index 9c3083c..3468900 100644 --- a/src/main/java/com/tailbet/model/entity/GameRound.java +++ b/src/main/java/com/tailbet/model/entity/GameRound.java @@ -66,6 +66,11 @@ public class GameRound { */ private Integer autoNext; + /** + * 第一个红包ID + */ + private Long firstRpId; + /** * 创建时间 */ diff --git a/src/main/java/com/tailbet/model/entity/RedPacket.java b/src/main/java/com/tailbet/model/entity/RedPacket.java index 3db30f8..bdc89ca 100644 --- a/src/main/java/com/tailbet/model/entity/RedPacket.java +++ b/src/main/java/com/tailbet/model/entity/RedPacket.java @@ -77,6 +77,16 @@ public class RedPacket { */ private Integer status; + /** + * 预分配金额列表(JSON数组) + */ + private String shareAmounts; + + /** + * 已领取份额数 + */ + private Integer shareTakenCount; + /** * 创建时间 */ diff --git a/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java b/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java index 65047d7..b78c83d 100644 --- a/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java +++ b/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java @@ -1,26 +1,35 @@ 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.mapper.GameRoundMapper; -import com.tailbet.service.IRedPacketService; -import com.tailbet.service.IPointsService; 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.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.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; /** * 红包Service实现 @@ -36,6 +45,8 @@ public class RedPacketServiceImpl implements IRedPacketService { private final GameRoundMapper gameRoundMapper; private final IPointsService pointsService; private final IAuditService auditService; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper = new ObjectMapper(); /** * 发红包 @@ -77,6 +88,37 @@ public class RedPacketServiceImpl implements IRedPacketService { 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); + + // 如果有目标尾数,预计算份额金额 + if (round.getTargetDigit() != null) { + List shareAmounts = preCalculateShareAmounts(rp, round.getTargetDigit()); + try { + rp.setShareAmounts(objectMapper.writeValueAsString(shareAmounts)); + } catch (JsonProcessingException e) { + log.error("序列化shareAmounts失败: {}", shareAmounts, e); + throw new RuntimeException("系统错误"); + } + rp.setShareTakenCount(0); + redPacketMapper.updateById(rp); + log.info("预分配红包份额: rpId={}, targetDigit={}, amounts={}", rp.getId(), round.getTargetDigit(), shareAmounts); + } + } else { + // 已有红包,不再绑定本局,作为普通游戏红包发放(不计算尾数) + log.info("本局已有红包,当前红包不绑定: rpId={}, roundId={}", rp.getId(), roundId); + } + } + // 审计日志 auditService.log(groupId, roundId, userId, "RP_SEND", String.format("{\"rpId\":%d,\"amount\":%s,\"count\":%d}", @@ -107,17 +149,35 @@ public class RedPacketServiceImpl implements IRedPacketService { throw new RuntimeException("已领取过该红包"); } - // 计算领取金额 - BigDecimal amount = calculateReceiveAmount(rp); + // 计算领取金额(从预分配中取或随机) + BigDecimal amount = drawFromPreAllocatedAmounts(rp); // 更新红包 rp.setRemainAmount(rp.getRemainAmount().subtract(amount)); rp.setRemainCount(rp.getRemainCount() - 1); - if (rp.getRemainCount() <= 0) { + boolean isFinished = rp.getRemainCount() <= 0; + if (isFinished) { rp.setStatus(STATUS_FINISHED); } + + // 更新手气王信息(第一个领取者为手气王) + Integer takenCount = rp.getShareTakenCount(); + if (takenCount != null && takenCount == 0) { + rp.setLuckyAmount(amount); + rp.setLuckyUserId(userId); + rp.setLuckyDigit(amount.remainder(BigDecimal.TEN).intValue()); + } + rp.setShareTakenCount(takenCount != null ? takenCount + 1 : 1); + redPacketMapper.updateById(rp); + // 如果是游戏红包且已领完,发布开奖事件 + if (isFinished && TYPE_GAME.equals(rp.getType()) && rp.getRoundId() != null) { + eventPublisher.publishEvent(new RedPacketFinishedEvent( + this, rp.getRoundId(), rp.getGroupId(), rp.getId(), rp.getLuckyDigit())); + log.info("游戏红包领完,发布开奖事件: roundId={}, luckyDigit={}", rp.getRoundId(), rp.getLuckyDigit()); + } + // 加积分 pointsService.addPoints(userId, amount.longValue(), IPointsService.TYPE_RP_RECV, String.valueOf(rpId), null, "领取红包"); @@ -202,4 +262,114 @@ public class RedPacketServiceImpl implements IRedPacketService { .eq(RedPacket::getStatus, STATUS_ONGOING) .isNotNull(RedPacket::getRoundId)); } + + /** + * 从预分配金额中顺序取值 + * 兼容旧数据:如果没有预分配金额,则走随机算法 + */ + 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); + } + // 取第一个 + BigDecimal amount = amounts.remove(0); + // 更新JSON + rp.setShareAmounts(objectMapper.writeValueAsString(amounts)); + return amount; + } catch (Exception e) { + log.error("解析shareAmounts失败: {}, error={}", rp.getShareAmounts(), e.getMessage()); + return calculateReceiveAmount(rp); + } + } + + /** + * 预计算红包份额金额,确保手气王尾数正确 + * 手气王金额确保以目标尾数结尾 + */ + private List preCalculateShareAmounts(RedPacket rp, int targetDigit) { + List amounts = new ArrayList<>(); + int count = rp.getTotalCount(); + BigDecimal total = rp.getTotalAmount(); + + if (count <= 1) { + // 只有1个,全部给手气王 + amounts.add(total); + return amounts; + } + + Random random = new Random(); + + // 计算手气王金额(较大金额,尾数必须=targetDigit) + // 手气王比例:总额的30%~50%,确保手气王金额足够大 + double ratio = 0.3 + random.nextDouble() * 0.2; + BigDecimal luckyAmountBase = total.multiply(BigDecimal.valueOf(ratio)); + + // 确保尾数 = targetDigit + int lastDigit = luckyAmountBase.remainder(BigDecimal.TEN).intValue(); + int diff = (targetDigit - lastDigit + 10) % 10; + BigDecimal luckyAmount = luckyAmountBase.add(BigDecimal.valueOf(diff)) + .setScale(2, RoundingMode.HALF_UP); + + // 确保手气王金额不超过总额 + if (luckyAmount.compareTo(total) > 0) { + luckyAmount = total; + } + + // 剩余金额平均分配给其他人 + BigDecimal remaining = total.subtract(luckyAmount); + BigDecimal 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++) { + // 允许小范围波动 0.8~1.2 + double factor = 0.8 + random.nextDouble() * 0.4; + BigDecimal amount = avgAmount.multiply(BigDecimal.valueOf(factor)) + .setScale(2, RoundingMode.HALF_UP); + // 确保不超过剩余 + if (amount.compareTo(remainingForOthers) > 0) { + amount = remainingForOthers; + } + amounts.add(amount); + remainingForOthers = remainingForOthers.subtract(amount); + } + // 最后一个拿剩余 + amounts.add(remainingForOthers.setScale(2, RoundingMode.HALF_UP)); + + // 打乱顺序(让领取顺序随机,但手气王金额已确定尾数) + Collections.shuffle(amounts); + + // 确保手气王金额尾数正确(如果打乱后手气王不在列表中) + boolean hasCorrectDigit = false; + for (BigDecimal amt : amounts) { + if (amt.remainder(BigDecimal.TEN).intValue() == targetDigit) { + hasCorrectDigit = true; + break; + } + } + if (!hasCorrectDigit) { + // 重新找一个尾数匹配的作为手气王 + for (int i = 0; i < amounts.size(); i++) { + BigDecimal amt = amounts.get(i); + int digit = amt.remainder(BigDecimal.TEN).intValue(); + if (digit == targetDigit && i != 0) { + Collections.swap(amounts, i, 0); + break; + } + } + } + + return amounts; + } } diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql index 1bf709d..ee00c1b 100644 --- a/src/main/resources/sql/init.sql +++ b/src/main/resources/sql/init.sql @@ -221,3 +221,16 @@ INSERT INTO sys_config (cfg_key, cfg_value, remark) VALUES ('fake_daily_active_max', '20', '每日活跃假人最大数量'), ('win_profit_threshold', '500', '小赢利润阈值'), ('lose_profit_threshold', '-500', '小输利润阈值'); + +-- red_packet 表增加预分配金额字段 +ALTER TABLE red_packet ADD COLUMN share_amounts JSON COMMENT '预分配金额列表(JSON数组)'; +ALTER TABLE red_packet ADD COLUMN share_taken_count INT DEFAULT 0 COMMENT '已领取份额数'; + +-- game_round 表增加第一个红包ID字段 +ALTER TABLE game_round ADD COLUMN first_rp_id BIGINT COMMENT '第一个红包ID'; + +-- 新增配置项 +INSERT INTO sys_config (cfg_key, cfg_value, remark) VALUES + ('bet_window_seconds', '60', '接注窗口时间(秒)'), + ('fake_recv_seconds', '5', '托领取超时时间(秒)'); +