fix
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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<Long, RoundTask> roundTasks = new ConcurrentHashMap<>();
|
||||
|
||||
// 配置缓存
|
||||
private final Map<String, String> configCache = new ConcurrentHashMap<>();
|
||||
|
||||
// 延迟任务队列
|
||||
private final Map<String, PendingTask> 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<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秒执行)
|
||||
*/
|
||||
@@ -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<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;
|
||||
|
||||
/**
|
||||
* 第一个红包ID
|
||||
*/
|
||||
private Long firstRpId;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
|
||||
@@ -77,6 +77,16 @@ public class RedPacket {
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 预分配金额列表(JSON数组)
|
||||
*/
|
||||
private String shareAmounts;
|
||||
|
||||
/**
|
||||
* 已领取份额数
|
||||
*/
|
||||
private Integer shareTakenCount;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
|
||||
@@ -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<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",
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', '托领取超时时间(秒)');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user