From 1c5e9d4cde71e07785a387ab1a211dbaf3f3940d Mon Sep 17 00:00:00 2001 From: wells <123456@qq.com> Date: Sun, 9 Aug 2026 19:37:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E6=B5=81=E7=A8=8B=E6=94=B9?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/tailbet/job/GameBroadcaster.java | 176 ++++++++++++++++++ src/main/java/com/tailbet/job/GameJob.java | 142 ++------------ 2 files changed, 193 insertions(+), 125 deletions(-) create mode 100644 src/main/java/com/tailbet/job/GameBroadcaster.java diff --git a/src/main/java/com/tailbet/job/GameBroadcaster.java b/src/main/java/com/tailbet/job/GameBroadcaster.java new file mode 100644 index 0000000..862057f --- /dev/null +++ b/src/main/java/com/tailbet/job/GameBroadcaster.java @@ -0,0 +1,176 @@ +package com.tailbet.job; + +import com.tailbet.config.OpenIMProperties; +import com.tailbet.mapper.DigitWhiteMapper; +import com.tailbet.mapper.DrawRecordMapper; +import com.tailbet.mapper.GameRoundMapper; +import com.tailbet.mapper.UserMapper; +import com.tailbet.model.entity.DigitWhite; +import com.tailbet.model.entity.DrawRecord; +import com.tailbet.model.entity.GameRound; +import com.tailbet.model.entity.User; +import com.tailbet.openim.OpenIMApiClient; +import com.tailbet.service.IGameService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 游戏广播器 —— 把 OpenIM 消息发送逻辑从 {@link GameJob} 抽出 + * + *

负责所有游戏流程的 OpenIM 消息: + *

+ * + *

