first commit
This commit is contained in:
+105
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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:";
|
||||
}
|
||||
+57
@@ -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};
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.game.wheel.contract.api.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GetGameConfigRequest {
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.game.wheel.contract.api.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GetLastRoundInfoRequest {
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+108
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+226
@@ -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);
|
||||
}
|
||||
}
|
||||
+121
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+14
@@ -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>> {
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
}
|
||||
+124
@@ -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);
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
+53
@@ -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();
|
||||
}
|
||||
+8
@@ -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> {}
|
||||
+8
@@ -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> {}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
skins-service/game-wheel-service/src/main/java/com/ruoyi/game/wheel/model/cache/WheelRoundCache.java
Vendored
+30
@@ -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());
|
||||
}
|
||||
}
|
||||
+264
@@ -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);
|
||||
}
|
||||
}
|
||||
+1880
File diff suppressed because it is too large
Load Diff
+182
@@ -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));
|
||||
}
|
||||
}
|
||||
+46
@@ -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 ----------");
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user