Compare commits
12
Commits
e6a4cf882a
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5e9d4cde | ||
|
|
dc98850b4d | ||
|
|
724dae91b1 | ||
|
|
aad68e407e | ||
|
|
6be2339d87 | ||
|
|
c5250e23ef | ||
|
|
2c6f405524 | ||
|
|
ee5f33aba6 | ||
|
|
114a0d93fe | ||
|
|
41e55bc94e | ||
|
|
d5440cc8db | ||
|
|
ef841a3e5d |
@@ -112,11 +112,7 @@ public class GameController {
|
||||
@PostMapping("/digit/set")
|
||||
public R<Void> setDigit(@RequestBody SetDigitDTO dto) {
|
||||
Long userId = UserContext.getUserId();
|
||||
|
||||
// 验证白名单权限
|
||||
DigitWhite white = digitWhiteMapper.selectOne(new LambdaQueryWrapper<DigitWhite>()
|
||||
.eq(DigitWhite::getUserId, userId));
|
||||
if (white == null) {
|
||||
if (!UserContext.isWhite()) {
|
||||
return R.fail("您不是白名单用户,无权设置尾数");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.tailbet.controller;
|
||||
|
||||
import com.tailbet.model.dto.CreateGroupDTO;
|
||||
import com.tailbet.model.dto.GroupMemberOperateDTO;
|
||||
import com.tailbet.model.dto.PointsDTO;
|
||||
import com.tailbet.model.dto.PullGroupDTO;
|
||||
import com.tailbet.model.entity.Group;
|
||||
@@ -78,4 +79,45 @@ public class OpsController {
|
||||
return R.ok();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出群成员
|
||||
* <p>
|
||||
* 操作人需为该群群主或管理员;同时从本地数据库和 OpenIM 移除成员。
|
||||
* </p>
|
||||
*/
|
||||
@PostMapping("/group/kick")
|
||||
public R<Void> kickGroup(@Valid @RequestBody GroupMemberOperateDTO dto) {
|
||||
Long operatorId = UserContext.getUserId();
|
||||
groupService.kickUser(dto.getGroupId(), dto.getUserId(), operatorId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为管理员
|
||||
* <p>
|
||||
* 操作人需为该群群主;同时本地与 OpenIM 角色都会更新(OpenIM roleLevel=60=管理员),
|
||||
* OpenIM 同步失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||
* </p>
|
||||
*/
|
||||
@PostMapping("/group/set-admin")
|
||||
public R<Void> setAdmin(@Valid @RequestBody GroupMemberOperateDTO dto) {
|
||||
Long operatorId = UserContext.getUserId();
|
||||
groupService.setAdmin(dto.getGroupId(), dto.getUserId(), operatorId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消管理员
|
||||
* <p>
|
||||
* 操作人需为该群群主;同时本地与 OpenIM 角色都会更新(OpenIM roleLevel=20=普通成员),
|
||||
* OpenIM 同步失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||
* </p>
|
||||
*/
|
||||
@PostMapping("/group/remove-admin")
|
||||
public R<Void> removeAdmin(@Valid @RequestBody GroupMemberOperateDTO dto) {
|
||||
Long operatorId = UserContext.getUserId();
|
||||
groupService.removeAdmin(dto.getGroupId(), dto.getUserId(), operatorId);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,17 @@
|
||||
package com.tailbet.job;
|
||||
|
||||
import com.tailbet.model.RoundTask;
|
||||
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.PendingTaskMapper;
|
||||
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.model.entity.PendingTask;
|
||||
import com.tailbet.model.PendingTaskInfo;
|
||||
import com.tailbet.openim.OpenIMApiClient;
|
||||
import com.tailbet.service.IGameService;
|
||||
import com.tailbet.service.IRedPacketService;
|
||||
import com.tailbet.service.IFakeUserService;
|
||||
@@ -40,6 +31,19 @@ import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 游戏定时任务
|
||||
*
|
||||
* <p>状态完全由 {@code game_round} 数据库表承载,不再使用内存 Map。
|
||||
* 定时任务只做"数据库扫描 + 状态机推进",天然支持单实例重启恢复。
|
||||
*
|
||||
* <p>扫描任务(每 2 秒):
|
||||
* <ul>
|
||||
* <li>{@link #scanRoundTimeouts()} —— 下注超时自动封盘</li>
|
||||
* <li>{@link #scanStartRpTimeouts()} —— 封盘后延迟开始发红包</li>
|
||||
* <li>{@link #scanBotSendRpTimeouts()} —— 发包超时由机器人代发</li>
|
||||
* <li>{@link #scanAutoNextTimeouts()} —— 结算后自动开下一期</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>所有 OpenIM 消息广播逻辑由 {@link GameBroadcaster} 负责。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -49,128 +53,34 @@ 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 PendingTaskMapper pendingTaskMapper;
|
||||
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;
|
||||
|
||||
// 局状态监控(使用内存缓存,实际生产应使用Redis)
|
||||
private final Map<Long, RoundTask> roundTasks = new ConcurrentHashMap<>();
|
||||
private final GameBroadcaster broadcaster;
|
||||
|
||||
// 配置缓存
|
||||
private final Map<String, String> configCache = new ConcurrentHashMap<>();
|
||||
|
||||
// 延迟任务队列
|
||||
private final Map<String, PendingTaskInfo> pendingTasks = new ConcurrentHashMap<>();
|
||||
|
||||
// 任务ID生成器 (格式: {type}_{groupId}_{自增序号})
|
||||
private java.util.concurrent.atomic.AtomicLong taskIdGenerator = new java.util.concurrent.atomic.AtomicLong(1);
|
||||
|
||||
/**
|
||||
* 启动时加载配置缓存并恢复内存状态
|
||||
* 启动时加载配置缓存
|
||||
* <p>不再做 roundTasks/pendingTasks 内存恢复 —— 扫描任务启动后会自动从数据库接管
|
||||
*/
|
||||
@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());
|
||||
|
||||
// 恢复进行中的局状态
|
||||
recoverRoundTasks();
|
||||
|
||||
// 恢复延迟任务队列
|
||||
recoverPendingTasks();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("启动初始化失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库恢复进行中的局状态
|
||||
*/
|
||||
private void recoverRoundTasks() {
|
||||
try {
|
||||
List<GameRound> ongoingRounds = gameRoundMapper.selectList(
|
||||
new LambdaQueryWrapper<GameRound>()
|
||||
.lt(GameRound::getStatus, IGameService.STATUS_FINISHED));
|
||||
|
||||
if (ongoingRounds.isEmpty()) {
|
||||
log.info("无进行中的局,无需恢复");
|
||||
return;
|
||||
}
|
||||
|
||||
int bettingInterval = getConfigInt("betting_interval", 60);
|
||||
|
||||
for (GameRound round : ongoingRounds) {
|
||||
RoundTask task = new RoundTask();
|
||||
task.setRoundId(round.getId());
|
||||
task.setGroupId(round.getGroupId());
|
||||
|
||||
// 优先使用数据库中的endBetTime,否则根据startTime计算
|
||||
if (round.getEndBetTime() != null) {
|
||||
task.setEndBetTime(round.getEndBetTime());
|
||||
} else if (round.getStartTime() != null) {
|
||||
task.setEndBetTime(round.getStartTime().plusSeconds(bettingInterval));
|
||||
} else {
|
||||
// 兜底:使用配置的下注时长
|
||||
task.setEndBetTime(LocalDateTime.now().plusSeconds(bettingInterval));
|
||||
}
|
||||
|
||||
roundTasks.put(round.getId(), task);
|
||||
}
|
||||
|
||||
log.info("局状态恢复完成,共恢复 {} 个进行中的局", ongoingRounds.size());
|
||||
} catch (Exception e) {
|
||||
log.error("恢复局状态失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库恢复延迟任务队列
|
||||
*/
|
||||
private void recoverPendingTasks() {
|
||||
try {
|
||||
List<PendingTask> dbTasks = pendingTaskMapper.selectList(
|
||||
new LambdaQueryWrapper<PendingTask>()
|
||||
.gt(PendingTask::getExecuteTime, LocalDateTime.now()));
|
||||
|
||||
if (dbTasks == null || dbTasks.isEmpty()) {
|
||||
log.info("无待执行的延迟任务,无需恢复");
|
||||
return;
|
||||
}
|
||||
|
||||
for (PendingTask dbTask : dbTasks) {
|
||||
if (dbTask.getExecuteTime() == null || !dbTask.getExecuteTime().isAfter(LocalDateTime.now())) {
|
||||
continue;
|
||||
}
|
||||
PendingTaskInfo info = new PendingTaskInfo();
|
||||
info.setTaskId(dbTask.getTaskId());
|
||||
info.setType(dbTask.getType());
|
||||
info.setRoundId(dbTask.getRoundId());
|
||||
info.setGroupId(dbTask.getGroupId());
|
||||
info.setDigit(dbTask.getDigit());
|
||||
info.setExecuteTime(dbTask.getExecuteTime());
|
||||
pendingTasks.put(info.getTaskId(), info);
|
||||
}
|
||||
|
||||
log.info("延迟任务恢复完成,共恢复 {} 个待执行任务", pendingTasks.size());
|
||||
} catch (Exception e) {
|
||||
log.error("恢复延迟任务失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置值
|
||||
*/
|
||||
@@ -229,8 +139,29 @@ public class GameJob {
|
||||
* 假人托领取剩余份额(循环领取直到红包领完)
|
||||
*/
|
||||
private void fakeUserReceiveRemaining(RedPacket rp) {
|
||||
List<User> fakes = fakeUserService.getFakeUsers();
|
||||
if (rp.getGroupId() == null) {
|
||||
log.warn("红包未关联群组,托跳过领取: rpId={}", rp.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅允许群内假人领取,避免跨群领取红包
|
||||
List<Long> groupMemberIds = groupMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<GroupMember>()
|
||||
.eq(GroupMember::getGroupId, rp.getGroupId())
|
||||
.select(GroupMember::getUserId))
|
||||
.stream()
|
||||
.map(GroupMember::getUserId)
|
||||
.collect(Collectors.toList());
|
||||
if (groupMemberIds.isEmpty()) {
|
||||
log.warn("红包所属群组无成员,托跳过领取: rpId={}, groupId={}", rp.getId(), rp.getGroupId());
|
||||
return;
|
||||
}
|
||||
|
||||
List<User> fakes = fakeUserService.getFakeUsers().stream()
|
||||
.filter(fake -> groupMemberIds.contains(fake.getId()))
|
||||
.toList();
|
||||
if (fakes.isEmpty()) {
|
||||
log.info("红包所属群组无可用假人,托跳过领取: rpId={}, groupId={}", rp.getId(), rp.getGroupId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,26 +207,109 @@ public class GameJob {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查局超时(每10秒执行)
|
||||
* 扫描下注超时局 —— 每 2 秒
|
||||
* <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 = 10000)
|
||||
public void checkRoundTimeout() {
|
||||
List<GameRound> ongoingRounds = gameRoundMapper.selectList(new LambdaQueryWrapper<GameRound>()
|
||||
.eq(GameRound::getStatus, IGameService.STATUS_BETTING));
|
||||
@Scheduled(fixedDelay = 2000)
|
||||
public void scanRoundTimeouts() {
|
||||
int betWindowSeconds = getConfigInt("bet_window_seconds", 60);
|
||||
LocalDateTime threshold = LocalDateTime.now().minusSeconds(betWindowSeconds);
|
||||
List<GameRound> overdue = gameRoundMapper.selectList(
|
||||
new LambdaQueryWrapper<GameRound>()
|
||||
.eq(GameRound::getStatus, IGameService.STATUS_BETTING)
|
||||
.isNotNull(GameRound::getStartTime)
|
||||
.lt(GameRound::getStartTime, threshold)
|
||||
.last("LIMIT 50"));
|
||||
|
||||
for (GameRound round : ongoingRounds) {
|
||||
// 检查是否超过接注时间
|
||||
RoundTask task = roundTasks.get(round.getId());
|
||||
if (task != null && task.getEndBetTime().isBefore(LocalDateTime.now())) {
|
||||
// 自动结束接注
|
||||
try {
|
||||
gameService.endBet(round.getGroupId(), 0L); // 0L表示系统自动触发
|
||||
// 触发开始发红包,延迟时间从配置读取
|
||||
int sendRpDelay = getConfigInt("send_rp_delay", 20);
|
||||
scheduleStartRp(round.getId(), round.getGroupId(), sendRpDelay);
|
||||
} catch (Exception e) {
|
||||
log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描"封盘后到点开始发红包" —— 每 2 秒
|
||||
* <p>条件:{@code status=1 (已封盘) AND start_rp_time IS NULL AND end_bet_time + send_rp_delay 秒 < NOW()}
|
||||
*/
|
||||
@Scheduled(fixedDelay = 2000)
|
||||
public void scanStartRpTimeouts() {
|
||||
int sendRpDelay = getConfigInt("send_rp_delay", 20);
|
||||
LocalDateTime threshold = LocalDateTime.now().minusSeconds(sendRpDelay);
|
||||
List<GameRound> due = gameRoundMapper.selectList(
|
||||
new LambdaQueryWrapper<GameRound>()
|
||||
.eq(GameRound::getStatus, IGameService.STATUS_BET_CLOSED)
|
||||
.isNull(GameRound::getStartRpTime)
|
||||
.lt(GameRound::getEndBetTime, threshold)
|
||||
.last("LIMIT 50"));
|
||||
|
||||
for (GameRound round : due) {
|
||||
try {
|
||||
onStartRp(round.getId(), round.getGroupId());
|
||||
} catch (Exception e) {
|
||||
log.error("延迟开始发红包失败: roundId={}, error={}", round.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描"发包超时由机器人代发" —— 每 2 秒
|
||||
* <p>条件:{@code status=2 (发红包中) AND first_rp_id IS NULL AND start_rp_time + auto_rp_timeout 秒 < NOW()}
|
||||
*/
|
||||
@Scheduled(fixedDelay = 2000)
|
||||
public void scanBotSendRpTimeouts() {
|
||||
int autoRpTimeout = getConfigInt("auto_rp_timeout", 10);
|
||||
LocalDateTime threshold = LocalDateTime.now().minusSeconds(autoRpTimeout);
|
||||
List<GameRound> due = gameRoundMapper.selectList(
|
||||
new LambdaQueryWrapper<GameRound>()
|
||||
.eq(GameRound::getStatus, IGameService.STATUS_RP_SENDING)
|
||||
.isNull(GameRound::getFirstRpId)
|
||||
.isNotNull(GameRound::getStartRpTime)
|
||||
.lt(GameRound::getStartRpTime, threshold)
|
||||
.last("LIMIT 50"));
|
||||
|
||||
for (GameRound round : due) {
|
||||
try {
|
||||
botSendRp(round.getId(), round.getGroupId());
|
||||
} catch (Exception e) {
|
||||
log.error("机器人自动发包扫描执行失败: roundId={}, error={}", round.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描"结算后自动开下一期" —— 每 2 秒
|
||||
* <p>条件:{@code status=3 (已结算) AND auto_next=1 AND finish_time + 5 秒 < NOW()}
|
||||
* 且本群当前无进行中局
|
||||
*/
|
||||
@Scheduled(fixedDelay = 2000)
|
||||
public void scanAutoNextTimeouts() {
|
||||
int autoNextDelay = getConfigInt("auto_next_delay", 5);
|
||||
LocalDateTime threshold = LocalDateTime.now().minusSeconds(autoNextDelay);
|
||||
List<GameRound> finished = gameRoundMapper.selectList(
|
||||
new LambdaQueryWrapper<GameRound>()
|
||||
.eq(GameRound::getStatus, IGameService.STATUS_FINISHED)
|
||||
.eq(GameRound::getAutoNext, 1)
|
||||
.isNotNull(GameRound::getFinishTime)
|
||||
.lt(GameRound::getFinishTime, threshold)
|
||||
.last("LIMIT 50"));
|
||||
|
||||
for (GameRound round : finished) {
|
||||
try {
|
||||
// 仅当该群当前无进行中局时才开下一期
|
||||
GameRound ongoing = gameService.getOngoingRound(round.getGroupId());
|
||||
if (ongoing != null) {
|
||||
continue;
|
||||
}
|
||||
onAutoNext(round.getGroupId());
|
||||
} catch (Exception e) {
|
||||
log.error("自动开下一期失败: groupId={}, error={}", round.getGroupId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,199 +323,83 @@ public class GameJob {
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排开始发红包
|
||||
*/
|
||||
public String scheduleStartRp(Long roundId, Long groupId, int delaySeconds) {
|
||||
String taskId = "start_rp_" + groupId + "_" + taskIdGenerator.getAndIncrement();
|
||||
PendingTaskInfo task = new PendingTaskInfo();
|
||||
task.setTaskId(taskId);
|
||||
task.setType("start_rp");
|
||||
task.setRoundId(roundId);
|
||||
task.setGroupId(groupId);
|
||||
LocalDateTime executeTime = LocalDateTime.now().plusSeconds(delaySeconds);
|
||||
task.setExecuteTime(executeTime);
|
||||
pendingTasks.put(taskId, task);
|
||||
// 持久化到数据库
|
||||
saveTaskToDb(taskId, "start_rp", roundId, groupId, null, executeTime);
|
||||
log.info("安排{}秒后开始发红包: roundId={}", delaySeconds, roundId);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排自动结算
|
||||
*/
|
||||
public String scheduleSettle(Long roundId, Long groupId, int digit, int delaySeconds) {
|
||||
String taskId = "settle_" + groupId + "_" + taskIdGenerator.getAndIncrement();
|
||||
PendingTaskInfo task = new PendingTaskInfo();
|
||||
task.setTaskId(taskId);
|
||||
task.setType("settle");
|
||||
task.setRoundId(roundId);
|
||||
task.setGroupId(groupId);
|
||||
task.setDigit(digit);
|
||||
LocalDateTime executeTime = LocalDateTime.now().plusSeconds(delaySeconds);
|
||||
task.setExecuteTime(executeTime);
|
||||
pendingTasks.put(taskId, task);
|
||||
// 持久化到数据库
|
||||
saveTaskToDb(taskId, "settle", roundId, groupId, digit, executeTime);
|
||||
log.info("安排{}秒后结算: taskId={}, roundId={}, digit={}", delaySeconds, taskId, roundId, digit);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排自动下一期
|
||||
*/
|
||||
public String scheduleAutoNextTask(Long groupId, int delaySeconds) {
|
||||
String taskId = "auto_next_" + groupId + "_" + taskIdGenerator.getAndIncrement();
|
||||
PendingTaskInfo task = new PendingTaskInfo();
|
||||
task.setTaskId(taskId);
|
||||
task.setType("auto_next");
|
||||
task.setGroupId(groupId);
|
||||
LocalDateTime executeTime = LocalDateTime.now().plusSeconds(delaySeconds);
|
||||
task.setExecuteTime(executeTime);
|
||||
pendingTasks.put(taskId, task);
|
||||
// 持久化到数据库
|
||||
saveTaskToDb(taskId, "auto_next", null, groupId, null, executeTime);
|
||||
log.info("安排{}秒后自动开始下一期: groupId={}", delaySeconds, groupId);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排机器人发包
|
||||
*/
|
||||
public String scheduleBotSendRp(Long roundId, Long groupId, int delaySeconds) {
|
||||
String taskId = "bot_send_rp_" + groupId + "_" + taskIdGenerator.getAndIncrement();
|
||||
PendingTaskInfo task = new PendingTaskInfo();
|
||||
task.setTaskId(taskId);
|
||||
task.setType("bot_send_rp");
|
||||
task.setRoundId(roundId);
|
||||
task.setGroupId(groupId);
|
||||
LocalDateTime executeTime = LocalDateTime.now().plusSeconds(delaySeconds);
|
||||
task.setExecuteTime(executeTime);
|
||||
pendingTasks.put(taskId, task);
|
||||
// 持久化到数据库
|
||||
saveTaskToDb(taskId, "bot_send_rp", roundId, groupId, null, executeTime);
|
||||
log.info("安排{}秒后机器人发包: roundId={}", delaySeconds, roundId);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存任务到数据库
|
||||
*/
|
||||
private void saveTaskToDb(String taskId, String type, Long roundId, Long groupId, Integer digit, LocalDateTime executeTime) {
|
||||
try {
|
||||
PendingTask dbTask = new PendingTask();
|
||||
dbTask.setTaskId(taskId);
|
||||
dbTask.setType(type);
|
||||
dbTask.setRoundId(roundId);
|
||||
dbTask.setGroupId(groupId);
|
||||
dbTask.setDigit(digit);
|
||||
dbTask.setExecuteTime(executeTime);
|
||||
dbTask.setCreateTime(LocalDateTime.now());
|
||||
pendingTaskMapper.insert(dbTask);
|
||||
} catch (Exception e) {
|
||||
log.error("保存任务到数据库失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库删除任务
|
||||
*/
|
||||
private void deleteTaskFromDb(String taskId) {
|
||||
try {
|
||||
pendingTaskMapper.delete(new LambdaQueryWrapper<PendingTask>()
|
||||
.eq(PendingTask::getTaskId, taskId));
|
||||
} catch (Exception e) {
|
||||
log.error("从数据库删除任务失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏后安排定时器
|
||||
* 开始游戏后广播并打日志
|
||||
*/
|
||||
public void onGameStart(Long roundId, Long groupId, int intervalSeconds) {
|
||||
RoundTask task = new RoundTask();
|
||||
task.setRoundId(roundId);
|
||||
task.setGroupId(groupId);
|
||||
task.setEndBetTime(LocalDateTime.now().plusSeconds(intervalSeconds));
|
||||
roundTasks.put(roundId, task);
|
||||
|
||||
// 广播开始下注
|
||||
GameRound round = gameRoundMapper.selectById(roundId);
|
||||
if (round != null) {
|
||||
broadcastStartBet(groupId, round.getRoundNo());
|
||||
broadcaster.broadcastStartBet(groupId, round.getRoundNo());
|
||||
}
|
||||
|
||||
log.info("游戏开始: roundId={}, {}秒后结束接注", roundId, intervalSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束接注后安排发红包
|
||||
* 结束接注后广播 + 通知白名单
|
||||
* <p>延迟开始发红包由 {@link #scanStartRpTimeouts()} 接管
|
||||
*/
|
||||
public void onEndBet(Long roundId, Long groupId) {
|
||||
// 移除结束接注定时器
|
||||
roundTasks.remove(roundId);
|
||||
|
||||
// 广播结束下注
|
||||
GameRound round = gameRoundMapper.selectById(roundId);
|
||||
if (round != null) {
|
||||
broadcastEndBet(groupId, round.getRoundNo());
|
||||
broadcaster.broadcastEndBet(groupId, round.getRoundNo());
|
||||
}
|
||||
|
||||
// 通知白名单用户尾数试算完成
|
||||
notifyWhiteUsers(roundId);
|
||||
|
||||
// 安排延迟后开始发红包
|
||||
int sendRpDelay = getConfigInt("send_rp_delay", 20);
|
||||
scheduleStartRp(roundId, groupId, sendRpDelay);
|
||||
broadcaster.notifyWhiteUsers(roundId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始发红包
|
||||
* <p>机器人超时发包由 {@link #scanBotSendRpTimeouts()} 接管
|
||||
*/
|
||||
public void onStartRp(Long roundId, Long groupId) {
|
||||
GameRound round = gameRoundMapper.selectById(roundId);
|
||||
if (round == null) {
|
||||
return;
|
||||
public boolean onStartRp(Long roundId, Long groupId) {
|
||||
try {
|
||||
GameRound round = gameRoundMapper.selectById(roundId);
|
||||
if (round == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 再次校验:避免重复推进状态机
|
||||
if (round.getStatus() == null || round.getStatus() != IGameService.STATUS_BET_CLOSED) {
|
||||
log.debug("本局{}当前状态非封盘中(status={}),跳过发红包开始",
|
||||
roundId, round.getStatus());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 更新局状态为开始发红包
|
||||
round.setStatus(IGameService.STATUS_RP_SENDING);
|
||||
round.setStartRpTime(LocalDateTime.now());
|
||||
gameRoundMapper.updateById(round);
|
||||
|
||||
// 广播开始发红包
|
||||
broadcaster.broadcastStartRp(groupId, round.getRoundNo());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("开始发红包失败: roundId={}, error={}", roundId, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新局状态为开始发红包
|
||||
round.setStatus(IGameService.STATUS_RP_SENDING);
|
||||
round.setStartRpTime(LocalDateTime.now());
|
||||
gameRoundMapper.updateById(round);
|
||||
|
||||
// 广播开始发红包
|
||||
broadcastStartRp(groupId, round.getRoundNo());
|
||||
|
||||
// 安排超时后如果没人发包则机器人自动发
|
||||
int autoRpTimeout = getConfigInt("auto_rp_timeout", 10);
|
||||
scheduleBotSendRp(roundId, groupId, autoRpTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 机器人自动发包
|
||||
*/
|
||||
public void botSendRp(Long roundId, Long groupId) {
|
||||
public boolean botSendRp(Long roundId, Long groupId) {
|
||||
GameRound round = gameRoundMapper.selectById(roundId);
|
||||
if (round == null) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 仅在"开始发红包"状态下才执行,避免误触发
|
||||
if (round.getStatus() == null || round.getStatus() != IGameService.STATUS_RP_SENDING) {
|
||||
log.debug("本局{}当前状态非发红包中(status={}),机器人跳过发包",
|
||||
roundId, round.getStatus());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 已有用户先发包,机器人不再重复发包(让用户的红包触发开奖)
|
||||
if (round.getFirstRpId() != null) {
|
||||
log.info("本局{}已有用户先发包(firstRpId={}),机器人跳过发包",
|
||||
roundId, round.getFirstRpId());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 即使未设置目标尾数,机器人也会发包(由RedPacketService按随机金额处理)
|
||||
|
||||
// 从本群成员中查找机器人账号
|
||||
List<Long> memberIds = groupMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<GroupMember>()
|
||||
@@ -513,7 +411,7 @@ public class GameJob {
|
||||
|
||||
if (memberIds.isEmpty()) {
|
||||
log.warn("群{}无成员,机器人跳过发包", groupId);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
User bot = userMapper.selectOne(new LambdaQueryWrapper<User>()
|
||||
@@ -524,7 +422,7 @@ public class GameJob {
|
||||
|
||||
if (bot == null) {
|
||||
log.warn("群{}成员中无可用机器人账号,跳过发包", groupId);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -536,205 +434,52 @@ public class GameJob {
|
||||
RedPacket rp = redPacketService.sendRp(bot.getId(), groupId, amount, count, roundId);
|
||||
log.info("机器人自动发包: roundId={}, rpId={}, amount={}, count={}, targetDigit={}",
|
||||
roundId, rp.getId(), amount, count, round.getTargetDigit());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("机器人发包失败: roundId={}, error={}", roundId, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开奖结算
|
||||
* <p>自动开下一期由 {@link #scanAutoNextTimeouts()} 接管
|
||||
*/
|
||||
public void onSettle(Long roundId, Long groupId, int digit) {
|
||||
public boolean onSettle(Long roundId, Long groupId, int digit) {
|
||||
try {
|
||||
gameService.settle(roundId, digit);
|
||||
|
||||
// 广播开奖结果
|
||||
GameRound round = gameRoundMapper.selectById(roundId);
|
||||
if (round != null) {
|
||||
broadcastResult(groupId, digit, round.getRoundNo());
|
||||
broadcaster.broadcastResult(groupId, digit, round.getRoundNo());
|
||||
}
|
||||
|
||||
// 检查是否自动开启下一期
|
||||
GameRound roundAfterSettle = gameRoundMapper.selectById(roundId);
|
||||
if (roundAfterSettle != null && roundAfterSettle.getAutoNext() == 1) {
|
||||
// 5秒后自动开始下一期
|
||||
scheduleAutoNextTask(groupId, 5);
|
||||
}
|
||||
|
||||
// 清理任务
|
||||
roundTasks.remove(roundId);
|
||||
|
||||
log.info("局结算完成: roundId={}, digit={}", roundId, digit);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("结算失败: roundId={}, error={}", roundId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知白名单用户(尾数试算完成)
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理延迟任务队列(每秒执行)
|
||||
*/
|
||||
@Scheduled(fixedDelay = 1000)
|
||||
public void processPendingTasks() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
pendingTasks.entrySet().removeIf(entry -> {
|
||||
PendingTaskInfo task = entry.getValue();
|
||||
if (task.getExecuteTime() == null) {
|
||||
deleteTaskFromDb(task.getTaskId());
|
||||
return true;
|
||||
}
|
||||
if (task.getExecuteTime().isBefore(now) || task.getExecuteTime().isEqual(now)) {
|
||||
executeTask(task);
|
||||
deleteTaskFromDb(task.getTaskId());
|
||||
return true;
|
||||
}
|
||||
log.error("结算失败: roundId={}, error={}", roundId, e.getMessage(), e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行延迟任务
|
||||
*/
|
||||
private void executeTask(PendingTaskInfo task) {
|
||||
try {
|
||||
switch (task.getType()) {
|
||||
case "settle":
|
||||
onSettle(task.getRoundId(), task.getGroupId(), task.getDigit());
|
||||
break;
|
||||
case "auto_next":
|
||||
onAutoNext(task.getGroupId());
|
||||
break;
|
||||
case "start_rp":
|
||||
onStartRp(task.getRoundId(), task.getGroupId());
|
||||
break;
|
||||
case "bot_send_rp":
|
||||
botSendRp(task.getRoundId(), task.getGroupId());
|
||||
break;
|
||||
default:
|
||||
log.warn("未知任务类型: {}", task.getType());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("执行任务失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动开始下一期
|
||||
*/
|
||||
private void onAutoNext(Long groupId) {
|
||||
private boolean onAutoNext(Long groupId) {
|
||||
try {
|
||||
gameService.startGame(groupId, 0L); // 0L表示系统自动
|
||||
GameRound round = gameService.startGame(groupId, 0L); // 0L表示系统自动
|
||||
if (round == null || round.getId() == null) {
|
||||
log.warn("自动开始下一期失败:startGame未返回局信息, groupId={}", groupId);
|
||||
return false;
|
||||
}
|
||||
// 复用与手动流程一致的逻辑:广播开始下注
|
||||
int betWindowSeconds = getConfigInt("bet_window_seconds", 60);
|
||||
onGameStart(round.getId(), groupId, betWindowSeconds);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("自动开始下一期失败: groupId={}, error={}", groupId, e.getMessage());
|
||||
log.error("自动开始下一期失败: groupId={}, error={}", groupId, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.tailbet.mapper;
|
||||
|
||||
import com.tailbet.model.entity.PendingTask;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 延迟任务Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface PendingTaskMapper extends BaseMapper<PendingTask> {
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 用户Mapper
|
||||
*/
|
||||
@@ -16,11 +18,11 @@ public interface UserMapper extends BaseMapper<User> {
|
||||
* 原子增加积分
|
||||
*/
|
||||
@Update("UPDATE user SET points = points + #{amount} WHERE id = #{id}")
|
||||
int addPoints(@Param("id") Long id, @Param("amount") Long amount);
|
||||
int addPoints(@Param("id") Long id, @Param("amount") BigDecimal amount);
|
||||
|
||||
/**
|
||||
* 原子扣除积分(积分不足时返回0)
|
||||
*/
|
||||
@Update("UPDATE user SET points = points - #{amount} WHERE id = #{id} AND points >= #{amount}")
|
||||
int subPoints(@Param("id") Long id, @Param("amount") Long amount);
|
||||
int subPoints(@Param("id") Long id, @Param("amount") BigDecimal amount);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.tailbet.model;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 延迟任务信息(内存缓存用)
|
||||
*/
|
||||
@Data
|
||||
public class PendingTaskInfo {
|
||||
private String taskId;
|
||||
private String type;
|
||||
private Long roundId;
|
||||
private Long groupId;
|
||||
private Integer digit;
|
||||
private LocalDateTime executeTime;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.tailbet.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 局任务信息
|
||||
*/
|
||||
@Data
|
||||
public class RoundTask {
|
||||
private Long roundId;
|
||||
private Long groupId;
|
||||
private LocalDateTime endBetTime;
|
||||
private LocalDateTime startRpTime;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.tailbet.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 下注请求
|
||||
*/
|
||||
@@ -25,5 +27,5 @@ public class BetDTO {
|
||||
/**
|
||||
* 下注金额
|
||||
*/
|
||||
private Integer betAmount;
|
||||
private BigDecimal betAmount;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.tailbet.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 群成员角色操作请求(设置/取消管理员)
|
||||
*/
|
||||
@Data
|
||||
public class GroupMemberOperateDTO {
|
||||
|
||||
/**
|
||||
* 群ID
|
||||
*/
|
||||
@NotNull(message = "群ID不能为空")
|
||||
private Long groupId;
|
||||
|
||||
/**
|
||||
* 目标用户ID
|
||||
*/
|
||||
@NotNull(message = "目标用户ID不能为空")
|
||||
private Long userId;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.tailbet.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 积分操作请求
|
||||
*/
|
||||
@@ -15,7 +17,7 @@ public class PointsDTO {
|
||||
/**
|
||||
* 积分数量
|
||||
*/
|
||||
private Long amount;
|
||||
private BigDecimal amount;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
|
||||
@@ -45,7 +45,7 @@ public class BetOrder {
|
||||
/**
|
||||
* 下注金额
|
||||
*/
|
||||
private Integer betAmount;
|
||||
private BigDecimal betAmount;
|
||||
|
||||
/**
|
||||
* 赔率
|
||||
@@ -65,7 +65,7 @@ public class BetOrder {
|
||||
/**
|
||||
* 中奖金额
|
||||
*/
|
||||
private Long winAmount;
|
||||
private BigDecimal winAmount;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.tailbet.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 延迟任务表
|
||||
*/
|
||||
@Data
|
||||
@TableName("pending_task")
|
||||
public class PendingTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 任务ID
|
||||
*/
|
||||
private String taskId;
|
||||
|
||||
/**
|
||||
* 任务类型: start_rp/settle/auto_next/bot_send_rp
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 局ID
|
||||
*/
|
||||
private Long roundId;
|
||||
|
||||
/**
|
||||
* 群ID
|
||||
*/
|
||||
private Long groupId;
|
||||
|
||||
/**
|
||||
* 开奖尾数(settle任务用)
|
||||
*/
|
||||
private Integer digit;
|
||||
|
||||
/**
|
||||
* 执行时间
|
||||
*/
|
||||
private LocalDateTime executeTime;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
@@ -29,17 +31,17 @@ public class PointsFlow {
|
||||
/**
|
||||
* 变动数值(正负)
|
||||
*/
|
||||
private Long amount;
|
||||
private BigDecimal amount;
|
||||
|
||||
/**
|
||||
* 变动前余额
|
||||
*/
|
||||
private Long balanceBefore;
|
||||
private BigDecimal balanceBefore;
|
||||
|
||||
/**
|
||||
* 变动后余额
|
||||
*/
|
||||
private Long balanceAfter;
|
||||
private BigDecimal balanceAfter;
|
||||
|
||||
/**
|
||||
* 业务单号
|
||||
|
||||
@@ -78,14 +78,14 @@ public class RedPacket {
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 预分配金额列表(JSON数组)
|
||||
* 预分配金额列表(JSON数组),发红包时写入,领取时不再修改
|
||||
*/
|
||||
private String shareAmounts;
|
||||
|
||||
/**
|
||||
* 已领取份额数
|
||||
* 下一个待领取份额的索引(从0开始,已领取N个则值为N)
|
||||
*/
|
||||
private Integer shareTakenCount;
|
||||
private Integer shareTakenIndex;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 用户表
|
||||
*/
|
||||
@@ -36,9 +38,9 @@ public class User {
|
||||
private String avatarUrl;
|
||||
|
||||
/**
|
||||
* 积分余额
|
||||
* 积分余额(与红包金额一致使用DECIMAL,避免浮点误差)
|
||||
*/
|
||||
private Long points;
|
||||
private BigDecimal points;
|
||||
|
||||
/**
|
||||
* 运营标记:0否 1是
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.tailbet.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 尾数试算结果
|
||||
*/
|
||||
@@ -21,15 +23,20 @@ public class DigitTrialVo {
|
||||
/**
|
||||
* 总投注次数
|
||||
*/
|
||||
private int totalBet;
|
||||
private int totalBetCount;
|
||||
|
||||
/**
|
||||
* 总投注金额
|
||||
*/
|
||||
private BigDecimal totalBet;
|
||||
|
||||
/**
|
||||
* 总奖励/赔付金额
|
||||
*/
|
||||
private long totalAward;
|
||||
private BigDecimal totalAward;
|
||||
|
||||
/**
|
||||
* 净盈亏(totalAward - totalBet)
|
||||
*/
|
||||
private long profit;
|
||||
private BigDecimal profit;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.tailbet.model.vo;
|
||||
import com.tailbet.model.entity.User;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 登录用户信息
|
||||
*/
|
||||
@@ -31,7 +33,7 @@ public class LoginUserVo {
|
||||
/**
|
||||
* 积分
|
||||
*/
|
||||
private Long points;
|
||||
private BigDecimal points;
|
||||
|
||||
/**
|
||||
* 是否运营
|
||||
|
||||
@@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* OpenIM回调接口
|
||||
*/
|
||||
@@ -101,9 +103,9 @@ public class OpenIMCallbackController {
|
||||
}
|
||||
|
||||
String playType = parts[0];
|
||||
Integer betAmount;
|
||||
BigDecimal betAmount;
|
||||
try {
|
||||
betAmount = Integer.parseInt(parts[1]);
|
||||
betAmount = new BigDecimal(parts[1]);
|
||||
} catch (NumberFormatException e) {
|
||||
sendGroupMessage(groupId, "金额格式错误");
|
||||
return;
|
||||
@@ -127,7 +129,7 @@ public class OpenIMCallbackController {
|
||||
var bet = betService.placeBet(userId, bizGroupId, msgId, playType, betAmount);
|
||||
|
||||
if (bet != null) {
|
||||
sendGroupMessage(groupId, String.format("已记录: %s %d", playType, betAmount));
|
||||
sendGroupMessage(groupId, String.format("已记录: %s %s", playType, betAmount.toPlainString()));
|
||||
}
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
|
||||
@@ -13,7 +13,7 @@ public interface IBetService {
|
||||
* 下注
|
||||
*/
|
||||
BetOrder placeBet(Long userId, Long groupId, String clientMsgId,
|
||||
String playType, Integer betAmount);
|
||||
String playType, BigDecimal betAmount);
|
||||
|
||||
/**
|
||||
* 获取赔率
|
||||
@@ -23,5 +23,5 @@ public interface IBetService {
|
||||
/**
|
||||
* 获取用户本局下注总额
|
||||
*/
|
||||
Integer getUserRoundBetAmount(Long userId, Long roundId);
|
||||
BigDecimal getUserRoundBetAmount(Long userId, Long roundId);
|
||||
}
|
||||
|
||||
@@ -38,4 +38,9 @@ public interface IFakeUserService {
|
||||
* 获取假人列表
|
||||
*/
|
||||
List<User> getFakeUsers();
|
||||
|
||||
/**
|
||||
* 从现有假人中随机挑选指定数量
|
||||
*/
|
||||
List<User> pickFakeUsers(int count);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ public interface IGameService {
|
||||
* 获取群当前可发红包的局(已封盘或正在发红包中)
|
||||
*/
|
||||
GameRound getActiveSendRound(Long groupId);
|
||||
|
||||
/**
|
||||
* 获取群当前结束下注的局
|
||||
*/
|
||||
GameRound getBetEndRound(Long groupId);
|
||||
/**
|
||||
* 生成期号
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.tailbet.service;
|
||||
import com.tailbet.model.entity.PointsFlow;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 积分Service接口
|
||||
*/
|
||||
@@ -19,17 +21,17 @@ public interface IPointsService {
|
||||
/**
|
||||
* 添加积分
|
||||
*/
|
||||
void addPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark);
|
||||
void addPoints(Long userId, BigDecimal amount, String type, String bizNo, Long operatorId, String remark);
|
||||
|
||||
/**
|
||||
* 扣除积分
|
||||
*/
|
||||
void subPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark);
|
||||
void subPoints(Long userId, BigDecimal amount, String type, String bizNo, Long operatorId, String remark);
|
||||
|
||||
/**
|
||||
* 获取用户积分
|
||||
*/
|
||||
Long getPoints(Long userId);
|
||||
BigDecimal getPoints(Long userId);
|
||||
|
||||
/**
|
||||
* 查询积分流水记录
|
||||
|
||||
@@ -39,7 +39,7 @@ public class BetServiceImpl implements IBetService {
|
||||
@Override
|
||||
@Transactional
|
||||
public BetOrder placeBet(Long userId, Long groupId, String clientMsgId,
|
||||
String playType, Integer betAmount) {
|
||||
String playType, BigDecimal betAmount) {
|
||||
// 防重检查
|
||||
if (clientMsgId != null) {
|
||||
BetOrder existing = betOrderMapper.selectOne(new LambdaQueryWrapper<BetOrder>()
|
||||
@@ -57,7 +57,7 @@ public class BetServiceImpl implements IBetService {
|
||||
if (user.getIsFake() != null && user.getIsFake() == 1) {
|
||||
// 假人下注只记录审计日志,不产生订单
|
||||
auditService.log(groupId, null, userId, "FAKE_BET",
|
||||
String.format("{\"playType\":\"%s\",\"amount\":%d}", playType, betAmount));
|
||||
String.format("{\"playType\":\"%s\",\"amount\":%s}", playType, betAmount.toPlainString()));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,25 +69,28 @@ public class BetServiceImpl implements IBetService {
|
||||
throw new RuntimeException("当前不在下注阶段");
|
||||
}
|
||||
|
||||
// 检查余额
|
||||
if (user.getPoints() < betAmount) {
|
||||
throw new RuntimeException("余额不足");
|
||||
}
|
||||
|
||||
// 检查限额
|
||||
if (betAmount < 1 || betAmount > 2000) {
|
||||
// 检查限额(必须为正整数,避免小数下注带来的精度问题)
|
||||
if (betAmount == null || betAmount.signum() <= 0
|
||||
|| betAmount.compareTo(BigDecimal.valueOf(2000)) > 0) {
|
||||
throw new RuntimeException("单笔下注金额需在1-2000之间");
|
||||
}
|
||||
|
||||
// 检查余额
|
||||
BigDecimal points = user.getPoints() == null ? BigDecimal.ZERO : user.getPoints();
|
||||
if (points.compareTo(betAmount) < 0) {
|
||||
throw new RuntimeException("余额不足");
|
||||
}
|
||||
|
||||
// 检查单局累计
|
||||
Integer roundTotal = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||
BigDecimal roundTotal = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||
.eq(BetOrder::getUserId, userId)
|
||||
.eq(BetOrder::getRoundId, round.getId())
|
||||
.eq(BetOrder::getIsFake, 0))
|
||||
.stream()
|
||||
.mapToInt(BetOrder::getBetAmount)
|
||||
.sum();
|
||||
if (roundTotal + betAmount > 20000) {
|
||||
.map(BetOrder::getBetAmount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (roundTotal.add(betAmount).compareTo(BigDecimal.valueOf(20000)) > 0) {
|
||||
throw new RuntimeException("单局累计下注不能超过20000");
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ public class BetServiceImpl implements IBetService {
|
||||
BigDecimal odds = getOdds(playType);
|
||||
|
||||
// 扣减积分
|
||||
pointsService.subPoints(userId, betAmount.longValue(),
|
||||
pointsService.subPoints(userId, betAmount,
|
||||
IPointsService.TYPE_BET, null, null, "游戏下注");
|
||||
|
||||
|
||||
@@ -115,8 +118,8 @@ public class BetServiceImpl implements IBetService {
|
||||
|
||||
// 审计日志
|
||||
auditService.log(groupId, round.getId(), userId, "BET_PLACE",
|
||||
String.format("{\"betId\":%d,\"playType\":\"%s\",\"amount\":%d}",
|
||||
bet.getId(), playType, betAmount));
|
||||
String.format("{\"betId\":%d,\"playType\":\"%s\",\"amount\":%s}",
|
||||
bet.getId(), playType, betAmount.toPlainString()));
|
||||
|
||||
return bet;
|
||||
}
|
||||
@@ -139,13 +142,14 @@ public class BetServiceImpl implements IBetService {
|
||||
* 获取用户本局下注总额
|
||||
*/
|
||||
@Override
|
||||
public Integer getUserRoundBetAmount(Long userId, Long roundId) {
|
||||
public BigDecimal getUserRoundBetAmount(Long userId, Long roundId) {
|
||||
return betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||
.eq(BetOrder::getUserId, userId)
|
||||
.eq(BetOrder::getRoundId, roundId)
|
||||
.eq(BetOrder::getIsFake, 0))
|
||||
.stream()
|
||||
.mapToInt(BetOrder::getBetAmount)
|
||||
.sum();
|
||||
.map(BetOrder::getBetAmount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
@@ -56,7 +58,7 @@ public class FakeUserServiceImpl implements IFakeUserService {
|
||||
User fake = new User();
|
||||
fake.setUsername("fake_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
|
||||
fake.setNickname(generateNickname());
|
||||
fake.setPoints((long) (1000 + RANDOM.nextInt(49000)));
|
||||
fake.setPoints(BigDecimal.valueOf(1000 + RANDOM.nextInt(49000)));
|
||||
fake.setIsFake(1);
|
||||
fake.setIsBot(0);
|
||||
fake.setIsOps(0);
|
||||
@@ -155,11 +157,11 @@ public class FakeUserServiceImpl implements IFakeUserService {
|
||||
// 随机玩法和金额
|
||||
String[] playTypes = {"单", "双", "大", "小"};
|
||||
String playType = playTypes[RANDOM.nextInt(playTypes.length)];
|
||||
int amount = (1 + RANDOM.nextInt(10)) * 10; // 10,20,30...100
|
||||
BigDecimal amount = BigDecimal.valueOf((1 + RANDOM.nextInt(10)) * 10L); // 10,20,30...100
|
||||
|
||||
try {
|
||||
betService.placeBet(fakeId, groupId, "fake_" + UUID.randomUUID(), playType, amount);
|
||||
log.debug("假人{}下注: {} {}", fakeId, playType, amount);
|
||||
log.debug("假人{}下注: {} {}", fakeId, playType, amount.toPlainString());
|
||||
} catch (Exception e) {
|
||||
log.debug("假人下注失败: {}", e.getMessage());
|
||||
}
|
||||
@@ -174,4 +176,18 @@ public class FakeUserServiceImpl implements IFakeUserService {
|
||||
.eq(User::getIsFake, 1)
|
||||
.eq(User::getStatus, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从现有假人中随机挑选指定数量
|
||||
*/
|
||||
@Override
|
||||
public List<User> pickFakeUsers(int count) {
|
||||
List<User> fakes = getFakeUsers();
|
||||
if (fakes.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Collections.shuffle(fakes, RANDOM);
|
||||
int size = Math.min(count, fakes.size());
|
||||
return new ArrayList<>(fakes.subList(0, size));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ public class GameServiceImpl implements IGameService {
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameRound getBetEndRound(Long groupId) {
|
||||
return gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||
.eq(GameRound::getGroupId, groupId)
|
||||
.eq(GameRound::getStatus, STATUS_BET_CLOSED)
|
||||
.orderByDesc(GameRound::getCreateTime)
|
||||
.last("LIMIT 1")); }
|
||||
|
||||
/**
|
||||
* 生成期号
|
||||
*/
|
||||
@@ -116,14 +124,18 @@ public class GameServiceImpl implements IGameService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏(群主操作)
|
||||
* 开始游戏(群主操作或系统自动,userId=0L 表示系统)
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public GameRound startGame(Long groupId, Long userId) {
|
||||
// 校验群主权限
|
||||
Group group = groupMapper.selectById(groupId);
|
||||
if (group == null || !group.getOwnerUserId().equals(userId)) {
|
||||
if (group == null) {
|
||||
throw new RuntimeException("群组不存在");
|
||||
}
|
||||
|
||||
// 校验群主权限(系统调用时 userId=0L 跳过校验)
|
||||
if (!Long.valueOf(0L).equals(userId) && !group.getOwnerUserId().equals(userId)) {
|
||||
throw new RuntimeException("只有群主可以开始游戏");
|
||||
}
|
||||
|
||||
@@ -180,13 +192,18 @@ public class GameServiceImpl implements IGameService {
|
||||
|
||||
/**
|
||||
* 停止自动连开
|
||||
* 直接修改最新一局(不限状态)——autoNext 是"本局结算后是否自动开下一期"的开关,
|
||||
* 即使本局已结算但下一期还未自动开出的窗口期内也要能关掉
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void stopAutoNext(Long groupId, Long userId) {
|
||||
GameRound round = getOngoingRound(groupId);
|
||||
GameRound round = gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||
.eq(GameRound::getGroupId, groupId)
|
||||
.orderByDesc(GameRound::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (round == null) {
|
||||
throw new RuntimeException("没有进行中的局");
|
||||
throw new RuntimeException("该群没有局");
|
||||
}
|
||||
|
||||
Group group = groupMapper.selectById(groupId);
|
||||
@@ -206,9 +223,9 @@ public class GameServiceImpl implements IGameService {
|
||||
@Override
|
||||
@Transactional
|
||||
public void setTargetDigit(Long groupId, Integer digit, Long userId) {
|
||||
GameRound round = getBettingRound(groupId);
|
||||
GameRound round = getBetEndRound(groupId);
|
||||
if (round == null) {
|
||||
throw new RuntimeException("当前没有下注中的局");
|
||||
throw new RuntimeException("当前没有下注结束的局");
|
||||
}
|
||||
|
||||
round.setTargetDigit(digit);
|
||||
@@ -247,19 +264,20 @@ public class GameServiceImpl implements IGameService {
|
||||
trial.setDigit(d);
|
||||
trial.setPlayWin(getPlayWinDesc(d));
|
||||
|
||||
int totalBet = 0;
|
||||
long totalAward = 0;
|
||||
BigDecimal totalBet = BigDecimal.ZERO;
|
||||
BigDecimal totalAward = BigDecimal.ZERO;
|
||||
|
||||
for (BetOrder bet : bets) {
|
||||
totalBet += bet.getBetAmount();
|
||||
totalBet = totalBet.add(nullToZero(bet.getBetAmount()));
|
||||
if (isWin(bet, d)) {
|
||||
totalAward += bet.getBetAmount() * (1 + bet.getOdds().doubleValue());
|
||||
totalAward = totalAward.add(nullToZero(bet.getBetAmount())
|
||||
.multiply(BigDecimal.ONE.add(nullToZero(bet.getOdds()))));
|
||||
}
|
||||
}
|
||||
|
||||
trial.setTotalBet(totalBet);
|
||||
trial.setTotalAward(totalAward);
|
||||
trial.setProfit(totalBet - totalAward);
|
||||
trial.setProfit(totalBet.subtract(totalAward));
|
||||
result.add(trial);
|
||||
}
|
||||
|
||||
@@ -316,15 +334,16 @@ public class GameServiceImpl implements IGameService {
|
||||
.eq(BetOrder::getRoundId, roundId)
|
||||
.eq(BetOrder::getStatus, 0)); // 待结算
|
||||
|
||||
long totalBet = 0;
|
||||
long totalAward = 0;
|
||||
BigDecimal totalBet = BigDecimal.ZERO;
|
||||
BigDecimal totalAward = BigDecimal.ZERO;
|
||||
|
||||
for (BetOrder bet : bets) {
|
||||
totalBet += bet.getBetAmount();
|
||||
totalBet = totalBet.add(nullToZero(bet.getBetAmount()));
|
||||
|
||||
if (isWin(bet, digit)) {
|
||||
// 中奖,派奖
|
||||
long winAmount = (long) (bet.getBetAmount() * (1 + bet.getOdds().doubleValue()));
|
||||
// 中奖,派奖(用BigDecimal精确计算,避免double转换误差)
|
||||
BigDecimal winAmount = nullToZero(bet.getBetAmount())
|
||||
.multiply(BigDecimal.ONE.add(nullToZero(bet.getOdds())));
|
||||
bet.setStatus(1); // 已结算
|
||||
bet.setWinAmount(winAmount);
|
||||
betOrderMapper.updateById(bet);
|
||||
@@ -333,7 +352,7 @@ public class GameServiceImpl implements IGameService {
|
||||
pointsService.addPoints(bet.getUserId(), winAmount, IPointsService.TYPE_AWARD,
|
||||
String.valueOf(bet.getId()), null, "游戏中奖");
|
||||
|
||||
totalAward += winAmount;
|
||||
totalAward = totalAward.add(winAmount);
|
||||
} else {
|
||||
// 未中奖
|
||||
bet.setStatus(2); // 未中奖
|
||||
@@ -348,9 +367,9 @@ public class GameServiceImpl implements IGameService {
|
||||
record.setRoundId(roundId);
|
||||
record.setDigit(digit);
|
||||
record.setPlayWins(getPlayWinDesc(digit));
|
||||
record.setTotalBet((int) totalBet);
|
||||
record.setTotalAward(totalAward);
|
||||
record.setProfit(totalBet - totalAward);
|
||||
record.setTotalBet(totalBet.intValue());
|
||||
record.setTotalAward(totalAward.longValue());
|
||||
record.setProfit(totalBet.subtract(totalAward).longValue());
|
||||
record.setCreateTime(LocalDateTime.now());
|
||||
drawRecordMapper.insert(record);
|
||||
|
||||
@@ -361,8 +380,9 @@ public class GameServiceImpl implements IGameService {
|
||||
|
||||
// 审计日志
|
||||
auditService.log(round.getGroupId(), roundId, null, "SETTLE",
|
||||
String.format("{\"digit\":%d,\"totalBet\":%d,\"totalAward\":%d,\"profit\":%d}",
|
||||
digit, totalBet, totalAward, totalBet - totalAward));
|
||||
String.format("{\"digit\":%d,\"totalBet\":%s,\"totalAward\":%s,\"profit\":%s}",
|
||||
digit, totalBet.toPlainString(), totalAward.toPlainString(),
|
||||
totalBet.subtract(totalAward).toPlainString()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,7 +416,7 @@ public class GameServiceImpl implements IGameService {
|
||||
.eq(BetOrder::getGroupId, groupId)
|
||||
.eq(BetOrder::getIsFake, 0));
|
||||
stat.setTotalBetCount(allBets.size());
|
||||
stat.setTotalBetAmount(allBets.stream().mapToLong(BetOrder::getBetAmount).sum());
|
||||
stat.setTotalBetAmount(allBets.stream().map(b -> b.getBetAmount() == null ? 0L : b.getBetAmount().longValue()).mapToLong(Long::longValue).sum());
|
||||
|
||||
// 统计派奖
|
||||
List<DrawRecord> records = drawRecordMapper.selectList(new LambdaQueryWrapper<DrawRecord>()
|
||||
@@ -406,4 +426,11 @@ public class GameServiceImpl implements IGameService {
|
||||
|
||||
return stat;
|
||||
}
|
||||
|
||||
/**
|
||||
* BigDecimal空值防御:null视为0
|
||||
*/
|
||||
private static BigDecimal nullToZero(BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO : v;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.tailbet.mapper.UserMapper;
|
||||
import com.tailbet.openim.OpenIMApiClient;
|
||||
import com.tailbet.service.IGroupService;
|
||||
import com.tailbet.service.IAuditService;
|
||||
import com.tailbet.service.IFakeUserService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +39,7 @@ public class GroupServiceImpl implements IGroupService {
|
||||
private final UserMapper userMapper;
|
||||
private final SysConfigMapper sysConfigMapper;
|
||||
private final IAuditService auditService;
|
||||
private final IFakeUserService fakeUserService;
|
||||
private final OpenIMApiClient openIMApiClient;
|
||||
|
||||
/**
|
||||
@@ -132,16 +134,26 @@ public class GroupServiceImpl implements IGroupService {
|
||||
groupMemberMapper.insert(buildMember(groupId, botUserId, ROLE_NORMAL, now));
|
||||
}
|
||||
|
||||
// OpenIM 侧建群:群主为 owner,机器人作为初始成员
|
||||
List<String> memberUserIDs = ownerIsBot
|
||||
? List.of()
|
||||
: List.of(String.valueOf(botUserId));
|
||||
// 从现有假人中挑选10个进群
|
||||
List<User> fakeUsers = fakeUserService.pickFakeUsers(10);
|
||||
for (User fake : fakeUsers) {
|
||||
groupMemberMapper.insert(buildMember(groupId, fake.getId(), ROLE_NORMAL, now));
|
||||
}
|
||||
|
||||
// OpenIM 侧建群:群主、机器人和假人作为初始成员
|
||||
List<String> memberUserIDs = new java.util.ArrayList<>();
|
||||
if (!ownerIsBot) {
|
||||
memberUserIDs.add(String.valueOf(botUserId));
|
||||
}
|
||||
fakeUsers.stream()
|
||||
.map(fake -> String.valueOf(fake.getId()))
|
||||
.forEach(memberUserIDs::add);
|
||||
openIMApiClient.createGroup(String.valueOf(groupId), name, String.valueOf(ownerUserId), memberUserIDs);
|
||||
|
||||
// 审计日志
|
||||
auditService.log(groupId, null, operatorId, "CREATE_GROUP",
|
||||
String.format("{\"name\":\"%s\",\"ownerUserId\":%d,\"botUserId\":%d}",
|
||||
name.replace("\\", "\\\\").replace("\"", "\\\""), ownerUserId, botUserId));
|
||||
String.format("{\"name\":\"%s\",\"ownerUserId\":%d,\"botUserId\":%d,\"fakeCount\":%d}",
|
||||
name.replace("\\", "\\\\").replace("\"", "\\\""), ownerUserId, botUserId, fakeUsers.size()));
|
||||
|
||||
log.info("创建群成功: groupId={}, name={}, ownerUserId={}, botUserId={}, operatorId={}",
|
||||
groupId, name, ownerUserId, botUserId, operatorId);
|
||||
@@ -219,8 +231,11 @@ public class GroupServiceImpl implements IGroupService {
|
||||
@Transactional
|
||||
public void kickUser(Long groupId, Long targetUserId, Long operatorId) {
|
||||
Group group = groupMapper.selectById(groupId);
|
||||
if (group == null) {
|
||||
throw new RuntimeException("群组不存在");
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
// 检查操作人权限
|
||||
GroupMember operator = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||
.eq(GroupMember::getGroupId, groupId)
|
||||
.eq(GroupMember::getUserId, operatorId));
|
||||
@@ -228,8 +243,6 @@ public class GroupServiceImpl implements IGroupService {
|
||||
if (operator == null) {
|
||||
throw new RuntimeException("你不是群成员");
|
||||
}
|
||||
|
||||
// 只有群主和管理员可以踢人
|
||||
if (operator.getRole() != ROLE_OWNER && operator.getRole() != ROLE_ADMIN) {
|
||||
throw new RuntimeException("只有群主和管理员可以踢人");
|
||||
}
|
||||
@@ -239,10 +252,18 @@ public class GroupServiceImpl implements IGroupService {
|
||||
throw new RuntimeException("不能踢出群主");
|
||||
}
|
||||
|
||||
// 删除成员
|
||||
groupMemberMapper.delete(new LambdaQueryWrapper<GroupMember>()
|
||||
GroupMember target = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||
.eq(GroupMember::getGroupId, groupId)
|
||||
.eq(GroupMember::getUserId, targetUserId));
|
||||
if (target == null) {
|
||||
throw new RuntimeException("目标用户不是群成员");
|
||||
}
|
||||
|
||||
// 先同步 OpenIM,失败时事务回滚,避免本地与 IM 状态不一致
|
||||
openIMApiClient.kickGroupMember(
|
||||
String.valueOf(groupId), List.of(String.valueOf(targetUserId)));
|
||||
|
||||
groupMemberMapper.deleteById(target.getId());
|
||||
|
||||
// 审计日志
|
||||
auditService.log(groupId, null, operatorId, "KICK_USER",
|
||||
@@ -251,6 +272,10 @@ public class GroupServiceImpl implements IGroupService {
|
||||
|
||||
/**
|
||||
* 设置管理员
|
||||
* <p>
|
||||
* 本地更新群成员角色后,调用 OpenIM 接口同步角色(roleLevel=60=管理员)。
|
||||
* OpenIM 调用失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||
* </p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -274,6 +299,10 @@ public class GroupServiceImpl implements IGroupService {
|
||||
member.setUpdateTime(LocalDateTime.now());
|
||||
groupMemberMapper.updateById(member);
|
||||
|
||||
// OpenIM 同步:roleLevel=60 表示管理员
|
||||
openIMApiClient.setGroupMemberRole(
|
||||
String.valueOf(groupId), String.valueOf(targetUserId), 60);
|
||||
|
||||
// 审计日志
|
||||
auditService.log(groupId, null, operatorId, "SET_ADMIN",
|
||||
String.format("{\"targetUserId\":%d}", targetUserId));
|
||||
@@ -281,6 +310,10 @@ public class GroupServiceImpl implements IGroupService {
|
||||
|
||||
/**
|
||||
* 取消管理员
|
||||
* <p>
|
||||
* 本地更新群成员角色后,调用 OpenIM 接口同步角色(roleLevel=20=普通成员)。
|
||||
* OpenIM 调用失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||
* </p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -303,6 +336,10 @@ public class GroupServiceImpl implements IGroupService {
|
||||
member.setUpdateTime(LocalDateTime.now());
|
||||
groupMemberMapper.updateById(member);
|
||||
|
||||
// OpenIM 同步:roleLevel=20 表示普通成员
|
||||
openIMApiClient.setGroupMemberRole(
|
||||
String.valueOf(groupId), String.valueOf(targetUserId), 20);
|
||||
|
||||
// 审计日志
|
||||
auditService.log(groupId, null, operatorId, "REMOVE_ADMIN",
|
||||
String.format("{\"targetUserId\":%d}", targetUserId));
|
||||
|
||||
@@ -12,6 +12,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 积分Service实现
|
||||
*/
|
||||
@@ -28,13 +30,16 @@ public class PointsServiceImpl implements IPointsService {
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void addPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark) {
|
||||
public void addPoints(Long userId, BigDecimal amount, String type, String bizNo, Long operatorId, String remark) {
|
||||
if (amount == null || amount.signum() == 0) {
|
||||
throw new RuntimeException("变动数值必须为正数");
|
||||
}
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
Long before = user.getPoints();
|
||||
BigDecimal before = user.getPoints() == null ? BigDecimal.ZERO : user.getPoints();
|
||||
|
||||
// 原子增加积分
|
||||
int rows = userMapper.addPoints(userId, amount);
|
||||
@@ -42,7 +47,7 @@ public class PointsServiceImpl implements IPointsService {
|
||||
throw new RuntimeException("积分增加失败");
|
||||
}
|
||||
|
||||
Long after = before + amount;
|
||||
BigDecimal after = before.add(amount);
|
||||
|
||||
// 记录流水
|
||||
PointsFlow flow = new PointsFlow();
|
||||
@@ -62,16 +67,20 @@ public class PointsServiceImpl implements IPointsService {
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void subPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark) {
|
||||
public void subPoints(Long userId, BigDecimal amount, String type, String bizNo, Long operatorId, String remark) {
|
||||
if (amount == null || amount.signum() <= 0) {
|
||||
throw new RuntimeException("变动数值必须为正数");
|
||||
}
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
if (user.getPoints() < amount) {
|
||||
BigDecimal current = user.getPoints() == null ? BigDecimal.ZERO : user.getPoints();
|
||||
if (current.compareTo(amount) < 0) {
|
||||
throw new RuntimeException("余额不足");
|
||||
}
|
||||
|
||||
Long before = user.getPoints();
|
||||
BigDecimal before = current;
|
||||
|
||||
// 原子扣除积分(SQL层保证余额充足才扣)
|
||||
int rows = userMapper.subPoints(userId, amount);
|
||||
@@ -79,13 +88,13 @@ public class PointsServiceImpl implements IPointsService {
|
||||
throw new RuntimeException("积分扣除失败");
|
||||
}
|
||||
|
||||
Long after = before - amount;
|
||||
BigDecimal after = before.subtract(amount);
|
||||
|
||||
// 记录流水
|
||||
PointsFlow flow = new PointsFlow();
|
||||
flow.setUserId(userId);
|
||||
flow.setType(type);
|
||||
flow.setAmount(-amount);
|
||||
flow.setAmount(amount.negate());
|
||||
flow.setBalanceBefore(before);
|
||||
flow.setBalanceAfter(after);
|
||||
flow.setBizNo(bizNo);
|
||||
@@ -99,9 +108,9 @@ public class PointsServiceImpl implements IPointsService {
|
||||
* 获取用户积分
|
||||
*/
|
||||
@Override
|
||||
public Long getPoints(Long userId) {
|
||||
public BigDecimal getPoints(Long userId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
return user != null ? user.getPoints() : 0L;
|
||||
return user != null && user.getPoints() != null ? user.getPoints() : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -60,8 +61,15 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 前置校验:总金额必须够每个包至少 1 分,避免产生 0 元红包
|
||||
BigDecimal minRequired = BigDecimal.valueOf(totalCount == null ? 0 : totalCount)
|
||||
.multiply(new BigDecimal("0.01"));
|
||||
if (totalAmount == null || totalAmount.compareTo(minRequired) < 0) {
|
||||
throw new RuntimeException("红包总金额不能低于 " + minRequired.toPlainString() + " 元");
|
||||
}
|
||||
|
||||
// 扣积分
|
||||
pointsService.subPoints(userId, totalAmount.longValue(),
|
||||
pointsService.subPoints(userId, totalAmount,
|
||||
IPointsService.TYPE_RP_SEND, null, null, "发送红包");
|
||||
|
||||
|
||||
@@ -116,7 +124,7 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
log.error("序列化shareAmounts失败: {}", shareAmounts, e);
|
||||
throw new RuntimeException("系统错误");
|
||||
}
|
||||
rp.setShareTakenCount(0);
|
||||
rp.setShareTakenIndex(0);
|
||||
redPacketMapper.updateById(rp);
|
||||
log.info("预分配红包份额: rpId={}, type={}, targetDigit={}, amounts={}",
|
||||
rp.getId(), rp.getType(), targetDigit, shareAmounts);
|
||||
@@ -163,26 +171,34 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
}
|
||||
|
||||
// 更新手气王信息(领取金额最大的用户为手气王)
|
||||
Integer takenCount = rp.getShareTakenCount();
|
||||
Integer takenIndex = rp.getShareTakenIndex();
|
||||
BigDecimal currentLucky = rp.getLuckyAmount();
|
||||
if (currentLucky == null || amount.compareTo(currentLucky) > 0) {
|
||||
rp.setLuckyAmount(amount);
|
||||
rp.setLuckyUserId(userId);
|
||||
rp.setLuckyDigit(amount.remainder(BigDecimal.TEN).intValue());
|
||||
rp.setLuckyDigit(digitOf(amount));
|
||||
}
|
||||
rp.setShareTakenCount(takenCount != null ? takenCount + 1 : 1);
|
||||
rp.setShareTakenIndex(takenIndex != null ? takenIndex + 1 : 1);
|
||||
|
||||
redPacketMapper.updateById(rp);
|
||||
|
||||
// 如果是游戏红包且已领完,发布开奖事件
|
||||
// 如果是游戏红包且已领完,标记手气王并发布开奖事件
|
||||
if (isFinished && TYPE_GAME.equals(rp.getType()) && rp.getRoundId() != null) {
|
||||
// 标记手气王的领取记录 is_lucky=1
|
||||
if (rp.getLuckyUserId() != null) {
|
||||
redPacketRecvMapper.update(null, new LambdaUpdateWrapper<RedPacketRecv>()
|
||||
.eq(RedPacketRecv::getRpId, rpId)
|
||||
.eq(RedPacketRecv::getUserId, rp.getLuckyUserId())
|
||||
.set(RedPacketRecv::getIsLucky, 1));
|
||||
}
|
||||
eventPublisher.publishEvent(new RedPacketFinishedEvent(
|
||||
this, rp.getRoundId(), rp.getGroupId(), rp.getId(), rp.getLuckyDigit()));
|
||||
log.info("游戏红包领完,发布开奖事件: roundId={}, luckyDigit={}", rp.getRoundId(), rp.getLuckyDigit());
|
||||
log.info("游戏红包领完,标记手气王并发布开奖事件: roundId={}, luckyDigit={}, luckyUserId={}",
|
||||
rp.getRoundId(), rp.getLuckyDigit(), rp.getLuckyUserId());
|
||||
}
|
||||
|
||||
// 加积分
|
||||
pointsService.addPoints(userId, amount.longValue(),
|
||||
pointsService.addPoints(userId, amount,
|
||||
IPointsService.TYPE_RP_RECV, String.valueOf(rpId), null, "领取红包");
|
||||
|
||||
// 记录领取
|
||||
@@ -279,9 +295,9 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从预分配金额中顺序取值
|
||||
* 兼容旧数据:如果没有预分配金额,则走随机算法
|
||||
*/
|
||||
* 从预分配金额中按索引顺序取值(不再修改 shareAmounts JSON)
|
||||
* 兼容旧数据:如果没有预分配金额,则走随机算法
|
||||
*/
|
||||
private BigDecimal drawFromPreAllocatedAmounts(RedPacket rp) {
|
||||
if (rp.getShareAmounts() == null || rp.getShareAmounts().isEmpty()) {
|
||||
// 兼容旧数据fallback到随机
|
||||
@@ -295,11 +311,13 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
if (amounts.isEmpty()) {
|
||||
return calculateReceiveAmount(rp);
|
||||
}
|
||||
// 取第一个
|
||||
BigDecimal amount = amounts.remove(0);
|
||||
// 更新JSON
|
||||
rp.setShareAmounts(objectMapper.writeValueAsString(amounts));
|
||||
return amount;
|
||||
// 按 shareTakenIndex 索引取值,shareAmounts 不再被修改
|
||||
Integer index = rp.getShareTakenIndex();
|
||||
if (index == null || index >= amounts.size()) {
|
||||
// 越界:兼容旧数据或异常情况,走随机
|
||||
return calculateReceiveAmount(rp);
|
||||
}
|
||||
return amounts.get(index);
|
||||
} catch (Exception e) {
|
||||
log.error("解析shareAmounts失败: {}, error={}", rp.getShareAmounts(), e.getMessage());
|
||||
return calculateReceiveAmount(rp);
|
||||
@@ -307,45 +325,76 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 预计算红包份额金额
|
||||
* targetDigit 非空时确保手气王金额尾数=targetDigit
|
||||
* targetDigit 为空时按纯随机份额处理
|
||||
*/
|
||||
* 预计算红包份额金额
|
||||
* targetDigit 非空时确保手气王金额尾数=targetDigit
|
||||
* targetDigit 为空时按纯随机份额处理
|
||||
*/
|
||||
private List<BigDecimal> preCalculateShareAmounts(RedPacket rp, Integer targetDigit) {
|
||||
List<BigDecimal> amounts = new ArrayList<>();
|
||||
int count = rp.getTotalCount();
|
||||
BigDecimal total = rp.getTotalAmount();
|
||||
BigDecimal minUnit = new BigDecimal("0.01"); // 最小份额:1 分
|
||||
|
||||
if (count <= 1) {
|
||||
// 只有1个,全部给手气王
|
||||
amounts.add(total);
|
||||
return amounts;
|
||||
}
|
||||
|
||||
Random random = new Random();
|
||||
|
||||
// 计算手气王金额
|
||||
BigDecimal luckyAmount = calculateLuckyAmount(total, targetDigit, random);
|
||||
// 确保手气王金额不超过总额
|
||||
if (luckyAmount.compareTo(total) > 0) {
|
||||
luckyAmount = total;
|
||||
// 1. 计算手气王金额(已保证不超过 luckyMax 且尾数=targetDigit)
|
||||
BigDecimal luckyAmount = calculateLuckyAmount(total, targetDigit, count, random);
|
||||
|
||||
// 2. 计算其他人总额(保证每人至少 minUnit)
|
||||
BigDecimal minForOthers = minUnit.multiply(BigDecimal.valueOf(count - 1));
|
||||
BigDecimal remaining = total.subtract(luckyAmount);
|
||||
if (remaining.compareTo(minForOthers) < 0) {
|
||||
// 手气王挤占了他人最小份额,回收
|
||||
luckyAmount = total.subtract(minForOthers);
|
||||
if (targetDigit != null) {
|
||||
luckyAmount = adjustToTargetDigit(luckyAmount, luckyAmount.add(minUnit), targetDigit);
|
||||
}
|
||||
remaining = minForOthers;
|
||||
}
|
||||
|
||||
// 剩余金额平均分配给其他人
|
||||
BigDecimal remaining = total.subtract(luckyAmount);
|
||||
// 3. 其他人金额(带小随机波动)
|
||||
// 计算 avgAmount 后,确保 luckyAmount ≥ avgAmount × 1.2(maxFactor)
|
||||
// 这样循环里的 amount 自然 ≤ luckyAmount,无需钳制,金额严格守恒
|
||||
double maxFactor = 1.2;
|
||||
BigDecimal avgAmount = remaining.divide(BigDecimal.valueOf(count - 1), 2, RoundingMode.HALF_UP);
|
||||
|
||||
// 添加手气王金额
|
||||
BigDecimal needed = avgAmount.multiply(BigDecimal.valueOf(maxFactor))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
if (luckyAmount.compareTo(needed) < 0) {
|
||||
// 手气王不够大,抬高到 needed(满足"分位尾数"=targetDigit 优先)
|
||||
BigDecimal lifted = needed;
|
||||
if (targetDigit != null) {
|
||||
lifted = adjustToTargetDigit(lifted, total, targetDigit);
|
||||
}
|
||||
// 若抬高后超过 luckyMax(total - minForOthers),回退取 luckyMax 并再调尾数
|
||||
BigDecimal cap = total.subtract(minForOthers);
|
||||
if (lifted.compareTo(cap) > 0) {
|
||||
lifted = cap;
|
||||
if (targetDigit != null) {
|
||||
lifted = adjustToTargetDigit(lifted, cap, targetDigit);
|
||||
}
|
||||
}
|
||||
luckyAmount = lifted;
|
||||
remaining = total.subtract(luckyAmount);
|
||||
// 重新计算 avgAmount,确保循环里的金额仍 ≥ minUnit
|
||||
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);
|
||||
// 确保不超过剩余
|
||||
// 下限保护:每个份额至少 minUnit
|
||||
if (amount.compareTo(minUnit) < 0) {
|
||||
amount = minUnit;
|
||||
}
|
||||
// 累加上限:不超过 remainingForOthers
|
||||
if (amount.compareTo(remainingForOthers) > 0) {
|
||||
amount = remainingForOthers;
|
||||
}
|
||||
@@ -353,7 +402,12 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
remainingForOthers = remainingForOthers.subtract(amount);
|
||||
}
|
||||
// 最后一个拿剩余
|
||||
amounts.add(remainingForOthers.setScale(2, RoundingMode.HALF_UP));
|
||||
BigDecimal last = remainingForOthers;
|
||||
// 下限保护:累减后可能 < minUnit
|
||||
if (last.compareTo(minUnit) < 0) {
|
||||
last = minUnit;
|
||||
}
|
||||
amounts.add(last.setScale(2, RoundingMode.HALF_UP));
|
||||
|
||||
// 打乱顺序(让领取顺序随机)
|
||||
Collections.shuffle(amounts);
|
||||
@@ -368,42 +422,88 @@ public class RedPacketServiceImpl implements IRedPacketService {
|
||||
|
||||
/**
|
||||
* 计算手气王金额
|
||||
* 比例为总额的30%~50%,有目标尾数时确保尾数=targetDigit
|
||||
* - 比例为总额的 [1/count, 50%],下限定为 1/count 以保证 luckyAmount ≥ avgAmount
|
||||
* (手气王必须 ≥ 其他人的平均金额,否则随机波动后有人会比手气王还大)
|
||||
* - 有目标尾数时确保"分位尾数"=targetDigit
|
||||
* - 不超过 luckyMax(total - minUnit * (count-1)),留出最小份额给其他人
|
||||
*/
|
||||
private BigDecimal calculateLuckyAmount(BigDecimal total, Integer targetDigit, Random random) {
|
||||
// 手气王比例:总额的30%~50%
|
||||
double ratio = 0.3 + random.nextDouble() * 0.2;
|
||||
BigDecimal luckyAmountBase = total.multiply(BigDecimal.valueOf(ratio));
|
||||
private BigDecimal calculateLuckyAmount(BigDecimal total, Integer targetDigit, int totalCount, Random random) {
|
||||
BigDecimal minUnit = new BigDecimal("0.01");
|
||||
BigDecimal minForOthers = minUnit.multiply(BigDecimal.valueOf(totalCount - 1));
|
||||
BigDecimal luckyMax = total.subtract(minForOthers);
|
||||
|
||||
// 未指定目标尾数时按基础金额返回
|
||||
if (targetDigit == null) {
|
||||
return luckyAmountBase.setScale(2, RoundingMode.HALF_UP);
|
||||
// 比例下限 1/count(保证 luckyAmount ≥ avgAmount),上限 50%
|
||||
double minRatio = 1.0 / totalCount;
|
||||
double ratio = minRatio + random.nextDouble() * (0.5 - minRatio);
|
||||
BigDecimal luckyAmount = total.multiply(BigDecimal.valueOf(ratio))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
// 调整尾数(如果指定)
|
||||
if (targetDigit != null) {
|
||||
luckyAmount = adjustToTargetDigit(luckyAmount, luckyMax, targetDigit);
|
||||
}
|
||||
|
||||
// 指定了目标尾数,调整金额使尾数=targetDigit
|
||||
int lastDigit = luckyAmountBase.remainder(BigDecimal.TEN).intValue();
|
||||
int diff = (targetDigit - lastDigit + 10) % 10;
|
||||
return luckyAmountBase.add(BigDecimal.valueOf(diff))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
// 上限保护:不超过 luckyMax
|
||||
if (luckyAmount.compareTo(luckyMax) > 0) {
|
||||
luckyAmount = luckyMax;
|
||||
if (targetDigit != null) {
|
||||
luckyAmount = adjustToTargetDigit(luckyAmount, luckyMax, targetDigit);
|
||||
}
|
||||
}
|
||||
|
||||
return luckyAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保份额列表中至少有1个金额的尾数=targetDigit
|
||||
* 若没有则从列表中找一个尾数匹配的金额,换到首位作为手气王
|
||||
* 调整金额让"分位尾数"=targetDigit,且不超过 maxAllowed
|
||||
* 优先向上调整(保持金额更大),如会超出则向下调整
|
||||
*/
|
||||
private BigDecimal adjustToTargetDigit(BigDecimal amount, BigDecimal maxAllowed, int targetDigit) {
|
||||
BigDecimal minUnit = new BigDecimal("0.01");
|
||||
int lastDigit = digitOf(amount);
|
||||
if (lastDigit == targetDigit) {
|
||||
return amount;
|
||||
}
|
||||
int upDiff = (targetDigit - lastDigit + 10) % 10; // 向上调(单位:分)
|
||||
int downDiff = (lastDigit - targetDigit + 10) % 10; // 向下调
|
||||
BigDecimal up = amount.add(BigDecimal.valueOf(upDiff).movePointLeft(2))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
if (up.compareTo(maxAllowed) <= 0 && up.compareTo(minUnit) >= 0) {
|
||||
return up;
|
||||
}
|
||||
BigDecimal down = amount.subtract(BigDecimal.valueOf(downDiff).movePointLeft(2))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
if (down.compareTo(minUnit) >= 0) {
|
||||
return down;
|
||||
}
|
||||
return amount; // 实在调不动,保持原值(兜底)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取金额的"分位尾数":金额×100 后取个位(如 0.45→5,1.05→5,0.40→0)
|
||||
* BigDecimal.remainder(BigDecimal.TEN) 对小数返回自身+intValue 截断会丢精度,
|
||||
* 这里按国内红包/微信红包标准按"分位"算尾数
|
||||
*/
|
||||
private static int digitOf(BigDecimal amount) {
|
||||
if (amount == null) return 0;
|
||||
return amount.movePointRight(2).remainder(BigDecimal.TEN).intValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 amounts[0] 的"分位尾数"=targetDigit(手气王必须在首位)
|
||||
* 如果首位已经命中则不操作;否则找一个命中项 swap 到首位;都没有则兜底不动
|
||||
*/
|
||||
private void ensureLuckyDigit(List<BigDecimal> amounts, int targetDigit) {
|
||||
for (BigDecimal amt : amounts) {
|
||||
if (amt.remainder(BigDecimal.TEN).intValue() == targetDigit) {
|
||||
return;
|
||||
}
|
||||
if (digitOf(amounts.get(0)) == targetDigit) {
|
||||
return;
|
||||
}
|
||||
// 没有尾数匹配的,找一个换到首位
|
||||
// 首位不命中,找一个命中项 swap 到首位
|
||||
for (int i = 1; i < amounts.size(); i++) {
|
||||
BigDecimal amt = amounts.get(i);
|
||||
if (amt.remainder(BigDecimal.TEN).intValue() == targetDigit) {
|
||||
Collections.swap(amounts, i, 0);
|
||||
if (digitOf(amounts.get(i)) == targetDigit) {
|
||||
Collections.swap(amounts, 0, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 没有任何命中项,无法保证(这种情况很罕见:total/count 太小无空间调整)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -53,7 +55,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IU
|
||||
user.setUsername(username);
|
||||
user.setPassword(passwordUtil.encode(password));
|
||||
user.setNickname(username);
|
||||
user.setPoints(0L);
|
||||
user.setPoints(BigDecimal.ZERO);
|
||||
user.setStatus(1);
|
||||
userMapper.insert(user);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
spring:
|
||||
application:
|
||||
name: guess-game
|
||||
profiles:
|
||||
active: dev
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://localhost:3306/guess_game?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8
|
||||
@@ -31,7 +29,9 @@ mybatis-plus:
|
||||
type-aliases-package: com.tailbet.entity
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: auto
|
||||
@@ -39,13 +39,6 @@ mybatis-plus:
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
#openim:
|
||||
## api-url: ${OPENIM_API_URL:http://172.31.5.16:10002}
|
||||
# api-url: ${OPENIM_API_URL:https://openim.xiu6688.com}
|
||||
# ws-url: ${OPENIM_WS_URL:wss://im.xiu6688.com}
|
||||
# admin-user: ${OPENIM_ADMIN_USER:imAdmin}
|
||||
# admin-token: ${OPENIM_ADMIN_TOKEN:d71c0861267DM23adg}
|
||||
|
||||
openim:
|
||||
# OpenIM REST API 外网地址(必填),下发给客户端 httpUrl
|
||||
apiUrl: https://openim.xiu6688.com
|
||||
@@ -71,9 +64,9 @@ openim:
|
||||
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:your-256-bit-secret-key-for-jwt-token-generation}
|
||||
secret: ${JWT_SECRET:ecc6de473d6f4e0fbdaf5ea899fd2e26}
|
||||
expiration: 604800000
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.tailbet: debug
|
||||
com.tailbet: info
|
||||
@@ -0,0 +1,72 @@
|
||||
spring:
|
||||
application:
|
||||
name: guess-game
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://13.212.30.9:3306/guess_game?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8
|
||||
username: app_user
|
||||
password: ${DB_PASSWORD:dhOmjPJ2321wq!.}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:ejgheyg2@eHSY2!.}
|
||||
database: ${REDIS_DB:0}
|
||||
timeout: 5000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-wait: -1ms
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
server:
|
||||
port: 9080
|
||||
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath:mapper/**/*.xml
|
||||
type-aliases-package: com.tailbet.entity
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: auto
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
|
||||
|
||||
openim:
|
||||
# OpenIM REST API 外网地址(必填),下发给客户端 httpUrl
|
||||
apiUrl: https://openim.xiu6688.com
|
||||
# OpenIM REST API 内网地址(可选),服务端调用,未配置则回落 apiUrl
|
||||
internalApiUrl: http://172.31.5.16:10002
|
||||
# OpenIM WebSocket 外网地址(必填),下发给客户端用于 IM SDK 初始化
|
||||
wsUrl: wss://im.xiu6688.com
|
||||
# OpenIM 管理员密钥(必填),用于获取 adminToken
|
||||
secret: d71c0861267DM23adg
|
||||
# OpenIM 管理员用户 ID(默认 imAdmin)
|
||||
adminUserId: imAdmin
|
||||
# 应用标识(默认 socialapp)
|
||||
appId: socialapp
|
||||
# adminToken 有效期秒数(默认 82800,即 23 小时)
|
||||
tokenExpireSeconds: 82800
|
||||
# 调用 OpenIM 接口的连接超时时间毫秒(默认 5000)
|
||||
connectTimeoutMs: 5000
|
||||
# 调用 OpenIM 接口的读取超时时间毫秒(默认 10000)
|
||||
readTimeoutMs: 10000
|
||||
# Webhook 签名密钥(可选),用于校验 OpenIM 回调请求
|
||||
webhookSecret: your-webhook-secret
|
||||
|
||||
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:ecc6de473d6f4e0fbdaf5ea899fd2e26}
|
||||
expiration: 604800000
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.tailbet: info
|
||||
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS user (
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt)',
|
||||
nickname VARCHAR(50) DEFAULT '' COMMENT '昵称',
|
||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||
points BIGINT DEFAULT 0 COMMENT '积分余额',
|
||||
points DECIMAL(20,2) DEFAULT 0 COMMENT '积分余额',
|
||||
is_ops TINYINT DEFAULT 0 COMMENT '运营标记:0否 1是',
|
||||
is_bot TINYINT DEFAULT 0 COMMENT '机器人标记:0否 1是',
|
||||
is_fake TINYINT DEFAULT 0 COMMENT '假人托标记:0否 1是',
|
||||
@@ -58,9 +58,9 @@ CREATE TABLE IF NOT EXISTS points_flow (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
type VARCHAR(20) NOT NULL COMMENT '类型:ops_add/ops_sub/bet/award/rp_send/rp_recv',
|
||||
amount BIGINT NOT NULL COMMENT '变动数值(正负)',
|
||||
balance_before BIGINT NOT NULL COMMENT '变动前余额',
|
||||
balance_after BIGINT NOT NULL COMMENT '变动后余额',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT '变动数值(正负)',
|
||||
balance_before DECIMAL(20,2) NOT NULL COMMENT '变动前余额',
|
||||
balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后余额',
|
||||
biz_no VARCHAR(100) COMMENT '业务单号',
|
||||
operator_id BIGINT COMMENT '操作人ID',
|
||||
remark VARCHAR(255) DEFAULT '' COMMENT '备注',
|
||||
@@ -89,7 +89,10 @@ CREATE TABLE IF NOT EXISTS game_round (
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_group_round (group_id, round_no),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_group_id (group_id)
|
||||
INDEX idx_group_id (group_id),
|
||||
INDEX idx_status_endbet (status, end_bet_time),
|
||||
INDEX idx_status_startrp (status, start_rp_time),
|
||||
INDEX idx_status_finishtime (status, finish_time)
|
||||
) COMMENT '游戏局表';
|
||||
|
||||
-- 注单表
|
||||
@@ -100,11 +103,11 @@ CREATE TABLE IF NOT EXISTS bet_order (
|
||||
round_id BIGINT NOT NULL COMMENT '局ID',
|
||||
client_msg_id VARCHAR(100) COMMENT '客户端消息ID(防重)',
|
||||
play_type VARCHAR(10) NOT NULL COMMENT '玩法:单/双/大/小/数字',
|
||||
bet_amount INT NOT NULL COMMENT '下注金额',
|
||||
bet_amount DECIMAL(20,2) NOT NULL COMMENT '下注金额',
|
||||
odds DECIMAL(5,2) NOT NULL COMMENT '赔率',
|
||||
is_fake TINYINT DEFAULT 0 COMMENT '是否假人下注',
|
||||
status TINYINT DEFAULT 0 COMMENT '状态:0待结算 1已结算 2未中奖',
|
||||
win_amount BIGINT DEFAULT 0 COMMENT '中奖金额',
|
||||
win_amount DECIMAL(20,2) DEFAULT 0 COMMENT '中奖金额',
|
||||
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_round (user_id, round_id),
|
||||
@@ -224,8 +227,8 @@ INSERT INTO sys_config (cfg_key, cfg_value, remark) VALUES
|
||||
INSERT INTO `guess_game`.`sys_config`(`id`, `cfg_key`, `cfg_value`, `remark`, `update_time`) VALUES (27, 'robot_id', '8', '机器人id', '2026-08-08 16:33:16');
|
||||
|
||||
-- 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 '已领取份额数';
|
||||
ALTER TABLE red_packet m COLUMN share_amounts JSON COMMENT '预分配金额列表(JSON数组,发红包时写入,领取时不再修改)';
|
||||
ALTER TABLE red_packet ADD COLUMN share_taken_index INT DEFAULT 0 COMMENT '下一个待领取份额的索引(从0开始)';
|
||||
|
||||
-- game_round 表增加第一个红包ID字段
|
||||
ALTER TABLE game_round ADD COLUMN first_rp_id BIGINT COMMENT '第一个红包ID';
|
||||
@@ -238,15 +241,29 @@ INSERT INTO sys_config (cfg_key, cfg_value, remark) VALUES
|
||||
('bet_window_seconds', '60', '接注窗口时间(秒)'),
|
||||
('fake_recv_seconds', '5', '托领取超时时间(秒)');
|
||||
|
||||
CREATE TABLE `pending_task` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_id` VARCHAR(64) NOT NULL COMMENT '任务ID',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT 'start_rp/settle/auto_next/bot_send_rp',
|
||||
`round_id` BIGINT COMMENT '局ID',
|
||||
`group_id` BIGINT NOT NULL COMMENT '群ID',
|
||||
`digit` INT COMMENT '开奖尾数(settle任务用)',
|
||||
`execute_time` DATETIME NOT NULL COMMENT '执行时间',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX `idx_execute_time` (`execute_time`),
|
||||
UNIQUE INDEX `idx_task_id` (`task_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
-- ====== 去内存 Map 重构后,pending_task 表已不再被应用写入,保留 DDL 仅作历史兼容参考 ======
|
||||
-- 实际生产如需清理,可执行:
|
||||
-- DROP TABLE IF EXISTS `pending_task`;
|
||||
-- CREATE TABLE `pending_task` (
|
||||
-- `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
-- `task_id` VARCHAR(64) NOT NULL COMMENT '任务ID',
|
||||
-- `type` VARCHAR(32) NOT NULL COMMENT 'start_rp/settle/auto_next/bot_send_rp',
|
||||
-- `round_id` BIGINT COMMENT '局ID',
|
||||
-- `group_id` BIGINT NOT NULL COMMENT '群ID',
|
||||
-- `digit` INT COMMENT '开奖尾数(settle任务用)',
|
||||
-- `execute_time` DATETIME NOT NULL COMMENT '执行时间',
|
||||
-- `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
-- INDEX `idx_execute_time` (`execute_time`),
|
||||
-- UNIQUE INDEX `idx_task_id` (`task_id`)
|
||||
-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ====== 积分字段类型迁移:BIGINT → DECIMAL(20,2),与红包金额统一精度 ======
|
||||
-- 历史BIGINT都是整数,DECIMAL(20,2)可无损容纳,无需特殊转换
|
||||
ALTER TABLE `user` MODIFY COLUMN `points` DECIMAL(20,2) DEFAULT 0 COMMENT '积分余额';
|
||||
ALTER TABLE `points_flow` MODIFY COLUMN `amount` DECIMAL(20,2) NOT NULL COMMENT '变动数值(正负)';
|
||||
ALTER TABLE `points_flow` MODIFY COLUMN `balance_before` DECIMAL(20,2) NOT NULL COMMENT '变动前余额';
|
||||
ALTER TABLE `points_flow` MODIFY COLUMN `balance_after` DECIMAL(20,2) NOT NULL COMMENT '变动后余额';
|
||||
|
||||
-- ====== 注单金额字段类型迁移:INT/BIGINT → DECIMAL(20,2),与积分/红包统一精度 ======
|
||||
ALTER TABLE `bet_order` MODIFY COLUMN `bet_amount` DECIMAL(20,2) NOT NULL COMMENT '下注金额';
|
||||
ALTER TABLE `bet_order` MODIFY COLUMN `win_amount` DECIMAL(20,2) DEFAULT 0 COMMENT '中奖金额';
|
||||
|
||||
Reference in New Issue
Block a user