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 消息:
+ *
+ * - {@link #broadcastStartBet} —— 开始下注
+ * - {@link #broadcastEndBet} —— 结束下注
+ * - {@link #broadcastStartRp} —— 开始发红包
+ * - {@link #broadcastResult} —— 开奖结果(含近30期历史)
+ * - {@link #notifyWhiteUsers} —— 通知尾数控制白名单
+ *
+ *
+ * 所有方法失败时只记日志、不抛异常,避免阻塞调用方业务流程。
+ */
+@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();
- }
-
/**
* 自动开始下一期
*/