游戏流程改版

This commit is contained in:
wells
2026-08-09 19:37:56 +08:00
parent dc98850b4d
commit 1c5e9d4cde
2 changed files with 193 additions and 125 deletions
@@ -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} 抽出
*
* <p>负责所有游戏流程的 OpenIM 消息:
* <ul>
* <li>{@link #broadcastStartBet} —— 开始下注</li>
* <li>{@link #broadcastEndBet} —— 结束下注</li>
* <li>{@link #broadcastStartRp} —— 开始发红包</li>
* <li>{@link #broadcastResult} —— 开奖结果(含近30期历史)</li>
* <li>{@link #notifyWhiteUsers} —— 通知尾数控制白名单</li>
* </ul>
*
* <p>所有方法失败时只记日志、不抛异常,避免阻塞调用方业务流程。
*/
@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<DigitWhite> 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<DrawRecord> records = drawRecordMapper.selectList(
new LambdaQueryWrapper<DrawRecord>()
.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);
}
}
}
+17 -125
View File
@@ -3,20 +3,15 @@ package com.tailbet.job;
import com.tailbet.model.entity.GameRound; import com.tailbet.model.entity.GameRound;
import com.tailbet.model.entity.RedPacket; import com.tailbet.model.entity.RedPacket;
import com.tailbet.model.entity.User; 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.GameRoundMapper;
import com.tailbet.mapper.GroupMemberMapper; import com.tailbet.mapper.GroupMemberMapper;
import com.tailbet.mapper.RedPacketMapper; import com.tailbet.mapper.RedPacketMapper;
import com.tailbet.mapper.RedPacketRecvMapper; import com.tailbet.mapper.RedPacketRecvMapper;
import com.tailbet.mapper.SysConfigMapper; import com.tailbet.mapper.SysConfigMapper;
import com.tailbet.mapper.UserMapper; 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.GroupMember;
import com.tailbet.model.entity.RedPacketRecv; import com.tailbet.model.entity.RedPacketRecv;
import com.tailbet.model.entity.SysConfig; import com.tailbet.model.entity.SysConfig;
import com.tailbet.openim.OpenIMApiClient;
import com.tailbet.service.IGameService; import com.tailbet.service.IGameService;
import com.tailbet.service.IRedPacketService; import com.tailbet.service.IRedPacketService;
import com.tailbet.service.IFakeUserService; import com.tailbet.service.IFakeUserService;
@@ -47,6 +42,8 @@ import java.util.stream.Collectors;
* <li>{@link #scanBotSendRpTimeouts()} —— 发包超时由机器人代发</li> * <li>{@link #scanBotSendRpTimeouts()} —— 发包超时由机器人代发</li>
* <li>{@link #scanAutoNextTimeouts()} —— 结算后自动开下一期</li> * <li>{@link #scanAutoNextTimeouts()} —— 结算后自动开下一期</li>
* </ul> * </ul>
*
* <p>所有 OpenIM 消息广播逻辑由 {@link GameBroadcaster} 负责。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -56,16 +53,13 @@ public class GameJob {
private final GameRoundMapper gameRoundMapper; private final GameRoundMapper gameRoundMapper;
private final RedPacketMapper redPacketMapper; private final RedPacketMapper redPacketMapper;
private final RedPacketRecvMapper redPacketRecvMapper; private final RedPacketRecvMapper redPacketRecvMapper;
private final DrawRecordMapper drawRecordMapper;
private final SysConfigMapper sysConfigMapper; private final SysConfigMapper sysConfigMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final DigitWhiteMapper digitWhiteMapper;
private final GroupMemberMapper groupMemberMapper; private final GroupMemberMapper groupMemberMapper;
private final IGameService gameService; private final IGameService gameService;
private final IRedPacketService redPacketService; private final IRedPacketService redPacketService;
private final IFakeUserService fakeUserService; private final IFakeUserService fakeUserService;
private final OpenIMApiClient openIMApiClient; private final GameBroadcaster broadcaster;
private final com.tailbet.config.OpenIMProperties openIMProperties;
// 配置缓存 // 配置缓存
private final Map<String, String> configCache = new ConcurrentHashMap<>(); private final Map<String, String> configCache = new ConcurrentHashMap<>();
@@ -214,19 +208,26 @@ public class GameJob {
/** /**
* 扫描下注超时局 —— 每 2 秒 * 扫描下注超时局 —— 每 2 秒
* <p>条件:{@code status=0 (下注中) AND end_bet_time < NOW()} * <p>条件:{@code status=0 (下注中) AND start_time + bet_window_seconds 秒 < NOW()}
* <p>说明:{@code end_bet_time} 在 {@code startGame} 时为 null(仅群主手动 {@code endBet} 时写入),
* 所以基准时间戳必须用 {@code start_time},与原 {@code recoverRoundTasks} 的 fallback 逻辑一致。
*/ */
@Scheduled(fixedDelay = 2000) @Scheduled(fixedDelay = 2000)
public void scanRoundTimeouts() { public void scanRoundTimeouts() {
int betWindowSeconds = getConfigInt("bet_window_seconds", 60);
LocalDateTime threshold = LocalDateTime.now().minusSeconds(betWindowSeconds);
List<GameRound> overdue = gameRoundMapper.selectList( List<GameRound> overdue = gameRoundMapper.selectList(
new LambdaQueryWrapper<GameRound>() new LambdaQueryWrapper<GameRound>()
.eq(GameRound::getStatus, IGameService.STATUS_BETTING) .eq(GameRound::getStatus, IGameService.STATUS_BETTING)
.lt(GameRound::getEndBetTime, LocalDateTime.now()) .isNotNull(GameRound::getStartTime)
.lt(GameRound::getStartTime, threshold)
.last("LIMIT 50")); .last("LIMIT 50"));
for (GameRound round : overdue) { for (GameRound round : overdue) {
try { try {
gameService.endBet(round.getGroupId(), 0L); // 0L 表示系统自动触发 gameService.endBet(round.getGroupId(), 0L); // 0L 表示系统自动触发
// 触发结束接注后的广播和发红包流程
onEndBet(round.getId(), round.getGroupId());
} catch (Exception e) { } catch (Exception e) {
log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage(), 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) { public void onGameStart(Long roundId, Long groupId, int intervalSeconds) {
GameRound round = gameRoundMapper.selectById(roundId); GameRound round = gameRoundMapper.selectById(roundId);
if (round != null) { if (round != null) {
broadcastStartBet(groupId, round.getRoundNo()); broadcaster.broadcastStartBet(groupId, round.getRoundNo());
} }
log.info("游戏开始: roundId={}, {}秒后结束接注", roundId, intervalSeconds); log.info("游戏开始: roundId={}, {}秒后结束接注", roundId, intervalSeconds);
} }
@@ -339,9 +340,9 @@ public class GameJob {
public void onEndBet(Long roundId, Long groupId) { public void onEndBet(Long roundId, Long groupId) {
GameRound round = gameRoundMapper.selectById(roundId); GameRound round = gameRoundMapper.selectById(roundId);
if (round != null) { 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); gameRoundMapper.updateById(round);
// 广播开始发红包 // 广播开始发红包
broadcastStartRp(groupId, round.getRoundNo()); broadcaster.broadcastStartRp(groupId, round.getRoundNo());
return true; return true;
} catch (Exception e) { } catch (Exception e) {
log.error("开始发红包失败: roundId={}, error={}", roundId, e.getMessage(), e); log.error("开始发红包失败: roundId={}, error={}", roundId, e.getMessage(), e);
@@ -451,7 +452,7 @@ public class GameJob {
// 广播开奖结果 // 广播开奖结果
GameRound round = gameRoundMapper.selectById(roundId); GameRound round = gameRoundMapper.selectById(roundId);
if (round != null) { if (round != null) {
broadcastResult(groupId, digit, round.getRoundNo()); broadcaster.broadcastResult(groupId, digit, round.getRoundNo());
} }
log.info("局结算完成: roundId={}, digit={}", roundId, digit); log.info("局结算完成: roundId={}, digit={}", roundId, digit);
@@ -462,115 +463,6 @@ public class GameJob {
} }
} }
/**
* 通知白名单用户(尾数试算完成)
*/
private void notifyWhiteUsers(Long roundId) {
List<DigitWhite> 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<DrawRecord> records = drawRecordMapper.selectList(
new LambdaQueryWrapper<DrawRecord>()
.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();
}
/** /**
* 自动开始下一期 * 自动开始下一期
*/ */