first commit

This commit is contained in:
2026-09-21 19:21:35 +08:00
commit 6c944c4ffd
2733 changed files with 296956 additions and 0 deletions
@@ -0,0 +1,105 @@
package com.ruoyi.game.wheel.cache;
import com.ruoyi.game.wheel.mapper.GameWheelPoolAdjustmentMapper;
import com.ruoyi.game.wheel.model.cache.WheelRewardPoolCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.admin.config.RedisConstants.WHEEL_GAME_REWARD_POOL_CACHE;
@Component
@Slf4j
public class WheelRewardPoolCacheRepository {
@Autowired
public RedisTemplate redisTemplate;
@Autowired
private GameWheelPoolAdjustmentMapper poolAdjustmentMapper;
/**
* 奖池余额(唯一口径)= 累计下注 − 累计返奖 + Σ人工调整(抽水为负)。
* 开奖上限与后台展示都必须走这里,否则会出现"抽水后页面变小、开奖仍按抽水前算"。
*/
public BigDecimal getBalance(String poolType) {
WheelRewardPoolCache cache = getCache(poolType);
return cache.getTotalBet().subtract(cache.getTotalWin())
.add(poolAdjustmentMapper.sumAdjustmentByPoolType(poolType));
}
/**
* 金额统一成 2 位小数再用/再存。
* 池子用 Redis INCRBYFLOAT(double)累加,不夹小数位会积出 0.0000001 级别的漂移;
* 入库/出库都按分(2 位)对齐,与 DB 里的 DECIMAL(10,2) 口径一致。
*/
private static BigDecimal scale2(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v.setScale(2, RoundingMode.HALF_UP);
}
/** 幂等键保留时长:异常重跑都发生在当天,8 天足够;periodNo 全局唯一,不会跨局复用 */
private static final long POOL_ONCE_TTL_DAYS = 8;
/**
* 按局注入本局下注(同一 periodNo 只注入一次)。
* 开奖流程在结算之前注入;一旦结算抛异常/进程中断,兜底路径会重跑开奖,
* 没有这个守卫就会把同一局的投注重复计入 totalBet,虚增奖池并抬高后续所有局的发奖上限。
*
* @return true=本次真正注入;false=该局已注入过(跳过)
*/
public boolean incrementalBetOnce(String poolType, Long periodNo, BigDecimal totalBet) {
if (periodNo == null || totalBet == null || totalBet.signum() <= 0) return false;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(poolKey(poolType) + ":betOnce:" + periodNo, "1", POOL_ONCE_TTL_DAYS, TimeUnit.DAYS);
if (!Boolean.TRUE.equals(first)) return false;
redisTemplate.opsForValue().increment(poolKey(poolType) + ":bet", scale2(totalBet).doubleValue());
return true;
}
/** 按局注入本局返奖(同一 periodNo 只注入一次) */
public boolean incrementalWinOnce(String poolType, Long periodNo, BigDecimal totalWin) {
if (periodNo == null || totalWin == null || totalWin.signum() <= 0) return false;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(poolKey(poolType) + ":winOnce:" + periodNo, "1", POOL_ONCE_TTL_DAYS, TimeUnit.DAYS);
if (!Boolean.TRUE.equals(first)) return false;
redisTemplate.opsForValue().increment(poolKey(poolType) + ":win", scale2(totalWin).doubleValue());
return true;
}
/**
* 退款时反向冲减该局已注入的下注(金额 = 这一笔被退回的真实本金)。
* 只在"该局确实注入过"(betOnce 键存在)时生效:投注期就被退款的局从未注入过,不能反向扣。
* 不会重复冲减:调用方(refundBets)按注单的 settled=0→1 CAS 保证一张注单只退一次。
* 方向说明:回滚等极端情况下宁可少冲(池子偏保守、少发),也不会出现多冲导致多发。
*/
public void reverseBetInjection(String poolType, Long periodNo, BigDecimal amount) {
if (periodNo == null || amount == null || amount.signum() <= 0) return;
if (!Boolean.TRUE.equals(redisTemplate.hasKey(poolKey(poolType) + ":betOnce:" + periodNo))) return;
redisTemplate.opsForValue().increment(poolKey(poolType) + ":bet", scale2(amount).negate().doubleValue());
}
/**
* 清空指定池的累计下注/返奖统计(配合作废人工调整记录,余额即归零)。
* 只删统计键,不动进行中回合的缓存。
*/
public void reset(String poolType) {
redisTemplate.delete(poolKey(poolType) + ":bet");
redisTemplate.delete(poolKey(poolType) + ":win");
}
/** 读取指定池的累计数据 */
public WheelRewardPoolCache getCache(String poolType) {
var betS = redisTemplate.opsForValue().get(poolKey(poolType) + ":bet");
var winS = redisTemplate.opsForValue().get(poolKey(poolType) + ":win");
WheelRewardPoolCache cache = new WheelRewardPoolCache();
cache.setTotalBet(scale2(betS == null ? null : new BigDecimal(betS.toString())));
cache.setTotalWin(scale2(winS == null ? null : new BigDecimal(winS.toString())));
return cache;
}
private String poolKey(String poolType) {
return WHEEL_GAME_REWARD_POOL_CACHE + ":" + poolType;
}
}
@@ -0,0 +1,30 @@
package com.ruoyi.game.wheel.cache;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.json.SnakeCaseJsonUtils;
import com.ruoyi.game.wheel.constants.CacheConstants;
import com.ruoyi.game.wheel.model.cache.WheelRoundCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class WheelRoundCacheRepository {
@Autowired
private RedisCache redisCache;
public WheelRoundCache getCache() {
String s = redisCache.getCacheObject(CacheConstants.WHEEL_ROUND);
return SnakeCaseJsonUtils.fromSnakeCaseJson(s, WheelRoundCache.class);
}
public void setCache(WheelRoundCache cache) {
if (cache == null) {
redisCache.setCacheObject(CacheConstants.WHEEL_ROUND, null, 1, TimeUnit.DAYS);
} else {
String json = SnakeCaseJsonUtils.toSnakeCaseJson(cache);
redisCache.setCacheObject(CacheConstants.WHEEL_ROUND, json, 1, TimeUnit.DAYS);
}
}
}
@@ -0,0 +1,15 @@
package com.ruoyi.game.wheel.constants;
public class CacheConstants {
private static final String PREFIX = "ruoyi:wheel:";
public static final String WHEEL_ROUND = PREFIX + "round";
/** 各期注单读取缓存(前缀,实际 key = 前缀 + periodNo):非投注期展示用,避免高并发轮询反复查库 */
public static final String WHEEL_ROUND_BETS = PREFIX + "round_bets:";
public static final String WHEEL_LOCK = PREFIX + "lock";
/** 用户级下注锁(前缀,实际 key = 前缀 + userId),串行化同用户下注防连点 */
public static final String WHEEL_BET_USER_LOCK = PREFIX + "bet:user:";
}
@@ -0,0 +1,57 @@
package com.ruoyi.game.wheel.constants;
/**
* 疯狂转盘决策池类型。
* <p>
* 单一真实奖池:所有参与落点/ROTP 决策与统计的下注都归入 real 池。
* 用户类型划分:
* real(真实池,参与落点与 RTP) = 普通(02) + 主播(01) + 招商(04) + 卡商(05)
* display(展示,可下注/返奖但绝不入真实池) = 机器人(03) + 白号(06)
* 超管(99) 不参与转盘。
* <p>
* 一轮只有一个落点,所有人看到同一个落点;机器人/白号跟同一落点结算,但资金不污染真实池。
*/
public final class WheelPoolType {
/** 唯一真实奖池:普通(02)+主播(01)+招商(04)+卡商(05) */
public static final String REAL = "real";
/** 展示类型:机器人(03)+白号(06),可下注/返奖但不入真实奖池 */
public static final String DISPLAY = "display";
private WheelPoolType() {
}
/**
* 用户类型 → 决策池。
*
* @param userType tt_user.user_type(01主播/02普通/03机器人/04招商/05卡商/06白号/99超管)
* @return real=进真实池;display=机器人/白号不入池;null=不参与转盘(如 99 超管)
*/
public static String ofUserType(String userType) {
if (userType == null) {
return null;
}
switch (userType) {
case "01":
case "02":
case "04":
case "05":
return REAL;
case "03":
case "06":
return DISPLAY;
default:
return null;
}
}
/** 是否进真实奖池(参与落点/RTP 决策与统计) */
public static boolean isReal(String poolType) {
return REAL.equals(poolType);
}
/** 唯一奖池(开奖/统计用) */
public static String[] all() {
return new String[]{REAL};
}
}
@@ -0,0 +1,11 @@
package com.ruoyi.game.wheel.contract.admin.response;
import com.ruoyi.game.wheel.domain.GameWheelGameConfig;
import lombok.Data;
@Data
public class QueryWheelGameConfig {
private GameWheelGameConfig config;
/** 4池统计:real/robot/blank/promo 各含 totalBet/totalWin/profit/rtp */
private java.util.Map<String, Object> poolStats;
}
@@ -0,0 +1,20 @@
package com.ruoyi.game.wheel.contract.api.request;
import lombok.Data;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@Data
public class DoBetRequest {
@NotNull(message = "投注金额不能为空")
@Min(value = 1, message = "投注金额不能小于1")
private Integer bet;
@NotBlank(message = "投注颜色不能为空")
private String color;
@NotBlank(message = "投注符号不能为空")
private String symbol;
}
@@ -0,0 +1,7 @@
package com.ruoyi.game.wheel.contract.api.request;
import lombok.Data;
@Data
public class GetGameConfigRequest {
}
@@ -0,0 +1,9 @@
package com.ruoyi.game.wheel.contract.api.request;
import lombok.Data;
@Data
public class GetHistoryBetRequest {
private Integer pageNum;
private Integer pageSize;
}
@@ -0,0 +1,7 @@
package com.ruoyi.game.wheel.contract.api.request;
import lombok.Data;
@Data
public class GetLastRoundInfoRequest {
}
@@ -0,0 +1,9 @@
package com.ruoyi.game.wheel.contract.api.request;
import lombok.Data;
@Data
public class GetRoundHistoryRequest {
private Integer pageNum;
private Integer pageSize;
}
@@ -0,0 +1,14 @@
package com.ruoyi.game.wheel.contract.api.request;
import lombok.Data;
/**
* 获取历史开奖结果请求
*/
@Data
public class GetRoundResultsRequest {
/** 页码(默认 1) */
private Integer pageNum;
/** 每页条数(默认 20) */
private Integer pageSize;
}
@@ -0,0 +1,13 @@
package com.ruoyi.game.wheel.contract.api.response;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class DoBetResponse {
private Long roundId;
private String cost;
private String balance;
private String credits;
}
@@ -0,0 +1,62 @@
package com.ruoyi.game.wheel.contract.api.response;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
@Data
public class GetGameConfigResponse {
/** 转盘总格数(内外圈各24,索引一一对应) */
private int segmentCount;
/** 外圈24格符号固定布局,如 AABBBCCCCCCCCDDDDDDDDDDD(A金符/B红符/C紫符/D蓝符) */
private String outerSymbols;
/** 内圈颜色分布(每轮随机排布,合计=segmentCount) */
private InnerColorDist innerColorDist;
/** 可选下注额(10档) */
private List<Integer> bets;
/** 12 个倍率(3内圈色 × 4外圈符号) */
private List<Multiplier> multipliers;
/** 当前用户所属决策池:real/robot/blank/promo;null=不可参与(如超管99) */
private String myPoolType;
/** 奖励矩阵(120条,前端下注面板按下注额筛选展示对应饰品) */
private List<RewardCell> rewardMatrix;
/** 内圈颜色分布 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class InnerColorDist {
private int gold;
private int red;
private int purple;
}
/** 倍率项:内圈色 × 外圈符号 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Multiplier {
private String color;
private String symbol;
private Integer multiplier;
}
/** 奖励矩阵单元:下注额 × 颜色 × 符号 → 自定义饰品 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class RewardCell {
private Integer betAmount;
private String color;
private String symbol;
private Integer multiplier;
private BigDecimal prizeValue;
private String prizeName;
private Integer prizeGrade;
private String prizeExterior;
private String prizeImage;
}
}
@@ -0,0 +1,41 @@
package com.ruoyi.game.wheel.contract.api.response;
import lombok.Data;
import java.util.List;
@Data
public class GetRoundHistoryResponse {
/**
* 总记录数
*/
private long total;
/**
* 列表数据
*/
private List<BetItem> rows;
@Data
public static class BetItem {
private Long periodNo;
private String endTime;
private Long userId;
private String nickName;
// 用户头像
private String avatar;
private String bet;
// 中奖符号
private String betSymbol;
private String win;
// 该中奖符号下注次数
private Integer betCount;
// 饰品品质等级
private Integer prizeGrade;
// 饰品名称
private String prizeName;
// 饰品图片
private String prizeImage;
}
}
@@ -0,0 +1,108 @@
package com.ruoyi.game.wheel.contract.api.response;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
@Data
public class GetRoundInfoResponse {
private Long roundId;
private Long periodNo;
private Long serverTime;
// 本局转盘开始时间
private Long startTime;
// 本局转盘下注结束时间(封盘)
private Long bettedTime;
// 开始旋转时间
private Long spinTime;
// 结束时间
private Long endTime;
/** @see com.ruoyi.game.wheel.enums.WheelRoundStatusEnum */
private String status;
// ===== 游戏配置 =====
private List<String> outerSymbols;
private List<Integer> bets;
private String myPoolType;
private List<RewardCell> rewardMatrix;
/**
* 游戏关闭 / 维护中(全站维护或轮盘维护),前端据此提示"维护中"而不是"当前不在下注期"。
* 注意类型必须是包装类 Boolean:字段名以 is 开头时,Lombok 只会给 Boolean 生成 getIsMaintenance(),
* 用基本类型 boolean 会生成 isMaintenance(),序列化出去的键会变成 maintenance,前端读不到。
*/
private Boolean isMaintenance;
/** 本轮内圈24色(gold/red/purple,前端渲染盘面) */
private List<String> innerColors;
// ===== 当前用户所属池的开奖结果(未开奖为 null) =====
private Integer resultIndex;
private String resultColor;
private String resultSymbol;
// 当前用户本轮总返奖(饰品价值合计)
private String totalWin;
// 当前用户本轮下注明细
private List<UserBet> userBets;
// 当前用户本轮中奖明细(命中的注,每注一条)
private List<WinResult> winResult;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class RewardCell {
private Integer betAmount;
private String color;
private String symbol;
private Integer multiplier;
private BigDecimal prizeValue;
private String prizeName;
private Integer prizeGrade;
private String prizeExterior;
private String prizeImage;
/** 饰品品质图片 */
private String prizeLevelImg;
}
/** 当前用户下注汇总(按颜色+符号聚合) */
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class UserBet {
private String color;
private String symbol;
/** 下注次数 */
private Integer count;
/** 总下注额 */
private BigDecimal totalBet;
}
/** 当前用户发奖明细(每注一条,命中发矩阵饰品,未命中发安慰饰品) */
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class WinResult {
private String color;
private String symbol;
/** 是否命中(true=中奖矩阵饰品,false=安慰饰品) */
private boolean hit;
/** 背包记录ID(用于分解/出售) */
private Long boxRecordId;
/** 饰品名称 */
private String prizeName;
/** 饰品图片 */
private String prizeImage;
/** 饰品品质等级 */
private Integer prizeGrade;
/** 饰品品质图片 */
private String prizeLevelImg;
/** 饰品磨损度 */
private String prizeExterior;
/** 饰品价格 */
private BigDecimal prizeValue;
}
}
@@ -0,0 +1,38 @@
package com.ruoyi.game.wheel.contract.api.response;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 获取历史开奖结果响应
*/
@Data
public class GetRoundResultsResponse {
/** 总记录数 */
private Long total;
/** 开奖记录列表 */
private List<RoundResult> rows;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class RoundResult {
/** 期号(完整13位) */
private Long periodNo;
/** 日期(yyyyMMdd,8位) */
private String date;
/** 当天局号(1-99999) */
private Long roundId;
/** 开奖内圈颜色 gold/red/purple */
private String resultColor;
/** 开奖外圈符号 A/B/C/D */
private String resultSymbol;
/** 开奖格子索引(0-23) */
private Integer resultIndex;
/** 结束时间(yyyy-MM-dd HH:mm:ss) */
private String endTime;
}
}
@@ -0,0 +1,226 @@
package com.ruoyi.game.wheel.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.annotation.RepeatSubmit;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.game.wheel.domain.*;
import com.ruoyi.game.wheel.mapper.*;
import com.ruoyi.game.wheel.constants.WheelPoolType;
import com.ruoyi.game.wheel.service.AdminWheelService;
import com.ruoyi.game.wheel.vo.WheelBetRecordVO;
import com.ruoyi.game.wheel.vo.WheelPoolAdjustmentVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Api(tags = "疯狂转盘")
@Slf4j
@RestController
@RequestMapping("/admin/wheel")
public class AdminWheelController extends BaseController {
@Autowired
private AdminWheelService adminWheelService;
@Autowired
private GameWheelPrizeMapper wheelPrizeMapper;
@Autowired
private GameWheelPrizeOrnamentMapper prizeOrnamentMapper;
@Autowired
private GameWheelPrizeRangeMapper prizeRangeMapper;
@Autowired
private ApiGameWheelRoundMapper wheelRoundMapper;
@Autowired
private ApiGameWheelRoundBetMapper wheelRoundBetMapper;
@Autowired
private GameWheelRewardMatrixMapper rewardMatrixMapper;
@Autowired
private GameWheelPoolAdjustmentMapper poolAdjustmentMapper;
@ApiOperation("获取疯狂转盘游戏配置")
@GetMapping("/gameconfig")
public AjaxResult getGameConfig() {
return adminWheelService.getGameConfig();
}
@Log(title = "游戏配置", action = "保存", businessType = BusinessType.UPDATE)
@ApiOperation("更新疯狂转盘游戏配置")
@PostMapping("/gameconfig")
public AjaxResult updateGameConfig(@RequestBody String body) {
return adminWheelService.updateGameConfig(body);
}
@ApiOperation("回合记录列表")
@GetMapping("/round/list")
public TableDataInfo roundList(
@RequestParam(required = false) String date,
@RequestParam(required = false) Long roundId) {
startPage();
LambdaQueryWrapper<GameWheelRound> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.isNotEmpty(date)) {
wrapper.eq(GameWheelRound::getDate, date);
}
if (roundId != null) {
wrapper.eq(GameWheelRound::getRoundId, roundId);
}
wrapper.orderByDesc(GameWheelRound::getId);
List<GameWheelRound> list = wheelRoundMapper.selectList(wrapper);
return getDataTable(list);
}
@ApiOperation("投注记录列表")
@GetMapping("/bet/list")
public TableDataInfo betList(
@RequestParam(required = false) Long roundId,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String periodNo) {
startPage();
List<WheelBetRecordVO> list = wheelRoundBetMapper.selectBetRecords(roundId, userId, periodNo);
return getDataTable(list);
}
// ======================== 奖励矩阵 CRUD ========================
@ApiOperation("奖励矩阵列表")
@GetMapping("/matrix/list")
public AjaxResult matrixList(
@RequestParam(required = false) Integer betAmount,
@RequestParam(required = false) String color,
@RequestParam(required = false) String symbol) {
LambdaQueryWrapper<GameWheelRewardMatrix> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(GameWheelRewardMatrix::getStatus, "1");
if (betAmount != null) wrapper.eq(GameWheelRewardMatrix::getBetAmount, betAmount);
if (StringUtils.isNotEmpty(color)) wrapper.eq(GameWheelRewardMatrix::getColor, color);
if (StringUtils.isNotEmpty(symbol)) wrapper.eq(GameWheelRewardMatrix::getSymbol, symbol);
wrapper.orderByAsc(GameWheelRewardMatrix::getBetAmount)
.orderByAsc(GameWheelRewardMatrix::getColor)
.orderByAsc(GameWheelRewardMatrix::getSymbol);
List<GameWheelRewardMatrix> list = rewardMatrixMapper.selectList(wrapper);
return AjaxResult.success(Map.of("list", list));
}
@Log(title = "奖励矩阵", action = "保存", businessType = BusinessType.UPDATE)
@ApiOperation("保存奖励矩阵行")
@PostMapping("/matrix/save")
public AjaxResult matrixSave(@RequestBody GameWheelRewardMatrix matrix) {
GameWheelRewardMatrix exist = matrix.getId() == null ? null : rewardMatrixMapper.selectById(matrix.getId());
if (exist != null) {
// 更新分支同样要重算奖品价值:否则 multiplier 变了、prize_value 还是旧值,
// 后台/客户端展示的价值与实际结算(下注额×快照倍率)长期不一致
Integer betAmount = matrix.getBetAmount() != null ? matrix.getBetAmount() : exist.getBetAmount();
Integer multiplier = matrix.getMultiplier() != null ? matrix.getMultiplier() : exist.getMultiplier();
if (betAmount != null && multiplier != null) {
matrix.setPrizeValue(BigDecimal.valueOf(betAmount).multiply(BigDecimal.valueOf(multiplier)));
}
rewardMatrixMapper.updateById(matrix);
} else {
if (matrix.getMultiplier() != null && matrix.getBetAmount() != null) {
matrix.setPrizeValue(BigDecimal.valueOf(matrix.getBetAmount())
.multiply(BigDecimal.valueOf(matrix.getMultiplier())));
}
matrix.setStatus("1");
rewardMatrixMapper.insert(matrix);
}
return AjaxResult.success();
}
@Log(title = "奖励矩阵", action = "删除", businessType = BusinessType.DELETE)
@ApiOperation("删除奖励矩阵行(软删:置 status=0)")
@DeleteMapping("/matrix/delete/{id}")
public AjaxResult matrixDelete(@PathVariable Long id) {
rewardMatrixMapper.update(null, new LambdaUpdateWrapper<GameWheelRewardMatrix>()
.eq(GameWheelRewardMatrix::getId, id)
.set(GameWheelRewardMatrix::getStatus, "0"));
return AjaxResult.success();
}
// ======================== 奖池统计 ========================
@ApiOperation("奖池统计")
@GetMapping("/pool/stats")
public AjaxResult poolStats() {
return AjaxResult.success(adminWheelService.buildPoolStats());
}
// ======================== 奖池调节 ========================
@Log(title = "游戏配置", action = "调整奖池", businessType = BusinessType.GRANT)
@ApiOperation("奖池调整(注水/抽水)")
@PostMapping("/pool/adjust")
@RepeatSubmit(interval = 3000)
public AjaxResult adjustPool(@RequestBody Map<String, Object> params) {
return adminWheelService.adjustPool(params, getUsername());
}
@Log(title = "游戏配置", action = "重置奖池", businessType = BusinessType.CLEAN)
@ApiOperation("重置奖池(累计统计与余额一起归零)")
@PostMapping("/pool/reset")
public AjaxResult resetPool(@RequestBody(required = false) Map<String, Object> params) {
Map<String, Object> p = params == null ? Collections.emptyMap() : params;
String poolType = (String) p.getOrDefault("poolType", WheelPoolType.REAL);
String reason = p.get("reason") == null ? "" : p.get("reason").toString();
return adminWheelService.resetPool(poolType, reason, getUsername());
}
@ApiOperation("奖池调整记录")
@GetMapping("/pool/adjustLog")
public TableDataInfo adjustLog(@RequestParam(required = false) String poolType) {
startPage();
LambdaQueryWrapper<GameWheelPoolAdjustment> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.isNotEmpty(poolType)) {
wrapper.eq(GameWheelPoolAdjustment::getPoolType, poolType);
}
wrapper.orderByDesc(GameWheelPoolAdjustment::getCreateTime);
List<WheelPoolAdjustmentVO> list = poolAdjustmentMapper.selectList(wrapper).stream()
.map(this::toPoolAdjustmentVO)
.collect(Collectors.toList());
return getDataTable(list);
}
private WheelPoolAdjustmentVO toPoolAdjustmentVO(GameWheelPoolAdjustment source) {
WheelPoolAdjustmentVO target = new WheelPoolAdjustmentVO();
target.setId(source.getId());
target.setPoolType(source.getPoolType());
target.setAdjustmentAmount(source.getAdjustmentAmount());
target.setBalanceBefore(source.getBalanceBefore());
target.setBalanceAfter(source.getBalanceAfter());
target.setReason(source.getReason());
target.setOperator(source.getOperator());
target.setCreateTime(source.getCreateTime() == null ? null
: source.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return target;
}
@ApiOperation("获取奖池余额(含人工调整)")
@GetMapping("/pool/balance")
public AjaxResult getPoolBalance(@RequestParam(required = false) String poolType) {
if (StringUtils.isEmpty(poolType)) {
poolType = WheelPoolType.REAL;
}
BigDecimal balance = adminWheelService.getPoolBalance(poolType);
Map<String, Object> result = new LinkedHashMap<>();
result.put("poolType", poolType);
result.put("balance", balance);
return AjaxResult.success(result);
}
}
@@ -0,0 +1,121 @@
package com.ruoyi.game.wheel.controller;
import cn.hutool.core.util.ObjectUtil;
import com.ruoyi.common.annotation.UpdateUserCache;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.game.wheel.contract.api.request.DoBetRequest;
import com.ruoyi.game.wheel.contract.api.request.GetGameConfigRequest;
import com.ruoyi.game.wheel.contract.api.request.GetHistoryBetRequest;
import com.ruoyi.game.wheel.contract.api.request.GetLastRoundInfoRequest;
import com.ruoyi.game.wheel.contract.api.request.GetRoundHistoryRequest;
import com.ruoyi.game.wheel.contract.api.request.GetRoundResultsRequest;
import com.ruoyi.game.wheel.contract.api.response.DoBetResponse;
import com.ruoyi.game.wheel.contract.api.response.GetGameConfigResponse;
import com.ruoyi.game.wheel.contract.api.response.GetRoundHistoryResponse;
import com.ruoyi.game.wheel.contract.api.response.GetRoundInfoResponse;
import com.ruoyi.game.wheel.contract.api.response.GetRoundResultsResponse;
import com.ruoyi.game.wheel.service.ApiWheelService;
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.user.service.ApiUserService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Map;
import org.springframework.web.bind.annotation.RestController;
@Api(tags = "疯狂转盘")
@Slf4j
@RestController
@RequestMapping("/api/wheel")
public class ApiWheelController extends BaseController {
@Autowired
private ApiWheelService apiWheelService;
@Autowired
private ApiUserService userService;
@Autowired
private ISysConfigService sysConfigService;
public R checkLogin() {
Long userId;
try {
userId = getUserId();
if (ObjectUtil.isEmpty(userId)) return R.fail(401, "登录过期,请重新登录。");
return R.ok(userId);
} catch (Exception e) {
return R.fail(401, "登录过期,请重新登录。");
}
}
@ApiOperation("投注")
@PostMapping("/dobet")
// 这里**不加** @RepeatSubmit:一局允许同一组合重复下注(最多 50 注),连点两次 5 元 = 两注,
// 是正常玩法而不是重复提交;加了会把第二次合法下注挡成"不允许重复提交"。
// 防连点由客户端 _bettingRequesting 锁负责,服务端由用户锁 + 50 注上限兜住。
@UpdateUserCache
public R<DoBetResponse> doBet(@RequestBody @Validated DoBetRequest request) {
String websiteMaintenance = sysConfigService.selectConfigByKey("websiteMaintenance");
if ("1".equals(websiteMaintenance)) {
return R.fail("网站维护中......");
}
String wheelMaintenance = sysConfigService.selectConfigByKey("wheelMaintenance");
if ("1".equals(wheelMaintenance)) {
return R.fail("疯狂转盘维护中......");
}
R loginCheck = checkLogin();
if (!loginCheck.getCode().equals(200)) return loginCheck;
Long userId = (Long) loginCheck.getData();
var r = userService.checkRecharge(userId.intValue());
if (R.isError(r)) {
// 透传业务码(如 703 未充值),供前端识别
return R.fail(r.getCode().intValue(), r.getMsg());
}
return apiWheelService.doBet(request, userId);
}
@ApiOperation("获取最新一期游戏状态")
@PostMapping("/last_round_info")
public R<GetRoundInfoResponse> getLastRoundInfo(@RequestBody GetLastRoundInfoRequest request) {
R loginCheck = checkLogin();
if (!loginCheck.getCode().equals(200)) return loginCheck;
Long userId = (Long) loginCheck.getData();
return apiWheelService.getLastRoundInfo(userId);
}
@ApiOperation("获取玩家的游戏历史记录")
@PostMapping("/history_bet_info")
public R<GetRoundHistoryResponse> getHistoryBetInfo(@RequestBody GetHistoryBetRequest request) {
R loginCheck = checkLogin();
if (!loginCheck.getCode().equals(200)) return loginCheck;
Long userId = (Long) loginCheck.getData();
return apiWheelService.getHistoryBetInfo(request, userId);
}
@ApiOperation("获取疯狂转盘游戏历史记录")
@PostMapping("/round_history_bet_info")
public R<GetRoundHistoryResponse> getRoundHistoryBetInfo(@RequestBody GetRoundHistoryRequest request) {
R loginCheck = checkLogin();
if (!loginCheck.getCode().equals(200)) return loginCheck;
Long userId = (Long) loginCheck.getData();
return apiWheelService.getRoundHistoryInfo(request);
}
@ApiOperation("获取历史开奖结果(每一轮的开奖符号)")
@PostMapping("/round_results")
public R<GetRoundResultsResponse> getRoundResults(@RequestBody GetRoundResultsRequest request) {
R loginCheck = checkLogin();
if (!loginCheck.getCode().equals(200)) return loginCheck;
return apiWheelService.getRoundResults(request);
}
}
@@ -0,0 +1,61 @@
package com.ruoyi.game.wheel.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
public class GameWheelGameConfig {
// 玩法配置
// 是否关闭游戏 false
private boolean switchOff;
// 可选的下注数
private List<Integer> bets;
// ===== 内圈玩法新配置(内圈3色 + 外圈4符号,各24格) =====
/** 转盘总格数(内外圈各24格,索引一一对应) */
private int segmentCount = 24;
/** 外圈24格符号固定布局(A金符/B红符/C紫符/D蓝符,分布 金2红3紫8蓝11) */
private String outerSymbols;
/** 内圈金色格数(每轮随机排布,三色合计=segmentCount) */
private int innerGoldCount = 6;
/** 内圈红色格数 */
private int innerRedCount = 8;
/** 内圈紫色格数 */
private int innerPurpleCount = 10;
/** 12个倍率:3内圈色 × 4外圈符号(跨10档下注额共用) */
private List<MultiplierItem> multipliers;
// 下注期
private int bettingDuration = 10;
// 封盘期5秒
private int bettedDuration = 0;
// 开奖动画时长(秒)
private int animationDuration = 5;
/** @deprecated 后端不使用此字段,前端也拿不到;保留仅为兼容已存储配置的反序列化 */
@Deprecated
private int resultDisplayDuration = 5;
// ===== 发奖上限(payoutCap = min(奖池余额×bufferRatio, 累计真实投注×rtp − 累计真实返奖)) =====
// 一次旋转最多可动用的奖池比例(作用在奖池余额上)。字段名沿用旧名,语义与"保留比例"相反:
// 0.6 = 最多拿出奖池余额的 60%,不是保留 60%。改键名会让存量配置反序列化失效,故保留 bufferRatio。
private double bufferRatio;
// 目标 RTP(累计口径):本局可发额度 = 累计真实投注 × rtp − 累计真实返奖,下限 0。
// 历史 RTP 恰好 = rtp 时该额度退化为「本局投注 × rtp」;历史返奖低则额度累积(可爆大奖),高则收紧。
private double rtp;
/** 内圈色×外圈符号的倍率项(12个组合) */
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class MultiplierItem {
/** 内圈颜色 gold/red/purple */
private String color;
/** 外圈符号 A/B/C/D */
private String symbol;
/** 倍率 */
private Integer multiplier;
}
}
@@ -0,0 +1,50 @@
package com.ruoyi.game.wheel.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 疯狂转盘奖池调节记录
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("game_wheel_pool_adjustment")
public class GameWheelPoolAdjustment implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
/** 池类型(real/display) */
private String poolType;
/** 调整金额(正数=注水,负数=抽水) */
private BigDecimal adjustmentAmount;
/** 调整前余额 */
private BigDecimal balanceBefore;
/** 调整后余额 */
private BigDecimal balanceAfter;
/** 调整原因 */
private String reason;
/** 操作人 */
private String operator;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
}
@@ -0,0 +1,36 @@
package com.ruoyi.game.wheel.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("game_wheel_prize_ornament")
public class GameWheelPrizeOrnament {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("game_type")
private String gameType;
private String name;
@TableField("image_url")
private String imageUrl;
private Integer grade;
private Integer sort;
private String status;
@TableField("is_fallback")
private Integer isFallback;
@TableField("exterior_name")
private String exteriorName;
@TableField("create_time")
private Date createTime;
@TableField(exist = false)
private BigDecimal winLow;
@TableField(exist = false)
private BigDecimal winHigh;
}
@@ -0,0 +1,21 @@
package com.ruoyi.game.wheel.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
@Data
@TableName("game_wheel_prize_range")
public class GameWheelPrizeRange {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("game_type")
private String gameType;
private BigDecimal winLow;
private BigDecimal winHigh;
private Long ornamentId;
private Integer sort;
}
@@ -0,0 +1,67 @@
package com.ruoyi.game.wheel.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 疯狂转盘奖励矩阵(方案B:完全自定义饰品)。
* <p>
* 10 档下注额 × 3 内圈色 × 4 外圈符号 = 120 条,每条绑定一件完全自定义饰品。
* 价值 prizeValue = betAmount × multiplier(自动计算);
* 名称/品质/磨损/图片为自定义属性,不受倍率影响。
*/
@Data
@TableName("game_wheel_reward_matrix")
public class GameWheelRewardMatrix {
@TableId(type = IdType.AUTO)
private Long id;
/** 下注额层:5/10/20/50/100/200/500/1000/2000/5000 */
@TableField("bet_amount")
private Integer betAmount;
/** 内圈颜色:gold/red/purple */
private String color;
/** 外圈符号:A/B/C/D */
private String symbol;
/** 倍率(后台可配;同一 color+symbol 的 12 个倍率跨下注额共用) */
private Integer multiplier;
/** 价值 = 下注额 × 倍率(自动计算,展示/发放记录用) */
@TableField("prize_value")
private BigDecimal prizeValue;
/** 饰品名称(自定义) */
@TableField("prize_name")
private String prizeName;
/** 品质等级(自定义,对应 tt_ornaments_level.id) */
@TableField("prize_grade")
private Integer prizeGrade;
/** 磨损度(自定义) */
@TableField("prize_exterior")
private String prizeExterior;
/** 饰品图片(自定义) */
@TableField("prize_image")
private String prizeImage;
/** 状态:0 停用 1 启用 */
private String status;
@TableField("create_time")
private Date createTime;
@TableField("update_time")
private Date updateTime;
}
@@ -0,0 +1,48 @@
package com.ruoyi.game.wheel.domain;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoyi.game.wheel.domain.handler.WheelRoundPhaseTimesTypeHandler;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Builder
@TableName(value = "game_wheel_round", autoResultMap = true)
public class GameWheelRound implements Serializable {
private Long id;
private String date;
private Long roundId;
/** 全局唯一期号 = date×100000 + 当日序号,业务主键(替代 date+roundId 复合键) */
private Long periodNo;
private LocalDateTime startTime;
private LocalDateTime endTime;
@TableField(typeHandler = WheelRoundPhaseTimesTypeHandler.class)
private List<Long> phaseTimes;
private String symbols;
/** 本轮内圈24色随机排布快照(外圈 symbols 为固定布局) */
private String innerColors;
// 下面的字段只做统计用,不参与业务逻辑
private BigDecimal totalBet;
private BigDecimal totalWin;
// 旋转的结果,包含中间旋转结果,每个结果用`,`分割。如S2,6,9,表示命中额外抽奖两次,6,9,表示命中额外抽奖一次
private String result;
private Integer isManual;
private String status;
private LocalDateTime create_time;
private LocalDateTime update_time;
}
@@ -0,0 +1,54 @@
package com.ruoyi.game.wheel.domain;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Builder
@TableName(value = "game_wheel_round_bet")
public class GameWheelRoundBet implements Serializable {
private Long id;
private Long roundId;
/** 全局唯一期号 = date×100000 + 当日序号,业务主键(查询/结算/判重单列定位) */
private Long periodNo;
/** 所属日期(yyyyMMdd):仅作按天统计,唯一定位改用 periodNo */
private String date;
private Long userId;
private BigDecimal bet;
private String betSymbol;
/** 下注内圈颜色 gold/red/purple */
private String color;
private BigDecimal win;
private Integer settled; // 0=等决定 1=已结算
/**
* 是否走了"退本金"(0=否 1=是)。
* settled 只有两态(未结算/已结算),发过奖和退过款在库里长得一样,
* 事后无法逐注区分;退款时额外打这个标记,便于对账与客服核对。
*/
private Integer refunded;
/** 决策池 real/robot/blank/promo(落库时定池,不受下注后用户类型变更影响) */
private String poolType;
/** 下注时快照倍率(防中途改配置影响已下注) */
private Integer multiplier;
/** 命中奖品矩阵行ID(game_wheel_reward_matrix.id)快照 */
private Long prizeId;
/** 下注时实际扣减的积分额(退款按此原路退回;仅金币扣款时为0) */
private BigDecimal creditsCut;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,31 @@
package com.ruoyi.game.wheel.domain;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Builder
@TableName(value = "game_wheel_user")
public class GameWheelUser implements Serializable {
private Long id;
private Long userId;
private BigDecimal totalBet;
private BigDecimal totalWin;
private Integer count;
private LocalDateTime create_time;
private LocalDateTime update_time;
}
@@ -0,0 +1,14 @@
package com.ruoyi.game.wheel.domain.handler;
import com.ruoyi.common.typehandler.JsonObjectHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import java.util.List;
@MappedJdbcTypes(JdbcType.VARCHAR)
@MappedTypes(List.class)
public class WheelRoundPhaseTimesTypeHandler extends JsonObjectHandler<List<Long>> {
}
@@ -0,0 +1,25 @@
package com.ruoyi.game.wheel.enums;
public enum SpecialEffectEnum {
SystemWin("系统赢"),
FreeSpin1("免费旋转1次"),
FreeSpin2("免费旋转2次"),
FreeSpin3("免费旋转3次");
private String desc;
SpecialEffectEnum(String desc) {
this.desc = desc;
}
@Override
public String toString() {
return this.name().toLowerCase();
}
public boolean equals(String other) {
return this.desc.equals(other);
}
}
@@ -0,0 +1,14 @@
package com.ruoyi.game.wheel.enums;
public enum WheelRoundStatusEnum {
BETTING,
@Deprecated
BETED,
SPINED;
@Override
public String toString() {
return this.name().toLowerCase();
}
}
@@ -0,0 +1,124 @@
package com.ruoyi.game.wheel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.wheel.domain.GameWheelRoundBet;
import com.ruoyi.game.wheel.vo.WheelBetRecordVO;
import com.ruoyi.game.wheel.vo.WheelWinBetVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ApiGameWheelRoundBetMapper extends BaseMapper<GameWheelRoundBet> {
@Select("select * from game_wheel_round_bet where round_id = #{roundId}")
List<GameWheelRoundBet> selectByRoundId(@Param("roundId") Long roundId);
// 直接按注单自身 date 列过滤(旧 join 写法只限定回合表日期,跨天同 roundId 的旧注单会被误查出)
@Select("select * from game_wheel_round_bet where date = #{date} and round_id = #{roundId}")
List<GameWheelRoundBet> selectByDateAndRoundId(@Param("date") String date, @Param("roundId") Long roundId);
// 按全局唯一期号查本轮全部注单(结算事实源:DB,而非 Redis 缓存)
// ORDER BY id:保证遍历顺序确定性,背包记录自增 id 顺序与注单 id 顺序一致,回显按下标匹配才不会错位
@Select("select * from game_wheel_round_bet where period_no = #{periodNo} order by id")
List<GameWheelRoundBet> selectByPeriodNo(@Param("periodNo") Long periodNo);
@Select("select * from game_wheel_round_bet where round_id = #{roundId} and bet_symbol = #{symbol}")
List<GameWheelRoundBet> selectByRoundIdAndTarget(@Param("roundId") Long roundId, @Param("symbol") String symbol);
@Select("select * from game_wheel_round_bet where round_id = #{roundId} and user_id = #{uid} limit 1")
GameWheelRoundBet selectByRoundIdAndUid(@Param("roundId") Long roundId, @Param("uid") Long uid);
/**
* 全服仅展示真实中奖记录;同一期、同用户、同符号、同一实际饰品合并展示。
*/
@Select("SELECT b.period_no, b.user_id, b.bet, b.bet_symbol, SUM(b.win) AS win, " +
"MAX(r.end_time) AS end_time, MAX(u.nick_name) AS nick_name, MAX(u.avatar) AS avatar, " +
"box.ornaments_level_id AS prize_grade, box.ornament_name AS prize_name, box.image_url AS prize_image, " +
"COUNT(*) AS bet_count " +
"FROM game_wheel_round_bet b " +
"JOIN tt_user u ON b.user_id = u.user_id " +
"JOIN game_wheel_round r ON b.period_no = r.period_no " +
"LEFT JOIN (SELECT box.*, ROW_NUMBER() OVER (PARTITION BY box.period_no, box.user_id ORDER BY box.id) AS rn " +
" FROM tt_box_records box WHERE box.source = 99 AND box.box_name = 'WHEEL') box " +
"ON box.period_no = b.period_no AND box.user_id = b.user_id AND box.rn = " +
"(SELECT COUNT(*) FROM game_wheel_round_bet b2 WHERE b2.period_no = b.period_no AND b2.user_id = b.user_id AND b2.id <= b.id) " +
"WHERE r.status = 'spined' AND r.result IS NOT NULL AND r.result != '' " +
"AND b.win > 0.01 AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' " +
"GROUP BY b.period_no, b.user_id, b.bet, b.bet_symbol, box.ornaments_level_id, box.ornament_name, box.image_url " +
"ORDER BY MAX(r.end_time) DESC, MAX(b.id) DESC LIMIT #{offset}, #{pageSize}")
List<WheelWinBetVO> selectWinBetsPage(@Param("offset") Integer offset, @Param("pageSize") Integer pageSize);
@Select("SELECT COUNT(*) FROM (SELECT 1 FROM game_wheel_round_bet b " +
"JOIN tt_user u ON b.user_id = u.user_id " +
"JOIN game_wheel_round r ON b.period_no = r.period_no " +
"LEFT JOIN (SELECT box.*, ROW_NUMBER() OVER (PARTITION BY box.period_no, box.user_id ORDER BY box.id) AS rn " +
" FROM tt_box_records box WHERE box.source = 99 AND box.box_name = 'WHEEL') box " +
"ON box.period_no = b.period_no AND box.user_id = b.user_id AND box.rn = " +
"(SELECT COUNT(*) FROM game_wheel_round_bet b2 WHERE b2.period_no = b.period_no AND b2.user_id = b.user_id AND b2.id <= b.id) " +
"WHERE r.status = 'spined' AND r.result IS NOT NULL AND r.result != '' " +
"AND b.win > 0.01 AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' " +
"GROUP BY b.period_no, b.user_id, b.bet, b.bet_symbol, box.ornaments_level_id, box.ornament_name, box.image_url) records")
Long countWinBets();
/**
* 个人记录同时展示中奖与安慰奖;同一期、同符号、同一实际饰品合并并累计总价值。
*/
@Select("SELECT b.period_no, b.user_id, b.bet, b.bet_symbol, SUM(b.win) AS win, " +
"MAX(r.end_time) AS end_time, MAX(u.nick_name) AS nick_name, MAX(u.avatar) AS avatar, " +
"box.ornaments_level_id AS prize_grade, box.ornament_name AS prize_name, box.image_url AS prize_image, " +
"COUNT(*) AS bet_count " +
"FROM game_wheel_round_bet b " +
"JOIN tt_user u ON b.user_id = u.user_id " +
"JOIN game_wheel_round r ON b.period_no = r.period_no " +
"LEFT JOIN (SELECT box.*, ROW_NUMBER() OVER (PARTITION BY box.period_no, box.user_id ORDER BY box.id) AS rn " +
" FROM tt_box_records box WHERE box.source = 99 AND box.box_name = 'WHEEL') box " +
"ON box.period_no = b.period_no AND box.user_id = b.user_id AND box.rn = " +
"(SELECT COUNT(*) FROM game_wheel_round_bet b2 WHERE b2.period_no = b.period_no AND b2.user_id = b.user_id AND b2.id <= b.id) " +
"WHERE r.status = 'spined' AND r.result IS NOT NULL AND r.result != '' AND b.user_id = #{userId} " +
"GROUP BY b.period_no, b.user_id, b.bet, b.bet_symbol, box.ornaments_level_id, box.ornament_name, box.image_url " +
"ORDER BY MAX(r.end_time) DESC, MAX(b.id) DESC LIMIT #{offset}, #{pageSize}")
List<WheelWinBetVO> selectUserBetsPage(@Param("userId") Long userId, @Param("offset") Integer offset, @Param("pageSize") Integer pageSize);
@Select("SELECT COUNT(*) FROM (SELECT 1 FROM game_wheel_round_bet b " +
"JOIN game_wheel_round r ON b.period_no = r.period_no " +
"LEFT JOIN (SELECT box.*, ROW_NUMBER() OVER (PARTITION BY box.period_no, box.user_id ORDER BY box.id) AS rn " +
" FROM tt_box_records box WHERE box.source = 99 AND box.box_name = 'WHEEL') box " +
"ON box.period_no = b.period_no AND box.user_id = b.user_id AND box.rn = " +
"(SELECT COUNT(*) FROM game_wheel_round_bet b2 WHERE b2.period_no = b.period_no AND b2.user_id = b.user_id AND b2.id <= b.id) " +
"WHERE r.status = 'spined' AND r.result IS NOT NULL AND r.result != '' AND b.user_id = #{userId} " +
"GROUP BY b.period_no, b.user_id, b.bet, b.bet_symbol, box.ornaments_level_id, box.ornament_name, box.image_url) records")
Long countUserBets(@Param("userId") Long userId);
/**
* 管理后台投注记录分页查询(按 round_id + user_id 分组,分页基于分组行)
* 用 DATE_FORMAT(create_time) 关联 game_wheel_round.date 避免跨天 round_id 串数据
*/
@Select("<script>" +
"SELECT b.round_id, b.user_id, u.nick_name, " +
"MAX(b.period_no) as period_no, " +
"GROUP_CONCAT(DISTINCT b.pool_type) as pool_types, " +
"GROUP_CONCAT(b.bet_symbol ORDER BY b.id SEPARATOR ',') as bet_symbols, " +
"SUM(b.bet) as total_bet_amount, " +
"GROUP_CONCAT(CONCAT(IFNULL(b.color,''), ':', b.bet_symbol, ':', b.bet, ':', IFNULL(b.win, 0)) ORDER BY b.id SEPARATOR '|') as bet_details, " +
"GREATEST(IFNULL(SUM(b.win), 0), 0.01) as user_round_payout, " +
"MAX(b.settled) as settled, " +
"MIN(b.create_time) as create_time, " +
"r.end_time, r.result, r.symbols, r.inner_colors " +
"FROM game_wheel_round_bet b " +
"LEFT JOIN game_wheel_round r ON b.round_id = r.round_id " +
" AND r.date = DATE_FORMAT(b.create_time, '%Y%m%d') " +
"LEFT JOIN tt_user u ON b.user_id = u.user_id " +
"<where>" +
"<if test='roundId != null'> AND b.round_id = #{roundId}</if>" +
"<if test='userId != null'> AND b.user_id = #{userId}</if>" +
// 期号支持前缀匹配:填完整 13 位=精确查该局,填 20260912=查当天全部
"<if test='periodNo != null and periodNo != \"\"'> AND b.period_no LIKE CONCAT(#{periodNo}, '%')</if>" +
"</where>" +
"GROUP BY b.round_id, b.user_id, u.nick_name, r.end_time, r.result, r.symbols, r.inner_colors " +
"ORDER BY MAX(b.id) DESC" +
"</script>")
List<WheelBetRecordVO> selectBetRecords(@Param("roundId") Long roundId, @Param("userId") Long userId,
@Param("periodNo") String periodNo);
}
@@ -0,0 +1,21 @@
package com.ruoyi.game.wheel.mapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.wheel.domain.GameWheelRound;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ApiGameWheelRoundMapper extends BaseMapper<GameWheelRound> {
@Select("SELECT MAX(round_id) FROM game_wheel_round where `date` = #{today}")
public Long getTodayLastRoundId(@Param("today") String today);
default GameWheelRound selectByRoundId(@Param("roundId") Long roundId) {
LambdaQueryWrapper<GameWheelRound> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(GameWheelRound::getRoundId, roundId);
return selectOne(wrapper);
}
}
@@ -0,0 +1,21 @@
package com.ruoyi.game.wheel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.wheel.domain.GameWheelUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
@Mapper
public interface ApiGameWheelUserMapper extends BaseMapper<GameWheelUser> {
@Select("select * from game_wheel_user where user_id = #{userId}")
GameWheelUser getByUserId(@Param("userId") Long userId);
@Update("update game_wheel_user set total_bet = total_bet + #{totalBet}, total_win = total_win + #{totalWin}, count = count + #{count} where user_id = #{userId}")
int updateIncrement(@Param("totalBet") BigDecimal totalBet,
@Param("totalWin") BigDecimal totalWin,
@Param("count") Integer count, @Param("userId") Long userId);
}
@@ -0,0 +1,22 @@
package com.ruoyi.game.wheel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.wheel.domain.GameWheelPoolAdjustment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
/**
* 疯狂转盘奖池调节记录 Mapper
*/
@Mapper
public interface GameWheelPoolAdjustmentMapper extends BaseMapper<GameWheelPoolAdjustment> {
/**
* 统计指定池的累计调节金额(正数=净注水,负数=净抽水)
*/
@Select("SELECT COALESCE(SUM(adjustment_amount), 0) FROM game_wheel_pool_adjustment WHERE pool_type = #{poolType}")
BigDecimal sumAdjustmentByPoolType(@Param("poolType") String poolType);
}
@@ -0,0 +1,53 @@
package com.ruoyi.game.wheel.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.Map;
@Mapper
public interface GameWheelPrizeMapper {
@Select("SELECT po.id, po.name, po.image_url AS imageUrl, po.grade, " +
"po.exterior_name AS exteriorName, " +
"lv.level_img AS levelImageUrl " +
"FROM game_wheel_prize_range pr " +
"JOIN game_wheel_prize_ornament po ON po.id = pr.ornament_id " +
"LEFT JOIN tt_ornaments_level lv ON lv.id = po.grade " +
"WHERE pr.game_type = 'wheel' " +
" AND #{totalWin} >= pr.win_low AND #{totalWin} <= pr.win_high " +
" AND po.status = '1' " +
"LIMIT 1")
Map<String, Object> findPrizeByWin(@Param("totalWin") BigDecimal totalWin);
@Select("SELECT po.id, po.name, po.image_url AS imageUrl, po.grade, " +
"po.exterior_name AS exteriorName, " +
"lv.level_img AS levelImageUrl " +
"FROM game_wheel_prize_ornament po " +
"LEFT JOIN tt_ornaments_level lv ON lv.id = po.grade " +
"WHERE po.game_type = 'wheel' " +
" AND po.is_fallback = 1 AND po.status = '1' " +
"ORDER BY RAND() LIMIT 1")
Map<String, Object> findFallbackPrize();
@Select("SELECT po.id, po.name, po.image_url AS imageUrl, po.grade, " +
"po.exterior_name AS exteriorName, " +
"lv.level_img AS levelImageUrl " +
"FROM game_wheel_prize_range pr " +
"JOIN game_wheel_prize_ornament po ON po.id = pr.ornament_id " +
"LEFT JOIN tt_ornaments_level lv ON lv.id = po.grade " +
"WHERE pr.game_type = 'wheel' AND po.status = '1' " +
"ORDER BY pr.win_low ASC LIMIT 1")
Map<String, Object> findLowestPrize();
@Select("SELECT po.id, po.name, po.image_url AS imageUrl, po.grade, " +
"po.exterior_name AS exteriorName, " +
"lv.level_img AS levelImageUrl " +
"FROM game_wheel_prize_range pr " +
"JOIN game_wheel_prize_ornament po ON po.id = pr.ornament_id " +
"LEFT JOIN tt_ornaments_level lv ON lv.id = po.grade " +
"WHERE pr.game_type = 'wheel' AND po.status = '1' " +
"ORDER BY pr.win_high DESC LIMIT 1")
Map<String, Object> findHighestPrize();
}
@@ -0,0 +1,8 @@
package com.ruoyi.game.wheel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.wheel.domain.GameWheelPrizeOrnament;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface GameWheelPrizeOrnamentMapper extends BaseMapper<GameWheelPrizeOrnament> {}
@@ -0,0 +1,8 @@
package com.ruoyi.game.wheel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.wheel.domain.GameWheelPrizeRange;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface GameWheelPrizeRangeMapper extends BaseMapper<GameWheelPrizeRange> {}
@@ -0,0 +1,21 @@
package com.ruoyi.game.wheel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.wheel.domain.GameWheelRewardMatrix;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 疯狂转盘奖励矩阵 Mapper(120 条自定义饰品配置:10 档 × 3 色 × 4 符号)。
*/
@Mapper
public interface GameWheelRewardMatrixMapper extends BaseMapper<GameWheelRewardMatrix> {
/**
* 按内圈色+外圈符号更新倍率和奖品价值(跨所有下注额层)。
* 配置层倍率变动时联动同步用。
*/
@Update("UPDATE game_wheel_reward_matrix SET multiplier = #{multiplier}, prize_value = bet_amount * #{multiplier} WHERE color = #{color} AND symbol = #{symbol} AND status = '1'")
int updateMultiplier(@Param("color") String color, @Param("symbol") String symbol, @Param("multiplier") int multiplier);
}
@@ -0,0 +1,16 @@
package com.ruoyi.game.wheel.model.cache;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class WheelRewardPoolCache {
/**
* 疯狂转盘奖池缓存
* totalBet:累计总投注
* totalWin:累计总返奖
*/
private BigDecimal totalBet;
private BigDecimal totalWin;
}
@@ -0,0 +1,30 @@
package com.ruoyi.game.wheel.model.cache;
import com.ruoyi.game.wheel.domain.GameWheelRound;
import com.ruoyi.game.wheel.domain.GameWheelRoundBet;
import lombok.Data;
import java.io.Serializable;
import java.util.Map;
@Data
public class WheelRoundCache implements Serializable {
private Long lastRoundId;
private GameWheelRound round;
private Map<String, GameWheelRoundBet> betMap;
/** processResult 写入背包后,保存每个用户对应的 boxRecordId(非自动分解模式) */
private Map<Long, Long> userBoxRecordIdMap;
public static String betKey(Long roundId, Long userId, String symbol) {
return String.format("%s_%s_%s", roundId, userId, symbol.toLowerCase());
}
public static String betKey(GameWheelRoundBet bet) {
if (bet == null) {
return "";
}
return String.format("%s_%s_%s", bet.getRoundId(), bet.getUserId(), bet.getBetSymbol().toLowerCase());
}
}
@@ -0,0 +1,264 @@
package com.ruoyi.game.wheel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.redis.config.RedisLock;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.game.wheel.cache.WheelRewardPoolCacheRepository;
import com.ruoyi.game.wheel.constants.CacheConstants;
import com.ruoyi.game.wheel.constants.WheelPoolType;
import com.ruoyi.game.wheel.contract.admin.response.QueryWheelGameConfig;
import com.ruoyi.game.wheel.domain.GameWheelGameConfig;
import com.ruoyi.game.wheel.domain.GameWheelPoolAdjustment;
import com.ruoyi.game.wheel.mapper.GameWheelRewardMatrixMapper;
import com.ruoyi.game.wheel.mapper.GameWheelPoolAdjustmentMapper;
import com.ruoyi.game.wheel.model.cache.WheelRewardPoolCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class AdminWheelService {
private static final String EVENT_TAG = "幸运转盘";
@Autowired
private CommonWheelService commonWheelService;
@Autowired
private WheelRewardPoolCacheRepository wheelRewardPoolCacheRepository;
@Autowired
private GameWheelRewardMatrixMapper rewardMatrixMapper;
@Autowired
private GameWheelPoolAdjustmentMapper poolAdjustmentMapper;
@Autowired
private RedisLock redisLock;
/** 配置合并保存用的解析器。别改成 @Autowired:Boot 4 只装配 tools.jackson 版的 ObjectMapper,注入 Jackson 2 版会启动失败 */
private static final com.fasterxml.jackson.databind.ObjectMapper OBJECT_MAPPER =
// 关掉未知字段报错,与 Spring MVC 默认的请求体绑定宽松度一致(前端多传字段不该让保存直接 500)
new com.fasterxml.jackson.databind.ObjectMapper()
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/** 单次人工调整金额上限(注水/抽水),防手滑输入天文数字把奖池打飞 */
private static final BigDecimal MAX_POOL_ADJUST_AMOUNT = new BigDecimal("100000");
public AjaxResult getGameConfig() {
GameWheelGameConfig config = commonWheelService.getGameConfig();
QueryWheelGameConfig result = new QueryWheelGameConfig();
result.setConfig(config);
result.setPoolStats(buildPoolStats());
return AjaxResult.success(result);
}
/**
* 奖池统计:真实池的下注/返奖/毛利/RTP + 人工调整 + 含调整的奖池余额。
* profit 与 rtp 保持毛口径(下注、返奖是历史事实),余额才扣人工调整。
*/
public Map<String, Object> buildPoolStats() {
Map<String, Object> poolStats = new LinkedHashMap<>();
for (String pool : WheelPoolType.all()) {
WheelRewardPoolCache cache = wheelRewardPoolCacheRepository.getCache(pool);
BigDecimal profit = cache.getTotalBet().subtract(cache.getTotalWin());
BigDecimal poolBalance = wheelRewardPoolCacheRepository.getBalance(pool);
Map<String, Object> poolData = new LinkedHashMap<>();
poolData.put("totalBet", cache.getTotalBet());
poolData.put("totalWin", cache.getTotalWin());
poolData.put("profit", profit);
// 人工调整(注水为正/抽水为负)= 含调整余额 − 毛利润
poolData.put("adjustment", poolBalance.subtract(profit));
poolData.put("poolBalance", poolBalance);
double rtp = cache.getTotalBet().compareTo(BigDecimal.ZERO) > 0
? cache.getTotalWin().divide(cache.getTotalBet(), 4, RoundingMode.HALF_UP).doubleValue() : 0;
poolData.put("rtp", rtp);
poolStats.put(pool, poolData);
}
return poolStats;
}
/**
* 保存配置:**合并式**(只在现有配置上覆盖请求体里出现的字段)+ **结构性校验**。
*
* 为什么收原始 JSON:反序列化成 POJO 后无法区分"字段没传"和"字段传了 0",
* 原来的整份覆盖保存会把没传的字段清成 Java 初值(只传 bets 就会清掉倍率/盘面,switchOff 缺失
* 还会把 wheelMaintenance 静默改回 0 = 解除维护)。合并保存后,缺失字段一律保持旧值。
*
* 校验不通过直接拒绝、不落库;倍率同步到奖励矩阵表与配置写在同一个事务里(避免"配置写了、矩阵只同步一半")。
*/
@Transactional(rollbackFor = Exception.class)
public AjaxResult updateGameConfig(String rawJson) {
if (StringUtils.isEmpty(rawJson)) {
return AjaxResult.error("配置内容为空");
}
GameWheelGameConfig merged;
boolean switchOffPresent;
try {
GameWheelGameConfig current = commonWheelService.getGameConfig();
switchOffPresent = OBJECT_MAPPER.readTree(rawJson).has("switchOff");
merged = OBJECT_MAPPER.readerForUpdating(current).readValue(rawJson);
} catch (Exception e) {
log.warn("{} 解析游戏配置失败: {}", EVENT_TAG, e.getMessage());
return AjaxResult.error("配置格式不正确,请检查字段类型");
}
String err = commonWheelService.validate(merged);
if (err != null) {
return AjaxResult.error(err);
}
commonWheelService.updateGameConfig(merged, switchOffPresent);
// 联动同步:配置层倍率变动 → 自动更新奖励矩阵表(跨所有下注额层)。
// validate() 已保证倍率恰好 12 项且颜色/符号/倍率都合法,这里不用再逐项判空
for (GameWheelGameConfig.MultiplierItem mi : merged.getMultipliers()) {
rewardMatrixMapper.updateMultiplier(mi.getColor(), mi.getSymbol(), mi.getMultiplier());
}
log.info("{} 配置层倍率已同步到奖励矩阵表", EVENT_TAG);
return AjaxResult.success();
}
/**
* 奖池调整(注水/抽水)。入参是原始 Map,所有解析与范围校验都在这里做,避免缺参/非数字直接 500。
* 全程持 WHEEL_LOCK:让"调整前后余额"快照与并发的注水/结算串行化(否则并发下审计记录自相矛盾)。
*/
@Transactional
public AjaxResult adjustPool(Map<String, Object> params, String operator) {
if (params == null) {
return AjaxResult.error("参数不能为空");
}
Object amountObj = params.get("amount");
if (amountObj == null) {
return AjaxResult.error("请填写调整金额");
}
BigDecimal adjustmentAmount;
try {
adjustmentAmount = new BigDecimal(amountObj.toString().trim());
} catch (Exception e) {
return AjaxResult.error("调整金额格式不正确");
}
if (adjustmentAmount.compareTo(BigDecimal.ZERO) == 0) {
return AjaxResult.error("调整金额不能为0");
}
// 小数位/单次金额上限:列定义是 DECIMAL(12,2),超过 2 位小数会被静默舍入成"成功但没效果"
if (adjustmentAmount.scale() > 2) {
return AjaxResult.error("调整金额最多两位小数");
}
if (adjustmentAmount.abs().compareTo(MAX_POOL_ADJUST_AMOUNT) > 0) {
return AjaxResult.error("单次调整金额不能超过 " + MAX_POOL_ADJUST_AMOUNT + " 元");
}
String poolType = params.get("poolType") == null ? WheelPoolType.REAL : params.get("poolType").toString();
String reason = params.get("reason") == null ? "" : params.get("reason").toString();
if (!WheelPoolType.REAL.equals(poolType)) {
return AjaxResult.error("只支持调整真实池(real)");
}
if (!redisLock.tryLock(CacheConstants.WHEEL_LOCK, 3, -1, TimeUnit.SECONDS)) {
return AjaxResult.error("游戏正在结算中,请稍后再试");
}
try {
// 计算调整前后余额(含历史人工调整)
BigDecimal balanceBefore = wheelRewardPoolCacheRepository.getBalance(poolType);
BigDecimal balanceAfter = balanceBefore.add(adjustmentAmount);
// 抽水红线:不能抽成负数。池子为负只该来自"全盘照赔"的正常透支(随后随下注自然回正),
// 人为抽成负数则会让 poolCap 长期为 0、玩家持续空转,故卡在这里。
if (balanceAfter.compareTo(BigDecimal.ZERO) < 0) {
return AjaxResult.error("抽水金额超过奖池余额,当前余额 " + balanceBefore);
}
// 保存调整记录
GameWheelPoolAdjustment record = GameWheelPoolAdjustment.builder()
.poolType(poolType)
.adjustmentAmount(adjustmentAmount)
.balanceBefore(balanceBefore)
.balanceAfter(balanceAfter)
.reason(reason)
.operator(operator)
.createTime(LocalDateTime.now())
.build();
poolAdjustmentMapper.insert(record);
log.info("{} 奖池调整成功: poolType={}, amount={}, operator={}, balanceBefore={}, balanceAfter={}",
EVENT_TAG, poolType, adjustmentAmount, operator, balanceBefore, balanceAfter);
return AjaxResult.success("奖池调整成功");
} finally {
redisLock.unlock(CacheConstants.WHEEL_LOCK);
}
}
/**
* 重置奖池:累计下注/返奖统计清零,并把历史人工调整"反向抵消",使余额归零。
*
* 审计要求:不再物理删除历史调整记录(历史注水/抽水/历次重置都要能在调整记录页查),
* 而是插入一条反向抵消记录(adjustmentAmount = −Σ历史调整),配合统计清零达成余额 = 0:
* 余额口径 = 累计下注 − 累计返奖 + Σ人工调整 → 0 − 0 + (Σ历史 + 反向抵消) = 0。
*
* 全程持 WHEEL_LOCK:避免与正在结算/注水的回合并发,导致"Redis 已清零但调整记录还在"的账实不符。
*/
@Transactional
public AjaxResult resetPool(String poolType, String reason, String operator) {
if (!WheelPoolType.REAL.equals(poolType)) {
return AjaxResult.error("只支持重置真实池(real)");
}
if (!redisLock.tryLock(CacheConstants.WHEEL_LOCK, 3, -1, TimeUnit.SECONDS)) {
return AjaxResult.error("游戏正在结算中,请稍后再试");
}
try {
WheelRewardPoolCache cache = wheelRewardPoolCacheRepository.getCache(poolType);
BigDecimal balanceBefore = wheelRewardPoolCacheRepository.getBalance(poolType);
BigDecimal netAdjustment = poolAdjustmentMapper.sumAdjustmentByPoolType(poolType);
BigDecimal offset = netAdjustment == null ? BigDecimal.ZERO : netAdjustment;
// 统计清零延到事务提交之后再执行:Redis 不参与数据库事务,
// 先清 Redis 再写库一旦 DB 回滚,就会出现"统计没了、调整记录还在"的账实不符。
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
wheelRewardPoolCacheRepository.reset(poolType);
}
});
String summary = String.format("【重置奖池】重置前 累计下注 %s / 累计返奖 %s / 人工调整 %s / 余额 %s",
cache.getTotalBet(), cache.getTotalWin(), offset, balanceBefore);
String resetReason = reason == null || reason.isBlank() ? summary : summary + ";备注:" + reason;
if (resetReason.length() > 200) {
resetReason = resetReason.substring(0, 200);
}
poolAdjustmentMapper.insert(GameWheelPoolAdjustment.builder()
.poolType(poolType)
.adjustmentAmount(offset.negate()) // 抵消历史人工调整;历史记录保留可见
.balanceBefore(balanceBefore)
.balanceAfter(BigDecimal.ZERO)
.reason(resetReason)
.operator(operator)
.createTime(LocalDateTime.now())
.build());
log.info("{} 奖池重置成功: poolType={}, operator={}, 抵消历史调整={}, {}", EVENT_TAG, poolType, operator, offset.negate(), summary);
return AjaxResult.success("奖池已重置(历史调整记录已保留)");
} finally {
redisLock.unlock(CacheConstants.WHEEL_LOCK);
}
}
/**
* 获取奖池余额(包含人工调整)
*/
public BigDecimal getPoolBalance(String poolType) {
return wheelRewardPoolCacheRepository.getBalance(poolType);
}
}
@@ -0,0 +1,182 @@
package com.ruoyi.game.wheel.service;
import com.ruoyi.common.utils.json.SnakeCaseJsonUtils;
import com.ruoyi.game.wheel.domain.GameWheelGameConfig;
import com.ruoyi.system.domain.SysConfig;
import com.ruoyi.system.mapper.SysConfigMapper;
import com.ruoyi.system.service.ISysConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
@Service
@Slf4j
public class CommonWheelService {
@Autowired
private SysConfigMapper sysConfigMapper;
@Autowired
private ISysConfigService sysConfigService;
public GameWheelGameConfig getGameConfig() {
SysConfig sysConfig = sysConfigMapper.checkConfigKeyUnique("game.wheel.setting");
if (sysConfig == null) {
GameWheelGameConfig config = new GameWheelGameConfig();
config.setSwitchOff(false);
config.setBets(Arrays.asList(5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000));
// ===== 内圈玩法配置 =====
config.setSegmentCount(24);
// 外圈24格固定布局:A金符×2 / B红符×3 / C紫符×8 / D蓝符×11(运营可重排)
config.setOuterSymbols("AABBBCCCCCCCCDDDDDDDDDDD");
// 内圈分布:紫10 / 红8 / 金6(每轮随机排布)
config.setInnerGoldCount(6);
config.setInnerRedCount(8);
config.setInnerPurpleCount(10);
// 12 个倍率:3内圈色 × 4外圈符号
config.setMultipliers(Arrays.asList(
new GameWheelGameConfig.MultiplierItem("gold", "A", 44),
new GameWheelGameConfig.MultiplierItem("gold", "B", 26),
new GameWheelGameConfig.MultiplierItem("gold", "C", 11),
new GameWheelGameConfig.MultiplierItem("gold", "D", 8),
new GameWheelGameConfig.MultiplierItem("red", "A", 33),
new GameWheelGameConfig.MultiplierItem("red", "B", 21),
new GameWheelGameConfig.MultiplierItem("red", "C", 8),
new GameWheelGameConfig.MultiplierItem("red", "D", 6),
new GameWheelGameConfig.MultiplierItem("purple", "A", 27),
new GameWheelGameConfig.MultiplierItem("purple", "B", 17),
new GameWheelGameConfig.MultiplierItem("purple", "C", 7),
new GameWheelGameConfig.MultiplierItem("purple", "D", 5)
));
// 发奖上限:奖池余额×0.6(保池)+ 累计投注×0.9 − 累计返奖(返奖额度)取小
config.setBufferRatio(0.6);
config.setRtp(0.9);
// 时间:下注 30s / 开盘动画 7s
config.setBettingDuration(30);
config.setAnimationDuration(7);
sysConfig = new SysConfig();
sysConfig.setConfigName("幸运转盘游戏配置");
sysConfig.setConfigKey("game.wheel.setting");
sysConfig.setConfigValue(SnakeCaseJsonUtils.toSnakeCaseJson(config));
sysConfig.setConfigType("N");
sysConfigMapper.insertConfig(sysConfig);
return config;
}
return SnakeCaseJsonUtils.fromSnakeCaseJson(sysConfig.getConfigValue(), GameWheelGameConfig.class);
}
/**
* 保存配置(合并后的完整对象)。
*
* @param syncMaintenance 是否同步维护开关:只有请求里**显式带了 switchOff** 才为 true,
* 否则部分字段请求会把 wheelMaintenance 静默改成 0(= 解除维护)
*/
public void updateGameConfig(GameWheelGameConfig config, boolean syncMaintenance) {
// 两个发奖比例只有 0~1 才有意义:越界一律夹回。前端与接口都走这里,是唯一入口
config.setBufferRatio(clamp01(config.getBufferRatio(), 0.6));
config.setRtp(clamp01(config.getRtp(), 0.9));
SysConfig sysConfig = sysConfigMapper.checkConfigKeyUnique("game.wheel.setting");
if (sysConfig == null) {
sysConfig = new SysConfig();
sysConfig.setConfigName("幸运转盘游戏配置");
sysConfig.setConfigKey("game.wheel.setting");
sysConfig.setConfigType("N");
sysConfig.setConfigValue(SnakeCaseJsonUtils.toSnakeCaseJson(config));
sysConfigMapper.insertConfig(sysConfig);
} else {
sysConfig.setConfigValue(SnakeCaseJsonUtils.toSnakeCaseJson(config));
sysConfigMapper.updateConfig(sysConfig);
}
if (!syncMaintenance) {
return; // 请求没带 switchOff:不动维护开关
}
// 同步 switchOff → wheelMaintenance 维护开关
SysConfig maintenanceConfig = sysConfigMapper.checkConfigKeyUnique("wheelMaintenance");
String newValue = config.isSwitchOff() ? "1" : "0";
if (maintenanceConfig == null) {
maintenanceConfig = new SysConfig();
maintenanceConfig.setConfigName("疯狂转盘维护");
maintenanceConfig.setConfigKey("wheelMaintenance");
maintenanceConfig.setConfigType("Y");
maintenanceConfig.setConfigValue(newValue);
sysConfigMapper.insertConfig(maintenanceConfig);
} else {
maintenanceConfig.setConfigValue(newValue);
sysConfigMapper.updateConfig(maintenanceConfig);
}
// 这里是直接走 mapper 改库,绕过了 SysConfig 的 Redis 缓存(该缓存没有过期时间),
// 不刷新的话 doBet/isMaintenanceMode 会一直读到旧值 → "关了游戏但还提示不在下注期"
sysConfigService.resetConfigCache();
}
/**
* 结构性校验:不合法返回错误信息,合法返回 null。配置面唯一入口,前端与接口都过这里。
*
* 为什么要拦:这些字段直接决定赔率与盘面,填错不会立刻报错但会算错赔率、甚至让每局都开不了奖
* (例如外圈布局长度 ≠ 总格数 → doSpin 里 charAt 越界;倍率为 0/负数 → 中了不赔或反向扣池)。
*/
public String validate(GameWheelGameConfig config) {
if (config == null) return "配置内容为空";
List<Integer> bets = config.getBets();
if (bets == null || bets.isEmpty()) return "下注档位不能为空";
for (Integer b : bets) {
if (b == null || !ALLOWED_BETS.contains(b)) return "下注档位只支持 " + ALLOWED_BETS;
}
int seg = config.getSegmentCount();
if (seg < 8 || seg > 48) return "转盘总格数需在 8~48 之间(当前 " + seg + ")";
String outer = config.getOuterSymbols();
if (outer == null || outer.isEmpty()) return "外圈符号布局不能为空";
if (outer.length() != seg) return "外圈符号布局长度(" + outer.length() + ")必须等于转盘总格数(" + seg + ")";
for (char ch : outer.toCharArray()) {
if (ch != 'A' && ch != 'B' && ch != 'C' && ch != 'D') return "外圈符号布局只能包含 A/B/C/D,发现非法字符:" + ch;
}
int innerSum = config.getInnerGoldCount() + config.getInnerRedCount() + config.getInnerPurpleCount();
if (innerSum != seg) return "内圈三色格数合计(" + innerSum + ")必须等于转盘总格数(" + seg + ")";
List<GameWheelGameConfig.MultiplierItem> multipliers = config.getMultipliers();
if (multipliers == null || multipliers.size() != 12) return "倍率矩阵必须是 12 个组合(3 色 × 4 符号)";
for (GameWheelGameConfig.MultiplierItem m : multipliers) {
if (m == null || !VALID_COLORS.contains(m.getColor()) || !VALID_SYMBOLS.contains(m.getSymbol())) {
return "倍率矩阵存在非法组合(颜色只支持 gold/red/purple,符号只支持 A/B/C/D)";
}
if (m.getMultiplier() == null || m.getMultiplier() < 1 || m.getMultiplier() > MAX_MULTIPLIER) {
return "倍率必须在 1~" + MAX_MULTIPLIER + " 之间(组合 " + m.getColor() + "/" + m.getSymbol() + ")";
}
}
// 12 个组合全押必须亏:这是"照规则全额赔"这个兜底分支唯一的资金护栏。
// 玩家把 12 个组合都押上时,引擎挑不出"无真实注的格",只能开"赔付最小格"并**不受奖池上限约束**地全额赔。
// 此时成本 = Σb_i、赔付 = min(b_i×m_i),按 b_i = c/m_i 配比下注可把赔付做成 c / Σ(1/m_i):
// 只要 Σ(1/m_i) ≤ 1 就是"全包必赚",池子再负也拦不住(每局稳吃 (1/Σ(1/m) 的倒数) 倍差)。
// 前端按目标RTP 重算出来的倍率天然满足(Σ(1/m) ≈ 1/rtp ≥ 1),但倍率可以手工改,所以这里必须卡。
double reciprocalSum = multipliers.stream().mapToDouble(m -> 1.0 / m.getMultiplier()).sum();
if (reciprocalSum <= 1.02) {
return String.format("倍率组合不合法:12 个组合全押的回报率约 %.0f%%(必须明显小于 100%%,否则玩家全包必赚,"
+ "且该分支不受奖池上限约束)", 100 / reciprocalSum);
}
if (config.getBettingDuration() <= 0 || config.getBettedDuration() < 0 || config.getAnimationDuration() <= 0) {
return "时间配置不合法(下注/动画时长必须大于 0,封盘时长不能小于 0)";
}
return null;
}
/** 可选下注档位(与奖励矩阵 10 档一致) */
private static final List<Integer> ALLOWED_BETS = Arrays.asList(5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000);
private static final List<String> VALID_COLORS = Arrays.asList("gold", "red", "purple");
private static final List<String> VALID_SYMBOLS = Arrays.asList("A", "B", "C", "D");
/** 单格倍率上限:5000 档 × 200 = 100 万,够用且防止把赔率打成天文数字 */
private static final int MAX_MULTIPLIER = 200;
/** 比例参数夹到 0~1;NaN(异常入参)回落到默认值 */
private static double clamp01(double v, double fallback) {
if (Double.isNaN(v)) return fallback;
return Math.max(0, Math.min(1, v));
}
}
@@ -0,0 +1,46 @@
package com.ruoyi.game.wheel.service;
import com.ruoyi.system.service.ISysConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 疯狂转盘异常兜底定时任务。
* 扫描 settled=0 的异常残留注单(开奖异常/结算事务回滚导致从未结算)并退还本金。
* 注单在 doBet 时即以 settled=0 落库,正常开奖后 processResult 会更新为 settled=1。
* 注册到 RuoYi 系统监控-定时任务,调用目标:wheelSettleTask.scheduledSettle
* 建议执行频率:每10分钟一次(非维护期仅处理超过一局时长的异常注单,不会误退进行中的回合)
*/
@Slf4j
@Component
public class WheelSettleTask {
@Autowired
private ApiWheelService apiWheelService;
@Autowired
private ISysConfigService sysConfigService;
/**
* 定时扫描异常兜底。
* 维护期间扫描所有 settled=0 记录(不限时间);非维护期间只扫描超过一局时长的异常记录。
*/
public void scheduledSettle() {
log.info("WheelSettleTask start ----------");
try {
boolean isMaintenance = false;
try {
String websiteMaintenance = sysConfigService.selectConfigByKey("websiteMaintenance");
String wheelMaintenance = sysConfigService.selectConfigByKey("wheelMaintenance");
isMaintenance = "1".equals(websiteMaintenance) || "1".equals(wheelMaintenance);
} catch (Exception e) {
log.warn("WheelSettleTask: failed to check maintenance, error={}", e.getMessage());
}
apiWheelService.settleAbandonedWheelBets(isMaintenance);
} catch (Exception e) {
log.error("WheelSettleTask error: {}", e.getMessage(), e);
}
log.info("WheelSettleTask done ----------");
}
}
@@ -0,0 +1,60 @@
package com.ruoyi.game.wheel.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 疑狂转盘投注记录VO(管理后台查看用,按 round_id + user_id 分组)
*/
@Data
public class WheelBetRecordVO implements Serializable {
private Long roundId;
/** 全局唯一期号 */
private Long periodNo;
private Long userId;
/** 用户昵称 */
private String nickName;
/** 投注符号(逗号分隔,如 A,B,C,D,E,F) */
private String betSymbols;
/** 用户该回合总下注 */
private BigDecimal totalBetAmount;
/** 投注明细(格式: 符号:金额:赢得|符号:金额:赢得) */
private String betDetails;
/** 用户回合实际赔付(含0.01保底) */
private BigDecimal userRoundPayout;
/** 结算状态 0=未结算 1=已结算 */
private Integer settled;
/** 投注时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** 中奖时间(回合结束时间) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/** 中奖结果(回合旋转结果,如 S2,6,9) */
private String result;
/** 回合符号序列 */
private String symbols;
/** 回合内圈色序列 */
private String innerColors;
/** 用户所属池类型(real/robot/blank/promo) */
private String poolTypes;
}
@@ -0,0 +1,22 @@
package com.ruoyi.game.wheel.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 疯狂转盘奖池调节记录响应。
* createTime 使用字符串,避免接口直接序列化 LocalDateTime。
*/
@Data
public class WheelPoolAdjustmentVO {
private Long id;
private String poolType;
private BigDecimal adjustmentAmount;
private BigDecimal balanceBefore;
private BigDecimal balanceAfter;
private String reason;
private String operator;
private String createTime;
}
@@ -0,0 +1,45 @@
package com.ruoyi.game.wheel.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 疯狂转盘全服中奖记录(含用户昵称、期号、中奖符号下注次数、饰品品质/名称/图片)
*/
@Data
public class WheelWinBetVO implements Serializable {
private Long periodNo;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date endTime;
private Long userId;
private String nickName;
/** 用户头像 */
private String avatar;
private BigDecimal bet;
private String betSymbol;
private BigDecimal win;
/** 该中奖符号下注次数 */
private Integer betCount;
/** 饰品品质等级(对应 tt_ornaments_level.id) */
private Integer prizeGrade;
/** 饰品名称 */
private String prizeName;
/** 饰品图片 */
private String prizeImage;
}