所有方法失败时只记日志、不抛异常,避免阻塞调用方业务流程。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GameBroadcaster { + + /** sessionType: 1=单聊 */ + private static final int SESSION_TYPE_C2C = 1; + /** sessionType: 3=群聊 */ + private static final int SESSION_TYPE_GROUP = 3; + /** contentType: 101=文本 */ + private static final int CONTENT_TYPE_TEXT = 101; + + private final OpenIMApiClient openIMApiClient; + private final OpenIMProperties openIMProperties; + private final GameRoundMapper gameRoundMapper; + private final DrawRecordMapper drawRecordMapper; + private final DigitWhiteMapper digitWhiteMapper; + private final UserMapper userMapper; + private final IGameService gameService; + + /** + * 通知白名单用户(尾数试算完成) + */ + public void notifyWhiteUsers(Long roundId) { + try { + GameRound round = gameRoundMapper.selectById(roundId); + if (round == null) return; + + List whites = digitWhiteMapper.selectList(new LambdaQueryWrapper<>()); + for (DigitWhite white : whites) { + User user = userMapper.selectById(white.getUserId()); + if (user == null) continue; + + sendC2C(user.getId(), "尾数试算完成,请前往修改尾数。期号: " + round.getRoundNo()); + log.info("通知白名单用户尾数试算完成: userId={}, roundId={}", user.getId(), roundId); + } + } catch (Exception e) { + log.error("通知白名单用户失败: roundId={}, error={}", roundId, e.getMessage(), e); + } + } + + /** + * 广播开始下注 + */ + public void broadcastStartBet(Long groupId, String roundNo) { + sendGroup(groupId, roundNo + "期开始下注"); + } + + /** + * 广播结束下注 + */ + public void broadcastEndBet(Long groupId, String roundNo) { + sendGroup(groupId, roundNo + "期结束下注"); + } + + /** + * 广播开始发红包 + */ + public void broadcastStartRp(Long groupId, String roundNo) { + sendGroup(groupId, roundNo + "期开始发红包"); + } + + /** + * 广播开奖结果(含近30期历史) + */ + public void broadcastResult(Long groupId, int digit, String roundNo) { + try { + String playWinDesc = gameService.getPlayWinDesc(digit); + StringBuilder message = new StringBuilder() + .append(roundNo).append("期开奖\n") + .append("尾数: ").append(digit).append("\n") + .append("中奖玩法: ").append(playWinDesc); + + String history = getRecentHistory(groupId); + if (!history.isEmpty()) { + message.append("\n\n近30期:\n").append(history); + } + + sendGroup(groupId, message.toString()); + } catch (Exception e) { + log.error("广播开奖结果失败: groupId={}, digit={}, error={}", groupId, digit, e.getMessage(), e); + } + } + + /** + * 获取近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(); + } + + // ==================== 底层发送封装 ==================== + + /** + * 发送群聊文本消息 + */ + private void sendGroup(Long groupId, String content) { + try { + // 注意: 实际生产中需要通过Group表映射获取OpenIM群ID + openIMApiClient.sendMsg( + openIMProperties.getAdminUserId(), + String.valueOf(groupId), + SESSION_TYPE_GROUP, + CONTENT_TYPE_TEXT, + java.util.Map.of("content", content) + ); + } catch (Exception e) { + log.error("群消息发送失败: groupId={}, content={}, error={}", groupId, content, e.getMessage(), e); + } + } + + /** + * 发送单聊文本消息 + */ + private void sendC2C(Long userId, String content) { + try { + openIMApiClient.sendMsg( + openIMProperties.getAdminUserId(), + String.valueOf(userId), + SESSION_TYPE_C2C, + CONTENT_TYPE_TEXT, + java.util.Map.of("content", content) + ); + } catch (Exception e) { + log.error("单聊消息发送失败: userId={}, content={}, error={}", userId, content, e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/tailbet/job/GameJob.java b/src/main/java/com/tailbet/job/GameJob.java index f76c67d..6b03edc 100644 --- a/src/main/java/com/tailbet/job/GameJob.java +++ b/src/main/java/com/tailbet/job/GameJob.java @@ -3,20 +3,15 @@ package com.tailbet.job; import com.tailbet.model.entity.GameRound; import com.tailbet.model.entity.RedPacket; import com.tailbet.model.entity.User; -import com.tailbet.mapper.DrawRecordMapper; -import com.tailbet.mapper.DigitWhiteMapper; import com.tailbet.mapper.GameRoundMapper; import com.tailbet.mapper.GroupMemberMapper; import com.tailbet.mapper.RedPacketMapper; import com.tailbet.mapper.RedPacketRecvMapper; import com.tailbet.mapper.SysConfigMapper; import com.tailbet.mapper.UserMapper; -import com.tailbet.model.entity.DigitWhite; -import com.tailbet.model.entity.DrawRecord; import com.tailbet.model.entity.GroupMember; import com.tailbet.model.entity.RedPacketRecv; import com.tailbet.model.entity.SysConfig; -import com.tailbet.openim.OpenIMApiClient; import com.tailbet.service.IGameService; import com.tailbet.service.IRedPacketService; import com.tailbet.service.IFakeUserService; @@ -47,6 +42,8 @@ import java.util.stream.Collectors; *

  • {@link #scanBotSendRpTimeouts()} —— 发包超时由机器人代发
  • *
  • {@link #scanAutoNextTimeouts()} —— 结算后自动开下一期
  • * + * + *

    所有 OpenIM 消息广播逻辑由 {@link GameBroadcaster} 负责。 */ @Slf4j @Component @@ -56,16 +53,13 @@ public class GameJob { private final GameRoundMapper gameRoundMapper; private final RedPacketMapper redPacketMapper; private final RedPacketRecvMapper redPacketRecvMapper; - private final DrawRecordMapper drawRecordMapper; private final SysConfigMapper sysConfigMapper; private final UserMapper userMapper; - private final DigitWhiteMapper digitWhiteMapper; private final GroupMemberMapper groupMemberMapper; private final IGameService gameService; private final IRedPacketService redPacketService; private final IFakeUserService fakeUserService; - private final OpenIMApiClient openIMApiClient; - private final com.tailbet.config.OpenIMProperties openIMProperties; + private final GameBroadcaster broadcaster; // 配置缓存 private final Map configCache = new ConcurrentHashMap<>(); @@ -214,19 +208,26 @@ public class GameJob { /** * 扫描下注超时局 —— 每 2 秒 - *

    条件:{@code status=0 (下注中) AND end_bet_time < NOW()} + *

    条件:{@code status=0 (下注中) AND start_time + bet_window_seconds 秒 < NOW()} + *

    说明:{@code end_bet_time} 在 {@code startGame} 时为 null(仅群主手动 {@code endBet} 时写入), + * 所以基准时间戳必须用 {@code start_time},与原 {@code recoverRoundTasks} 的 fallback 逻辑一致。 */ @Scheduled(fixedDelay = 2000) public void scanRoundTimeouts() { + int betWindowSeconds = getConfigInt("bet_window_seconds", 60); + LocalDateTime threshold = LocalDateTime.now().minusSeconds(betWindowSeconds); List overdue = gameRoundMapper.selectList( new LambdaQueryWrapper() .eq(GameRound::getStatus, IGameService.STATUS_BETTING) - .lt(GameRound::getEndBetTime, LocalDateTime.now()) + .isNotNull(GameRound::getStartTime) + .lt(GameRound::getStartTime, threshold) .last("LIMIT 50")); for (GameRound round : overdue) { try { gameService.endBet(round.getGroupId(), 0L); // 0L 表示系统自动触发 + // 触发结束接注后的广播和发红包流程 + onEndBet(round.getId(), round.getGroupId()); } catch (Exception e) { log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage(), e); } @@ -327,7 +328,7 @@ public class GameJob { public void onGameStart(Long roundId, Long groupId, int intervalSeconds) { GameRound round = gameRoundMapper.selectById(roundId); if (round != null) { - broadcastStartBet(groupId, round.getRoundNo()); + broadcaster.broadcastStartBet(groupId, round.getRoundNo()); } log.info("游戏开始: roundId={}, {}秒后结束接注", roundId, intervalSeconds); } @@ -339,9 +340,9 @@ public class GameJob { public void onEndBet(Long roundId, Long groupId) { GameRound round = gameRoundMapper.selectById(roundId); if (round != null) { - broadcastEndBet(groupId, round.getRoundNo()); + broadcaster.broadcastEndBet(groupId, round.getRoundNo()); } - notifyWhiteUsers(roundId); + broadcaster.notifyWhiteUsers(roundId); } /** @@ -368,7 +369,7 @@ public class GameJob { gameRoundMapper.updateById(round); // 广播开始发红包 - broadcastStartRp(groupId, round.getRoundNo()); + broadcaster.broadcastStartRp(groupId, round.getRoundNo()); return true; } catch (Exception e) { log.error("开始发红包失败: roundId={}, error={}", roundId, e.getMessage(), e); @@ -451,7 +452,7 @@ public class GameJob { // 广播开奖结果 GameRound round = gameRoundMapper.selectById(roundId); if (round != null) { - broadcastResult(groupId, digit, round.getRoundNo()); + broadcaster.broadcastResult(groupId, digit, round.getRoundNo()); } log.info("局结算完成: roundId={}, digit={}", roundId, digit); @@ -462,115 +463,6 @@ public class GameJob { } } - /** - * 通知白名单用户(尾数试算完成) - */ - private void notifyWhiteUsers(Long roundId) { - List whites = digitWhiteMapper.selectList(new LambdaQueryWrapper<>()); - GameRound round = gameRoundMapper.selectById(roundId); - if (round == null) return; - - for (DigitWhite white : whites) { - User user = userMapper.selectById(white.getUserId()); - if (user != null) { - // 通过OpenIM发送通知 - openIMApiClient.sendMsg( - openIMProperties.getAdminUserId(), - String.valueOf(user.getId()), - 1, // sessionType 1=单聊 - 101, // contentType 101=文本 - java.util.Map.of("content", "尾数试算完成,请前往修改尾数。期号: " + round.getRoundNo()) - ); - log.info("通知白名单用户尾数试算完成: userId={}, roundId={}", user.getId(), roundId); - } - } - } - - /** - * 广播开始下注 - */ - private void broadcastStartBet(Long groupId, String roundNo) { - // 注意: 实际生产中需要通过Group表映射获取OpenIM群ID - openIMApiClient.sendMsg( - openIMProperties.getAdminUserId(), - String.valueOf(groupId), - 3, // sessionType 3=群聊 - 101, // contentType 101=文本 - java.util.Map.of("content", roundNo + "期开始下注") - ); - } - - /** - * 广播结束下注 - */ - private void broadcastEndBet(Long groupId, String roundNo) { - openIMApiClient.sendMsg( - openIMProperties.getAdminUserId(), - String.valueOf(groupId), - 3, // sessionType 3=群聊 - 101, // contentType 101=文本 - java.util.Map.of("content", roundNo + "期结束下注") - ); - } - - /** - * 广播开始发红包 - */ - private void broadcastStartRp(Long groupId, String roundNo) { - openIMApiClient.sendMsg( - openIMProperties.getAdminUserId(), - String.valueOf(groupId), - 3, // sessionType 3=群聊 - 101, // contentType 101=文本 - java.util.Map.of("content", roundNo + "期开始发红包") - ); - } - - /** - * 广播开奖结果 - */ - private void broadcastResult(Long groupId, int digit, String roundNo) { - String playWinDesc = gameService.getPlayWinDesc(digit); - String message = roundNo + "期开奖\n" + - "尾数: " + digit + "\n" + - "中奖玩法: " + playWinDesc; - - // 查询近30期历史 - String history = getRecentHistory(groupId); - if (history != null && !history.isEmpty()) { - message += "\n\n近30期:\n" + history; - } - - openIMApiClient.sendMsg( - openIMProperties.getAdminUserId(), - String.valueOf(groupId), - 3, // sessionType 3=群聊 - 101, // contentType 101=文本 - java.util.Map.of("content", 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(); - } - /** * 自动开始下一期 */