first commit
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>skins-service</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>game-olympus-service</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>8</maven.compiler.source>
|
||||
<maven.compiler.target>8</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>service-admin</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>service-user</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ruoyi.game.olympus.contract.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 玫瑰女郎游戏记录分页请求(scope: 0全服/1我的,默认1我的;均分页) */
|
||||
@Data
|
||||
public class RecordPageRequest {
|
||||
|
||||
/** 记录范围:0=全服,1=我的(默认) */
|
||||
private Integer scope;
|
||||
|
||||
/** 页码,默认 1 */
|
||||
private Integer pageNum;
|
||||
|
||||
/** 每页条数,默认 10 */
|
||||
private Integer pageSize;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.game.olympus.contract.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SpinRequest {
|
||||
/** 非空=开始新游戏;null=继续当前游戏(下一步) */
|
||||
private Long boxId;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.ruoyi.game.olympus.contract.response;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class BoxResponse {
|
||||
private Long boxId;
|
||||
private String boxName;
|
||||
private BigDecimal basicBet;
|
||||
private BigDecimal price;
|
||||
private String boxType;
|
||||
private String modeName;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ruoyi.game.olympus.contract.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/** 玫瑰女郎战绩列表项(我的记录/全服记录共用,只暴露客户端展示需要的字段) */
|
||||
@Data
|
||||
public class RecordItemResponse {
|
||||
|
||||
/** 玩家昵称 */
|
||||
private String nickName;
|
||||
|
||||
/** 玩家头像 */
|
||||
private String avatar;
|
||||
|
||||
/** 本局消费(下注金额) */
|
||||
private BigDecimal cost;
|
||||
|
||||
/** 本局获得饰品的价格(= 结算金额) */
|
||||
private BigDecimal settledWin;
|
||||
|
||||
/** 饰品图片 */
|
||||
private String prizeImageUrl;
|
||||
|
||||
/** 饰品全名:名称(磨损品质),无品质时仅名称 */
|
||||
private String prizeName;
|
||||
|
||||
/** 开局时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private Date createTime;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.game.olympus.contract.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SpinResponse {
|
||||
|
||||
private String state; // "PLAYING" | "FINISH"
|
||||
|
||||
private Integer spinNum; // 免费旋转序号(0=基础旋转)
|
||||
private Integer tumbleNum; // 本旋内翻滚序号
|
||||
private Integer globalStep;
|
||||
private Integer globalTotalSteps;
|
||||
private Integer spinsRemaining;
|
||||
|
||||
private Object gridState;
|
||||
private Object multiplierDetail;
|
||||
private Object symbolLinks; // 本旋累积连线(每波追加,tumble 标明旋内第几波;面板由它汇总)
|
||||
private Object dropData;
|
||||
private Object fillData;
|
||||
private Object scatterTrigger;
|
||||
|
||||
private BigDecimal winAmount; // 本旋累计(该旋末帧 = 这一旋的总额)
|
||||
private BigDecimal spinWin; // 本局累计(末帧 = totalWin)
|
||||
private BigDecimal accumulator; // 本步后炸弹累计倍数(未 ×bet;FS 跨旋传递,重连恢复累计用)
|
||||
|
||||
private Object retriggerInfo;
|
||||
private String bigWinLevel; // 小/中/大:small | middle | big(仅免费旋转的旋末帧可能有值,其余 null)
|
||||
|
||||
// ---- 仅 FINISH ----
|
||||
private BigDecimal totalWin;
|
||||
private BigDecimal totalMul;
|
||||
private Boolean maxWinReached; // 1000x 封顶标识
|
||||
private Object prizeInfo;
|
||||
private Long boxRecordId;
|
||||
}
|
||||
+729
@@ -0,0 +1,729 @@
|
||||
package com.ruoyi.game.olympus.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBox;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBoxOrnament;
|
||||
import com.ruoyi.game.olympus.enums.OlympusAudience;
|
||||
import com.ruoyi.game.olympus.enums.OlympusModeType;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusBoxMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusBoxOrnamentMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusRecordMapper;
|
||||
import com.ruoyi.game.olympus.repository.OlympusRewardPoolCacheRepository;
|
||||
import com.ruoyi.game.olympus.domain.OlympusOrnament;
|
||||
import com.ruoyi.game.olympus.domain.OlympusPrizeOrnament;
|
||||
import com.ruoyi.game.olympus.domain.OlympusPrizeRange;
|
||||
import com.ruoyi.game.olympus.domain.OlympusRound;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusOrnamentMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusPrizeOrnamentMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusPrizeRangeMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusRoundMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBoxTemplate;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusBoxTemplateMapper;
|
||||
import com.ruoyi.game.olympus.repository.OlympusCycleRepository;
|
||||
import com.ruoyi.game.olympus.service.OlympusDistributionService;
|
||||
import com.ruoyi.admin.mapper.TtOrnamentsLevelMapper;
|
||||
import com.ruoyi.admin.service.TtUserService;
|
||||
import com.ruoyi.domain.entity.sys.TtUser;
|
||||
import com.ruoyi.domain.other.TtOrnamentsLevel;
|
||||
import com.ruoyi.game.olympus.domain.OlympusRewardLog;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusRewardLogMapper;
|
||||
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.math.RoundingMode;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 奥林匹斯管理端:箱子配置 / 桶分布 / 水位(按箱注水抽水)。 */
|
||||
@RestController
|
||||
@RequestMapping("/admin/olympus")
|
||||
public class OlympusAdminController extends BaseController {
|
||||
|
||||
@Autowired private OlympusBoxMapper boxMapper;
|
||||
@Autowired private OlympusBoxOrnamentMapper boxOrnamentMapper;
|
||||
@Autowired private OlympusRecordMapper recordMapper;
|
||||
@Autowired private OlympusRewardPoolCacheRepository rewardPoolRepository;
|
||||
@Autowired private OlympusOrnamentMapper ornamentMapper;
|
||||
@Autowired private OlympusRoundMapper roundMapper;
|
||||
@Autowired private OlympusPrizeOrnamentMapper prizeOrnamentMapper;
|
||||
@Autowired private OlympusPrizeRangeMapper prizeRangeMapper;
|
||||
@Autowired private OlympusBoxTemplateMapper boxTemplateMapper;
|
||||
@Autowired private OlympusCycleRepository cycleRepository;
|
||||
@Autowired private OlympusDistributionService distributionService;
|
||||
@Autowired(required = false) private TtOrnamentsLevelMapper ornamentsLevelMapper;
|
||||
@Autowired private TtUserService userService;
|
||||
@Autowired private OlympusRewardLogMapper rewardLogMapper;
|
||||
|
||||
@GetMapping("/box/list")
|
||||
public R boxList(@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(required = false) String boxType) {
|
||||
LambdaQueryWrapper<OlympusBox> qw = new LambdaQueryWrapper<OlympusBox>().eq(OlympusBox::getGameType, "OLYMPUS");
|
||||
if (boxType != null && !boxType.isEmpty()) qw.eq(OlympusBox::getBoxType, boxType);
|
||||
qw.orderByAsc(OlympusBox::getBoxId);
|
||||
Page<OlympusBox> page = boxMapper.selectPage(new Page<>(pageNum, pageSize), qw);
|
||||
|
||||
// 各身份分布一次全量查出(30箱×4身份量小),内存分组,避免逐身份 N+1
|
||||
// 桶期望按"这个箱子的池子实际会赔多少"取(每个箱子查一次,缓存复用)
|
||||
Map<Long, Map<Integer, Double>> expectByBox = new HashMap<>();
|
||||
Map<String, List<OlympusBoxOrnament>> distByKey = new HashMap<>();
|
||||
for (OlympusBoxOrnament bo : boxOrnamentMapper.selectList(null)) {
|
||||
if (bo.getBoxId() == null || bo.getAudience() == null) continue;
|
||||
distByKey.computeIfAbsent(bo.getBoxId() + ":" + bo.getAudience(), k -> new ArrayList<>()).add(bo);
|
||||
}
|
||||
|
||||
for (OlympusBox box : page.getRecords()) {
|
||||
try {
|
||||
OlympusModeType m = OlympusModeType.fromBoxType(box.getBoxType());
|
||||
double cm = m.getCostMultiplier();
|
||||
Map<Integer, Double> ornMul = expectByBox.computeIfAbsent(box.getBoxId(),
|
||||
k -> distributionService.loadPoolExpect(k));
|
||||
box.setModeName(m.getName());
|
||||
box.setCostMultiplier(BigDecimal.valueOf(cm));
|
||||
Map<String, BigDecimal> targetMap = new HashMap<>();
|
||||
Map<String, BigDecimal> actualMap = new HashMap<>();
|
||||
Map<String, BigDecimal> totalMap = new HashMap<>();
|
||||
for (OlympusAudience aud : OlympusAudience.values()) {
|
||||
// 目标RTP:从该身份分布反推 = Σ(count×expect_mul) / Σ(count) / CM × 100
|
||||
List<OlympusBoxOrnament> list = distByKey.get(box.getBoxId() + ":" + aud.getCode());
|
||||
if (list != null && !list.isEmpty()) {
|
||||
double sumMul = 0; int totalCount = 0;
|
||||
for (OlympusBoxOrnament bo : list) {
|
||||
int c = bo.getCount() != null ? bo.getCount() : 0;
|
||||
if (c <= 0) continue;
|
||||
sumMul += c * ornMul.getOrDefault(bo.getOrnamentId(), 0.0);
|
||||
totalCount += c;
|
||||
}
|
||||
if (totalCount > 0) {
|
||||
targetMap.put(aud.getCode(), BigDecimal.valueOf(sumMul / totalCount / cm * 100)
|
||||
.setScale(2, RoundingMode.HALF_UP));
|
||||
// 该身份一轮总回合 = 固定池 count 和反推(身份级独立)
|
||||
double cr = box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15;
|
||||
totalMap.put(aud.getCode(), BigDecimal.valueOf(
|
||||
OlympusDistributionService.deriveTotalRounds(totalCount, cr)));
|
||||
}
|
||||
}
|
||||
// 实时RTP:按 (boxId, audience) 循环账本各查一次
|
||||
BigDecimal r = cycleRepository.realtimeRtp(box.getBoxId(), aud.getCode(), cm);
|
||||
if (r != null) actualMap.put(aud.getCode(), r);
|
||||
}
|
||||
box.setTargetRtpByAudience(targetMap);
|
||||
box.setActualRtpByAudience(actualMap);
|
||||
box.setTotalRoundsByAudience(totalMap);
|
||||
box.setActualRtp(actualMap.get("common")); // 兼容保留 common 口径
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
|
||||
}
|
||||
|
||||
@Log(title = "箱子配置", action = "编辑", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/box/save")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R boxSave(@RequestBody OlympusBox box) {
|
||||
if (box.getBet() == null || box.getBoxType() == null) return R.fail("下注与模式不能为空");
|
||||
try {
|
||||
OlympusModeType m = OlympusModeType.fromBoxType(box.getBoxType());
|
||||
box.setGameType("OLYMPUS");
|
||||
box.setPrice(box.getBet().multiply(BigDecimal.valueOf(m.getCostMultiplier())));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return R.fail("无效模式: " + box.getBoxType());
|
||||
}
|
||||
if (box.getBoxId() != null && boxMapper.selectById(box.getBoxId()) != null) boxMapper.updateById(box);
|
||||
else boxMapper.insert(box);
|
||||
return R.ok(Map.of("boxId", box.getBoxId()));
|
||||
}
|
||||
|
||||
/** 某箱某身份的桶分布:含 convergeCount(收敛账本) + fixedRounds/convergeRounds(按该身份分布反推) */
|
||||
@GetMapping("/box/ornaments/{boxId}")
|
||||
public R boxOrnaments(@PathVariable Long boxId,
|
||||
@RequestParam(required = false, defaultValue = "common") String audience) {
|
||||
List<OlympusBoxOrnament> list = boxOrnamentMapper.selectByBoxIdAndAudience(boxId, audience);
|
||||
for (OlympusBoxOrnament bo : list) {
|
||||
bo.setConvergeCount(cycleRepository.convergeCount(boxId, audience, bo.getOrnamentId()));
|
||||
}
|
||||
OlympusBox box = boxMapper.selectById(boxId);
|
||||
// 固定/收敛回合按该身份分布 count 之和反推(身份级独立),不使用箱子级 total_ornaments
|
||||
int fixedSum = list.stream().mapToInt(bo -> bo.getCount() != null ? bo.getCount() : 0).sum();
|
||||
double cr = (box != null && box.getRtpConvergeRatio() != null) ? box.getRtpConvergeRatio().doubleValue() : 0.15;
|
||||
int fixedRounds = fixedSum;
|
||||
int convergeRounds = fixedSum > 0 ? Math.max(0, OlympusDistributionService.deriveTotalRounds(fixedSum, cr) - fixedSum) : 0;
|
||||
// poolExpect:该箱每个桶"池子里实际会赔多少"的均值(= 列表页「目标RTP」用的同一口径)。
|
||||
// 前端「计算RTP」必须拿它算,否则会拿桶定义的名义 expect_mul(区间中点)算出跟目标RTP不一致的数。
|
||||
return R.ok(Map.of("list", list, "audience", audience, "fixedRounds", fixedRounds,
|
||||
"convergeRounds", convergeRounds, "poolExpect", distributionService.loadPoolExpect(boxId)));
|
||||
}
|
||||
|
||||
@Log(title = "箱子配置", action = "保存", businessType = BusinessType.UPDATE)
|
||||
/** 保存某箱某身份的饰品回合分布(items 为固定池回合,反推回写总回合) */
|
||||
@PostMapping("/box/ornaments/save")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R boxOrnamentsSave(@RequestBody Map<String, Object> params) {
|
||||
Long boxId = asLong(params.get("boxId"));
|
||||
if (boxId == null) return R.fail("boxId 不能为空");
|
||||
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
|
||||
distributionService.writeDistribution(boxId, audience, items);
|
||||
cycleRepository.resetCycle(boxId, audience);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/** 按目标 RTP 重算某箱某身份分布(只写 box_ornament) */
|
||||
@PostMapping("/box/ornaments/calc")
|
||||
public R calcDistribution(@RequestBody Map<String, Object> params) {
|
||||
Long boxId = asLong(params.get("boxId"));
|
||||
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
|
||||
Integer totalRounds = asInt(params.get("totalRounds"));
|
||||
Double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : 0.8;
|
||||
Double convergeRatio = params.get("convergeRatio") != null ? Double.parseDouble(params.get("convergeRatio").toString()) : null;
|
||||
OlympusBox box = boxMapper.selectById(boxId);
|
||||
if (box == null) return R.fail("箱子不存在");
|
||||
OlympusModeType mode = OlympusModeType.fromBoxType(box.getBoxType());
|
||||
double cr = convergeRatio != null ? convergeRatio
|
||||
: (box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15);
|
||||
int fixedRoundsN = OlympusDistributionService.deriveFixedRounds(totalRounds != null ? totalRounds : 200, cr);
|
||||
int canonicalN = OlympusDistributionService.deriveTotalRounds(fixedRoundsN, cr);
|
||||
box.setTotalOrnaments(canonicalN);
|
||||
box.setRtp(BigDecimal.valueOf(rtp));
|
||||
if (convergeRatio != null) box.setRtpConvergeRatio(BigDecimal.valueOf(convergeRatio));
|
||||
boxMapper.updateById(box);
|
||||
distributionService.reset(boxId, audience);
|
||||
cycleRepository.resetCycle(boxId, audience);
|
||||
List<OlympusBoxOrnament> saved = boxOrnamentMapper.selectByBoxIdAndAudience(boxId, audience);
|
||||
Map<Integer, Double> ornMul = distributionService.loadPoolExpect(boxId);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
int total = 0; double contrib = 0;
|
||||
for (OlympusBoxOrnament bo : saved) {
|
||||
int c = bo.getCount() != null ? bo.getCount() : 0;
|
||||
result.add(Map.of("ornamentId", bo.getOrnamentId(), "count", c));
|
||||
total += c;
|
||||
contrib += c * ornMul.getOrDefault(bo.getOrnamentId(), 0.0);
|
||||
}
|
||||
double cm = mode.getCostMultiplier();
|
||||
double calcRtpValue = total > 0 ? contrib / total / cm * 100 : 0;
|
||||
return R.ok(Map.of("items", result, "totalRounds", total, "boxTotal", canonicalN,
|
||||
"convergeRounds", Math.max(0, canonicalN - total),
|
||||
"calcRtp", String.format("%.1f%%", calcRtpValue)));
|
||||
}
|
||||
|
||||
/** 模拟 payoutCap 约束下的预估实际 RTP */
|
||||
@PostMapping("/box/ornaments/simulate-rtp")
|
||||
public R simulateRtp(@RequestBody Map<String, Object> params) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
|
||||
Long boxId = asLong(params.get("boxId"));
|
||||
double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : 0.8;
|
||||
if (items == null || items.isEmpty()) return R.ok(Map.of("estimatedRtp", "0.0%"));
|
||||
Map<Integer, Integer> dist = new LinkedHashMap<>();
|
||||
for (Map<String, Object> item : items) {
|
||||
Integer ornamentId = asInt(item.get("ornamentId"));
|
||||
Integer count = asInt(item.get("count"));
|
||||
if (ornamentId != null && count != null && count > 0) dist.put(ornamentId, count);
|
||||
}
|
||||
double est = distributionService.simulateActualRtp(dist, boxId, rtp, 10);
|
||||
return R.ok(Map.of("estimatedRtp", String.format("%.1f%%", est)));
|
||||
}
|
||||
|
||||
// ==================== 分布模板(按身份) ====================
|
||||
|
||||
@GetMapping("/box/templates/{boxId}")
|
||||
public R templateList(@PathVariable Long boxId,
|
||||
@RequestParam(required = false, defaultValue = "common") String audience) {
|
||||
List<OlympusBoxTemplate> list = boxTemplateMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusBoxTemplate>()
|
||||
.eq(OlympusBoxTemplate::getBoxId, boxId)
|
||||
.eq(OlympusBoxTemplate::getAudience, audience)
|
||||
.orderByDesc(OlympusBoxTemplate::getId));
|
||||
for (OlympusBoxTemplate t : list) {
|
||||
t.setItems(distributionService.parseItems(t.getItemsJson()));
|
||||
t.setItemsJson(null);
|
||||
}
|
||||
return R.ok(Map.of("list", list));
|
||||
}
|
||||
|
||||
@Log(title = "箱子配置", action = "保存为模板", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/box/template/save")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R templateSave(@RequestBody Map<String, Object> params) {
|
||||
Long boxId = asLong(params.get("boxId"));
|
||||
if (boxId == null) return R.fail("boxId 不能为空");
|
||||
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
|
||||
String name = params.get("name") != null ? params.get("name").toString().trim() : "";
|
||||
if (name.isEmpty()) return R.fail("模板名称不能为空");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
|
||||
if (items == null || items.isEmpty()) return R.fail("模板饰品分布不能为空");
|
||||
Long dup = boxTemplateMapper.selectCount(new LambdaQueryWrapper<OlympusBoxTemplate>()
|
||||
.eq(OlympusBoxTemplate::getBoxId, boxId).eq(OlympusBoxTemplate::getAudience, audience)
|
||||
.eq(OlympusBoxTemplate::getName, name));
|
||||
if (dup != null && dup > 0) return R.fail("模板名称已存在,请使用不同的名称");
|
||||
int total = 0;
|
||||
for (Map<String, Object> item : items) { Integer c = asInt(item.get("count")); if (c != null && c > 0) total += c; }
|
||||
OlympusBoxTemplate tpl = new OlympusBoxTemplate();
|
||||
tpl.setBoxId(boxId);
|
||||
tpl.setAudience(audience);
|
||||
tpl.setName(name);
|
||||
tpl.setTotalRounds(total);
|
||||
tpl.setItemsJson(distributionService.toItemsJson(items));
|
||||
tpl.setIsActive(0);
|
||||
boxTemplateMapper.insert(tpl);
|
||||
return R.ok(Map.of("id", tpl.getId()));
|
||||
}
|
||||
|
||||
@Log(title = "箱子配置", action = "删除", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/box/template/{id}")
|
||||
public R templateDelete(@PathVariable Long id) {
|
||||
boxTemplateMapper.deleteById(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Log(title = "箱子配置", action = "应用选中模板", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/box/template/apply")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R templateApply(@RequestBody Map<String, Object> params) {
|
||||
Long templateId = asLong(params.get("templateId"));
|
||||
if (templateId == null) return R.fail("templateId 不能为空");
|
||||
OlympusBoxTemplate tpl = boxTemplateMapper.selectById(templateId);
|
||||
if (tpl == null) return R.fail("模板不存在");
|
||||
distributionService.writeDistribution(tpl.getBoxId(), tpl.getAudience(), distributionService.parseItems(tpl.getItemsJson()));
|
||||
cycleRepository.resetCycle(tpl.getBoxId(), tpl.getAudience());
|
||||
List<OlympusBoxTemplate> siblings = boxTemplateMapper.selectList(new LambdaQueryWrapper<OlympusBoxTemplate>()
|
||||
.eq(OlympusBoxTemplate::getBoxId, tpl.getBoxId()).eq(OlympusBoxTemplate::getAudience, tpl.getAudience()));
|
||||
for (OlympusBoxTemplate s : siblings) {
|
||||
int active = s.getId().equals(templateId) ? 1 : 0;
|
||||
if (!Integer.valueOf(active).equals(s.getIsActive())) { s.setIsActive(active); boxTemplateMapper.updateById(s); }
|
||||
}
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/** 一键重算所有箱子 × 所有身份分布 */
|
||||
@PostMapping("/box/recalc_all")
|
||||
public R recalcAll() {
|
||||
List<OlympusBox> boxes = boxMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusBox>().eq(OlympusBox::getGameType, "OLYMPUS"));
|
||||
int done = 0;
|
||||
for (OlympusBox box : boxes) {
|
||||
for (OlympusAudience aud : OlympusAudience.values()) {
|
||||
try {
|
||||
distributionService.reset(box.getBoxId(), aud.getCode());
|
||||
cycleRepository.resetCycle(box.getBoxId(), aud.getCode());
|
||||
done++;
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
}
|
||||
return R.ok(Map.of("recalculated", done));
|
||||
}
|
||||
|
||||
@Log(title = "箱子配置", action = "一键重算分布", businessType = BusinessType.CLEAN)
|
||||
/**
|
||||
* 重算分布(弹窗):按统一参数批量重算。
|
||||
* 入参:boxType 可选(空=全部模式);audience 可选(空=全部身份);rtp/totalRounds/convergeRatio 非空则统一设置到箱,再按最新配置重算分布。
|
||||
*/
|
||||
@PostMapping("/box/recalcBatch")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R recalcBatch(@RequestBody Map<String, Object> params) {
|
||||
String boxType = params.get("boxType") != null && !params.get("boxType").toString().isEmpty()
|
||||
? params.get("boxType").toString() : null;
|
||||
String audience = params.get("audience") != null && !params.get("audience").toString().isEmpty()
|
||||
? params.get("audience").toString() : null;
|
||||
Double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : null;
|
||||
Integer totalRounds = asInt(params.get("totalRounds"));
|
||||
Double convergeRatio = params.get("convergeRatio") != null
|
||||
? Double.parseDouble(params.get("convergeRatio").toString()) : null;
|
||||
|
||||
LambdaQueryWrapper<OlympusBox> qw = new LambdaQueryWrapper<OlympusBox>()
|
||||
.eq(OlympusBox::getGameType, "OLYMPUS");
|
||||
if (boxType != null) qw.eq(OlympusBox::getBoxType, boxType);
|
||||
List<OlympusBox> boxes = boxMapper.selectList(qw);
|
||||
if (boxes.isEmpty()) return R.fail("没有匹配的箱子");
|
||||
|
||||
List<String> audiences = audience != null ? Collections.singletonList(audience)
|
||||
: Arrays.stream(OlympusAudience.values()).map(OlympusAudience::getCode).collect(Collectors.toList());
|
||||
|
||||
int done = 0;
|
||||
for (OlympusBox box : boxes) {
|
||||
boolean changed = false;
|
||||
if (rtp != null) { box.setRtp(BigDecimal.valueOf(rtp)); changed = true; }
|
||||
if (totalRounds != null && totalRounds > 0) { box.setTotalOrnaments(totalRounds); changed = true; }
|
||||
if (convergeRatio != null) { box.setRtpConvergeRatio(BigDecimal.valueOf(convergeRatio)); changed = true; }
|
||||
if (changed) boxMapper.updateById(box);
|
||||
for (String aud : audiences) {
|
||||
try {
|
||||
distributionService.reset(box.getBoxId(), aud);
|
||||
cycleRepository.resetCycle(box.getBoxId(), aud);
|
||||
done++;
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
}
|
||||
return R.ok(Map.of("recalculated", done, "boxes", boxes.size()));
|
||||
}
|
||||
|
||||
/** 玩家记录列表:可按玩家ID + 身份筛选;行内回填 audience(默认全部身份,含白号/机器人) */
|
||||
@GetMapping("/record/list")
|
||||
public R recordList(@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(required = false) Long userId,
|
||||
@RequestParam(required = false) String audience) {
|
||||
LambdaQueryWrapper<com.ruoyi.game.olympus.domain.OlympusRecord> qw = new LambdaQueryWrapper<>();
|
||||
// 空 = 全部身份(与奖池页"未选身份默认普通"不同:这里空是不过滤)
|
||||
if (audience != null && !audience.trim().isEmpty() && !POOL_AUDIENCES.contains(audience.trim())) {
|
||||
return R.fail("身份无效");
|
||||
}
|
||||
qw.inSql(com.ruoyi.game.olympus.domain.OlympusRecord::getUserId, audienceUserSubQuery(audience));
|
||||
if (userId != null) qw.eq(com.ruoyi.game.olympus.domain.OlympusRecord::getUserId, userId);
|
||||
qw.orderByDesc(com.ruoyi.game.olympus.domain.OlympusRecord::getCreateTime);
|
||||
Page<com.ruoyi.game.olympus.domain.OlympusRecord> page = recordMapper.selectPage(new Page<>(pageNum, pageSize), qw);
|
||||
fillAudience(page.getRecords());
|
||||
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份 → tt_user 子查询。奖池 4 池口径(common/anchor/robot/white),另附封禁/逻辑删除兜底:
|
||||
* 封禁(status=1)和已删除用户任何身份都不展示;身份为空=全部身份(不再无条件排除白号/机器人)。
|
||||
*/
|
||||
private static String audienceUserSubQuery(String audience) {
|
||||
String base = "SELECT user_id FROM tt_user WHERE status != '1' AND del_flag = '0' AND ";
|
||||
if ("white".equals(audience)) return base + "is_blank_account = 1";
|
||||
if ("anchor".equals(audience)) return base + "COALESCE(is_blank_account,0) != 1 AND user_type = '01'";
|
||||
if ("robot".equals(audience)) return base + "COALESCE(is_blank_account,0) != 1 AND user_type = '03'";
|
||||
if ("common".equals(audience)) return base + "COALESCE(is_blank_account,0) != 1 AND (user_type IS NULL OR user_type NOT IN ('01','03'))";
|
||||
return base + "1 = 1";
|
||||
}
|
||||
|
||||
/** 批量回填每行身份:复用 OlympusAudience.resolve,勿另写一套判定 */
|
||||
private void fillAudience(List<com.ruoyi.game.olympus.domain.OlympusRecord> rows) {
|
||||
if (rows == null || rows.isEmpty()) return;
|
||||
Set<Long> ids = rows.stream().map(com.ruoyi.game.olympus.domain.OlympusRecord::getUserId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
if (ids.isEmpty()) return;
|
||||
Map<Long, String> audMap = new HashMap<>();
|
||||
for (TtUser u : userService.listByIds(ids)) {
|
||||
if (u.getUserId() != null) audMap.put(u.getUserId().longValue(), OlympusAudience.resolve(u).getCode());
|
||||
}
|
||||
for (com.ruoyi.game.olympus.domain.OlympusRecord r : rows) {
|
||||
r.setAudience(audMap.getOrDefault(r.getUserId(), "common"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/win/summary")
|
||||
public R winSummary(@RequestParam(required = false) String dateStart,
|
||||
@RequestParam(required = false) String dateEnd) {
|
||||
Map<String, Object> row = recordMapper.winSummary(dateStart, dateEnd);
|
||||
return R.ok(row);
|
||||
}
|
||||
|
||||
// ==================== 饰品桶定义(饰品列表) ====================
|
||||
|
||||
@GetMapping("/ornament/list")
|
||||
public R ornamentList() {
|
||||
List<OlympusOrnament> list = ornamentMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusOrnament>().orderByAsc(OlympusOrnament::getId));
|
||||
return R.ok(Map.of("list", list));
|
||||
}
|
||||
|
||||
// ==================== 预生成回合(回合管理) ====================
|
||||
|
||||
@GetMapping("/round/list")
|
||||
public R roundList(@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(required = false) Long roundId,
|
||||
@RequestParam(required = false) String boxType) {
|
||||
LambdaQueryWrapper<OlympusRound> qw = new LambdaQueryWrapper<>();
|
||||
if (roundId != null) qw.eq(OlympusRound::getRoundId, roundId);
|
||||
// 收集箱子 id→模式,供筛选与回填 boxType
|
||||
List<OlympusBox> boxes = boxMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusBox>().eq(OlympusBox::getGameType, "OLYMPUS"));
|
||||
Map<Long, String> boxTypeMap = new HashMap<>();
|
||||
List<Long> filterBoxIds = new ArrayList<>();
|
||||
for (OlympusBox b : boxes) {
|
||||
boxTypeMap.put(b.getBoxId(), b.getBoxType());
|
||||
if (boxType != null && !boxType.isEmpty() && boxType.equals(b.getBoxType())) filterBoxIds.add(b.getBoxId());
|
||||
}
|
||||
if (boxType != null && !boxType.isEmpty()) {
|
||||
if (filterBoxIds.isEmpty()) return R.ok(Map.of("rows", Collections.emptyList(), "total", 0));
|
||||
qw.in(OlympusRound::getBoxId, filterBoxIds);
|
||||
}
|
||||
qw.orderByAsc(OlympusRound::getRoundId);
|
||||
Page<OlympusRound> page = roundMapper.selectPage(new Page<>(pageNum, pageSize), qw);
|
||||
for (OlympusRound r : page.getRecords()) r.setBoxType(boxTypeMap.get(r.getBoxId()));
|
||||
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
|
||||
}
|
||||
|
||||
// ==================== 每日统计 ====================
|
||||
|
||||
/** 每日统计列表:按天聚合 game_olympus_record(与 winSummary 同源同口径,支持日期筛选) */
|
||||
@GetMapping("/win/list")
|
||||
public R winList(@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(required = false) String dateStart,
|
||||
@RequestParam(required = false) String dateEnd) {
|
||||
List<Map<String, Object>> all = recordMapper.dailyList(dateStart, dateEnd);
|
||||
int from = Math.max(0, (pageNum - 1) * pageSize);
|
||||
if (from >= all.size()) return R.ok(Map.of("rows", Collections.emptyList(), "total", all.size()));
|
||||
return R.ok(Map.of("rows", all.subList(from, Math.min(all.size(), from + pageSize)), "total", all.size()));
|
||||
}
|
||||
|
||||
// ==================== 返奖饰品(奖品管理) ====================
|
||||
|
||||
@GetMapping("/prize/list")
|
||||
public R prizeList() {
|
||||
List<OlympusPrizeOrnament> list = prizeOrnamentMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusPrizeOrnament>().orderByAsc(OlympusPrizeOrnament::getSort));
|
||||
List<OlympusPrizeRange> ranges = prizeRangeMapper.selectList(new LambdaQueryWrapper<>());
|
||||
Map<Long, OlympusPrizeRange> rangeMap = new HashMap<>();
|
||||
for (OlympusPrizeRange r : ranges) rangeMap.put(r.getPrizeId(), r);
|
||||
for (OlympusPrizeOrnament o : list) {
|
||||
OlympusPrizeRange r = rangeMap.get(o.getId());
|
||||
if (r != null) { o.setWinLow(r.getWinLow()); o.setWinHigh(r.getWinHigh()); }
|
||||
if (o.getGrade() != null && ornamentsLevelMapper != null) {
|
||||
TtOrnamentsLevel lv = ornamentsLevelMapper.selectById(o.getGrade());
|
||||
if (lv != null) o.setLevelImg(lv.getLevelImg());
|
||||
}
|
||||
}
|
||||
return R.ok(Map.of("list", list));
|
||||
}
|
||||
|
||||
@Log(title = "奖品管理", action = "编辑", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/prize/save")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R prizeSave(@RequestBody OlympusPrizeOrnament prize) {
|
||||
if (prize.getName() == null || prize.getName().trim().isEmpty()) return R.fail("饰品名称不能为空");
|
||||
prize.setGameType("OLYMPUS");
|
||||
if (prize.getStatus() == null) prize.setStatus("1");
|
||||
if (prize.getId() != null && prizeOrnamentMapper.selectById(prize.getId()) != null) prizeOrnamentMapper.updateById(prize);
|
||||
else prizeOrnamentMapper.insert(prize);
|
||||
// 同步区间(按 prizeId 唯一)
|
||||
if (prize.getWinLow() != null && prize.getWinHigh() != null) {
|
||||
OlympusPrizeRange r = prizeRangeMapper.selectOne(
|
||||
new LambdaQueryWrapper<OlympusPrizeRange>().eq(OlympusPrizeRange::getPrizeId, prize.getId()).last("LIMIT 1"));
|
||||
if (r == null) { r = new OlympusPrizeRange(); r.setPrizeId(prize.getId()); }
|
||||
r.setWinLow(prize.getWinLow());
|
||||
r.setWinHigh(prize.getWinHigh());
|
||||
if (r.getId() != null) prizeRangeMapper.updateById(r); else prizeRangeMapper.insert(r);
|
||||
}
|
||||
return R.ok(Map.of("id", prize.getId()));
|
||||
}
|
||||
|
||||
// ==================== 水位(按【箱 × 身份】注水/抽水) ====================
|
||||
|
||||
/** 奖池身份维度,与 OlympusAudience 对齐 */
|
||||
private static final List<String> POOL_AUDIENCES = Arrays.asList("common", "anchor", "robot", "white");
|
||||
|
||||
/** 身份入参归一化:空=common;非法返回 null(调用方报错,不静默兜底) */
|
||||
private static String audienceOf(Object v) {
|
||||
if (v == null || v.toString().trim().isEmpty()) return "common";
|
||||
String s = v.toString().trim();
|
||||
return POOL_AUDIENCES.contains(s) ? s : null;
|
||||
}
|
||||
|
||||
/** 奖池配置快照:某身份各箱水位/RTP/本轮进度/收敛占比 + 一轮花费基数 */
|
||||
@GetMapping("/rewardPool/config")
|
||||
public R rewardPoolConfig(@RequestParam(required = false) String audience) {
|
||||
String aud = audienceOf(audience);
|
||||
if (aud == null) return R.fail("身份无效");
|
||||
return R.ok(poolConfigMap(aud));
|
||||
}
|
||||
|
||||
/** 组装奖池配置(total + boxes),供 config 与 adjust/batch/reset 复用 */
|
||||
private Map<String, Object> poolConfigMap(String audience) {
|
||||
List<OlympusBox> boxes = boxMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusBox>().eq(OlympusBox::getGameType, "OLYMPUS").orderByAsc(OlympusBox::getBoxId));
|
||||
List<Map<String, Object>> boxList = new ArrayList<>();
|
||||
BigDecimal totalBet = BigDecimal.ZERO, totalWin = BigDecimal.ZERO, totalBalance = BigDecimal.ZERO;
|
||||
for (OlympusBox box : boxes) {
|
||||
Long boxId = box.getBoxId();
|
||||
com.ruoyi.game.olympus.model.cache.OlympusRewardPoolCache c = rewardPoolRepository.getBoxCache(boxId, audience);
|
||||
BigDecimal balance = rewardPoolRepository.getBoxBalance(boxId, audience);
|
||||
double cm = 1.0;
|
||||
try { cm = OlympusModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
|
||||
int rounds = box.getTotalOrnaments() != null ? box.getTotalOrnaments() : 0;
|
||||
BigDecimal costPerRound = coalesce(box.getBet()).multiply(BigDecimal.valueOf(cm));
|
||||
BigDecimal cycleCost = costPerRound.multiply(BigDecimal.valueOf(rounds));
|
||||
long drawn = cycleRepository.drawn(boxId, audience);
|
||||
Double cycleRtp = null;
|
||||
if (drawn > 0 && cm > 0) cycleRtp = cycleRepository.mulSum(boxId, audience) / drawn / cm;
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("boxId", boxId);
|
||||
m.put("name", box.getName());
|
||||
m.put("audience", audience);
|
||||
m.put("boxType", box.getBoxType());
|
||||
m.put("bet", box.getBet());
|
||||
m.put("totalBet", c.getTotalBet());
|
||||
m.put("totalWin", c.getTotalWin());
|
||||
m.put("adjust", c.getAdjust());
|
||||
m.put("balance", balance);
|
||||
m.put("costPerRound", costPerRound.setScale(2, RoundingMode.HALF_UP));
|
||||
m.put("cycleRounds", rounds);
|
||||
m.put("cycleCost", cycleCost.setScale(2, RoundingMode.HALF_UP));
|
||||
m.put("totalRounds", rounds);
|
||||
m.put("convergeRatio", box.getRtpConvergeRatio());
|
||||
m.put("cycleDrawn", drawn);
|
||||
m.put("cycleRtp", cycleRtp);
|
||||
m.put("rtp", c.getTotalBet().signum() > 0
|
||||
? c.getTotalWin().divide(c.getTotalBet(), 4, RoundingMode.HALF_UP) : null);
|
||||
boxList.add(m);
|
||||
totalBet = totalBet.add(c.getTotalBet());
|
||||
totalWin = totalWin.add(c.getTotalWin());
|
||||
totalBalance = totalBalance.add(balance);
|
||||
}
|
||||
Map<String, Object> total = new LinkedHashMap<>();
|
||||
total.put("audience", audience);
|
||||
total.put("totalBet", totalBet);
|
||||
total.put("totalWin", totalWin);
|
||||
total.put("adjust", rewardPoolRepository.getGlobalAdjust(audience));
|
||||
total.put("balance", totalBalance);
|
||||
total.put("rtp", totalBet.signum() > 0 ? totalWin.divide(totalBet, 4, RoundingMode.HALF_UP) : null);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("audience", audience);
|
||||
result.put("total", total);
|
||||
result.put("boxes", boxList);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Log(title = "奖池水位", action = "注水/抽水", businessType = BusinessType.GRANT)
|
||||
/** 逐箱注水/抽水:type=1注水/2抽水,记审计日志 */
|
||||
@PostMapping("/rewardPool/adjust")
|
||||
public R rewardPoolAdjust(@RequestBody Map<String, Object> body) {
|
||||
String audience = audienceOf(body.get("audience"));
|
||||
if (audience == null) return R.fail("身份无效");
|
||||
Long boxId = asLong(body.get("boxId"));
|
||||
Integer type = asInt(body.get("type"));
|
||||
BigDecimal amount = asBd(body.get("amount"));
|
||||
String remark = body.get("remark") != null ? body.get("remark").toString() : null;
|
||||
OlympusBox box = boxId != null ? boxMapper.selectById(boxId) : null;
|
||||
if (box == null) return R.fail("箱子不存在");
|
||||
if (type == null || (type != 1 && type != 2)) return R.fail("调整类型无效");
|
||||
if (amount.signum() <= 0) return R.fail("金额必须大于 0");
|
||||
BigDecimal before = rewardPoolRepository.getBoxBalance(boxId, audience);
|
||||
rewardPoolRepository.adjustBox(boxId, audience, type == 2 ? amount.negate() : amount);
|
||||
BigDecimal after = rewardPoolRepository.getBoxBalance(boxId, audience);
|
||||
writeLog(boxId, box.getName(), audience, type, amount, before, after, remark);
|
||||
return R.ok(poolConfigMap(audience));
|
||||
}
|
||||
|
||||
@Log(title = "奖池水位", action = "批量注水/抽水", businessType = BusinessType.GRANT)
|
||||
/** 批量按一轮花费比例注水/抽水:ratio 百分比(0~1000],记一条汇总日志 */
|
||||
@PostMapping("/rewardPool/adjustBatch")
|
||||
public R rewardPoolAdjustBatch(@RequestBody Map<String, Object> body) {
|
||||
String audience = audienceOf(body.get("audience"));
|
||||
if (audience == null) return R.fail("身份无效");
|
||||
Integer type = asInt(body.get("type"));
|
||||
BigDecimal ratio = asBd(body.get("ratio"));
|
||||
String remark = body.get("remark") != null ? body.get("remark").toString() : null;
|
||||
if (type == null || (type != 1 && type != 2)) return R.fail("调整类型无效");
|
||||
if (ratio.signum() <= 0 || ratio.compareTo(new BigDecimal("1000")) > 0) return R.fail("比例须在 0~1000");
|
||||
List<OlympusBox> boxes = boxMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusBox>().eq(OlympusBox::getGameType, "OLYMPUS"));
|
||||
int adjusted = 0;
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
for (OlympusBox box : boxes) {
|
||||
double cm = 1.0;
|
||||
try { cm = OlympusModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
|
||||
BigDecimal base = coalesce(box.getBet()).multiply(BigDecimal.valueOf(cm))
|
||||
.multiply(BigDecimal.valueOf(box.getTotalOrnaments() != null ? box.getTotalOrnaments() : 0));
|
||||
if (base.signum() <= 0) continue;
|
||||
BigDecimal amount = base.multiply(ratio).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
|
||||
if (amount.signum() <= 0) continue;
|
||||
rewardPoolRepository.adjustBox(box.getBoxId(), audience, type == 2 ? amount.negate() : amount);
|
||||
totalAmount = totalAmount.add(amount);
|
||||
adjusted++;
|
||||
}
|
||||
writeLog(null, "批量(" + adjusted + "箱 " + ratio.stripTrailingZeros().toPlainString() + "%)",
|
||||
audience, type, totalAmount, null, null, remark);
|
||||
Map<String, Object> r = poolConfigMap(audience);
|
||||
r.put("adjusted", adjusted);
|
||||
r.put("totalAmount", totalAmount);
|
||||
return R.ok(r);
|
||||
}
|
||||
|
||||
@Log(title = "奖池水位", action = "重置调整", businessType = BusinessType.CLEAN)
|
||||
/** 重置人工调整:该身份所有箱子 adjust 归零,仅保留真实水位;记一条汇总日志 */
|
||||
@PostMapping("/rewardPool/adjustReset")
|
||||
public R rewardPoolAdjustReset(@RequestBody(required = false) Map<String, Object> body) {
|
||||
String audience = audienceOf(body != null ? body.get("audience") : null);
|
||||
if (audience == null) return R.fail("身份无效");
|
||||
String remark = body != null && body.get("remark") != null ? body.get("remark").toString() : null;
|
||||
List<OlympusBox> boxes = boxMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusBox>().eq(OlympusBox::getGameType, "OLYMPUS"));
|
||||
int cleared = 0;
|
||||
BigDecimal totalCleared = BigDecimal.ZERO;
|
||||
for (OlympusBox box : boxes) {
|
||||
BigDecimal adjust = rewardPoolRepository.getBoxAdjust(box.getBoxId(), audience);
|
||||
if (adjust.signum() == 0) continue;
|
||||
rewardPoolRepository.adjustBox(box.getBoxId(), audience, adjust.negate());
|
||||
totalCleared = totalCleared.add(adjust.abs());
|
||||
cleared++;
|
||||
}
|
||||
BigDecimal residual = rewardPoolRepository.getGlobalAdjust(audience);
|
||||
if (residual.signum() != 0) rewardPoolRepository.adjustGlobal(audience, residual.negate());
|
||||
writeLog(null, "全部(" + cleared + "箱)", audience, 3, totalCleared, null, null, remark);
|
||||
Map<String, Object> r = poolConfigMap(audience);
|
||||
r.put("cleared", cleared);
|
||||
r.put("totalCleared", totalCleared);
|
||||
return R.ok(r);
|
||||
}
|
||||
|
||||
/** 奖池调整历史(分页,可按身份筛选) */
|
||||
@GetMapping("/rewardPool/adjustLog")
|
||||
public R rewardPoolAdjustLog(@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String audience) {
|
||||
String aud = audienceOf(audience);
|
||||
if (aud == null) return R.fail("身份无效");
|
||||
Page<OlympusRewardLog> p = rewardLogMapper.selectPage(new Page<>(page, size),
|
||||
new LambdaQueryWrapper<OlympusRewardLog>()
|
||||
.eq(OlympusRewardLog::getAudience, aud)
|
||||
.orderByDesc(OlympusRewardLog::getId));
|
||||
return R.ok(Map.of("rows", p.getRecords(), "total", p.getTotal()));
|
||||
}
|
||||
|
||||
private void writeLog(Long boxId, String boxName, String audience, Integer type, BigDecimal amount,
|
||||
BigDecimal before, BigDecimal after, String remark) {
|
||||
try {
|
||||
OlympusRewardLog rlog = new OlympusRewardLog();
|
||||
rlog.setBoxId(boxId);
|
||||
rlog.setBoxName(boxName);
|
||||
rlog.setAudience(audience);
|
||||
rlog.setType(type);
|
||||
rlog.setAmount(amount);
|
||||
rlog.setBalanceBefore(before);
|
||||
rlog.setBalanceAfter(after);
|
||||
rlog.setPoolBalance(totalPoolBalance(audience));
|
||||
rlog.setOperator(getUsername());
|
||||
rlog.setRemark(remark);
|
||||
rlog.setCreateTime(new java.util.Date());
|
||||
rewardLogMapper.insert(rlog);
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
|
||||
/** 该身份全站总池余额 = 各箱水位之和(写日志时取操作后快照) */
|
||||
private BigDecimal totalPoolBalance(String audience) {
|
||||
List<OlympusBox> list = boxMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusBox>().eq(OlympusBox::getGameType, "OLYMPUS"));
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
for (OlympusBox b : list) sum = sum.add(rewardPoolRepository.getBoxBalance(b.getBoxId(), audience));
|
||||
return sum;
|
||||
}
|
||||
|
||||
private static BigDecimal coalesce(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
|
||||
|
||||
private static Long asLong(Object v) {
|
||||
if (v == null) return null;
|
||||
try { return Long.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
|
||||
}
|
||||
private static Integer asInt(Object v) {
|
||||
if (v == null) return null;
|
||||
try { return Integer.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
|
||||
}
|
||||
private static BigDecimal asBd(Object v) {
|
||||
if (v == null) return BigDecimal.ZERO;
|
||||
try { return new BigDecimal(v.toString().trim()); } catch (Exception e) { return BigDecimal.ZERO; }
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package com.ruoyi.game.olympus.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.game.olympus.contract.request.SpinRequest;
|
||||
import com.ruoyi.game.olympus.contract.request.RecordPageRequest;
|
||||
import com.ruoyi.game.olympus.contract.response.BoxResponse;
|
||||
import com.ruoyi.game.olympus.contract.response.RecordItemResponse;
|
||||
import com.ruoyi.game.olympus.contract.response.SpinResponse;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBox;
|
||||
import com.ruoyi.game.olympus.domain.OlympusPrizeOrnament;
|
||||
import com.ruoyi.game.olympus.domain.OlympusRecord;
|
||||
import com.ruoyi.game.olympus.enums.OlympusModeType;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusBoxMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusPrizeOrnamentMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusRecordMapper;
|
||||
import com.ruoyi.game.olympus.service.OlympusGameService;
|
||||
import com.ruoyi.system.service.ISysConfigService;
|
||||
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.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Api(tags = "玫瑰女郎游戏")
|
||||
@RestController
|
||||
@RequestMapping("/api/olympus")
|
||||
public class OlympusClientController extends BaseController {
|
||||
|
||||
@Autowired private OlympusGameService gameService;
|
||||
@Autowired private OlympusBoxMapper boxMapper;
|
||||
@Autowired private OlympusRecordMapper recordMapper;
|
||||
@Autowired private OlympusPrizeOrnamentMapper prizeOrnamentMapper;
|
||||
@Autowired private ISysConfigService sysConfigService;
|
||||
|
||||
/** 进行中的局不展示:结算时 settle_source=start,播完末帧才改 finish;超过单局缓存 TTL(30分钟) 视为已结束兜底 */
|
||||
private static final long IN_PROGRESS_MS = 30 * 60 * 1000L;
|
||||
|
||||
@ApiOperation("获取箱子列表")
|
||||
@PostMapping("/boxes")
|
||||
public R boxes() {
|
||||
List<OlympusBox> list = boxMapper.selectList(new LambdaQueryWrapper<OlympusBox>()
|
||||
.eq(OlympusBox::getGameType, "OLYMPUS").eq(OlympusBox::getStatus, "1")
|
||||
.orderByAsc(OlympusBox::getBoxId));
|
||||
List<BoxResponse> boxes = list.stream().map(b -> {
|
||||
BoxResponse r = new BoxResponse();
|
||||
r.setBoxId(b.getBoxId());
|
||||
r.setBoxName(b.getName());
|
||||
r.setBasicBet(b.getBet());
|
||||
r.setPrice(b.getPrice());
|
||||
r.setBoxType(b.getBoxType());
|
||||
try { r.setModeName(OlympusModeType.fromBoxType(b.getBoxType()).getName()); }
|
||||
catch (Exception e) { r.setModeName(b.getBoxType()); }
|
||||
return r;
|
||||
}).collect(Collectors.toList());
|
||||
return R.ok(Map.of("list", boxes));
|
||||
}
|
||||
|
||||
@ApiOperation("执行旋转(新游戏或继续游戏)")
|
||||
@PostMapping("/dospin")
|
||||
public R dospin(@RequestBody SpinRequest req) {
|
||||
Long userId = getUserId();
|
||||
String websiteMaintenance = sysConfigService.selectConfigByKey("websiteMaintenance");
|
||||
String olympusMaintenance = sysConfigService.selectConfigByKey("olympusMaintenance");
|
||||
boolean isMaintenance = "1".equals(websiteMaintenance) || "1".equals(olympusMaintenance);
|
||||
|
||||
// 仅“新开一局”(boxId 非空)受维护限制;继续当前对局(boxId 为空)不受影响,可正常播完
|
||||
boolean isNewGame = req != null && req.getBoxId() != null;
|
||||
if (isMaintenance && isNewGame) {
|
||||
if ("1".equals(websiteMaintenance)) return R.fail("网站维护中......");
|
||||
if ("1".equals(olympusMaintenance)) return R.fail("玫瑰女郎维护中......");
|
||||
}
|
||||
try {
|
||||
SpinResponse resp = gameService.doSpin(userId, req);
|
||||
return R.ok(resp);
|
||||
} catch (ServiceException se) {
|
||||
return se.getCode() != null ? R.fail(se.getCode().intValue(), se.getMessage()) : R.fail(se.getMessage());
|
||||
} catch (Exception e) {
|
||||
return R.fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation("查询游戏记录(分页;scope: 0全服/1我的,默认1我的;只含已播完的局)")
|
||||
@PostMapping("/records")
|
||||
public R records(@RequestBody(required = false) RecordPageRequest req) {
|
||||
Long userId = getUserId();
|
||||
int scope = (req != null && req.getScope() != null) ? req.getScope() : 1;
|
||||
int pageNum = (req != null && req.getPageNum() != null) ? req.getPageNum() : 1;
|
||||
int pageSize = (req != null && req.getPageSize() != null) ? req.getPageSize() : 10;
|
||||
|
||||
// 用 Java 时间算 cutoff(勿改成 SQL NOW():DB 会话时区是 UTC,create_time 存的是本地时间)
|
||||
Date cutoff = new Date(System.currentTimeMillis() - IN_PROGRESS_MS);
|
||||
LambdaQueryWrapper<OlympusRecord> qw = new LambdaQueryWrapper<OlympusRecord>()
|
||||
.eq(OlympusRecord::getSettled, 1)
|
||||
// 只展示已结束的局:还在播动画的那局 settle_source 仍为 start,等播完(或超时兜底)才进列表
|
||||
.and(w -> w.ne(OlympusRecord::getSettleSource, "start")
|
||||
.or().lt(OlympusRecord::getCreateTime, cutoff));
|
||||
if (scope == 1) {
|
||||
qw.eq(OlympusRecord::getUserId, userId);
|
||||
} else {
|
||||
// 全服:排除机器人/封禁/已删除用户(保留白号,白号可上榜)
|
||||
qw.inSql(OlympusRecord::getUserId,
|
||||
"SELECT user_id FROM tt_user WHERE user_type != '03' AND status != '1' AND del_flag = '0'");
|
||||
}
|
||||
qw.orderByDesc(OlympusRecord::getCreateTime);
|
||||
|
||||
Page<OlympusRecord> page = recordMapper.selectPage(new Page<>(pageNum, pageSize), qw);
|
||||
List<OlympusRecord> records = page.getRecords();
|
||||
|
||||
// 昵称/头像(两种 scope 都返回)
|
||||
Map<Long, Map<String, Object>> userMap = new HashMap<>();
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
for (OlympusRecord r : records) userIds.add(r.getUserId());
|
||||
if (!userIds.isEmpty()) {
|
||||
for (Map<String, Object> m : recordMapper.getUserInfos(userIds)) {
|
||||
Object uid = m.get("user_id") != null ? m.get("user_id") : m.get("userId");
|
||||
if (uid != null) userMap.put(Long.valueOf(uid.toString()), m);
|
||||
}
|
||||
}
|
||||
|
||||
// 饰品全名 = 名称(磨损品质):新记录按 prize_id 关联,老记录(prize_id 为空)按名称兜底
|
||||
Map<Long, String> exteriorById = new HashMap<>();
|
||||
Map<String, String> exteriorByName = new HashMap<>();
|
||||
for (OlympusPrizeOrnament o : prizeOrnamentMapper.selectList(null)) {
|
||||
if (o.getExteriorName() == null || o.getExteriorName().isEmpty()) continue;
|
||||
exteriorById.putIfAbsent(o.getId(), o.getExteriorName());
|
||||
if (o.getName() != null) exteriorByName.putIfAbsent(o.getName(), o.getExteriorName());
|
||||
}
|
||||
|
||||
List<RecordItemResponse> rows = new ArrayList<>();
|
||||
for (OlympusRecord r : records) {
|
||||
RecordItemResponse vo = new RecordItemResponse();
|
||||
Map<String, Object> u = userMap.get(r.getUserId());
|
||||
if (u != null) {
|
||||
vo.setNickName(str(u.get("nick_name")));
|
||||
vo.setAvatar(str(u.get("avatar")));
|
||||
}
|
||||
vo.setCost(r.getCost());
|
||||
vo.setSettledWin(r.getSettledWin());
|
||||
vo.setPrizeImageUrl(r.getPrizeImageUrl());
|
||||
vo.setPrizeName(fullPrizeName(r.getPrizeName(), r.getPrizeId(), exteriorById, exteriorByName));
|
||||
vo.setCreateTime(r.getCreateTime());
|
||||
rows.add(vo);
|
||||
}
|
||||
return R.ok(Map.of("rows", rows, "total", page.getTotal()));
|
||||
}
|
||||
|
||||
private static String str(Object v) {
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
|
||||
/** 饰品全名:名称已是全名,有磨损品质时追加 " (品质)" */
|
||||
private static String fullPrizeName(String name, Long prizeId,
|
||||
Map<Long, String> exteriorById, Map<String, String> exteriorByName) {
|
||||
if (name == null) return null;
|
||||
String exterior = prizeId != null ? exteriorById.get(prizeId) : null;
|
||||
if (exterior == null) exterior = exteriorByName.get(name);
|
||||
return exterior == null || exterior.isEmpty() ? name : name + " (" + exterior + ")";
|
||||
}
|
||||
|
||||
@ApiOperation("查询是否有进行中的游戏(断线重连)")
|
||||
@PostMapping("/hasActive")
|
||||
public R hasActive() {
|
||||
Long userId = getUserId();
|
||||
// 维护期间不强制结算:进行中的对局可正常重连并播完(“当前不受影响”,仅拦新开一局)
|
||||
return R.ok(gameService.hasActive(userId));
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.ruoyi.game.olympus.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 com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/** 箱子配置表 game_olympus_box —— 每种模式×下注档位一个箱子 */
|
||||
@Data
|
||||
@TableName("game_olympus_box")
|
||||
public class OlympusBox {
|
||||
|
||||
@TableId(value = "box_id", type = IdType.INPUT)
|
||||
private Long boxId;
|
||||
|
||||
@TableField("game_type")
|
||||
private String gameType;
|
||||
|
||||
/** 模式:normal | ante | buy_fs */
|
||||
@TableField("box_type")
|
||||
private String boxType;
|
||||
|
||||
private BigDecimal bet;
|
||||
|
||||
private String name;
|
||||
|
||||
/** 实际扣费 = bet × CM (normal=1/ante=2/buy_fs=20) */
|
||||
private BigDecimal price;
|
||||
|
||||
@TableField("total_ornaments")
|
||||
private Integer totalOrnaments;
|
||||
|
||||
private BigDecimal rtp;
|
||||
|
||||
@TableField("rtp_converge_ratio")
|
||||
private BigDecimal rtpConvergeRatio;
|
||||
|
||||
private Integer sort;
|
||||
|
||||
private String status;
|
||||
|
||||
@TableField("del_flag")
|
||||
private String delFlag;
|
||||
|
||||
private String remark;
|
||||
|
||||
@TableField("create_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
@TableField("update_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
|
||||
// ======== transient ========
|
||||
|
||||
/** 模式中文名,运行时由 OlympusModeType 填充 */
|
||||
@TableField(exist = false)
|
||||
private String modeName;
|
||||
|
||||
/** 费用倍率 CM,运行时填充 */
|
||||
@TableField(exist = false)
|
||||
private BigDecimal costMultiplier;
|
||||
|
||||
/** 实时RTP,从 game_olympus_record 统计 */
|
||||
@TableField(exist = false)
|
||||
private BigDecimal actualRtp;
|
||||
|
||||
/** 各身份分布反推目标RTP(%),运行时填充 {audience: percent} */
|
||||
@TableField(exist = false)
|
||||
private Map<String, BigDecimal> targetRtpByAudience;
|
||||
|
||||
/** 各身份实时RTP(%),运行时填充 {audience: percent} */
|
||||
@TableField(exist = false)
|
||||
private Map<String, BigDecimal> actualRtpByAudience;
|
||||
|
||||
/** 各身份一轮总回合(按各自固定池分布反推),运行时填充 {audience: n} */
|
||||
@TableField(exist = false)
|
||||
private Map<String, BigDecimal> totalRoundsByAudience;
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.ruoyi.game.olympus.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;
|
||||
|
||||
/**
|
||||
* 箱子-饰品桶分布表 game_olympus_box_ornament(多身份)
|
||||
* 唯一键 (box_id, ornament_id, audience):每种身份一套独立的 count/used 分布。
|
||||
* 白号(audience=white)独立库存,抽取只递减自己的 used,永不消耗其他身份。
|
||||
*/
|
||||
@Data
|
||||
@TableName("game_olympus_box_ornament")
|
||||
public class OlympusBoxOrnament {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("box_id")
|
||||
private Long boxId;
|
||||
|
||||
@TableField("ornament_id")
|
||||
private Integer ornamentId;
|
||||
|
||||
/** 身份维度 common/anchor/robot/white */
|
||||
private String audience;
|
||||
|
||||
/** 分配回合数(加权随机权重) */
|
||||
private Integer count;
|
||||
|
||||
/** 已消耗回合数:剩余 = count - used */
|
||||
private Integer used;
|
||||
|
||||
// ======== transient(SQL JOIN 填充)========
|
||||
|
||||
@TableField(exist = false)
|
||||
private String ornamentName;
|
||||
|
||||
@TableField(exist = false)
|
||||
private java.math.BigDecimal expectMul;
|
||||
|
||||
/** 剩余 = count - used,SQL 计算填充 */
|
||||
@TableField(exist = false)
|
||||
private Integer remaining;
|
||||
|
||||
/** 本轮收敛阶段该桶已动态选取回合数(轮次账本) */
|
||||
@TableField(exist = false)
|
||||
private Long convergeCount;
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.ruoyi.game.olympus.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 com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 箱子饰品分布模板 game_olympus_box_template(按身份 audience,抽干补桶依据) */
|
||||
@Data
|
||||
@TableName("game_olympus_box_template")
|
||||
public class OlympusBoxTemplate {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("box_id")
|
||||
private Long boxId;
|
||||
|
||||
private String audience;
|
||||
|
||||
private String name;
|
||||
|
||||
@TableField("total_rounds")
|
||||
private Integer totalRounds;
|
||||
|
||||
@TableField("items_json")
|
||||
private String itemsJson;
|
||||
|
||||
@TableField("is_active")
|
||||
private Integer isActive;
|
||||
|
||||
@TableField("create_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/** 反序列化后的分布,回传前端用 */
|
||||
@TableField(exist = false)
|
||||
private List<Map<String, Object>> items;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.game.olympus.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 饰品桶定义表 game_olympus_ornament(17 桶:0x~1000x,展示与落桶区间参考) */
|
||||
@Data
|
||||
@TableName("game_olympus_ornament")
|
||||
public class OlympusOrnament {
|
||||
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
|
||||
private String gameType;
|
||||
|
||||
/** 期望倍率(区间均值,最高 1000) */
|
||||
private BigDecimal expectMul;
|
||||
|
||||
private String name;
|
||||
|
||||
/** 落桶区间下限(含) */
|
||||
private BigDecimal rangeLow;
|
||||
|
||||
/** 落桶区间上限(含) */
|
||||
private BigDecimal rangeHigh;
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.game.olympus.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;
|
||||
|
||||
/** 返奖饰品定义表 game_olympus_prize_ornament(展示用,按赢额区间匹配) */
|
||||
@Data
|
||||
@TableName("game_olympus_prize_ornament")
|
||||
public class OlympusPrizeOrnament {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String gameType;
|
||||
private String name;
|
||||
private String imageUrl;
|
||||
private Integer grade;
|
||||
private String exteriorName;
|
||||
private Integer sort;
|
||||
private String status;
|
||||
private Integer isFallback;
|
||||
|
||||
// ======== transient ========
|
||||
|
||||
@TableField(exist = false)
|
||||
private java.math.BigDecimal winLow;
|
||||
|
||||
@TableField(exist = false)
|
||||
private java.math.BigDecimal winHigh;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String levelImg;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.ruoyi.game.olympus.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 返奖饰品区间表 game_olympus_prize_range */
|
||||
@Data
|
||||
@TableName("game_olympus_prize_range")
|
||||
public class OlympusPrizeRange {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long prizeId;
|
||||
private BigDecimal winLow;
|
||||
private BigDecimal winHigh;
|
||||
|
||||
// 兼容 sweet 的字段命名(部分查询用 gameType/ornamentId/sort,olympus 表无这些列时忽略)
|
||||
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||
private String gameType;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||
private Long ornamentId;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||
private Integer sort;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.ruoyi.game.olympus.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 com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/** 玩家游玩记录表 game_olympus_record —— 每次开局一条 */
|
||||
@Data
|
||||
@TableName("game_olympus_record")
|
||||
public class OlympusRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
private Long boxId;
|
||||
private Long roundId;
|
||||
private BigDecimal cost;
|
||||
private BigDecimal totalWin;
|
||||
|
||||
private Integer settled; // 0未结算 1已结算(幂等守卫)
|
||||
private String modeType; // normal/ante/buy_fs
|
||||
private String rewardType; // gold / ornament
|
||||
private BigDecimal settledWin;
|
||||
private Long prizeId;
|
||||
private String prizeName;
|
||||
private String prizeImageUrl;
|
||||
private String prizeLevelImg;
|
||||
private String settleSource; // start=开局已结算(动画还在播) / finish=末帧播完(已结束,进战绩列表);reconnect/scheduled/force_settle 为历史值
|
||||
|
||||
/** 身份 common/anchor/robot/white:不入库,列表查询时按 tt_user 运行期回填(与奖池 4 池同一口径) */
|
||||
@TableField(exist = false)
|
||||
private String audience;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private Date createTime;
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ruoyi.game.olympus.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 com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/** 奖池水位调整日志 game_olympus_reward_log(注水/抽水/重置审计) */
|
||||
@Data
|
||||
@TableName("game_olympus_reward_log")
|
||||
public class OlympusRewardLog {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("box_id")
|
||||
private Long boxId;
|
||||
|
||||
@TableField("box_name")
|
||||
private String boxName;
|
||||
|
||||
/** 身份:common/anchor/robot/white(奖池按 箱子×身份 拆池) */
|
||||
private String audience;
|
||||
|
||||
/** 1注水 2抽水 3重置 */
|
||||
private Integer type;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
@TableField("balance_before")
|
||||
private BigDecimal balanceBefore;
|
||||
|
||||
@TableField("balance_after")
|
||||
private BigDecimal balanceAfter;
|
||||
|
||||
/** 操作后全站总池余额快照 */
|
||||
@TableField("pool_balance")
|
||||
private BigDecimal poolBalance;
|
||||
|
||||
private String operator;
|
||||
|
||||
private String remark;
|
||||
|
||||
@TableField("create_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.game.olympus.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;
|
||||
|
||||
/** 预生成回合表 game_olympus_round */
|
||||
@Data
|
||||
@TableName("game_olympus_round")
|
||||
public class OlympusRound {
|
||||
|
||||
@TableId(value = "round_id", type = IdType.INPUT)
|
||||
private Long roundId;
|
||||
|
||||
private Long boxId;
|
||||
|
||||
/** 落入的饰品桶ID (1-17) */
|
||||
private Integer ornamentId;
|
||||
|
||||
/** 实际总倍率 = 所有步骤赢额之和 / bet(≤1000,已封顶) */
|
||||
private BigDecimal actualMul;
|
||||
|
||||
/** 免费旋转总次数(0=未进FS) */
|
||||
private Integer freeSpinCount;
|
||||
|
||||
/** 总 cascade 步数 */
|
||||
private Integer totalCascades;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
// ======== transient ========
|
||||
|
||||
@TableField(exist = false)
|
||||
private String boxType;
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.ruoyi.game.olympus.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 回合步骤表 game_olympus_step —— 前端逐步播放的每一帧 */
|
||||
@Data
|
||||
@TableName("game_olympus_step")
|
||||
public class OlympusStep {
|
||||
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
|
||||
private Long roundId;
|
||||
|
||||
/** 免费旋转序号 (0=基础旋转, >0=第N次免费旋转) */
|
||||
private Integer freeSpinId;
|
||||
|
||||
/** 本旋转内 cascade 序号 */
|
||||
private Integer cascadeSeq;
|
||||
|
||||
/** 网格状态 JSON 6×5 */
|
||||
private String gridState;
|
||||
|
||||
/** 炸弹符号 JSON [{"pos","val","quality"}] */
|
||||
private String multiplierDetail;
|
||||
|
||||
/** 本步中奖明细 JSON */
|
||||
private String symbolLinks;
|
||||
|
||||
/** 本旋累计消除符号 JSON {"CROWN":9,...} */
|
||||
private String accumulatedSymbols;
|
||||
|
||||
/** 获胜者面板 JSON:本旋逐波追加 [{"symbol","count","win"}] */
|
||||
private String spinWinners;
|
||||
|
||||
/** 消除位置 JSON */
|
||||
private String removedPositions;
|
||||
|
||||
/** 下落数据 JSON */
|
||||
private String dropData;
|
||||
|
||||
/** 填充数据 JSON */
|
||||
private String fillData;
|
||||
|
||||
/** 基础盘触发免费旋转 JSON */
|
||||
private String scatterTrigger;
|
||||
|
||||
/** 免费旋转重触发 JSON */
|
||||
private String retriggerInfo;
|
||||
|
||||
/** 本步基础分(未乘炸弹) */
|
||||
private BigDecimal baseScore;
|
||||
|
||||
/** 本步赢额(回合末帧含炸弹结算) */
|
||||
private BigDecimal winAmount;
|
||||
|
||||
/** 本步前累计炸弹倍数 */
|
||||
private BigDecimal accumulatorBefore;
|
||||
|
||||
/** 本步后累计炸弹倍数(跨旋传递) */
|
||||
private BigDecimal accumulatorAfter;
|
||||
|
||||
/** 整局累计赢额 */
|
||||
private BigDecimal spinWin;
|
||||
|
||||
/** 本步赢额被 1000x 封顶截断 0否1是 */
|
||||
private Integer winCapped;
|
||||
|
||||
/** 爆奖档位(小/中/大:small/middle/big;只在免费旋转里判档,按单旋赢额) */
|
||||
private String bigwinLevel;
|
||||
|
||||
/** 本旋末帧 */
|
||||
private Integer isFinal;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.game.olympus.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/** 玩家统计表 game_olympus_user —— 每次结算累加 */
|
||||
@Data
|
||||
@TableName("game_olympus_user")
|
||||
public class OlympusUser {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
private Long totalSpins;
|
||||
private BigDecimal totalCost;
|
||||
private BigDecimal totalWin;
|
||||
private Integer totalFreeSpins;
|
||||
private BigDecimal maxWinMul;
|
||||
private Date updateTime;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.ruoyi.game.olympus.enums;
|
||||
|
||||
import com.ruoyi.domain.entity.sys.TtUser;
|
||||
|
||||
/**
|
||||
* 身份维度(audience):每种身份一套独立的饰品桶数量分布,实现差异化 RTP。
|
||||
* 与 olympus-py config.AUDIENCES 对齐。
|
||||
* 解析优先级(白号最高,压过 user_type):
|
||||
* is_blank_account==1 → white;user_type=01 → anchor;03 → robot;其余 → common。
|
||||
*/
|
||||
public enum OlympusAudience {
|
||||
COMMON("common"), // 普通用户(user_type=02 及招商/卡商/超管等其他)
|
||||
ANCHOR("anchor"), // 主播(user_type=01)
|
||||
ROBOT("robot"), // 机器人(user_type=03)
|
||||
WHITE("white"); // 白号(is_blank_account=1,压过 user_type)
|
||||
|
||||
private final String code;
|
||||
|
||||
OlympusAudience(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 白号:纯概率隔离池,不计水位、不消耗其他身份库存 */
|
||||
public boolean isWhite() {
|
||||
return this == WHITE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按玩家解析 audience:白号最高优先,压过 user_type。
|
||||
*/
|
||||
public static OlympusAudience resolve(TtUser player) {
|
||||
if (player == null) return COMMON;
|
||||
if (Integer.valueOf(1).equals(player.getIsBlankAccount())) return WHITE;
|
||||
String userType = player.getUserType();
|
||||
if ("01".equals(userType)) return ANCHOR;
|
||||
if ("03".equals(userType)) return ROBOT;
|
||||
return COMMON;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.ruoyi.game.olympus.enums;
|
||||
|
||||
/**
|
||||
* Olympus 三种互斥模式(对齐 olympus-py config.MODES):
|
||||
* normal(1x) / ante(2x, 底注) / buy_fs(50x, 购买免费旋转)。
|
||||
*/
|
||||
public enum OlympusModeType {
|
||||
NORMAL("normal", "普通旋转", 1.0),
|
||||
ANTE("ante", "底注", 2.0),
|
||||
// 售价 50 倍(原 20 倍):局均赔付 = 50×0.8 = 40 倍底注 → 配比落在 25x/40x/60x 桶
|
||||
// (FS 可用预算 22~57),每局才演得出 6~10 次消除 + 4~6 次炸弹累计;
|
||||
// 20 倍时局均只有 16 倍,配比被迫压在 10x/16x 桶(预算 7~13),动画"钱不够"。
|
||||
BUY_FS("buy_fs", "购买免费旋转", 50.0);
|
||||
|
||||
private final String boxType;
|
||||
private final String name;
|
||||
private final double costMultiplier;
|
||||
|
||||
OlympusModeType(String boxType, String name, double costMultiplier) {
|
||||
this.boxType = boxType;
|
||||
this.name = name;
|
||||
this.costMultiplier = costMultiplier;
|
||||
}
|
||||
|
||||
public String getBoxType() { return boxType; }
|
||||
public String getName() { return name; }
|
||||
public double getCostMultiplier() { return costMultiplier; }
|
||||
|
||||
/** 购买模式:保证直接进入免费旋转 */
|
||||
public boolean isBuyMode() {
|
||||
return this == BUY_FS;
|
||||
}
|
||||
|
||||
/** 底注效果:SCATTER 概率提升(仅 ante) */
|
||||
public boolean anteBoost() {
|
||||
return this == ANTE;
|
||||
}
|
||||
|
||||
/** 该模式可用饰品桶 ID 范围 [minId, maxId],对齐 py 端 MODE_ORNAMENT_IDS */
|
||||
public int[] getOrnamentRange() {
|
||||
switch (this) {
|
||||
case NORMAL: return new int[]{1, 13}; // 0x-150x
|
||||
case ANTE: return new int[]{1, 14}; // 0x-200x
|
||||
// buy_fs 从 10x 起(原 5 = 4x 起):4x/6.5x 桶的整局预算只有 1~3.5 倍底注,
|
||||
// 演不出"15 旋里 10 旋都在消 + 炸弹累计",玩家看到的一直是空转动画。
|
||||
// 10x 起后每局预算 7~13 倍底注,能装 5~8 次消除 + 1~2 次炸弹累计。
|
||||
case BUY_FS: return new int[]{7, 17}; // 10x-1000x
|
||||
default: return new int[]{1, 17};
|
||||
}
|
||||
}
|
||||
|
||||
public static OlympusModeType fromBoxType(String boxType) {
|
||||
for (OlympusModeType t : values()) {
|
||||
if (t.boxType.equals(boxType)) return t;
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown boxType: " + boxType);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBox;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface OlympusBoxMapper extends BaseMapper<OlympusBox> {
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBoxOrnament;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 箱子-饰品桶分布 Mapper(多身份 audience)。
|
||||
* 所有查询/消耗均按 (boxId, audience) 维度隔离,白号不碰其他身份库存。
|
||||
*/
|
||||
@Mapper
|
||||
public interface OlympusBoxOrnamentMapper extends BaseMapper<OlympusBoxOrnament> {
|
||||
|
||||
/** 查某箱某身份的桶分布(含 expectMul/remaining 连表填充) */
|
||||
List<OlympusBoxOrnament> selectByBoxIdAndAudience(@Param("boxId") Long boxId,
|
||||
@Param("audience") String audience);
|
||||
|
||||
int batchInsert(@Param("list") List<OlympusBoxOrnament> list);
|
||||
|
||||
/** 删除某箱某身份的分布(重算/应用模板前清空该身份) */
|
||||
int deleteByBoxIdAndAudience(@Param("boxId") Long boxId, @Param("audience") String audience);
|
||||
|
||||
/** 该身份某桶 used+1(仅固定阶段消耗) */
|
||||
int incrUsed(@Param("boxId") Long boxId, @Param("ornamentId") Integer ornamentId,
|
||||
@Param("audience") String audience);
|
||||
|
||||
/** 该身份 used 归 0(补桶) */
|
||||
int resetUsed(@Param("boxId") Long boxId, @Param("audience") String audience);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBoxTemplate;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/** 箱子饰品分布模板 game_olympus_box_template(按身份 audience)。 */
|
||||
@Mapper
|
||||
public interface OlympusBoxTemplateMapper extends BaseMapper<OlympusBoxTemplate> {
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusOrnament;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/** 饰品桶定义 game_olympus_ornament(17 桶)。 */
|
||||
@Mapper
|
||||
public interface OlympusOrnamentMapper extends BaseMapper<OlympusOrnament> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusPrizeOrnament;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface OlympusPrizeOrnamentMapper extends BaseMapper<OlympusPrizeOrnament> {
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusPrizeRange;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 返奖饰品区间:按赢额匹配展示饰品。联表 game_olympus_prize_ornament 取 name/imageUrl/grade,
|
||||
* 再联 tt_ornaments_level 取 level_img AS levelImageUrl(品质/等级图片,前端 prizeInfo.levelImageUrl 用这个)。
|
||||
*/
|
||||
@Mapper
|
||||
public interface OlympusPrizeRangeMapper extends BaseMapper<OlympusPrizeRange> {
|
||||
|
||||
/** 精确区间匹配 win_low < win <= win_high */
|
||||
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
|
||||
+ "l.level_img AS levelImageUrl "
|
||||
+ "FROM game_olympus_prize_range r JOIN game_olympus_prize_ornament o ON o.id = r.prize_id "
|
||||
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
|
||||
+ "WHERE #{win} > r.win_low AND #{win} <= r.win_high AND o.status = '1' "
|
||||
+ "ORDER BY r.win_low DESC LIMIT 1")
|
||||
Map<String, Object> findPrizeByWin(@Param("win") BigDecimal win);
|
||||
|
||||
/** 兜底饰品(is_fallback=1) */
|
||||
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
|
||||
+ "l.level_img AS levelImageUrl "
|
||||
+ "FROM game_olympus_prize_ornament o LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
|
||||
+ "WHERE o.is_fallback = 1 AND o.status = '1' ORDER BY o.sort ASC LIMIT 1")
|
||||
Map<String, Object> findFallbackPrize();
|
||||
|
||||
/** 最低区间饰品 */
|
||||
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
|
||||
+ "l.level_img AS levelImageUrl "
|
||||
+ "FROM game_olympus_prize_range r JOIN game_olympus_prize_ornament o ON o.id = r.prize_id "
|
||||
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
|
||||
+ "WHERE o.status = '1' ORDER BY r.win_low ASC LIMIT 1")
|
||||
Map<String, Object> findLowestPrize();
|
||||
|
||||
/** 最高区间饰品 */
|
||||
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
|
||||
+ "l.level_img AS levelImageUrl "
|
||||
+ "FROM game_olympus_prize_range r JOIN game_olympus_prize_ornament o ON o.id = r.prize_id "
|
||||
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
|
||||
+ "WHERE o.status = '1' ORDER BY r.win_high DESC LIMIT 1")
|
||||
Map<String, Object> findHighestPrize();
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Mapper
|
||||
public interface OlympusRecordMapper extends BaseMapper<OlympusRecord> {
|
||||
|
||||
/** 批量查各箱实时 RTP(排除白号/机器人/封禁/删除,与首页口径一致) */
|
||||
@Select("<script>SELECT r.box_id AS boxId, ROUND(SUM(r.total_win)/SUM(r.cost)*100, 2) AS rtp "
|
||||
+ "FROM game_olympus_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
|
||||
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
|
||||
+ "WHERE r.settled = 1 AND r.box_id IN "
|
||||
+ "<foreach collection='boxIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
|
||||
+ "GROUP BY r.box_id</script>")
|
||||
List<Map<String, Object>> selectBoxRtpList(@Param("boxIds") List<Long> boxIds);
|
||||
|
||||
@Select("<script>SELECT user_id, nick_name, avatar FROM tt_user WHERE user_id IN "
|
||||
+ "<foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<Map<String, Object>> getUserInfos(@Param("userIds") Set<Long> userIds);
|
||||
|
||||
/** 每日统计汇总(排除白号/机器人/封禁/删除) */
|
||||
@Select("<script>SELECT COUNT(*) AS totalSpins, COALESCE(SUM(r.cost),0) AS totalCost, "
|
||||
+ "COALESCE(SUM(r.total_win),0) AS totalWin "
|
||||
+ "FROM game_olympus_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
|
||||
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
|
||||
+ "WHERE r.settled = 1 "
|
||||
+ "<if test='dateStart != null and dateStart != \"\"'> AND r.create_time >= #{dateStart}</if> "
|
||||
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time < DATE_ADD(#{dateEnd}, INTERVAL 1 DAY)</if> "
|
||||
+ "</script>")
|
||||
Map<String, Object> winSummary(@Param("dateStart") String dateStart, @Param("dateEnd") String dateEnd);
|
||||
|
||||
/** 每日统计列表:按天聚合(口径与 winSummary 一致,排除白号/机器人/封禁/删除) */
|
||||
@Select("<script>SELECT DATE_FORMAT(r.create_time, '%Y-%m-%d') AS date, COUNT(*) AS totalSpins, "
|
||||
+ "COALESCE(SUM(r.cost),0) AS totalCost, COALESCE(SUM(r.total_win),0) AS totalWin, "
|
||||
+ "COUNT(DISTINCT r.user_id) AS playerCount "
|
||||
+ "FROM game_olympus_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
|
||||
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
|
||||
+ "WHERE r.settled = 1 "
|
||||
+ "<if test='dateStart != null and dateStart != \"\"'> AND r.create_time >= #{dateStart}</if> "
|
||||
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time < DATE_ADD(#{dateEnd}, INTERVAL 1 DAY)</if> "
|
||||
+ "GROUP BY DATE_FORMAT(r.create_time, '%Y-%m-%d') ORDER BY date DESC</script>")
|
||||
List<Map<String, Object>> dailyList(@Param("dateStart") String dateStart, @Param("dateEnd") String dateEnd);
|
||||
|
||||
/**
|
||||
* 某箱 + 某身份奖池真实累计(按已结算记录派生)。身份口径同 {@link com.ruoyi.game.olympus.enums.OlympusAudience#resolve},
|
||||
* 四个身份互斥且并集 = 全部已结算记录(拆分口径,总量与旧"非白号一个池"一致)。
|
||||
* totalBet=Σcost,totalWin=Σsettled_win(实际发放价值)。
|
||||
*/
|
||||
@Select("<script>SELECT COALESCE(SUM(r.cost),0) AS totalBet, COALESCE(SUM(r.settled_win),0) AS totalWin "
|
||||
+ "FROM game_olympus_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
|
||||
+ "WHERE r.box_id = #{boxId} AND r.settled = 1 "
|
||||
+ "<choose>"
|
||||
+ "<when test=\"audience == 'white'\">AND COALESCE(u.is_blank_account,0) = 1 </when>"
|
||||
+ "<when test=\"audience == 'anchor'\">AND COALESCE(u.is_blank_account,0) != 1 AND u.user_type = '01' </when>"
|
||||
+ "<when test=\"audience == 'robot'\">AND COALESCE(u.is_blank_account,0) != 1 AND u.user_type = '03' </when>"
|
||||
+ "<otherwise>AND COALESCE(u.is_blank_account,0) != 1 AND (u.user_type IS NULL OR u.user_type NOT IN ('01','03')) </otherwise>"
|
||||
+ "</choose></script>")
|
||||
Map<String, Object> sumPool(@Param("boxId") Long boxId, @Param("audience") String audience);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusRewardLog;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/** 奖池水位调整日志 game_olympus_reward_log。 */
|
||||
@Mapper
|
||||
public interface OlympusRewardLogMapper extends BaseMapper<OlympusRewardLog> {
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusRound;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface OlympusRoundMapper extends BaseMapper<OlympusRound> {
|
||||
|
||||
OlympusRound pickByOrnament(@Param("boxId") Long boxId, @Param("ornamentId") Integer ornamentId,
|
||||
@Param("needFreeSpins") Boolean needFreeSpins);
|
||||
|
||||
OlympusRound pickAnyByBox(@Param("boxId") Long boxId, @Param("needFreeSpins") Boolean needFreeSpins);
|
||||
|
||||
/** 收敛阶段:桶内取 actual_mul 最接近 desiredMul 的回合,优先 actual_mul ≤ capMul */
|
||||
OlympusRound pickClosestMul(@Param("boxId") Long boxId, @Param("ornamentId") Integer ornamentId,
|
||||
@Param("needFreeSpins") Boolean needFreeSpins,
|
||||
@Param("desiredMul") BigDecimal desiredMul,
|
||||
@Param("capMul") BigDecimal capMul);
|
||||
|
||||
/** 统计某箱池内各桶实际均值:[{ornamentId, sampleCount, avgMul}](配比/RTP 按它算) */
|
||||
List<Map<String, Object>> avgMulByOrnament(@Param("boxId") Long boxId);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusStep;
|
||||
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 OlympusStepMapper extends BaseMapper<OlympusStep> {
|
||||
|
||||
@Select("SELECT * FROM game_olympus_step WHERE round_id = #{roundId} ORDER BY id ASC")
|
||||
List<OlympusStep> selectByRoundId(@Param("roundId") Long roundId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ruoyi.game.olympus.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface OlympusUserMapper extends BaseMapper<OlympusUser> {
|
||||
|
||||
@Select("SELECT * FROM game_olympus_user WHERE user_id = #{userId} LIMIT 1")
|
||||
OlympusUser selectByUserId(@Param("userId") Long userId);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ruoyi.game.olympus.model;
|
||||
|
||||
import com.ruoyi.game.olympus.domain.OlympusRound;
|
||||
import com.ruoyi.game.olympus.domain.OlympusStep;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 选中回合 + 其步骤 + 阶段标记(固定/收敛,决定是否 incrUsed 与 conv 记账) */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class PickedRound {
|
||||
private OlympusRound round;
|
||||
private List<OlympusStep> steps;
|
||||
private boolean converge;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.ruoyi.game.olympus.model.cache;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** Olympus 单局进行中缓存(Redis,TTL 30 分钟) */
|
||||
@Data
|
||||
public class OlympusGameCache implements Serializable {
|
||||
|
||||
private Long boxId;
|
||||
private String boxType; // normal|ante|buy_fs
|
||||
private BigDecimal bet;
|
||||
private BigDecimal cost;
|
||||
|
||||
private Long roundId;
|
||||
private BigDecimal totalMul; // round.actualMul
|
||||
private Integer freeSpinCount;
|
||||
|
||||
private int stepIndex; // 当前已返回步数(1-based)
|
||||
private List<CachedStep> steps;
|
||||
|
||||
private BigDecimal totalWin;
|
||||
|
||||
private Long recordId;
|
||||
private Long boxRecordId;
|
||||
|
||||
/** 身份维度 common/anchor/robot/white,白号隔离依据 */
|
||||
private String audience;
|
||||
/** 是否白号:白号不注水/不扣水/不受水位约束 */
|
||||
private boolean blankAccount;
|
||||
|
||||
@Data
|
||||
public static class CachedStep implements Serializable {
|
||||
private int freeSpinId;
|
||||
private int cascadeSeq;
|
||||
private String gridState;
|
||||
private String multiplierDetail;
|
||||
private String symbolLinks; // 本旋累积连线(含 tumble)
|
||||
private BigDecimal winAmount; // 本旋累计(该旋末帧 = 这一旋的总额)
|
||||
private BigDecimal spinWin; // 本局累计(末帧 = totalWin)
|
||||
/** 本步后炸弹累计倍数(FS 跨旋传递);断线重连时前端靠它恢复累计倍数 */
|
||||
private BigDecimal accumulatorAfter;
|
||||
private int winCapped;
|
||||
private String bigwinLevel;
|
||||
private int isFinal;
|
||||
private String retriggerInfo;
|
||||
private String dropData;
|
||||
private String fillData;
|
||||
private String scatterTrigger;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.game.olympus.model.cache;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* Olympus 按箱奖池(水位)缓存。
|
||||
* 水位 = totalBet - totalWin + adjust(人工注水/抽水),仅真实玩家(白号隔离)。
|
||||
*/
|
||||
@Data
|
||||
public class OlympusRewardPoolCache {
|
||||
private BigDecimal totalBet;
|
||||
private BigDecimal totalWin;
|
||||
private BigDecimal adjust;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.game.olympus.repository;
|
||||
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.game.olympus.model.cache.OlympusGameCache;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.ruoyi.admin.config.RedisConstants.OLYMPUS_GAME_CACHE;
|
||||
|
||||
/** 单局进行中缓存(Redis,TTL 30 分钟) */
|
||||
@Component
|
||||
public class OlympusCacheRepository {
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
public OlympusGameCache getCache(Long userId) {
|
||||
return redisCache.getCacheObject(OLYMPUS_GAME_CACHE + userId);
|
||||
}
|
||||
|
||||
public void setCache(Long userId, OlympusGameCache cache) {
|
||||
redisCache.setCacheObject(OLYMPUS_GAME_CACHE + userId, cache, 30, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
public void clearCache(Long userId) {
|
||||
redisCache.deleteObject(OLYMPUS_GAME_CACHE + userId);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.ruoyi.game.olympus.repository;
|
||||
|
||||
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.OLYMPUS_GAME_CYCLE;
|
||||
|
||||
/**
|
||||
* 一轮内 RTP 动态收敛账本,按 (boxId, audience) 隔离。
|
||||
* Hash key = ruoyi:olympus:cycle:{boxId}:{audience},字段 drawn/mulSum/conv:{ornamentId}。
|
||||
* 白号(audience=white)也有独立账本,与其他身份完全隔离。
|
||||
*/
|
||||
@Component
|
||||
public class OlympusCycleRepository {
|
||||
|
||||
@Autowired
|
||||
public RedisTemplate redisTemplate;
|
||||
|
||||
private static final String F_DRAWN = "drawn";
|
||||
private static final String F_MUL_SUM = "mulSum";
|
||||
private static final String F_CONV_PREFIX = "conv:";
|
||||
private static final long TTL_DAYS = 30;
|
||||
|
||||
private String key(Long boxId, String audience) {
|
||||
return OLYMPUS_GAME_CYCLE + boxId + ":" + audience;
|
||||
}
|
||||
|
||||
public long drawn(Long boxId, String audience) {
|
||||
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_DRAWN);
|
||||
return v == null ? 0L : toLong(v);
|
||||
}
|
||||
|
||||
public double mulSum(Long boxId, String audience) {
|
||||
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_MUL_SUM);
|
||||
return v == null ? 0d : toDouble(v);
|
||||
}
|
||||
|
||||
/** 记一抽:drawn+1、mulSum+actualMul;收敛阶段额外 conv:{ornamentId}+1。 */
|
||||
public void recordDraw(Long boxId, String audience, Integer ornamentId, BigDecimal actualMul, boolean converge) {
|
||||
String k = key(boxId, audience);
|
||||
redisTemplate.opsForHash().increment(k, F_DRAWN, 1L);
|
||||
double mul = actualMul != null ? actualMul.doubleValue() : 0d;
|
||||
if (mul != 0d) redisTemplate.opsForHash().increment(k, F_MUL_SUM, mul);
|
||||
if (converge && ornamentId != null) {
|
||||
redisTemplate.opsForHash().increment(k, F_CONV_PREFIX + ornamentId, 1L);
|
||||
}
|
||||
redisTemplate.expire(k, TTL_DAYS, TimeUnit.DAYS);
|
||||
}
|
||||
|
||||
public void resetCycle(Long boxId, String audience) {
|
||||
redisTemplate.delete(key(boxId, audience));
|
||||
}
|
||||
|
||||
public long convergeCount(Long boxId, String audience, Integer ornamentId) {
|
||||
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_CONV_PREFIX + ornamentId);
|
||||
return v == null ? 0L : toLong(v);
|
||||
}
|
||||
|
||||
/** 本轮实时 RTP(%) = mulSum / drawn / cm × 100(common 身份口径,供后台展示)。 */
|
||||
public BigDecimal realtimeRtp(Long boxId, String audience, double cm) {
|
||||
long d = drawn(boxId, audience);
|
||||
if (d <= 0 || cm <= 0) return null;
|
||||
double rtp = mulSum(boxId, audience) / d / cm * 100;
|
||||
return BigDecimal.valueOf(rtp).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static long toLong(Object v) {
|
||||
if (v instanceof Number) return ((Number) v).longValue();
|
||||
try { return Long.parseLong(v.toString().trim()); } catch (Exception e) { return 0L; }
|
||||
}
|
||||
|
||||
private static double toDouble(Object v) {
|
||||
if (v instanceof Number) return ((Number) v).doubleValue();
|
||||
try { return Double.parseDouble(v.toString().trim()); } catch (Exception e) { return 0d; }
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.ruoyi.game.olympus.repository;
|
||||
|
||||
import com.ruoyi.game.olympus.model.cache.OlympusRewardPoolCache;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusRecordMapper;
|
||||
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.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.ruoyi.admin.config.RedisConstants.OLYMPUS_GAME_REWARD_POOL_CACHE;
|
||||
|
||||
/**
|
||||
* Olympus 按【箱子 × 身份】奖池(水位)仓储 —— 对齐荒野争锋 bullet 的按箱水位模型,再按身份(audience)拆池。
|
||||
* 每箱每身份独立键::box:{boxId}:{audience}:dbbet / :dbwin / :adjust;全局 adjust 也按身份一份。
|
||||
* 水位 = box:bet − box:win + box:adjust(可能为负)。
|
||||
* <p>
|
||||
* 记账口径(方案B''):bet/win **由 DB 已结算记录按身份派生**(game_olympus_record + tt_user),
|
||||
* Redis 仅做短缓存供 payoutCap/展示快速读取;adjust 为管理员人工注水/抽水(Redis)。
|
||||
* 因 record 与发奖同一事务提交,池值恒等于已提交的真实账,无"提交后记账崩溃窗口"。
|
||||
*/
|
||||
@Component
|
||||
public class OlympusRewardPoolCacheRepository {
|
||||
|
||||
@Autowired
|
||||
public RedisTemplate redisTemplate;
|
||||
|
||||
@Autowired
|
||||
private OlympusRecordMapper recordMapper;
|
||||
|
||||
private static final String KEY_BOX_PREFIX = OLYMPUS_GAME_REWARD_POOL_CACHE + ":box:";
|
||||
/** 全局人工调整累计(各箱 adjust 之和,仅展示),按身份一份 */
|
||||
private static final String KEY_ADJUST = OLYMPUS_GAME_REWARD_POOL_CACHE + ":adjust:";
|
||||
|
||||
private static final String KEY_DB_BET = ":dbbet";
|
||||
private static final String KEY_DB_WIN = ":dbwin";
|
||||
/** ponytail: DB 派生结果短缓存;DB 为真相,TTL 内只是短暂滞后、自愈,无永久漂移 */
|
||||
private static final long DB_CACHE_TTL_SECONDS = 5;
|
||||
|
||||
private static String boxKey(Long boxId, String audience, String suffix) {
|
||||
return KEY_BOX_PREFIX + boxId + ":" + audience + suffix;
|
||||
}
|
||||
|
||||
private static String adjustKey(String audience) {
|
||||
return KEY_ADJUST + audience;
|
||||
}
|
||||
|
||||
/** 管理员逐箱注水/抽水:delta 为正注水、为负抽水;同时累加该身份的全局 adjust 键 */
|
||||
public void adjustBox(Long boxId, String audience, BigDecimal delta) {
|
||||
if (boxId == null || delta == null || delta.signum() == 0) return;
|
||||
redisTemplate.opsForValue().increment(boxKey(boxId, audience, ":adjust"), delta.doubleValue());
|
||||
redisTemplate.opsForValue().increment(adjustKey(audience), delta.doubleValue());
|
||||
}
|
||||
|
||||
/** 仅调整全局 adjust 键(重置已删除箱子遗留残余),不动箱子键 */
|
||||
public void adjustGlobal(String audience, BigDecimal delta) {
|
||||
if (delta == null || delta.signum() == 0) return;
|
||||
redisTemplate.opsForValue().increment(adjustKey(audience), delta.doubleValue());
|
||||
}
|
||||
|
||||
public BigDecimal getGlobalAdjust(String audience) {
|
||||
return readValue(adjustKey(audience));
|
||||
}
|
||||
|
||||
public OlympusRewardPoolCache getBoxCache(Long boxId, String audience) {
|
||||
OlympusRewardPoolCache cache = new OlympusRewardPoolCache();
|
||||
cache.setTotalBet(dbPoolTotal(boxId, audience, KEY_DB_BET));
|
||||
cache.setTotalWin(dbPoolTotal(boxId, audience, KEY_DB_WIN));
|
||||
cache.setAdjust(readValue(boxKey(boxId, audience, ":adjust")));
|
||||
return cache;
|
||||
}
|
||||
|
||||
public BigDecimal getBoxAdjust(Long boxId, String audience) {
|
||||
return readValue(boxKey(boxId, audience, ":adjust"));
|
||||
}
|
||||
|
||||
/** 箱子×身份水位 = 该身份箱内累计投注 − 累计返奖 + 人工调整(可能为负) */
|
||||
public BigDecimal getBoxBalance(Long boxId, String audience) {
|
||||
OlympusRewardPoolCache c = getBoxCache(boxId, audience);
|
||||
return c.getTotalBet().subtract(c.getTotalWin()).add(c.getAdjust())
|
||||
.setScale(2, java.math.RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** 读取某箱某身份 bet/win 的 DB 派生值(短缓存;缓存缺失时按 record 聚合重算) */
|
||||
private BigDecimal dbPoolTotal(Long boxId, String audience, String suffix) {
|
||||
String key = boxKey(boxId, audience, suffix);
|
||||
Object v = redisTemplate.opsForValue().get(key);
|
||||
if (v != null) return toBd(v);
|
||||
refreshDbPool(boxId, audience);
|
||||
return toBd(redisTemplate.opsForValue().get(key));
|
||||
}
|
||||
|
||||
private void refreshDbPool(Long boxId, String audience) {
|
||||
Map<String, Object> row = boxId != null ? recordMapper.sumPool(boxId, audience) : null;
|
||||
BigDecimal bet = toBd(row != null ? row.get("totalBet") : null);
|
||||
BigDecimal win = toBd(row != null ? row.get("totalWin") : null);
|
||||
redisTemplate.opsForValue().set(boxKey(boxId, audience, KEY_DB_BET), bet.doubleValue(), DB_CACHE_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
redisTemplate.opsForValue().set(boxKey(boxId, audience, KEY_DB_WIN), win.doubleValue(), DB_CACHE_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private BigDecimal readValue(String key) {
|
||||
return toBd(redisTemplate.opsForValue().get(key));
|
||||
}
|
||||
|
||||
private static BigDecimal toBd(Object v) {
|
||||
if (v == null) return BigDecimal.ZERO;
|
||||
try {
|
||||
return new BigDecimal(v.toString());
|
||||
} catch (Exception e) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package com.ruoyi.game.olympus.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBox;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBoxOrnament;
|
||||
import com.ruoyi.game.olympus.domain.OlympusBoxTemplate;
|
||||
import com.ruoyi.game.olympus.domain.OlympusOrnament;
|
||||
import com.ruoyi.game.olympus.enums.OlympusModeType;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusBoxMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusBoxOrnamentMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusBoxTemplateMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusOrnamentMapper;
|
||||
import com.ruoyi.game.olympus.mapper.OlympusRoundMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Olympus 饰品桶分布计算 / 重置服务(按身份 audience)。
|
||||
* 算法移植自 sweet:目标 RTP → 幂律权重二分 → 单调约束 → 微调逼近;
|
||||
* 差异:expect_mul 从 game_olympus_ornament 读,基础权重按模式桶区间通用生成(30×0.6^k)。
|
||||
*/
|
||||
@Service
|
||||
public class OlympusDistributionService {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired private OlympusBoxMapper boxMapper;
|
||||
@Autowired private OlympusBoxOrnamentMapper boxOrnamentMapper;
|
||||
@Autowired private OlympusBoxTemplateMapper boxTemplateMapper;
|
||||
@Autowired private OlympusOrnamentMapper ornamentMapper;
|
||||
@Autowired private OlympusRoundMapper roundMapper;
|
||||
|
||||
/**
|
||||
* 桶"实际发出期望"覆盖:桶 17 展示 1000x(区间 630~1370),但单局封顶 1000,
|
||||
* 真正发出去的是 min(原始分, 1000)。理论值(原始分均匀分布)E[min(x,1000)] = 907.5;
|
||||
* 实测(buy_fs 10 箱 × 桶17 全部回合 = 200 个)均值 834.6、中位 821.3、27.5% 触顶,
|
||||
* 因为生成端种的大奖目标集中在 1000 附近而不是均匀分布。
|
||||
* 配比计算(calcRounds)与后台「目标RTP」都按"实际会发出去多少钱"算,所以用实测值:
|
||||
* 用名义 1000 会把 RTP 高估约 0.5~1.6 个百分点。
|
||||
* 注:抽回合时的 payoutCap 过滤(OlympusRoundPicker)仍读 DB 的名义 expect_mul,偏保守、保留安全余量。
|
||||
*/
|
||||
private static final Map<Integer, Double> PAYOUT_EXPECT_OVERRIDE = Map.of(17, 835.0);
|
||||
|
||||
/** 饰品桶 ID → 实际发出期望(受封顶影响的桶用覆盖值,其余用 DB 的 expect_mul)。 */
|
||||
public Map<Integer, Double> loadOrnamentMul() {
|
||||
Map<Integer, Double> m = new HashMap<>();
|
||||
for (OlympusOrnament o : ornamentMapper.selectList(null)) {
|
||||
if (o.getId() == null) continue;
|
||||
int id = o.getId().intValue();
|
||||
Double override = PAYOUT_EXPECT_OVERRIDE.get(id);
|
||||
if (override != null) { m.put(id, override); continue; }
|
||||
m.put(id, o.getExpectMul() != null ? o.getExpectMul().doubleValue() : 0.0);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 该桶池子里一条回合都没有时才回退名义期望:只要池子有回合,它的均值就是这个箱子的真实期望 */
|
||||
private static final int MIN_POOL_SAMPLE = 1;
|
||||
|
||||
/**
|
||||
* 桶 → **这个箱子的池子实际会赔出去多少钱**的期望(AVG(actual_mul),按 boxId 统计)。
|
||||
*
|
||||
* 为什么不用名义 expect_mul:桶内实际均值与区间中点有偏差(实测 -18% ~ +11%,桶 17 还受 1000x 封顶
|
||||
* 影响只有 ~835),用名义值算配比 → 「设 80% 实际赔 81.8%」这种系统性偏差;而且每个箱子的池子是
|
||||
* 独立生成的一批回合,只有按它自己的池内均值算,算出来的配比才真的等于该箱的期望赔付。
|
||||
* 样本不足的桶回退名义值(池子还没生成时也能算)。
|
||||
*/
|
||||
public Map<Integer, Double> loadPoolExpect(Long boxId) {
|
||||
Map<Integer, Double> expect = new HashMap<>(loadOrnamentMul());
|
||||
if (boxId == null) return expect;
|
||||
try {
|
||||
for (Map<String, Object> row : roundMapper.avgMulByOrnament(boxId)) {
|
||||
Object idObj = row.get("ornamentId");
|
||||
Object nObj = row.get("sampleCount");
|
||||
Object avgObj = row.get("avgMul");
|
||||
if (idObj == null || avgObj == null) continue;
|
||||
int id = ((Number) idObj).intValue();
|
||||
long n = nObj instanceof Number ? ((Number) nObj).longValue() : 0L;
|
||||
double avg = ((Number) avgObj).doubleValue();
|
||||
if (!expect.containsKey(id)) continue;
|
||||
if (n < MIN_POOL_SAMPLE || avg <= 0) continue;
|
||||
expect.put(id, avg);
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 池子还没生成/查询异常 → 用名义值兜底
|
||||
}
|
||||
return expect;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按目标 RTP 动态计算各饰品桶回合数(幂律 power 二分搜索 + 单调 + 微调)。
|
||||
* 期望值用 {@link #loadPoolExpect}(**该箱**池内实际均值),所以算出来的配比实际赔付就等于 targetRtp。
|
||||
* @param mode 模式 @param boxId 箱子(决定用哪个池子的实际均值) @param totalRounds 固定池回合数 @param targetRtp 目标 RTP(如 0.8)
|
||||
*/
|
||||
public Map<Integer, Integer> calcRounds(OlympusModeType mode, Long boxId, int totalRounds, double targetRtp) {
|
||||
double cm = mode.getCostMultiplier();
|
||||
double targetAvg = targetRtp * cm;
|
||||
Map<Integer, Double> ornMul = loadPoolExpect(boxId);
|
||||
|
||||
// 基础权重:模式桶区间 [lo,hi] 内 30×0.6^k 等比递减
|
||||
int[] range = mode.getOrnamentRange();
|
||||
int lo = range[0], hi = range[1];
|
||||
int n = Math.max(1, hi - lo + 1);
|
||||
double[][] baseWeights = new double[n][2];
|
||||
for (int i = 0; i < n; i++) {
|
||||
baseWeights[i][0] = lo + i;
|
||||
baseWeights[i][1] = 30.0 * Math.pow(0.6, i);
|
||||
}
|
||||
|
||||
int nOrns = baseWeights.length;
|
||||
// 回合数不足:每桶至少 1,均摊
|
||||
if (totalRounds <= nOrns) {
|
||||
Map<Integer, Integer> flat = new LinkedHashMap<>();
|
||||
for (double[] w : baseWeights) flat.put((int) w[0], 1);
|
||||
return flat;
|
||||
}
|
||||
int remainingRounds = totalRounds - nOrns;
|
||||
|
||||
double guaranteedMulSum = 0;
|
||||
for (double[] w : baseWeights) guaranteedMulSum += ornMul.getOrDefault((int) w[0], 0.0);
|
||||
double targetTotalMul = targetAvg * totalRounds;
|
||||
double remainingTargetMul = targetTotalMul - guaranteedMulSum;
|
||||
double remainingTargetAvg = remainingTargetMul / remainingRounds;
|
||||
|
||||
// 二分 power
|
||||
double power = 1.0, plo = 0.2, phi = 10.0;
|
||||
for (int iter = 0; iter < 30; iter++) {
|
||||
double sumW = 0, sumWMul = 0;
|
||||
for (double[] w : baseWeights) {
|
||||
double weight = Math.pow(w[1], power);
|
||||
sumW += weight;
|
||||
sumWMul += weight * ornMul.getOrDefault((int) w[0], 0.0);
|
||||
}
|
||||
double avg = sumW > 0 ? sumWMul / sumW : 0;
|
||||
if (Math.abs(avg - remainingTargetAvg) < Math.max(remainingTargetAvg * 0.01, 0.001)) break;
|
||||
if (avg < remainingTargetAvg) phi = power; else plo = power;
|
||||
power = (plo + phi) / 2;
|
||||
}
|
||||
|
||||
Map<Integer, Integer> result = new LinkedHashMap<>();
|
||||
for (double[] w : baseWeights) result.put((int) w[0], 1);
|
||||
double totalW = 0;
|
||||
Map<Integer, Double> adjWeights = new LinkedHashMap<>();
|
||||
for (double[] w : baseWeights) {
|
||||
double weight = Math.pow(w[1], power);
|
||||
adjWeights.put((int) w[0], weight);
|
||||
totalW += weight;
|
||||
}
|
||||
int rem = remainingRounds;
|
||||
for (double[] w : baseWeights) {
|
||||
int ornId = (int) w[0];
|
||||
int extra = totalW > 0 ? (int) Math.round(adjWeights.get(ornId) / totalW * remainingRounds) : 0;
|
||||
int put = Math.min(extra, rem);
|
||||
if (put > 0) { result.put(ornId, result.get(ornId) + put); rem -= put; }
|
||||
}
|
||||
if (rem > 0) { int first = (int) baseWeights[0][0]; result.put(first, result.get(first) + rem); }
|
||||
|
||||
// 单调:count[i] >= count[i+1]
|
||||
List<Integer> sortedIds = new ArrayList<>(result.keySet());
|
||||
sortedIds.sort(Integer::compareTo);
|
||||
for (int i = sortedIds.size() - 2; i >= 0; i--) {
|
||||
int cur = sortedIds.get(i), next = sortedIds.get(i + 1);
|
||||
if (result.get(cur) < result.get(next)) result.put(cur, result.get(next));
|
||||
}
|
||||
int curTotal = result.values().stream().mapToInt(Integer::intValue).sum();
|
||||
int diff = totalRounds - curTotal;
|
||||
if (diff != 0) { int first = sortedIds.get(0); result.put(first, result.get(first) + diff); }
|
||||
|
||||
// 微调:任取两个桶搬 1 个(保持单调),只要更接近目标
|
||||
// (原来只在"最大的 3 个桶"之间搬,池内均值口径下根本走不到目标,会差 0.5~1 个百分点)
|
||||
for (int iter = 0; iter < 400; iter++) {
|
||||
double currentMul = 0;
|
||||
for (Map.Entry<Integer, Integer> e : result.entrySet())
|
||||
currentMul += e.getValue() * ornMul.getOrDefault(e.getKey(), 0.0);
|
||||
double deltaMul = targetAvg * totalRounds - currentMul;
|
||||
if (Math.abs(deltaMul) < 0.01) break;
|
||||
int bestLo = -1, bestHi = -1;
|
||||
double bestDiff = Math.abs(deltaMul);
|
||||
for (int loId : sortedIds) {
|
||||
if (result.get(loId) <= 1) continue;
|
||||
for (int hiId : sortedIds) {
|
||||
if (loId == hiId) continue;
|
||||
double change = ornMul.getOrDefault(hiId, 0.0) - ornMul.getOrDefault(loId, 0.0);
|
||||
if (Math.abs(change) < 0.001) continue;
|
||||
double nd = Math.abs(deltaMul - change);
|
||||
if (nd < bestDiff) {
|
||||
boolean ok = true;
|
||||
int ca = result.get(loId) - 1, idxLo = sortedIds.indexOf(loId);
|
||||
if (idxLo < sortedIds.size() - 1 && ca < result.get(sortedIds.get(idxLo + 1))) ok = false;
|
||||
int ca2 = result.get(hiId) + 1, idxHi = sortedIds.indexOf(hiId);
|
||||
if (idxHi > 0 && ca2 > result.get(sortedIds.get(idxHi - 1))) ok = false;
|
||||
if (sortedIds.indexOf(loId) < sortedIds.indexOf(hiId) && ca < ca2) ok = false;
|
||||
if (ok) { bestDiff = nd; bestLo = loId; bestHi = hiId; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestLo < 0) break;
|
||||
result.put(bestLo, result.get(bestLo) - 1);
|
||||
result.put(bestHi, result.get(bestHi) + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 模拟 payoutCap 约束下的实际 RTP(%):桶的赔付期望按该箱池内实际均值算。 */
|
||||
public double simulateActualRtp(Map<Integer, Integer> distribution, Long boxId, double rtp, int simRounds) {
|
||||
if (distribution == null || distribution.isEmpty() || simRounds <= 0) return 0;
|
||||
Map<Integer, Double> ornMul = loadPoolExpect(boxId);
|
||||
double cm = 1.0;
|
||||
if (boxId != null) {
|
||||
OlympusBox box = boxMapper.selectById(boxId);
|
||||
if (box != null) cm = OlympusModeType.fromBoxType(box.getBoxType()).getCostMultiplier();
|
||||
}
|
||||
if (cm <= 0) return 0;
|
||||
List<Double> mulList = new ArrayList<>();
|
||||
for (Map.Entry<Integer, Integer> e : distribution.entrySet()) {
|
||||
if (e.getValue() == null || e.getValue() <= 0) continue;
|
||||
double mul = ornMul.getOrDefault(e.getKey(), 0.0);
|
||||
for (int i = 0; i < e.getValue(); i++) mulList.add(mul);
|
||||
}
|
||||
if (mulList.isEmpty()) return 0;
|
||||
Random rnd = new Random();
|
||||
double totalRtpSum = 0;
|
||||
for (int round = 0; round < simRounds; round++) {
|
||||
Collections.shuffle(mulList, rnd);
|
||||
double balance = 0, totalPayout = 0, totalCost = 0;
|
||||
for (double mul : mulList) {
|
||||
double payoutCap = Math.max(0, balance + cm) * rtp;
|
||||
double actualMul = (mul <= payoutCap) ? mul : 0;
|
||||
balance += cm - actualMul;
|
||||
totalPayout += actualMul;
|
||||
totalCost += cm;
|
||||
}
|
||||
totalRtpSum += totalCost > 0 ? totalPayout / totalCost : 0;
|
||||
}
|
||||
return (totalRtpSum / simRounds) * 100;
|
||||
}
|
||||
|
||||
/** 由固定池回合数反推总回合 N = round(fixed/(1-cr))。 */
|
||||
public static int deriveTotalRounds(int fixedRounds, double convergeRatio) {
|
||||
double cr = convergeRatio < 0 ? 0 : (convergeRatio > 0.9 ? 0.9 : convergeRatio);
|
||||
return (int) Math.round(fixedRounds / (1.0 - cr));
|
||||
}
|
||||
|
||||
/** 由总回合 N 算固定池回合数 fixed = round(N*(1-cr))。 */
|
||||
public static int deriveFixedRounds(int totalRounds, double convergeRatio) {
|
||||
double cr = convergeRatio < 0 ? 0 : (convergeRatio > 0.9 ? 0.9 : convergeRatio);
|
||||
return (int) Math.round(totalRounds * (1.0 - cr));
|
||||
}
|
||||
|
||||
private static double convergeRatioOf(OlympusBox box) {
|
||||
double cr = (box != null && box.getRtpConvergeRatio() != null) ? box.getRtpConvergeRatio().doubleValue() : 0.15;
|
||||
if (cr < 0) cr = 0;
|
||||
if (cr > 0.9) cr = 0.9;
|
||||
return cr;
|
||||
}
|
||||
|
||||
/** 重算某箱某身份分布:按目标 RTP 分配固定池 count,used 归 0。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reset(Long boxId, String audience) {
|
||||
OlympusBox box = boxMapper.selectById(boxId);
|
||||
if (box == null) return;
|
||||
OlympusModeType mode = OlympusModeType.fromBoxType(box.getBoxType());
|
||||
int totalRounds = box.getTotalOrnaments() != null ? box.getTotalOrnaments() : 200;
|
||||
double targetRtp = box.getRtp() != null ? box.getRtp().doubleValue() : 0.8;
|
||||
int fixedRounds = deriveFixedRounds(totalRounds, convergeRatioOf(box));
|
||||
if (fixedRounds < 1) fixedRounds = totalRounds;
|
||||
Map<Integer, Integer> dist = calcRounds(mode, boxId, fixedRounds, targetRtp);
|
||||
List<OlympusBoxOrnament> list = new ArrayList<>();
|
||||
for (Map.Entry<Integer, Integer> e : dist.entrySet()) {
|
||||
if (e.getValue() == null || e.getValue() <= 0) continue;
|
||||
OlympusBoxOrnament bo = new OlympusBoxOrnament();
|
||||
bo.setBoxId(boxId);
|
||||
bo.setOrnamentId(e.getKey());
|
||||
bo.setAudience(audience);
|
||||
bo.setCount(e.getValue());
|
||||
bo.setUsed(0);
|
||||
list.add(bo);
|
||||
}
|
||||
boxOrnamentMapper.deleteByBoxIdAndAudience(boxId, audience);
|
||||
if (!list.isEmpty()) boxOrnamentMapper.batchInsert(list);
|
||||
}
|
||||
|
||||
/** 应用模板分布到某箱某身份(count 取模板、used 归 0),并按固定池反推回写总回合。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void writeDistribution(Long boxId, String audience, List<Map<String, Object>> items) {
|
||||
boxOrnamentMapper.deleteByBoxIdAndAudience(boxId, audience);
|
||||
int total = 0;
|
||||
List<OlympusBoxOrnament> list = new ArrayList<>();
|
||||
if (items != null) {
|
||||
for (Map<String, Object> item : items) {
|
||||
Integer ornamentId = toInt(item.get("ornamentId"));
|
||||
Integer count = toInt(item.get("count"));
|
||||
if (ornamentId == null || count == null || count <= 0) continue;
|
||||
OlympusBoxOrnament bo = new OlympusBoxOrnament();
|
||||
bo.setBoxId(boxId);
|
||||
bo.setOrnamentId(ornamentId);
|
||||
bo.setAudience(audience);
|
||||
bo.setCount(count);
|
||||
bo.setUsed(0);
|
||||
list.add(bo);
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
if (!list.isEmpty()) boxOrnamentMapper.batchInsert(list);
|
||||
OlympusBox box = boxMapper.selectById(boxId);
|
||||
if (box != null) {
|
||||
box.setTotalOrnaments(deriveTotalRounds(total, convergeRatioOf(box)));
|
||||
boxMapper.updateById(box);
|
||||
}
|
||||
}
|
||||
|
||||
/** 某箱某身份抽干补桶:优先按该身份生效模板重写;否则仅 used 归 0。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void refillFromConfig(Long boxId, String audience) {
|
||||
OlympusBoxTemplate active = boxTemplateMapper.selectOne(
|
||||
new LambdaQueryWrapper<OlympusBoxTemplate>()
|
||||
.eq(OlympusBoxTemplate::getBoxId, boxId)
|
||||
.eq(OlympusBoxTemplate::getAudience, audience)
|
||||
.eq(OlympusBoxTemplate::getIsActive, 1)
|
||||
.orderByDesc(OlympusBoxTemplate::getId).last("LIMIT 1"));
|
||||
if (active != null && active.getItemsJson() != null) {
|
||||
List<Map<String, Object>> items = parseItems(active.getItemsJson());
|
||||
if (!items.isEmpty()) { writeDistribution(boxId, audience, items); return; }
|
||||
}
|
||||
boxOrnamentMapper.resetUsed(boxId, audience);
|
||||
}
|
||||
|
||||
public List<OlympusBoxOrnament> distribution(Long boxId, String audience) {
|
||||
return boxOrnamentMapper.selectByBoxIdAndAudience(boxId, audience);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> parseItems(String itemsJson) {
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(itemsJson, new TypeReference<List<Map<String, Object>>>() {});
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
public String toItemsJson(Object items) {
|
||||
try { return OBJECT_MAPPER.writeValueAsString(items); } catch (Exception e) { return "[]"; }
|
||||
}
|
||||
|
||||
private static Integer toInt(Object v) {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Number) return ((Number) v).intValue();
|
||||
try { return Integer.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
|
||||
}
|
||||
}
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
package com.ruoyi.game.olympus.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.ruoyi.admin.mapper.TtBoxRecordsMapper;
|
||||
import com.ruoyi.admin.mapper.TtOrnamentsLevelMapper;
|
||||
import com.ruoyi.admin.service.TtUserService;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.message.service.MessageService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.redis.config.RedisLock;
|
||||
import com.ruoyi.domain.common.constant.MessageCategoryEnum;
|
||||
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
|
||||
import com.ruoyi.domain.common.constant.TtboxRecordSource;
|
||||
import com.ruoyi.domain.entity.TtBoxRecords;
|
||||
import com.ruoyi.domain.entity.sys.TtUser;
|
||||
import com.ruoyi.domain.other.TtOrnamentsLevel;
|
||||
import com.ruoyi.game.olympus.contract.request.SpinRequest;
|
||||
import com.ruoyi.game.olympus.contract.response.SpinResponse;
|
||||
import com.ruoyi.game.olympus.domain.*;
|
||||
import com.ruoyi.game.olympus.enums.OlympusAudience;
|
||||
import com.ruoyi.game.olympus.enums.OlympusModeType;
|
||||
import com.ruoyi.game.olympus.mapper.*;
|
||||
import com.ruoyi.game.olympus.model.PickedRound;
|
||||
import com.ruoyi.game.olympus.model.cache.OlympusGameCache;
|
||||
import com.ruoyi.game.olympus.repository.OlympusCacheRepository;
|
||||
import com.ruoyi.game.olympus.repository.OlympusCycleRepository;
|
||||
import com.ruoyi.game.olympus.repository.OlympusRewardPoolCacheRepository;
|
||||
import com.ruoyi.user.service.ApiUserService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.ruoyi.admin.config.RedisConstants.COMMON_LOCK;
|
||||
|
||||
/**
|
||||
* Olympus 核心游戏服务:dospin 开局/播放/结算 + 多身份选池 + 按箱水位 + 白号隔离。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class OlympusGameService {
|
||||
|
||||
@Autowired private OlympusBoxMapper boxMapper;
|
||||
@Autowired private OlympusRoundPicker roundPicker;
|
||||
@Autowired private OlympusBoxOrnamentMapper boxOrnamentMapper;
|
||||
@Autowired private OlympusRecordMapper recordMapper;
|
||||
@Autowired private OlympusUserMapper userMapper;
|
||||
@Autowired private OlympusRoundMapper roundMapper;
|
||||
@Autowired private OlympusPrizeRangeMapper prizeRangeMapper;
|
||||
@Autowired private OlympusDistributionService distributionService;
|
||||
@Autowired private OlympusCacheRepository cacheRepository;
|
||||
@Autowired private OlympusCycleRepository cycleRepository;
|
||||
@Autowired private OlympusRewardPoolCacheRepository rewardPoolRepository;
|
||||
|
||||
@Autowired private TtUserService userService;
|
||||
@Autowired private ApiUserService apiUserService;
|
||||
@Autowired private RedisLock redisLock;
|
||||
@Autowired private TransactionTemplate transactionTemplate;
|
||||
|
||||
@Autowired(required = false) private TtBoxRecordsMapper boxRecordsMapper;
|
||||
@Autowired(required = false) private TtOrnamentsLevelMapper ornamentsLevelMapper;
|
||||
@Autowired private MessageService messageService;
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
/** 单局封顶:总倍率≥此值视为已 1000x 封顶 */
|
||||
private static final BigDecimal MAX_WIN_CAP_MUL = new BigDecimal("1000");
|
||||
|
||||
/** 免费旋转总旋数上限:15 起,每次重触发 +5,封顶 30 */
|
||||
private static final int MAX_FREE_SPINS = 30;
|
||||
|
||||
// ============================================================
|
||||
// dospin 主入口
|
||||
// ============================================================
|
||||
|
||||
public SpinResponse doSpin(Long userId, SpinRequest req) throws Exception {
|
||||
Boolean lock = redisLock.tryLock(COMMON_LOCK + userId, 3L, 7L, TimeUnit.SECONDS);
|
||||
if (!Boolean.TRUE.equals(lock)) throw new RuntimeException("请求频繁,请稍后再试");
|
||||
try {
|
||||
OlympusGameCache cache = cacheRepository.getCache(userId);
|
||||
return cache == null ? startNewGame(userId, req != null ? req.getBoxId() : null) : continueGame(userId, cache);
|
||||
} finally {
|
||||
redisLock.unlock(COMMON_LOCK + userId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 新游戏:扣费 + 多身份选池 + 缓存步骤 + 返回第一步
|
||||
// ============================================================
|
||||
|
||||
private SpinResponse startNewGame(Long userId, Long boxId) {
|
||||
if (boxId == null) throw new IllegalArgumentException("boxId 不能为空");
|
||||
OlympusBox box = boxMapper.selectById(boxId);
|
||||
if (box == null || !"1".equals(box.getStatus())) throw new IllegalArgumentException("箱子不可用");
|
||||
|
||||
OlympusModeType mode = OlympusModeType.fromBoxType(box.getBoxType());
|
||||
BigDecimal bet = coalesce(box.getBet());
|
||||
BigDecimal cost = bet.multiply(BigDecimal.valueOf(mode.getCostMultiplier()));
|
||||
|
||||
R<Void> rechargeCheck = apiUserService.checkRecharge(userId.intValue());
|
||||
if (R.isError(rechargeCheck)) throw new ServiceException("玩家未充值不能游玩任何游戏", 703);
|
||||
|
||||
TtUser player = userService.getById(userId);
|
||||
if (player == null || player.getAccountAmount() == null || player.getAccountAmount().compareTo(cost) < 0) {
|
||||
throw new ServiceException("账户余额不足", 702);
|
||||
}
|
||||
|
||||
// 身份解析:白号最高优先,压过 user_type
|
||||
OlympusAudience audience = OlympusAudience.resolve(player);
|
||||
boolean isBlank = audience.isWhite();
|
||||
|
||||
// 购买模式必须选有免费旋转的回合
|
||||
Boolean needFreeSpins = mode.isBuyMode() ? Boolean.TRUE : null;
|
||||
|
||||
// 水位赔付上限:真实玩家按 (本箱水位 + 本局成本) × rtp 约束;白号 payoutCap=null(纯概率、不受水位)
|
||||
BigDecimal payoutCap = null;
|
||||
if (!isBlank) {
|
||||
double ratio = box.getRtp() != null ? box.getRtp().doubleValue() : 0.8;
|
||||
BigDecimal balance = rewardPoolRepository.getBoxBalance(boxId, audience.getCode()).add(cost);
|
||||
if (balance.signum() < 0) balance = BigDecimal.ZERO;
|
||||
payoutCap = scale2(balance.multiply(BigDecimal.valueOf(ratio)));
|
||||
}
|
||||
|
||||
PickedRound picked = roundPicker.pick(boxId, audience.getCode(), needFreeSpins, bet, payoutCap);
|
||||
if (picked == null || picked.getRound() == null) throw new RuntimeException("该箱子暂无可用回合");
|
||||
|
||||
BigDecimal totalMul = coalesce(picked.getRound().getActualMul());
|
||||
BigDecimal totalWin = scale2(totalMul.multiply(bet));
|
||||
|
||||
List<OlympusGameCache.CachedStep> cachedSteps = new ArrayList<>();
|
||||
for (OlympusStep s : picked.getSteps()) {
|
||||
OlympusGameCache.CachedStep cs = new OlympusGameCache.CachedStep();
|
||||
cs.setFreeSpinId(coalesce0(s.getFreeSpinId()));
|
||||
cs.setCascadeSeq(coalesce0(s.getCascadeSeq()));
|
||||
cs.setGridState(s.getGridState());
|
||||
cs.setMultiplierDetail(s.getMultiplierDetail());
|
||||
cs.setSymbolLinks(s.getSymbolLinks());
|
||||
cs.setWinAmount(s.getWinAmount());
|
||||
cs.setSpinWin(coalesce(s.getSpinWin()));
|
||||
cs.setAccumulatorAfter(coalesce(s.getAccumulatorAfter()));
|
||||
cs.setWinCapped(coalesce0(s.getWinCapped()));
|
||||
cs.setBigwinLevel(s.getBigwinLevel());
|
||||
cs.setIsFinal(coalesce0(s.getIsFinal()));
|
||||
cs.setRetriggerInfo(s.getRetriggerInfo());
|
||||
cs.setDropData(s.getDropData());
|
||||
cs.setFillData(s.getFillData());
|
||||
cs.setScatterTrigger(s.getScatterTrigger());
|
||||
cachedSteps.add(cs);
|
||||
}
|
||||
|
||||
OlympusGameCache cache = new OlympusGameCache();
|
||||
cache.setBoxId(boxId);
|
||||
cache.setBoxType(box.getBoxType());
|
||||
cache.setBet(bet);
|
||||
cache.setCost(cost);
|
||||
cache.setRoundId(picked.getRound().getRoundId());
|
||||
cache.setTotalMul(totalMul);
|
||||
cache.setFreeSpinCount(coalesce0(picked.getRound().getFreeSpinCount()));
|
||||
cache.setStepIndex(1);
|
||||
cache.setSteps(cachedSteps);
|
||||
cache.setTotalWin(totalWin);
|
||||
cache.setAudience(audience.getCode());
|
||||
cache.setBlankAccount(isBlank);
|
||||
|
||||
boolean firstIsFinal = cachedSteps.size() == 1;
|
||||
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
R<?> deductR = userService.updateUserAccount(userId.intValue(), cost.negate(), TtAccountRecordSource.OLYMPUS_SPIN);
|
||||
if (R.isError(deductR)) throw new RuntimeException(deductR.getMsg());
|
||||
|
||||
OlympusRecord record = new OlympusRecord();
|
||||
record.setUserId(userId);
|
||||
record.setBoxId(boxId);
|
||||
record.setRoundId(picked.getRound().getRoundId());
|
||||
record.setCost(cost);
|
||||
record.setTotalWin(BigDecimal.ZERO);
|
||||
record.setModeType(box.getBoxType());
|
||||
record.setSettled(0);
|
||||
record.setCreateTime(new Date());
|
||||
recordMapper.insert(record);
|
||||
cache.setRecordId(record.getId());
|
||||
|
||||
// 仅固定阶段消耗对应 audience 一个回合(白号消耗自己的 white 库存,不碰其他身份)
|
||||
if (!picked.isConverge()) {
|
||||
boxOrnamentMapper.incrUsed(boxId, picked.getRound().getOrnamentId(), audience.getCode());
|
||||
}
|
||||
// 开局即结算:回合结果已确定,立即将饰品写入背包(与扣费同事务,中途退出/刷新无损)
|
||||
settleAtStart(userId, cache);
|
||||
});
|
||||
|
||||
// 事务已提交(钱已扣、奖已落库)。以下全是 Redis 缓存/节奏账本/补桶,非资金操作:
|
||||
// 一旦失败必须吞掉——否则接口返回 500,玩家以为没开局而重试,会再开一局、再扣一次钱。
|
||||
try {
|
||||
cycleRepository.recordDraw(boxId, audience.getCode(), picked.getRound().getOrnamentId(),
|
||||
coalesce(picked.getRound().getActualMul()), picked.isConverge());
|
||||
// 一轮 N 按该身份固定池分布反推(与 RoundPicker 一致,身份级独立)
|
||||
int fixedSum = boxOrnamentMapper.selectByBoxIdAndAudience(boxId, audience.getCode()).stream()
|
||||
.mapToInt(bo -> bo.getCount() != null ? bo.getCount() : 0).sum();
|
||||
double cr = box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15;
|
||||
int cycleN = fixedSum > 0 ? OlympusDistributionService.deriveTotalRounds(fixedSum, cr) : coalesce0(box.getTotalOrnaments());
|
||||
if (cycleN > 0 && cycleRepository.drawn(boxId, audience.getCode()) >= cycleN) {
|
||||
cycleRepository.resetCycle(boxId, audience.getCode());
|
||||
distributionService.refillFromConfig(boxId, audience.getCode());
|
||||
}
|
||||
|
||||
if (firstIsFinal) {
|
||||
if (cache.getTotalWin().compareTo(BigDecimal.ZERO) <= 0) cache.setTotalWin(new BigDecimal("0.01"));
|
||||
cacheRepository.clearCache(userId);
|
||||
} else {
|
||||
cacheRepository.setCache(userId, cache);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("olympus 开局后置处理失败(扣费与发奖已提交,不影响本局结果)userId={} boxId={}", userId, boxId, e);
|
||||
}
|
||||
// 单步局开局即播完,通知此刻发;多步局等末帧(continueGame)
|
||||
if (firstIsFinal) sendWinNotice(userId, cache);
|
||||
return buildStepResponse(cache, cachedSteps.get(0), 1, firstIsFinal);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 继续游戏:取下一步
|
||||
// ============================================================
|
||||
|
||||
private SpinResponse continueGame(Long userId, OlympusGameCache cache) {
|
||||
int nextIdx = cache.getStepIndex() + 1;
|
||||
if (nextIdx > cache.getSteps().size()) {
|
||||
cacheRepository.clearCache(userId);
|
||||
throw new RuntimeException("游戏已结束");
|
||||
}
|
||||
OlympusGameCache.CachedStep step = cache.getSteps().get(nextIdx - 1);
|
||||
cache.setStepIndex(nextIdx);
|
||||
|
||||
if (nextIdx == cache.getSteps().size()) {
|
||||
if (cache.getTotalWin().compareTo(BigDecimal.ZERO) <= 0) cache.setTotalWin(new BigDecimal("0.01"));
|
||||
// 末帧已播完:这一局才算结束,战绩列表(我的/全服)才会展示
|
||||
recordMapper.update(null, new LambdaUpdateWrapper<OlympusRecord>()
|
||||
.eq(OlympusRecord::getId, cache.getRecordId())
|
||||
.set(OlympusRecord::getSettleSource, "finish"));
|
||||
// 末帧到了才发结算通知(开局发会在动画播完前剧透),失败只记日志不影响本局
|
||||
sendWinNotice(userId, cache);
|
||||
// 已在开局结算入背包,末帧仅揭晓;回填 boxRecordId 供前端选择保留/立即分解
|
||||
SpinResponse resp = buildStepResponse(cache, step, nextIdx, true);
|
||||
cacheRepository.clearCache(userId);
|
||||
return resp;
|
||||
}
|
||||
cacheRepository.setCache(userId, cache);
|
||||
return buildStepResponse(cache, step, nextIdx, false);
|
||||
}
|
||||
|
||||
/** 结算通知(站内信):只在"这一局播完"的时刻发——单步局=开局,多步局=末帧。
|
||||
* 开局就发会在动画播完前把结果剧透给玩家(饰品开局已入背包,通知是唯一会提前泄露的通道)。
|
||||
* 多步局中途退出/断线未播完的局不发通知,饰品仍在背包,战绩按 30 分钟兜底照常展示。 */
|
||||
private void sendWinNotice(Long userId, OlympusGameCache cache) {
|
||||
if (cache.getRecordId() == null) return;
|
||||
try {
|
||||
OlympusRecord rec = recordMapper.selectById(cache.getRecordId());
|
||||
if (rec == null || rec.getPrizeName() == null) return;
|
||||
String priceStr = rec.getSettledWin() == null ? "0" : rec.getSettledWin().stripTrailingZeros().toPlainString();
|
||||
messageService.sendNotice(
|
||||
userId,
|
||||
MessageCategoryEnum.GAME_OLYMPUS.getTitle(),
|
||||
"玫瑰女郎挑战结算:您本局获得饰品 " + rec.getPrizeName() + "(价值 ¥" + priceStr + ")",
|
||||
MessageCategoryEnum.GAME_OLYMPUS.getCode());
|
||||
} catch (Exception e) {
|
||||
log.error("olympus 结算通知发送失败 userId={} recordId={}", userId, cache.getRecordId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 开局即结算(事务中,饰品直接写入背包,中途退出/刷新无损)
|
||||
// ============================================================
|
||||
|
||||
private void settleAtStart(Long userId, OlympusGameCache cache) {
|
||||
BigDecimal totalWin = cache.getTotalWin();
|
||||
if (totalWin == null || totalWin.compareTo(BigDecimal.ZERO) <= 0) totalWin = new BigDecimal("0.01");
|
||||
BigDecimal win = scale2(totalWin);
|
||||
|
||||
Map<String, Object> prize = findPrizeWithFallback(win);
|
||||
Long prizeId = asLong(prize != null ? prize.get("id") : null);
|
||||
String prizeName = prize != null ? (String) prize.get("name") : null;
|
||||
Integer prizeGrade = prize != null && prize.get("grade") instanceof Integer ? (Integer) prize.get("grade") : 1;
|
||||
String prizeImageUrl = prize != null ? (String) prize.get("imageUrl") : null;
|
||||
String prizeExteriorName = prize != null ? (String) prize.get("exteriorName") : null;
|
||||
String prizeLevelImg = prize != null ? (String) prize.get("levelImageUrl") : null;
|
||||
|
||||
// 幂等守卫。settle_source:开局写 start,客户端播完末帧才改 finish(战绩列表只展示已结束的局);
|
||||
// 单步局开局即播完,直接写 finish。
|
||||
boolean playedOut = cache.getSteps() == null || cache.getSteps().size() <= 1;
|
||||
int guard = recordMapper.update(null, new LambdaUpdateWrapper<OlympusRecord>()
|
||||
.eq(OlympusRecord::getId, cache.getRecordId())
|
||||
.eq(OlympusRecord::getSettled, 0)
|
||||
.set(OlympusRecord::getSettled, 1)
|
||||
.set(OlympusRecord::getRewardType, "ornament")
|
||||
.set(OlympusRecord::getTotalWin, totalWin)
|
||||
.set(OlympusRecord::getSettledWin, win)
|
||||
.set(OlympusRecord::getPrizeId, prizeId)
|
||||
.set(OlympusRecord::getSettleSource, playedOut ? "finish" : "start")
|
||||
.set(OlympusRecord::getPrizeName, prizeName)
|
||||
.set(OlympusRecord::getPrizeImageUrl, prizeImageUrl));
|
||||
if (guard == 0) { cacheRepository.clearCache(userId); return; }
|
||||
|
||||
// 写入背包
|
||||
Long boxRecordId = null;
|
||||
if (boxRecordsMapper != null && prizeId != null) {
|
||||
if (prizeLevelImg == null && ornamentsLevelMapper != null) {
|
||||
TtOrnamentsLevel lv = ornamentsLevelMapper.selectById(prizeGrade);
|
||||
if (lv != null) prizeLevelImg = lv.getLevelImg();
|
||||
}
|
||||
TtBoxRecords bi = TtBoxRecords.builder()
|
||||
.userId(userId.intValue()).holderUserId(userId.intValue())
|
||||
.boxId(cache.getBoxId() != null ? cache.getBoxId().intValue() : 0)
|
||||
.boxName("OLYMPUS").boxPrice(cache.getCost())
|
||||
.ornamentId(prizeId).ornamentsPrice(win)
|
||||
.ornamentName(prizeName).imageUrl(prizeImageUrl)
|
||||
.marketHashName(prizeExteriorName != null ? prizeName + " (" + prizeExteriorName + ")" : prizeName)
|
||||
.ornamentsLevelId(prizeGrade != null ? prizeGrade : 1)
|
||||
.ornamentLevelImg(prizeLevelImg)
|
||||
.source(TtboxRecordSource.OLYMPUS_BOX.getCode())
|
||||
.status(0).isShow(1).createTime(new Date())
|
||||
.build();
|
||||
boxRecordsMapper.insert(bi);
|
||||
boxRecordId = bi.getId();
|
||||
cache.setBoxRecordId(bi.getId());
|
||||
// 注:结算通知不在这里发——开局就发会在动画播完前把结果剧透给玩家,改由 startNewGame/continueGame 在末帧发
|
||||
}
|
||||
|
||||
// 注:饰品一律以 ornament 写入背包(开局即结算,断网也不丢)。自动分解由前端开关控制:
|
||||
// 末帧前端调 /api/userPackSack/decompose 即按玫瑰女郎奖励(61)入账;调不动也只是留在背包,无资金风险。
|
||||
|
||||
// 更新用户统计
|
||||
OlympusUser user = userMapper.selectByUserId(userId);
|
||||
if (user == null) {
|
||||
user = new OlympusUser();
|
||||
user.setUserId(userId);
|
||||
user.setTotalSpins(1L);
|
||||
user.setTotalCost(cache.getCost());
|
||||
user.setTotalWin(totalWin);
|
||||
user.setTotalFreeSpins(cache.getFreeSpinCount() != null && cache.getFreeSpinCount() > 0 ? 1 : 0);
|
||||
user.setMaxWinMul(cache.getTotalMul());
|
||||
userMapper.insert(user);
|
||||
} else {
|
||||
user.setTotalSpins(coalesceL(user.getTotalSpins()) + 1L);
|
||||
user.setTotalCost(coalesce(user.getTotalCost()).add(cache.getCost()));
|
||||
user.setTotalWin(coalesce(user.getTotalWin()).add(totalWin));
|
||||
if (cache.getFreeSpinCount() != null && cache.getFreeSpinCount() > 0)
|
||||
user.setTotalFreeSpins(coalesce0(user.getTotalFreeSpins()) + 1);
|
||||
if (cache.getTotalMul() != null && cache.getTotalMul().compareTo(coalesce(user.getMaxWinMul())) > 0)
|
||||
user.setMaxWinMul(cache.getTotalMul());
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
|
||||
// 注:奖池注水与返奖记入由 startNewGame 事务提交后统一执行(开局即结算,白号隔离)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 断线重连
|
||||
// ============================================================
|
||||
|
||||
public Map<String, Object> hasActive(Long userId) {
|
||||
OlympusGameCache cache = cacheRepository.getCache(userId);
|
||||
// 开局即结算模型:无缓存即无进行中动画可恢复(饰品已在背包),直接返回
|
||||
if (cache == null) return Map.of("hasActive", false);
|
||||
List<OlympusGameCache.CachedStep> steps = cache.getSteps();
|
||||
int played = Math.min(Math.max(cache.getStepIndex(), 0), steps.size());
|
||||
BigDecimal bet = coalesce(cache.getBet());
|
||||
// 已播累计直接取最后一步的 spin_win(本局累计),不能逐帧加 win_amount:
|
||||
// win_amount 是"本旋内累计",同一旋的每一波都是同一个累计量的快照,相加会把中间波重复计入。
|
||||
BigDecimal playedWin = played > 0
|
||||
? scale2(coalesce(steps.get(played - 1).getSpinWin()).multiply(bet))
|
||||
: BigDecimal.ZERO;
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("hasActive", true);
|
||||
result.put("boxId", cache.getBoxId());
|
||||
result.put("boxType", cache.getBoxType());
|
||||
result.put("bet", cache.getBet());
|
||||
result.put("cost", cache.getCost());
|
||||
result.put("stepIndex", cache.getStepIndex());
|
||||
result.put("totalSteps", steps.size());
|
||||
result.put("freeSpinCount", cache.getFreeSpinCount());
|
||||
result.put("spinsRemaining", spinsRemainingAfter(steps, played));
|
||||
result.put("totalWin", scale2(playedWin));
|
||||
result.put("roundId", cache.getRoundId());
|
||||
if (played > 0) result.put("lastStep", buildStepResponse(cache, steps.get(played - 1), played, false));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 构建单步响应
|
||||
// ============================================================
|
||||
|
||||
private SpinResponse buildStepResponse(OlympusGameCache cache, OlympusGameCache.CachedStep cs, int idx, boolean isFinal) {
|
||||
BigDecimal bet = coalesce(cache.getBet());
|
||||
SpinResponse resp = new SpinResponse();
|
||||
resp.setState(isFinal ? "FINISH" : "PLAYING");
|
||||
resp.setTumbleNum(cs.getCascadeSeq());
|
||||
resp.setSpinNum(cs.getFreeSpinId());
|
||||
resp.setGridState(toJson(cs.getGridState()));
|
||||
resp.setMultiplierDetail(toJson(cs.getMultiplierDetail()));
|
||||
resp.setSymbolLinks(scaleMoneyList(cs.getSymbolLinks(), bet, new String[]{"base_score", "score", "win"}));
|
||||
resp.setDropData(toJson(cs.getDropData()));
|
||||
resp.setFillData(toJson(cs.getFillData()));
|
||||
resp.setScatterTrigger(scaleMoneyMap(cs.getScatterTrigger(), bet, new String[]{"scatterWin", "startAmount"}));
|
||||
resp.setWinAmount(scale2(coalesce(cs.getWinAmount()).multiply(bet)));
|
||||
resp.setSpinWin(scale2(coalesce(cs.getSpinWin()).multiply(bet)));
|
||||
// 炸弹累计倍数(未 ×bet):跨旋传递的累计器,断线重连只回最后一步,前端靠这一步的值接回累计
|
||||
resp.setAccumulator(scale2(coalesce(cs.getAccumulatorAfter())));
|
||||
if (cs.getRetriggerInfo() != null && !cs.getRetriggerInfo().isEmpty())
|
||||
resp.setRetriggerInfo(scaleMoneyMap(cs.getRetriggerInfo(), bet, new String[]{"scatterWin"}));
|
||||
// 爆奖动画:py 已按单旋赢额判档写入 bigwin_level,直接透传
|
||||
if (cs.getBigwinLevel() != null && !cs.getBigwinLevel().isEmpty()) resp.setBigWinLevel(cs.getBigwinLevel());
|
||||
|
||||
resp.setGlobalStep(idx);
|
||||
resp.setGlobalTotalSteps(cache.getSteps().size());
|
||||
// 剩余免费旋转:每帧都给(前端直接显示倒计,不用自己加减);1000x 封顶时下面覆盖成 0
|
||||
resp.setSpinsRemaining(spinsRemainingAfter(cache.getSteps(), idx));
|
||||
|
||||
if (isFinal) {
|
||||
resp.setTotalWin(scale2(cache.getTotalWin()));
|
||||
resp.setTotalMul(scale2(cache.getTotalMul()));
|
||||
if (coalesce(cache.getTotalMul()).compareTo(MAX_WIN_CAP_MUL) >= 0) {
|
||||
resp.setMaxWinReached(true);
|
||||
resp.setSpinsRemaining(0);
|
||||
}
|
||||
// 开局已结算入背包:末帧揭晓饰品 + 回填 boxRecordId 供前端选择保留/立即分解
|
||||
try {
|
||||
BigDecimal win = scale2(cache.getTotalWin());
|
||||
if (win.compareTo(BigDecimal.ZERO) <= 0) win = new BigDecimal("0.01");
|
||||
Map<String, Object> prize = findPrizeWithFallback(win);
|
||||
if (prize != null && !prize.isEmpty()) resp.setPrizeInfo(prize);
|
||||
} catch (Exception ignore) {}
|
||||
resp.setBoxRecordId(cache.getBoxRecordId());
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具
|
||||
// ============================================================
|
||||
|
||||
private Map<String, Object> findPrizeWithFallback(BigDecimal totalWin) {
|
||||
Map<String, Object> prize = prizeRangeMapper.findPrizeByWin(totalWin);
|
||||
if (prize != null && !prize.isEmpty()) { prize.put("isFallback", false); return prize; }
|
||||
prize = prizeRangeMapper.findFallbackPrize();
|
||||
if (prize == null || prize.isEmpty()) prize = prizeRangeMapper.findLowestPrize();
|
||||
if (prize == null || prize.isEmpty()) prize = prizeRangeMapper.findHighestPrize();
|
||||
if (prize != null) prize.put("isFallback", true);
|
||||
return prize;
|
||||
}
|
||||
|
||||
private static BigDecimal scale2(BigDecimal v) { return v == null ? BigDecimal.ZERO : v.setScale(2, java.math.RoundingMode.HALF_UP); }
|
||||
private static BigDecimal coalesce(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
|
||||
private static int coalesce0(Integer v) { return v != null ? v : 0; }
|
||||
private static long coalesceL(Long v) { return v != null ? v : 0L; }
|
||||
|
||||
private static Long asLong(Object v) {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Long) return (Long) v;
|
||||
if (v instanceof Number) return ((Number) v).longValue();
|
||||
try { return Long.valueOf(v.toString()); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private static Object toJson(String s) {
|
||||
if (s == null || s.isEmpty()) return null;
|
||||
try { return MAPPER.readTree(s); } catch (Exception e) { return s; }
|
||||
}
|
||||
|
||||
/** 剩余免费旋转 = 已发放 − 已播完的 FS 旋数(played = 算到第几步,含这一步;中间波帧不减,旋末帧才 −1)。
|
||||
* 已发放只算玩家**已经看到**的:触发帧的 freeSpins(=15) + 已播步里的 retriggerInfo.extraSpins(=5),封顶 30
|
||||
* (封顶后 3+ 个 S 的旋仍会写 retrigger_info=5 但不再加旋,所以这里必须夹 30)。
|
||||
* 例:第 2 旋末帧出 3 个 S → 15+5−2 = 18(那一帧 −1 与 +5 同时结算,净 +4)。*/
|
||||
private static int spinsRemainingAfter(List<OlympusGameCache.CachedStep> steps, int played) {
|
||||
int awarded = 0, completed = 0;
|
||||
for (int i = 0; i < played && i < steps.size(); i++) {
|
||||
OlympusGameCache.CachedStep s = steps.get(i);
|
||||
awarded += jsonInt(s.getScatterTrigger(), "freeSpins") + jsonInt(s.getRetriggerInfo(), "extraSpins");
|
||||
if (s.getIsFinal() == 1 && s.getFreeSpinId() > 0) completed++;
|
||||
}
|
||||
return Math.max(0, Math.min(awarded, MAX_FREE_SPINS) - completed);
|
||||
}
|
||||
|
||||
/** 取 JSON 对象里的整数字段(缺失 / null / 解析失败一律按 0) */
|
||||
private static int jsonInt(String json, String key) {
|
||||
if (json == null || json.isEmpty()) return 0;
|
||||
try {
|
||||
com.fasterxml.jackson.databind.JsonNode node = MAPPER.readTree(json).get(key);
|
||||
return node == null ? 0 : node.asInt();
|
||||
} catch (Exception e) { return 0; }
|
||||
}
|
||||
|
||||
/** 解析 JSON 数组,把指定金额字段 ×bet */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Object scaleMoneyList(String json, BigDecimal bet, String[] keys) {
|
||||
if (json == null || json.isEmpty()) return null;
|
||||
try {
|
||||
List<Map<String, Object>> list = MAPPER.readValue(json, List.class);
|
||||
for (Map<String, Object> m : list) scaleKeys(m, bet, keys);
|
||||
return list;
|
||||
} catch (Exception e) { return toJson(json); }
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Object scaleMoneyMap(String json, BigDecimal bet, String[] keys) {
|
||||
if (json == null || json.isEmpty()) return null;
|
||||
try {
|
||||
Map<String, Object> m = MAPPER.readValue(json, Map.class);
|
||||
scaleKeys(m, bet, keys);
|
||||
return m;
|
||||
} catch (Exception e) { return toJson(json); }
|
||||
}
|
||||
|
||||
private static void scaleKeys(Map<String, Object> m, BigDecimal bet, String[] keys) {
|
||||
for (String key : keys) {
|
||||
Object v = m.get(key);
|
||||
if (v instanceof Number) {
|
||||
m.put(key, BigDecimal.valueOf(((Number) v).doubleValue())
|
||||
.multiply(coalesce(bet)).setScale(2, java.math.RoundingMode.HALF_UP));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package com.ruoyi.game.olympus.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.ruoyi.game.olympus.domain.*;
|
||||
import com.ruoyi.game.olympus.enums.OlympusModeType;
|
||||
import com.ruoyi.game.olympus.mapper.*;
|
||||
import com.ruoyi.game.olympus.model.PickedRound;
|
||||
import com.ruoyi.game.olympus.repository.OlympusCycleRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Olympus 回合选择器(多身份 + 两阶段 + 白号隔离)。
|
||||
*
|
||||
* 每个身份(audience)读自己的桶分布与轮次账本:
|
||||
* - 白号(payoutCap==null):纯概率原逻辑,不进两阶段、不受水位约束,但消耗自己的 white 库存(isConverge=false);
|
||||
* - 真实身份(payoutCap!=null):固定阶段(payoutCap 过滤+剩余加权) → 收敛阶段(按 desiredNextMul 在水位内选桶)。
|
||||
*
|
||||
* 消耗(used+1,仅固定阶段)与账本 recordDraw 由 OlympusGameService 在扣费事务内/提交后执行。
|
||||
*/
|
||||
@Service
|
||||
public class OlympusRoundPicker {
|
||||
|
||||
@Autowired
|
||||
private OlympusBoxOrnamentMapper boxOrnamentMapper;
|
||||
@Autowired
|
||||
private OlympusRoundMapper roundMapper;
|
||||
@Autowired
|
||||
private OlympusStepMapper stepMapper;
|
||||
@Autowired
|
||||
private OlympusDistributionService distributionService;
|
||||
@Autowired
|
||||
private OlympusBoxMapper boxMapper;
|
||||
@Autowired
|
||||
private OlympusCycleRepository cycleRepository;
|
||||
|
||||
private static final double DEFAULT_CONVERGE_RATIO = 0.15;
|
||||
|
||||
public PickedRound pick(Long boxId, String audience, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
|
||||
List<OlympusBoxOrnament> dist = boxOrnamentMapper.selectByBoxIdAndAudience(boxId, audience);
|
||||
if (dist.isEmpty()) throw new RuntimeException("Box " + boxId + " audience=" + audience + " 暂无饰品分布数据");
|
||||
|
||||
// 白号 / 无水位约束:纯概率原逻辑(消耗自己的库存,不受水位)
|
||||
if (payoutCap == null) {
|
||||
int totalRemaining = dist.stream().mapToInt(bo -> safe(bo.getRemaining())).sum();
|
||||
if (totalRemaining <= 0) {
|
||||
distributionService.refillFromConfig(boxId, audience);
|
||||
dist = boxOrnamentMapper.selectByBoxIdAndAudience(boxId, audience);
|
||||
}
|
||||
Integer ornId = weightedPickByRemaining(dist);
|
||||
if (ornId == null) ornId = dist.get(0).getOrnamentId();
|
||||
OlympusRound r = resolveRound(boxId, ornId, needFreeSpins, bet, null);
|
||||
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), false);
|
||||
}
|
||||
|
||||
// 真实玩家:两阶段
|
||||
OlympusBox box = boxMapper.selectById(boxId);
|
||||
double cm = 1.0, rtp = 0.8, convergeRatio = DEFAULT_CONVERGE_RATIO;
|
||||
if (box != null) {
|
||||
try { cm = OlympusModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
|
||||
if (box.getRtp() != null) rtp = box.getRtp().doubleValue();
|
||||
if (box.getRtpConvergeRatio() != null) convergeRatio = box.getRtpConvergeRatio().doubleValue();
|
||||
}
|
||||
if (convergeRatio < 0) convergeRatio = 0;
|
||||
if (convergeRatio > 0.9) convergeRatio = 0.9;
|
||||
// 一轮总回合 N 按该身份固定池分布反推(身份级独立,各身份可不同),不使用箱子级 total_ornaments;
|
||||
// 无分布时回退箱子级值兜底
|
||||
int fixedSum = dist.stream().mapToInt(bo -> safe(bo.getCount())).sum();
|
||||
int N;
|
||||
if (fixedSum > 0) {
|
||||
N = (int) Math.round(fixedSum / (1.0 - convergeRatio));
|
||||
} else {
|
||||
N = (box != null && box.getTotalOrnaments() != null && box.getTotalOrnaments() > 0)
|
||||
? box.getTotalOrnaments() : fixedSum;
|
||||
}
|
||||
int fixedRounds = (int) Math.round(N * (1 - convergeRatio));
|
||||
long drawn = cycleRepository.drawn(boxId, audience);
|
||||
int totalRemaining = dist.stream().mapToInt(bo -> safe(bo.getRemaining())).sum();
|
||||
|
||||
// 固定阶段
|
||||
if (drawn < fixedRounds && totalRemaining > 0) {
|
||||
Integer ornId = pickFixedOrnId(dist, bet, payoutCap);
|
||||
if (ornId == null) ornId = dist.get(0).getOrnamentId();
|
||||
OlympusRound r = resolveRound(boxId, ornId, needFreeSpins, bet, payoutCap);
|
||||
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), false);
|
||||
}
|
||||
|
||||
// 收敛阶段
|
||||
double targetMul = rtp * cm;
|
||||
long remainingDraws = Math.max(1, N - drawn);
|
||||
double remainingBudget = N * targetMul - cycleRepository.mulSum(boxId, audience);
|
||||
double desiredNextMul = Math.max(0, remainingBudget / remainingDraws);
|
||||
OlympusRound r = pickConverge(boxId, dist, needFreeSpins, bet, payoutCap, desiredNextMul);
|
||||
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), true);
|
||||
}
|
||||
|
||||
private Integer pickFixedOrnId(List<OlympusBoxOrnament> dist, BigDecimal bet, BigDecimal payoutCap) {
|
||||
List<OlympusBoxOrnament> eligible = new ArrayList<>();
|
||||
for (OlympusBoxOrnament bo : dist) {
|
||||
if (Math.max(0, safe(bo.getRemaining())) <= 0) continue;
|
||||
if (expectPayout(bo, bet).compareTo(payoutCap) <= 0) eligible.add(bo);
|
||||
}
|
||||
return !eligible.isEmpty() ? weightedPickByRemaining(eligible) : minPayoutOrnId(dist, bet);
|
||||
}
|
||||
|
||||
private OlympusRound resolveRound(Long boxId, Integer ornId, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
|
||||
OlympusRound round = pickRoundWithCap(boxId, ornId, needFreeSpins, bet, payoutCap);
|
||||
if (round == null) round = copyFromSameBucket(boxId, ornId, needFreeSpins);
|
||||
if (round == null && needFreeSpins != null) {
|
||||
round = roundMapper.pickByOrnament(boxId, ornId, null);
|
||||
if (round == null) round = copyFromSameBucket(boxId, ornId, null);
|
||||
}
|
||||
if (round == null) throw new RuntimeException("暂无可用回合数据");
|
||||
return round;
|
||||
}
|
||||
|
||||
/** 桶内随机取一条,真实玩家还要 actual_mul×bet ≤ payoutCap(最多重抽 5 次,抽不到就退回原条) */
|
||||
private OlympusRound pickRoundWithCap(Long boxId, Integer ornId, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
|
||||
OlympusRound round = roundMapper.pickByOrnament(boxId, ornId, needFreeSpins);
|
||||
if (round == null || payoutCap == null) return round;
|
||||
BigDecimal b = bet != null ? bet : BigDecimal.ZERO;
|
||||
if (withinCap(round, b, payoutCap)) return round;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
OlympusRound r = roundMapper.pickByOrnament(boxId, ornId, needFreeSpins);
|
||||
if (r != null && withinCap(r, b, payoutCap)) return r;
|
||||
}
|
||||
return round;
|
||||
}
|
||||
|
||||
private OlympusRound pickConverge(Long boxId, List<OlympusBoxOrnament> dist, Boolean needFreeSpins,
|
||||
BigDecimal bet, BigDecimal payoutCap, double desiredNextMul) {
|
||||
BigDecimal b = (bet != null && bet.signum() > 0) ? bet : BigDecimal.ONE;
|
||||
List<OlympusBoxOrnament> allowed = new ArrayList<>();
|
||||
for (OlympusBoxOrnament bo : dist) {
|
||||
if (expectPayout(bo, b).compareTo(payoutCap) <= 0) allowed.add(bo);
|
||||
}
|
||||
OlympusBoxOrnament chosen = allowed.isEmpty() ? minExpectMul(dist) : closestExpectMul(allowed, desiredNextMul);
|
||||
Integer ornId = chosen.getOrnamentId();
|
||||
|
||||
double capMul = payoutCap.doubleValue() / b.doubleValue();
|
||||
double effectiveDesired = Math.min(desiredNextMul, capMul);
|
||||
BigDecimal desiredBd = BigDecimal.valueOf(effectiveDesired).setScale(4, RoundingMode.HALF_UP);
|
||||
BigDecimal capBd = BigDecimal.valueOf(capMul).setScale(4, RoundingMode.HALF_UP);
|
||||
|
||||
OlympusRound round = roundMapper.pickClosestMul(boxId, ornId, needFreeSpins, desiredBd, capBd);
|
||||
if (round == null && needFreeSpins != null) round = roundMapper.pickClosestMul(boxId, ornId, null, desiredBd, capBd);
|
||||
if (round == null) round = copyFromSameBucket(boxId, ornId, needFreeSpins);
|
||||
if (round == null) round = copyFromSameBucket(boxId, ornId, null);
|
||||
if (round == null) throw new RuntimeException("收敛阶段暂无可用回合数据");
|
||||
return round;
|
||||
}
|
||||
|
||||
private OlympusBoxOrnament closestExpectMul(List<OlympusBoxOrnament> list, double desired) {
|
||||
List<OlympusBoxOrnament> sorted = new ArrayList<>(list);
|
||||
sorted.sort(Comparator.comparingDouble(bo -> {
|
||||
double mul = bo.getExpectMul() != null ? bo.getExpectMul().doubleValue() : 0;
|
||||
return Math.abs(mul - desired);
|
||||
}));
|
||||
int pickCount = Math.min(3, sorted.size());
|
||||
return sorted.get(ThreadLocalRandom.current().nextInt(pickCount));
|
||||
}
|
||||
|
||||
private OlympusBoxOrnament minExpectMul(List<OlympusBoxOrnament> list) {
|
||||
OlympusBoxOrnament best = list.get(0);
|
||||
double bestMul = Double.MAX_VALUE;
|
||||
for (OlympusBoxOrnament bo : list) {
|
||||
double mul = bo.getExpectMul() != null ? bo.getExpectMul().doubleValue() : 0;
|
||||
if (mul < bestMul) { bestMul = mul; best = bo; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 同桶复制已有回合(新ID,动画数据原样拷贝),保证一定有回合可用。 */
|
||||
private OlympusRound copyFromSameBucket(Long boxId, Integer ornamentId, Boolean needFreeSpins) {
|
||||
OlympusRound template = null;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
template = roundMapper.pickByOrnament(boxId, ornamentId, needFreeSpins);
|
||||
if (template != null && nz(template.getActualMul()).signum() > 0) break;
|
||||
}
|
||||
if (template == null) {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
template = roundMapper.pickAnyByBox(boxId, needFreeSpins);
|
||||
if (template != null && nz(template.getActualMul()).signum() > 0) break;
|
||||
}
|
||||
if (template == null) return null;
|
||||
}
|
||||
List<OlympusStep> templateSteps = stepMapper.selectByRoundId(template.getRoundId());
|
||||
Long newRoundId = nextRoundId();
|
||||
long newStepId = nextStepId();
|
||||
for (OlympusStep ts : templateSteps) {
|
||||
OlympusStep ns = new OlympusStep();
|
||||
ns.setId(newStepId++);
|
||||
ns.setRoundId(newRoundId);
|
||||
ns.setFreeSpinId(ts.getFreeSpinId());
|
||||
ns.setCascadeSeq(ts.getCascadeSeq());
|
||||
ns.setGridState(ts.getGridState());
|
||||
ns.setMultiplierDetail(ts.getMultiplierDetail());
|
||||
ns.setSymbolLinks(ts.getSymbolLinks());
|
||||
ns.setAccumulatedSymbols(ts.getAccumulatedSymbols());
|
||||
ns.setSpinWinners(ts.getSpinWinners());
|
||||
ns.setRemovedPositions(ts.getRemovedPositions());
|
||||
ns.setDropData(ts.getDropData());
|
||||
ns.setFillData(ts.getFillData());
|
||||
ns.setScatterTrigger(ts.getScatterTrigger());
|
||||
ns.setRetriggerInfo(ts.getRetriggerInfo());
|
||||
ns.setBaseScore(ts.getBaseScore());
|
||||
ns.setWinAmount(ts.getWinAmount());
|
||||
ns.setAccumulatorBefore(ts.getAccumulatorBefore());
|
||||
ns.setAccumulatorAfter(ts.getAccumulatorAfter());
|
||||
ns.setSpinWin(ts.getSpinWin());
|
||||
ns.setWinCapped(ts.getWinCapped());
|
||||
ns.setBigwinLevel(ts.getBigwinLevel());
|
||||
ns.setIsFinal(ts.getIsFinal());
|
||||
stepMapper.insert(ns);
|
||||
}
|
||||
OlympusRound copy = new OlympusRound();
|
||||
copy.setRoundId(newRoundId);
|
||||
copy.setBoxId(boxId);
|
||||
copy.setOrnamentId(ornamentId);
|
||||
copy.setActualMul(template.getActualMul());
|
||||
copy.setFreeSpinCount(template.getFreeSpinCount());
|
||||
copy.setTotalCascades(template.getTotalCascades());
|
||||
copy.setCreateTime(new Date());
|
||||
roundMapper.insert(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private Long nextRoundId() {
|
||||
List<OlympusRound> max = roundMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusRound>().orderByDesc(OlympusRound::getRoundId).last("limit 1"));
|
||||
return max.isEmpty() ? 1L : max.get(0).getRoundId() + 1;
|
||||
}
|
||||
|
||||
private Long nextStepId() {
|
||||
List<OlympusStep> max = stepMapper.selectList(
|
||||
new LambdaQueryWrapper<OlympusStep>().orderByDesc(OlympusStep::getId).last("limit 1"));
|
||||
return max.isEmpty() ? 1L : max.get(0).getId() + 1;
|
||||
}
|
||||
|
||||
private BigDecimal expectPayout(OlympusBoxOrnament bo, BigDecimal bet) {
|
||||
BigDecimal mul = bo.getExpectMul() != null ? bo.getExpectMul() : BigDecimal.ZERO;
|
||||
return mul.multiply(bet != null ? bet : BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
private Integer weightedPickByRemaining(List<OlympusBoxOrnament> list) {
|
||||
if (list.isEmpty()) return null;
|
||||
int totalWeight = list.stream().mapToInt(bo -> Math.max(0, safe(bo.getRemaining()))).sum();
|
||||
if (totalWeight <= 0) return list.get(0).getOrnamentId();
|
||||
int r = ThreadLocalRandom.current().nextInt(totalWeight);
|
||||
int acc = 0;
|
||||
for (OlympusBoxOrnament bo : list) {
|
||||
acc += Math.max(0, safe(bo.getRemaining()));
|
||||
if (r < acc) return bo.getOrnamentId();
|
||||
}
|
||||
return list.get(0).getOrnamentId();
|
||||
}
|
||||
|
||||
private Integer minPayoutOrnId(List<OlympusBoxOrnament> dist, BigDecimal bet) {
|
||||
OlympusBoxOrnament best = null;
|
||||
for (OlympusBoxOrnament bo : dist) {
|
||||
if (Math.max(0, safe(bo.getRemaining())) <= 0) continue;
|
||||
if (best == null || expectPayout(bo, bet).compareTo(expectPayout(best, bet)) < 0) best = bo;
|
||||
}
|
||||
return best != null ? best.getOrnamentId() : dist.get(0).getOrnamentId();
|
||||
}
|
||||
|
||||
private boolean withinCap(OlympusRound round, BigDecimal bet, BigDecimal payoutCap) {
|
||||
BigDecimal mul = nz(round.getActualMul());
|
||||
return mul.signum() > 0 && mul.multiply(bet).compareTo(payoutCap) <= 0;
|
||||
}
|
||||
|
||||
private static BigDecimal nz(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
|
||||
private static int safe(Integer v) { return v != null ? v : 0; }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.game.olympus.mapper.OlympusBoxOrnamentMapper">
|
||||
|
||||
<!-- 按 (boxId, audience) 查桶分布,连表带出 expectMul,计算 remaining=count-used -->
|
||||
<select id="selectByBoxIdAndAudience" resultType="com.ruoyi.game.olympus.domain.OlympusBoxOrnament">
|
||||
SELECT bo.*, o.name AS ornamentName, o.expect_mul AS expectMul,
|
||||
(bo.count - bo.used) AS remaining
|
||||
FROM game_olympus_box_ornament bo
|
||||
LEFT JOIN game_olympus_ornament o ON o.id = bo.ornament_id
|
||||
WHERE bo.box_id = #{boxId} AND bo.audience = #{audience}
|
||||
ORDER BY bo.ornament_id ASC
|
||||
</select>
|
||||
|
||||
<insert id="batchInsert">
|
||||
INSERT INTO game_olympus_box_ornament (box_id, ornament_id, audience, count, used) VALUES
|
||||
<foreach collection="list" item="it" separator=",">
|
||||
(#{it.boxId}, #{it.ornamentId}, #{it.audience}, #{it.count}, #{it.used})
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<delete id="deleteByBoxIdAndAudience">
|
||||
DELETE FROM game_olympus_box_ornament WHERE box_id = #{boxId} AND audience = #{audience}
|
||||
</delete>
|
||||
|
||||
<update id="incrUsed">
|
||||
UPDATE game_olympus_box_ornament SET used = used + 1
|
||||
WHERE box_id = #{boxId} AND ornament_id = #{ornamentId} AND audience = #{audience}
|
||||
</update>
|
||||
|
||||
<update id="resetUsed">
|
||||
UPDATE game_olympus_box_ornament SET used = 0
|
||||
WHERE box_id = #{boxId} AND audience = #{audience}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.game.olympus.mapper.OlympusRoundMapper">
|
||||
|
||||
<select id="pickByOrnament" resultType="com.ruoyi.game.olympus.domain.OlympusRound">
|
||||
SELECT * FROM game_olympus_round
|
||||
WHERE box_id = #{boxId} AND ornament_id = #{ornamentId}
|
||||
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count > 0</if>
|
||||
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
|
||||
ORDER BY RAND() LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="pickAnyByBox" resultType="com.ruoyi.game.olympus.domain.OlympusRound">
|
||||
SELECT * FROM game_olympus_round
|
||||
WHERE box_id = #{boxId}
|
||||
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count > 0</if>
|
||||
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
|
||||
ORDER BY RAND() LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 收敛阶段:桶内取 actual_mul 最接近 desiredMul 的回合,优先 actual_mul ≤ capMul,从 top-10 随机取1 -->
|
||||
<select id="pickClosestMul" resultType="com.ruoyi.game.olympus.domain.OlympusRound">
|
||||
SELECT * FROM (
|
||||
SELECT * FROM game_olympus_round
|
||||
WHERE box_id = #{boxId} AND ornament_id = #{ornamentId}
|
||||
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count > 0</if>
|
||||
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
|
||||
ORDER BY (CASE WHEN actual_mul <= #{capMul} THEN 0 ELSE 1 END),
|
||||
ABS(actual_mul - #{desiredMul})
|
||||
LIMIT 10
|
||||
) AS closest_pool
|
||||
ORDER BY RAND() LIMIT 1
|
||||
</select>
|
||||
<!-- 统计某个箱子池子里每个桶"实际赔出去多少"的均值:配比计算与后台「目标RTP」按它算 -->
|
||||
<select id="avgMulByOrnament" resultType="java.util.Map">
|
||||
SELECT ornament_id AS ornamentId, COUNT(*) AS sampleCount, AVG(actual_mul) AS avgMul
|
||||
FROM game_olympus_round
|
||||
WHERE box_id = #{boxId}
|
||||
GROUP BY ornament_id
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1 @@
|
||||
todo
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE `game_wheel_round` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '唯一自增id',
|
||||
`date` varchar(10) NOT NULL COMMENT '所属日期',
|
||||
`round_id` bigint NOT NULL COMMENT 'round id',
|
||||
`start_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间',
|
||||
`end_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '结束时间',
|
||||
`phase_times` varchar(128) NOT NULL DEFAULT '' COMMENT '每个阶段的时间点',
|
||||
`total_bet` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '本期总下注数',
|
||||
`total_win` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '本期玩家总赢数',
|
||||
`symbols` varchar(64) NOT NULL DEFAULT '' COMMENT '转盘上的符号',
|
||||
`result` varchar(64) NOT NULL COMMENT '开出的奖的集合',
|
||||
`is_manual` int NOT NULL DEFAULT 0 COMMENT '是否后台开奖',
|
||||
`status` varchar(16) NOT NULL DEFAULT '' COMMENT '状态',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_date_round_id` (`date`, `round_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘每期表';
|
||||
|
||||
CREATE TABLE `game_wheel_round_bet` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '唯一自增id',
|
||||
`round_id` bigint NOT NULL COMMENT 'round id',
|
||||
`date` varchar(10) NOT NULL DEFAULT '' COMMENT '所属日期yyyyMMdd(roundId每日重置,需与date联合定位回合)',
|
||||
`user_id` bigint NOT NULL DEFAULT 0 COMMENT '用户id',
|
||||
`bet` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '下注数',
|
||||
`bet_symbol` varchar(4) NOT NULL DEFAULT '' COMMENT '压注目标',
|
||||
`win` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '赔付结果',
|
||||
`settled` int NOT NULL DEFAULT 0 COMMENT '是否已结算 0=未结算 1=已结算',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_date_round_user_symbol` (`date`, `round_id`, `user_id`, `bet_symbol`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘下注表';
|
||||
|
||||
CREATE TABLE `game_wheel_user` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
|
||||
`user_id` bigint NOT NULL COMMENT 'user表的id',
|
||||
`total_bet` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '用户总下注额',
|
||||
`total_win` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '用户总赢得金额',
|
||||
`count` int NOT NULL DEFAULT '0' COMMENT '总下注次数',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘用户表';
|
||||
|
||||
|
||||
ALTER TABLE sys_config MODIFY COLUMN config_value VARCHAR(1024);
|
||||
|
||||
游戏配置数据:
|
||||
```
|
||||
{
|
||||
// 核心配置
|
||||
"symbols": [1,2,3,4,5,6,7,8,7,6,5,4,3,2,1], // 转盘格子配置
|
||||
"buffer_ratio": 0.9, // 一次旋转,最多可用的奖池比例
|
||||
"rtp": 0.85, // 一次旋转最多返回给玩家的总投注比例
|
||||
// 下面是体验相关,不影响利润
|
||||
"special_effects": [{"name": "系统赢", "ratio": 0.7},{"name": "免费旋转1次", "ratio": 0.2},{"name": "免费旋转2次", "ratio": 0.1}]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>skins-service</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</parent>
|
||||
|
||||
<description>
|
||||
疯狂转盘
|
||||
</description>
|
||||
|
||||
<artifactId>game-wheel-service</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>8</maven.compiler.source>
|
||||
<maven.compiler.target>8</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>service-admin</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>service-user</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>service-thirdparty</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 消息通知统一发送模块 -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>message-service</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>skins-service</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>message-service</artifactId>
|
||||
|
||||
<description>
|
||||
消息通知统一发送模块
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>skins-model</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ruoyi.message.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.domain.other.TtNotice;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 消息通知 Mapper。
|
||||
*/
|
||||
@Mapper
|
||||
public interface MessageMapper extends BaseMapper<TtNotice> {
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.message.service;
|
||||
|
||||
/**
|
||||
* 消息通知统一发送服务。
|
||||
* <p>
|
||||
* 负责向 tt_notice 表写入用户通知,供邮件/消息中心展示。
|
||||
*/
|
||||
public interface MessageService {
|
||||
|
||||
/**
|
||||
* 发送通知(不带分类,兼容旧调用)。
|
||||
*/
|
||||
void sendNotice(Long userId, String title, String content);
|
||||
|
||||
/**
|
||||
* 发送通知(带消息分类)。
|
||||
*
|
||||
* @param category 分类 code,如 GAME_PVP_TOP / SYS_VIP_REBATE
|
||||
*/
|
||||
void sendNotice(Long userId, String title, String content, String category);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.ruoyi.message.service.impl;
|
||||
|
||||
import com.ruoyi.common.utils.DateUtils;
|
||||
import com.ruoyi.domain.other.TtNotice;
|
||||
import com.ruoyi.message.mapper.MessageMapper;
|
||||
import com.ruoyi.message.service.MessageService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 消息通知统一发送服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class MessageServiceImpl implements MessageService {
|
||||
|
||||
@Autowired
|
||||
private MessageMapper messageMapper;
|
||||
|
||||
@Override
|
||||
public void sendNotice(Long userId, String title, String content) {
|
||||
sendNotice(userId, title, content, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendNotice(Long userId, String title, String content, String category) {
|
||||
TtNotice notice = new TtNotice();
|
||||
notice.setUserId(userId);
|
||||
notice.setTitle(title);
|
||||
notice.setContent(content);
|
||||
notice.setRead("0");
|
||||
notice.setCategory(category);
|
||||
notice.setCreateTime(DateUtils.getNowDate());
|
||||
messageMapper.insert(notice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>skins-service</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<modules>
|
||||
<module>message-service</module>
|
||||
<module>service-user</module>
|
||||
<module>service-thirdparty</module>
|
||||
<module>service-admin</module>
|
||||
<module>service-playingmethod</module>
|
||||
<module>service-task</module>
|
||||
<module>game-wheel-service</module>
|
||||
<module>game-olympus-service</module>
|
||||
<module>reward-service</module>
|
||||
</modules>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-system</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>skins-model</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger3依赖 -->
|
||||
<dependency>
|
||||
<groupId>io.springfox</groupId>
|
||||
<artifactId>springfox-boot-starter</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
<!-- <exclusions>-->
|
||||
<!-- <exclusion>-->
|
||||
<!-- <groupId>io.swagger</groupId>-->
|
||||
<!-- <artifactId>swagger-models</artifactId>-->
|
||||
<!-- </exclusion>-->
|
||||
<!-- </exclusions>-->
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>skins-service</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>reward-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ruoyi.reward.contract.admin.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class AdjustRewardPoolRequest {
|
||||
// tighten(抽水: bet→profit) | loosen(放水: profit→bet)
|
||||
private String direction;
|
||||
private BigDecimal amount;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user