first commit

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