This commit is contained in:
wells
2026-08-05 17:07:00 +08:00
parent ce7a619791
commit 16242b97de
6 changed files with 298 additions and 16 deletions
@@ -1,5 +1,6 @@
package com.tailbet.controller; package com.tailbet.controller;
import com.tailbet.job.GameJob;
import com.tailbet.model.dto.BetDTO; import com.tailbet.model.dto.BetDTO;
import com.tailbet.model.dto.GameOperateDTO; import com.tailbet.model.dto.GameOperateDTO;
import com.tailbet.model.dto.HistoryQueryDTO; import com.tailbet.model.dto.HistoryQueryDTO;
@@ -27,6 +28,7 @@ public class GameController {
private final IGameService gameService; private final IGameService gameService;
private final IBetService betService; private final IBetService betService;
private final GameJob gameJob;
/** /**
* 开始游戏(群主) * 开始游戏(群主)
@@ -36,6 +38,9 @@ public class GameController {
try { try {
Long userId = UserContext.getUserId(); Long userId = UserContext.getUserId();
var round = gameService.startGame(dto.getGroupId(), userId); 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); return R.ok(round);
} catch (RuntimeException e) { } catch (RuntimeException e) {
return R.fail(e.getMessage()); return R.fail(e.getMessage());
@@ -50,6 +55,8 @@ public class GameController {
try { try {
Long userId = UserContext.getUserId(); Long userId = UserContext.getUserId();
var round = gameService.endBet(dto.getGroupId(), userId); var round = gameService.endBet(dto.getGroupId(), userId);
// 触发结束接注后的广播和发红包流程
gameJob.onEndBet(round.getId(), dto.getGroupId());
return R.ok(round); return R.ok(round);
} catch (RuntimeException e) { } catch (RuntimeException e) {
return R.fail(e.getMessage()); return R.fail(e.getMessage());
+86 -9
View File
@@ -7,7 +7,11 @@ import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.mapper.RedPacketMapper; import com.tailbet.mapper.RedPacketMapper;
import com.tailbet.mapper.UserMapper; import com.tailbet.mapper.UserMapper;
import com.tailbet.mapper.DigitWhiteMapper; 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.DigitWhite;
import com.tailbet.model.entity.DrawRecord;
import com.tailbet.model.entity.SysConfig;
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;
@@ -34,6 +38,8 @@ public class GameJob {
private final GameRoundMapper gameRoundMapper; private final GameRoundMapper gameRoundMapper;
private final RedPacketMapper redPacketMapper; private final RedPacketMapper redPacketMapper;
private final DrawRecordMapper drawRecordMapper;
private final SysConfigMapper sysConfigMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final DigitWhiteMapper digitWhiteMapper; private final DigitWhiteMapper digitWhiteMapper;
private final IGameService gameService; private final IGameService gameService;
@@ -44,12 +50,51 @@ public class GameJob {
// 局状态监控(使用内存缓存,实际生产应使用Redis) // 局状态监控(使用内存缓存,实际生产应使用Redis)
private final Map<Long, RoundTask> roundTasks = new ConcurrentHashMap<>(); private final Map<Long, RoundTask> roundTasks = new ConcurrentHashMap<>();
// 配置缓存
private final Map<String, String> configCache = new ConcurrentHashMap<>();
// 延迟任务队列 // 延迟任务队列
private final Map<String, PendingTask> pendingTasks = new ConcurrentHashMap<>(); private final Map<String, PendingTask> pendingTasks = new ConcurrentHashMap<>();
// 任务ID生成器 // 任务ID生成器
private java.util.concurrent.atomic.AtomicLong taskIdGenerator = new java.util.concurrent.atomic.AtomicLong(1); private java.util.concurrent.atomic.AtomicLong taskIdGenerator = new java.util.concurrent.atomic.AtomicLong(1);
/**
* 启动时加载配置缓存
*/
@jakarta.annotation.PostConstruct
public void loadConfig() {
try {
List<SysConfig> 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秒执行) * 检查红包是否需要托领取(每3秒执行)
*/ */
@@ -63,8 +108,9 @@ public class GameJob {
for (RedPacket rp : ongoingRps) { for (RedPacket rp : ongoingRps) {
LocalDateTime createTime = rp.getCreateTime(); 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 { try {
fakeUserReceiveRemaining(rp); fakeUserReceiveRemaining(rp);
@@ -111,14 +157,15 @@ public class GameJob {
.eq(GameRound::getStatus, 0)); .eq(GameRound::getStatus, 0));
for (GameRound round : ongoingRounds) { for (GameRound round : ongoingRounds) {
// 检查是否超过接注时间(默认60秒) // 检查是否超过接注时间
RoundTask task = roundTasks.get(round.getId()); RoundTask task = roundTasks.get(round.getId());
if (task != null && task.endBetTime.isBefore(LocalDateTime.now())) { if (task != null && task.endBetTime.isBefore(LocalDateTime.now())) {
// 自动结束接注 // 自动结束接注
try { try {
gameService.endBet(round.getGroupId(), 0L); // 0L表示系统自动触发 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) { } catch (Exception e) {
log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage()); log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage());
} }
@@ -233,8 +280,9 @@ public class GameJob {
// 通知白名单用户尾数试算完成 // 通知白名单用户尾数试算完成
notifyWhiteUsers(roundId); 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()); 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" + String message = roundNo + "期开奖\n" +
"尾数: " + digit + "\n" + "尾数: " + digit + "\n" +
"中奖玩法: " + playWinDesc; "中奖玩法: " + playWinDesc;
// 查询近30期历史
String history = getRecentHistory(groupId);
if (history != null && !history.isEmpty()) {
message += "\n\n近30期:\n" + history;
}
openIMClient.sendGroupMessage(String.valueOf(groupId), message); openIMClient.sendGroupMessage(String.valueOf(groupId), 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();
}
/** /**
* 局任务信息 * 局任务信息
*/ */
@@ -66,6 +66,11 @@ public class GameRound {
*/ */
private Integer autoNext; private Integer autoNext;
/**
* 第一个红包ID
*/
private Long firstRpId;
/** /**
* 创建时间 * 创建时间
*/ */
@@ -77,6 +77,16 @@ public class RedPacket {
*/ */
private Integer status; private Integer status;
/**
* 预分配金额列表(JSON数组)
*/
private String shareAmounts;
/**
* 已领取份额数
*/
private Integer shareTakenCount;
/** /**
* 创建时间 * 创建时间
*/ */
@@ -1,26 +1,35 @@
package com.tailbet.service.impl; 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.RedPacket;
import com.tailbet.model.entity.RedPacketRecv; import com.tailbet.model.entity.RedPacketRecv;
import com.tailbet.model.entity.User; import com.tailbet.model.entity.User;
import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.mapper.RedPacketMapper; import com.tailbet.mapper.RedPacketMapper;
import com.tailbet.mapper.RedPacketRecvMapper; import com.tailbet.mapper.RedPacketRecvMapper;
import com.tailbet.mapper.UserMapper; 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.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.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
/** /**
* 红包Service实现 * 红包Service实现
@@ -36,6 +45,8 @@ public class RedPacketServiceImpl implements IRedPacketService {
private final GameRoundMapper gameRoundMapper; private final GameRoundMapper gameRoundMapper;
private final IPointsService pointsService; private final IPointsService pointsService;
private final IAuditService auditService; 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); 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<BigDecimal> 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", auditService.log(groupId, roundId, userId, "RP_SEND",
String.format("{\"rpId\":%d,\"amount\":%s,\"count\":%d}", String.format("{\"rpId\":%d,\"amount\":%s,\"count\":%d}",
@@ -107,17 +149,35 @@ public class RedPacketServiceImpl implements IRedPacketService {
throw new RuntimeException("已领取过该红包"); throw new RuntimeException("已领取过该红包");
} }
// 计算领取金额 // 计算领取金额(从预分配中取或随机)
BigDecimal amount = calculateReceiveAmount(rp); BigDecimal amount = drawFromPreAllocatedAmounts(rp);
// 更新红包 // 更新红包
rp.setRemainAmount(rp.getRemainAmount().subtract(amount)); rp.setRemainAmount(rp.getRemainAmount().subtract(amount));
rp.setRemainCount(rp.getRemainCount() - 1); rp.setRemainCount(rp.getRemainCount() - 1);
if (rp.getRemainCount() <= 0) { boolean isFinished = rp.getRemainCount() <= 0;
if (isFinished) {
rp.setStatus(STATUS_FINISHED); 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); 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(), pointsService.addPoints(userId, amount.longValue(),
IPointsService.TYPE_RP_RECV, String.valueOf(rpId), null, "领取红包"); IPointsService.TYPE_RP_RECV, String.valueOf(rpId), null, "领取红包");
@@ -202,4 +262,114 @@ public class RedPacketServiceImpl implements IRedPacketService {
.eq(RedPacket::getStatus, STATUS_ONGOING) .eq(RedPacket::getStatus, STATUS_ONGOING)
.isNotNull(RedPacket::getRoundId)); .isNotNull(RedPacket::getRoundId));
} }
/**
* 从预分配金额中顺序取值
* 兼容旧数据:如果没有预分配金额,则走随机算法
*/
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);
}
// 取第一个
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<BigDecimal> preCalculateShareAmounts(RedPacket rp, int targetDigit) {
List<BigDecimal> 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;
}
} }
+13
View File
@@ -221,3 +221,16 @@ INSERT INTO sys_config (cfg_key, cfg_value, remark) VALUES
('fake_daily_active_max', '20', '每日活跃假人最大数量'), ('fake_daily_active_max', '20', '每日活跃假人最大数量'),
('win_profit_threshold', '500', '小赢利润阈值'), ('win_profit_threshold', '500', '小赢利润阈值'),
('lose_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', '托领取超时时间(秒)');