修改河南项目的管理后台ui

This commit is contained in:
2026-09-24 11:18:30 +08:00
parent 23fe1f3995
commit 4be9ce4e85
19 changed files with 2513 additions and 178 deletions
@@ -75,6 +75,11 @@ public class RedisConstants {
// 幸运升级
public static final String UPGRADE_RANGE = "upgrade_range:"; // 概率区间 '业务key:饰品id:用户类型'
public static final String UPGRADE_RANGE_FIXED = "upgrade_range_fixed:"; // 固定概率 '业务key:饰品id:用户类型'
// 幸运升级·全局奖池水位累计量(Redis 增量计数,DB 只在初始化/重算时读):
// upgrade_pool:bet / :win(排除白号)、upgrade_pool:blank:bet / :blank:win(白号单独统计)、:init_at(上次重算时间)、
// :pending:bet / :pending:win —— 「已抽走随机数、但还没出货」的那些轮次的**预估交易**,
// 用来算「虚拟水位」:避免一批饰品同时开新轮时都按同一个水位放水(见 UpgradeWatermarkService)。
public static final String UPGRADE_POOL = "upgrade_pool:";
/**
@@ -0,0 +1,111 @@
package com.ruoyi.admin.controller;
import com.ruoyi.admin.service.UpgradeWatermarkService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.domain.other.UpgradeWatermarkSettingBody;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
/**
* 幸运升级·奖池水位(后台)。Base = /admin/upgradeWatermark。
* <p>
* 水位是**全局**口径(整个幸运升级一个水位,不按饰品位拆),且**排除白号**。
* 后台只配一个「目标水位(%)」:水位高于它 → 随机数中心下移(放水);低于它 → 上移(收紧),
* 长期自动收敛回目标值。随机数的取值范围仍由每个饰品自己的区间限定。
*/
@Api(tags = "幸运升级·奖池水位")
@RestController
@RequestMapping("/admin/upgradeWatermark")
public class TtUpgradeWatermarkController extends BaseController {
@Autowired
private UpgradeWatermarkService watermarkService;
@ApiOperation("当前水位快照 + 目标水位策略 + 按目标水位推算的随机数中心")
@GetMapping("/info")
public R<Map<String, Object>> info() {
return R.ok(watermarkService.info());
}
@ApiOperation("保存目标水位策略(目标水位 + 灵敏度)")
@Log(title = "幸运升级水位策略", action = "保存", businessType = BusinessType.UPDATE)
@PostMapping("/setting")
public R<Map<String, Object>> saveSetting(@RequestBody UpgradeWatermarkSettingBody body) {
String err = watermarkService.saveSetting(body);
if (err != null) {
return R.fail(err);
}
return R.ok(watermarkService.info());
}
@ApiOperation("注水/抽水(type 1注水 2抽水;调整原因必填)")
@Log(title = "幸运升级水位", action = "注水/抽水", businessType = BusinessType.GRANT)
@PostMapping("/adjust")
public R<Map<String, Object>> adjust(@RequestBody Map<String, Object> body) {
Integer type = asInt(body.get("type"));
BigDecimal amount = asBd(body.get("amount"));
String remark = body.get("remark") == null ? null : body.get("remark").toString();
try {
return R.ok(watermarkService.adjust(type == null ? 0 : type, amount, remark, getUsername()));
} catch (IllegalArgumentException e) {
return R.fail(e.getMessage());
}
}
@ApiOperation("重置人工调整(插入抵消日志,SUM(delta) 归零,历史保留)")
@Log(title = "幸运升级水位", action = "重置调整", businessType = BusinessType.CLEAN)
@PostMapping("/adjustReset")
public R<Map<String, Object>> adjustReset(@RequestBody(required = false) Map<String, Object> body) {
String remark = body == null || body.get("remark") == null ? null : body.get("remark").toString();
try {
return R.ok(watermarkService.resetAdjust(remark, getUsername()));
} catch (IllegalArgumentException e) {
return R.fail(e.getMessage());
}
}
@ApiOperation("调整历史(分页)")
@GetMapping("/adjustLog")
public R<Map<String, Object>> adjustLog(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return R.ok(watermarkService.pageLog(page, size));
}
@ApiOperation("按历史记录全量重算累计下注/返奖(纠正 Redis 计数漂移)")
@Log(title = "幸运升级水位", action = "重算累计量", businessType = BusinessType.CLEAN)
@PostMapping("/recalc")
public R<Map<String, Object>> recalc() {
watermarkService.ensureInit(true);
return R.ok(watermarkService.info());
}
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,32 @@
package com.ruoyi.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.other.TtUpgradeRewardLog;
import org.apache.ibatis.annotations.Mapper;
import java.math.BigDecimal;
import java.util.Map;
/**
* 幸运升级奖池水位:人工调整日志 + 水位统计口径的聚合查询。
* <p>
* 水位统计**全局**(不按饰品位拆)且**排除白号**;白号的量单独返回,供后台并列展示
* ——白号发出去的饰品是真金白银的成本,只是不进返礼口径,让它隐形会让 RTP 台账偏乐观。
*/
@Mapper
public interface TtUpgradeRewardLogMapper extends BaseMapper<TtUpgradeRewardLog> {
/**
* 全站累计下注/返奖(分口径)。
* <p>
* 白号判定双重兜底:记录上的身份快照 {@code r.user_type='06'},或关联用户在
* {@code tt_user.is_blank_account=1}(历史上有只改标记没改 user_type 的行)。
* 用户已删除时 LEFT JOIN 为 null,按「非白号」计入统计。
*
* @return totalBet / totalWin(排除白号)、blankBet / blankWin(仅白号)、adjust(人工调整额 = SUM(delta))
*/
Map<String, BigDecimal> sumRecordTotals();
/** 人工调整净额 = SUM(delta) */
BigDecimal sumAdjust();
}
@@ -0,0 +1,678 @@
package com.ruoyi.admin.service;
import cn.hutool.json.JSONUtil;
import com.ruoyi.admin.config.RedisConstants;
import com.ruoyi.admin.mapper.TtUpgradeRewardLogMapper;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.domain.other.TtUpgradeRewardLog;
import com.ruoyi.domain.other.UpgradeWatermarkSettingBody;
import com.ruoyi.system.domain.SysConfig;
import com.ruoyi.system.mapper.SysConfigMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
/**
* 幸运升级奖池水位(全局口径)。
* <p>
* <b>水位定义</b>:{@code 水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100},
* 统计范围是**整个幸运升级**(不分饰品位),且**排除白号**;白号的量与成本单独返回供后台并列展示。
* 水位为正表示平台已净赚该比例;为负表示已放水过头。
* <p>
* <b>怎么用</b>:后台只配一个「目标水位」{@code T},随机数全自动推算 ——
* <ol>
* <li><b>中心</b>:{@code V* = 100 / ((1 − T/100) × k)},即「长期出这个随机数,水位正好停在 T」。
* {@code k} 是实测标定系数(判定溢出使实际需求约为名义值的 1.18~1.20 倍)。</li>
* <li><b>负反馈</b>:{@code 中心 = V* × (1 − (当前水位 − T)/100 × gain)}。
* 水位高于目标 ⇒ 中心下调(放水);低于目标 ⇒ 上调(收紧)。长期自动收敛回 T。</li>
* <li><b>落点</b>:在该饰品位自己的随机数区间 {@code [min, max]} 内,围绕中心均匀抖动,
* 并夹取到区间边界。区间越宽,单轮波动越大、越难被玩家归纳出规律。</li>
* </ol>
* ⚠️ 因此**饰品区间必须包含 {@code V*}**,否则中心会被长期夹在边界上,水位停在别处而非目标值。
* <p>
* <b>记账方式</b>:累计下注/返奖走 Redis 增量计数(每次拉取 +一次),DB 只用于初始化与重算
* ——避免在拉取热路径上对 {@code tt_upgrade_record} 做全表聚合。人工调整额 = 日志表
* {@code SUM(delta)}(DB 为真相)。
*/
@Slf4j
@Service
public class UpgradeWatermarkService {
public static final String CONFIG_KEY = "luckyUpgradeWatermark";
/** 默认策略:目标水位 30%、灵敏度 2、幸运值口径 raw */
public static final String DEFAULT_SETTING_JSON =
"{\"enabled\":true,\"targetWatermark\":30,\"gain\":2,\"luckyValueMode\":\"raw\"}";
/** 默认目标水位(%) */
public static final double DEFAULT_TARGET_WATERMARK = 30d;
/** 默认灵敏度:水位每偏离目标 1 个点,随机数中心反向调整 2% */
public static final double DEFAULT_GAIN = 2d;
/**
* 标定系数兜底值:实测「实际下注 / 名义折价」约 1.18~1.20
* (判定跨过阈值即出货、最后一拉整段 eff 计入并丢弃溢出)。样本不足时按它推算中心。
*/
public static final double DEFAULT_CALIBRATION = 1.19d;
/** 目标水位合法区间(含端点外开) */
private static final double MIN_TARGET_WATERMARK = -50d;
private static final double MAX_TARGET_WATERMARK = 95d;
/** 随机数中心的兜底上下限 */
private static final double MIN_CENTER = 1d;
private static final double MAX_CENTER = 500d;
/**
* 分布重心在区间内的相对位置上下限。太小 / 太大都会让偏斜指数 p 爆炸,
* 分布退化成「几乎必出边界」;同时它也决定了中心的可表达范围 = 区间下界 + [10%, 90%] 宽度。
*/
private static final double MIN_CENTER_RATIO = 0.10d;
private static final double MAX_CENTER_RATIO = 0.90d;
private static final String KEY_BET = RedisConstants.UPGRADE_POOL + "bet";
private static final String KEY_WIN = RedisConstants.UPGRADE_POOL + "win";
private static final String KEY_BLANK_BET = RedisConstants.UPGRADE_POOL + "blank:bet";
private static final String KEY_BLANK_WIN = RedisConstants.UPGRADE_POOL + "blank:win";
private static final String KEY_INIT_AT = RedisConstants.UPGRADE_POOL + "init_at";
/** 已抽走随机数、但还没出货的那些轮次的**剩余**预估交易(实际口径的下注 / 返奖),用于算虚拟水位 */
private static final String KEY_PENDING_BET = RedisConstants.UPGRADE_POOL + "pending:bet";
private static final String KEY_PENDING_WIN = RedisConstants.UPGRADE_POOL + "pending:win";
private static final long INIT_THROTTLE_MILLIS = TimeUnit.HOURS.toMillis(1);
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private TtUpgradeRewardLogMapper rewardLogMapper;
@Autowired
private SysConfigMapper sysConfigMapper;
/**
* 启动时从 DB 重算一次累计量,纠正 Redis 丢失/回滚导致的漂移,并预热标定系数。
* 限流到 1 小时一次,避免开发期频繁重启反复全表扫。异常只告警,不阻断启动。
*/
@PostConstruct
public void bootInit() {
try {
ensureInit(false);
calibrationFactor();
} catch (Exception e) {
log.warn("幸运升级水位启动初始化失败(不阻断启动,首次拉取时会自动重试)", e);
}
}
// ==================== 配置 ====================
public UpgradeWatermarkSettingBody getSetting() {
UpgradeWatermarkSettingBody cached = cachedSetting;
if (cached != null && System.currentTimeMillis() - cachedSettingAt < SETTING_TTL_MILLIS) {
return cached;
}
UpgradeWatermarkSettingBody loaded = loadSetting();
cachedSetting = loaded;
cachedSettingAt = System.currentTimeMillis();
return loaded;
}
private UpgradeWatermarkSettingBody loadSetting() {
try {
SysConfig cfg = sysConfigMapper.checkConfigKeyUnique(CONFIG_KEY);
if (cfg != null && StringUtils.isNotEmpty(cfg.getConfigValue())) {
UpgradeWatermarkSettingBody body = JSONUtil.toBean(cfg.getConfigValue(), UpgradeWatermarkSettingBody.class);
if (body != null && body.getTargetWatermark() != null) {
return withDefaults(body);
}
}
} catch (Exception e) {
log.warn("解析幸运升级水位策略失败,改用默认值", e);
}
return withDefaults(JSONUtil.toBean(DEFAULT_SETTING_JSON, UpgradeWatermarkSettingBody.class));
}
private static UpgradeWatermarkSettingBody withDefaults(UpgradeWatermarkSettingBody body) {
if (body == null) {
body = new UpgradeWatermarkSettingBody();
}
if (body.getTargetWatermark() == null) {
body.setTargetWatermark(DEFAULT_TARGET_WATERMARK);
}
if (body.getGain() == null || body.getGain() <= 0) {
body.setGain(DEFAULT_GAIN);
}
if (StringUtils.isEmpty(body.getLuckyValueMode())) {
body.setLuckyValueMode("raw");
}
if (body.getEnabled() == null) {
body.setEnabled(Boolean.TRUE);
}
return body;
}
/** 策略是否生效(false = 回退旧的固定幸运区间行为) */
public boolean isEnabled() {
try {
return Boolean.TRUE.equals(getSetting().getEnabled());
} catch (Exception e) {
return false;
}
}
/** 目标水位(%):平台希望长期维持的净收益比例 */
public double targetWatermark() {
try {
Double t = getSetting().getTargetWatermark();
return t == null ? DEFAULT_TARGET_WATERMARK : t;
} catch (Exception e) {
return DEFAULT_TARGET_WATERMARK;
}
}
/** 响应灵敏度(水位每偏离目标 1 个点,随机数中心反向调整的百分比) */
public double gain() {
try {
Double g = getSetting().getGain();
return (g == null || g <= 0) ? DEFAULT_GAIN : g;
} catch (Exception e) {
return DEFAULT_GAIN;
}
}
/**
* 幸运值累加口径:默认 {@code raw}(累加前端原始概率,展示口径,保持与历史一致);
* 仅当显式配置为 {@code eff} 时才累加 {@code min(概率,75)}(与中奖判定同口径)。
* <p>
* 注意这是**展示口径**:不管取哪个值,中奖判定始终用 {@code min(概率,75)},不受这里影响。
*/
public boolean isLuckyValueEffMode() {
try {
UpgradeWatermarkSettingBody s = getSetting();
return s != null && "eff".equalsIgnoreCase(s.getLuckyValueMode());
} catch (Exception e) {
return false;
}
}
/**
* 保存策略并做校验。
*
* @return 校验失败的原因,通过时返回 {@code null}
*/
public String saveSetting(UpgradeWatermarkSettingBody body) {
String err = validate(body);
if (err != null) {
return err;
}
UpgradeWatermarkSettingBody normalized = withDefaults(body);
String json = JSONUtil.toJsonStr(normalized);
SysConfig cfg = sysConfigMapper.checkConfigKeyUnique(CONFIG_KEY);
if (cfg == null) {
cfg = new SysConfig();
cfg.setConfigName("幸运升级水位策略");
cfg.setConfigKey(CONFIG_KEY);
cfg.setConfigValue(json);
sysConfigMapper.insertConfig(cfg);
} else {
cfg.setConfigValue(json);
sysConfigMapper.updateConfig(cfg);
}
// 立即失效内存缓存,让后台改完下一轮就生效
cachedSetting = null;
cachedSettingAt = 0L;
return null;
}
/** 校验:目标水位范围 + 灵敏度范围 + 幸运值口径取值 */
public String validate(UpgradeWatermarkSettingBody body) {
if (body == null) {
return "配置不能为空";
}
if (StringUtils.isNotEmpty(body.getLuckyValueMode())
&& !"eff".equalsIgnoreCase(body.getLuckyValueMode())
&& !"raw".equalsIgnoreCase(body.getLuckyValueMode())) {
return "幸运值口径只能是 eff 或 raw";
}
if (Boolean.FALSE.equals(body.getEnabled())) {
// 关闭时不校验水位参数(此时回退旧行为,水位参数用不上)
return null;
}
Double t = body.getTargetWatermark();
if (t == null) {
return "请填写目标水位";
}
if (t <= MIN_TARGET_WATERMARK || t >= MAX_TARGET_WATERMARK) {
return "目标水位需在 " + (int) MIN_TARGET_WATERMARK + "% ~ " + (int) MAX_TARGET_WATERMARK + "% 之间";
}
Double g = body.getGain();
if (g != null && (g < 0.5d || g > 10d)) {
return "灵敏度需在 0.5 ~ 10 之间";
}
return null;
}
// ==================== 水位 → 随机数 ====================
/**
* 使水位长期收敛到目标值所需的**随机数中心**(已含实测标定系数)。
* <p>
* 推导:玩家为该饰品付出的成本 ≈ 饰品价 × k × 随机数 / 100 ⇒ {@code RTP = 100/(k×随机数)},
* 水位 {@code = 1 − RTP}。令水位 = 目标值反解即得 {@code V* = 100 / ((1 − T/100) × k)}。
*/
public double targetCenter() {
double denom = (1 - targetWatermark() / 100d) * effectiveCalibration();
if (denom <= 0.01d) {
return MAX_CENTER;
}
double v = 100d / denom;
if (v < MIN_CENTER) {
return MIN_CENTER;
}
return Math.min(v, MAX_CENTER);
}
/**
* 当前水位下的随机数中心(含负反馈,未按饰品区间夹取)。
* 水位高于目标 → 中心下调(放水);水位低于目标 → 中心上调(收紧)。
*/
public double centerAt(double watermark) {
double base = targetCenter();
double v = base * (1 - (watermark - targetWatermark()) / 100d * gain());
if (v < MIN_CENTER) {
return MIN_CENTER;
}
return Math.min(v, MAX_CENTER);
}
/**
* 抽本轮的随机数(单位:饰品价的百分比):在饰品区间 {@code [lo, hi]} 内做**偏斜随机** ——
* 支撑集始终是**整个区间**(任何值都可能出),只有**概率密度**随水位移动:
* <pre>
* t = (中心 − 下限) / (上限 − 下限) // 中心在区间内的相对位置,夹到 [0.10, 0.90]
* p = 1/t − 1 // 偏斜指数
* V = 下限 + (上限 − 下限) × u^p // u ~ U(0,1)
* </pre>
* {@code E[u^p] = 1/(p+1) = t} ⇒ {@code E[V] = 中心},所以水位仍精确收敛到目标值。
* <p>
* 水位 = 目标时中心落在区间正中 ⇒ {@code t = 0.5, p = 1} ⇒ **均匀分布**(整个区间等概率);
* 水位高于目标(放水)⇒ 中心下移 ⇒ {@code p &gt; 1} ⇒ 概率向**下限**堆积;
* 水位低于目标(收紧)⇒ {@code p &lt; 1} ⇒ 概率向**上限**堆积。
* <p>
* ⚠️ 相比「中心平移 + 固定宽度抖动再夹取」的写法,这里抖动宽度不受中心位置影响
* —— 后者在中心贴近边界时会把落点压成贴着边界的一条窄带(水位偏离越大越窄)。
* <p>
* ★ <b>用的是「虚拟水位」而不是真实水位</b>:真实水位只反映**已经结算**的下注/返奖。
* 一批饰品同时开新轮时(同时上架、或几个玩家同时首次拉取),若都用同一个真实水位去算,
* 它们会全部落在同一侧偏斜上 —— 水位高时连着出好几个低随机数,而这批货真出完后水位其实已经变了。
* ⇒ 每抽一个就把这一轮的**预估交易**预留进 {@code upgrade_pool:pending:*}(见 {@link #reserve}),
* 下一个随机数就用含预留的虚拟水位算;这一轮出货过程中产生的实际流水再逐步兑现掉预留。
*
* @param ornamentPrice 目标饰品实时价,用于估算本轮出货的交易量;为空时退化为真实水位
* @param blank 白号轮次不进水位口径,因此不参与预留
*/
public int drawTarget(int lo, int hi, BigDecimal ornamentPrice, boolean blank) {
int min = Math.min(lo, hi);
int max = Math.max(lo, hi);
if (max <= min) {
return Math.max(1, min);
}
double center = centerAt(getWatermarkWithPending());
if (center < min) {
center = min;
}
if (center > max) {
center = max;
}
double width = max - min;
double t = (center - min) / width;
if (t < MIN_CENTER_RATIO) {
t = MIN_CENTER_RATIO;
}
if (t > MAX_CENTER_RATIO) {
t = MAX_CENTER_RATIO;
}
double p = 1d / t - 1d;
double u = ThreadLocalRandom.current().nextDouble();
long rounded = Math.round(min + width * Math.pow(u, p));
if (rounded < min) {
rounded = min;
}
if (rounded > max) {
rounded = max;
}
int target = (int) rounded;
// 抽完立刻预留:这一轮将来会产生的预估下注 / 返奖(出货过程中按实际流水逐步兑现)
reserve(ornamentPrice, target, blank);
return target;
}
/**
* 预留「这一轮出货时会产生的预估交易」:预估下注 = 饰品价 × k × 随机数 / 100(乘 k 是因为判定溢出,
* 实测等效需求是名义的 1.18~1.20 倍),预估返奖 = 饰品价。
* <p>
* 预留值会在 {@link #recordPull} 里按**实际**下注/返奖逐步扣减 —— 两者口径一致
* (k 就是「实际 / 名义」的实测系数),所以这一轮真正出货时预留基本归零,不会长期挂账。
* 白号轮次不进水位口径,直接跳过。
*/
public void reserve(BigDecimal ornamentPrice, int roundTarget, boolean blank) {
if (blank || ornamentPrice == null || ornamentPrice.signum() <= 0 || roundTarget <= 0) {
return;
}
try {
double price = ornamentPrice.doubleValue();
redisTemplate.opsForValue().increment(KEY_PENDING_BET, price * effectiveCalibration() * roundTarget / 100d);
redisTemplate.opsForValue().increment(KEY_PENDING_WIN, price);
} catch (Exception e) {
log.warn("幸运升级水位预留失败(不影响本次抽数)price={}, roundTarget={}", ornamentPrice, roundTarget, e);
}
}
/**
* 虚拟水位:把「已抽走随机数、还没出货」的那些轮次的**剩余**预估交易算进来。
* 抽随机数时用它,避免一批饰品同时开轮时全都按同一个水位放水 / 收紧。
*/
public double getWatermarkWithPending() {
WatermarkSnapshot s = readTotals();
double realBet = s.totalBet.doubleValue();
double pendingBet = readNum(KEY_PENDING_BET).doubleValue();
double pendingWin = readNum(KEY_PENDING_WIN).doubleValue();
double bet = realBet + pendingBet;
if (bet <= 0) {
return s.watermark;
}
double profit = realBet - s.totalWin.doubleValue() + s.adjust.doubleValue() + pendingBet - pendingWin;
return profit / bet * 100d;
}
/** 当前全局水位(%),可为负;累计下注为 0 时返回 0 */
public double getWatermark() {
return readTotals().watermark;
}
/**
* 一次拉取后的增量记账。在扣款与记录落库之后调用;
* 本轮用的水位是本轮开始时的值,所以这次增量只影响下一轮,顺序不能反。
* <p>
* 非白号的量同时会**兑现预留**(见 {@link #reserve}):这部分钱已经从「预估」变成「实际」,
* 所以要从 pending 里扣掉 —— 两边口径一致(都是实际金额),这一轮出货时 pending 基本归零。
*/
public void recordPull(BigDecimal bet, BigDecimal win, boolean blank) {
try {
double b = bet == null ? 0d : bet.doubleValue();
double w = win == null ? 0d : win.doubleValue();
if (blank) {
if (b != 0d) {
redisTemplate.opsForValue().increment(KEY_BLANK_BET, b);
}
if (w != 0d) {
redisTemplate.opsForValue().increment(KEY_BLANK_WIN, w);
}
} else {
if (b != 0d) {
redisTemplate.opsForValue().increment(KEY_BET, b);
redisTemplate.opsForValue().increment(KEY_PENDING_BET, -b);
}
if (w != 0d) {
redisTemplate.opsForValue().increment(KEY_WIN, w);
redisTemplate.opsForValue().increment(KEY_PENDING_WIN, -w);
}
}
} catch (Exception e) {
log.warn("幸运升级水位增量记账失败(不影响本次拉取)bet={}, win={}, blank={}", bet, win, blank, e);
}
}
/** 从 DB 全量重算累计下注/返奖并覆盖 Redis 计数(幂等)。force=false 时限流 1 小时一次。 */
public void ensureInit(boolean force) {
Object at = redisTemplate.opsForValue().get(KEY_INIT_AT);
long last = at == null ? 0L : (long) toDouble(at);
if (!force && last > 0 && System.currentTimeMillis() - last < INIT_THROTTLE_MILLIS) {
return;
}
Map<String, BigDecimal> row = rewardLogMapper.sumRecordTotals();
setIfPresent(KEY_BET, row, "totalBet");
setIfPresent(KEY_WIN, row, "totalWin");
setIfPresent(KEY_BLANK_BET, row, "blankBet");
setIfPresent(KEY_BLANK_WIN, row, "blankWin");
// 预留量同步归零:重算是「以真实流水为唯一真相」的时刻,未出货轮次的预估不再保留
//(也顺手兜住「某个轮次永远没出货、pending 长期挂账」的漂移)
redisTemplate.opsForValue().set(KEY_PENDING_BET, 0d);
redisTemplate.opsForValue().set(KEY_PENDING_WIN, 0d);
redisTemplate.opsForValue().set(KEY_INIT_AT, System.currentTimeMillis());
// 顺带刷新标定系数缓存
cachedCalibrationAt = 0L;
calibrationFactor();
}
private void setIfPresent(String key, Map<String, BigDecimal> row, String field) {
BigDecimal v = row == null ? null : row.get(field);
redisTemplate.opsForValue().set(key, (v == null ? BigDecimal.ZERO : v).doubleValue());
}
/** 人工调整净额 = SUM(delta) */
public BigDecimal getAdjust() {
try {
BigDecimal v = rewardLogMapper.sumAdjust();
return v == null ? BigDecimal.ZERO : v;
} catch (Exception e) {
log.warn("读取幸运升级人工调整额失败,按 0 处理", e);
return BigDecimal.ZERO;
}
}
/** 水位快照(后台展示 + 拉取判定共用) */
public WatermarkSnapshot readTotals() {
WatermarkSnapshot s = new WatermarkSnapshot();
s.totalBet = readNum(KEY_BET);
s.totalWin = readNum(KEY_WIN);
s.blankBet = readNum(KEY_BLANK_BET);
s.blankWin = readNum(KEY_BLANK_WIN);
s.adjust = getAdjust();
if (s.totalBet.compareTo(BigDecimal.ZERO) > 0) {
BigDecimal profit = s.totalBet.subtract(s.totalWin).add(s.adjust);
s.watermark = profit.multiply(HUNDRED).divide(s.totalBet, 4, RoundingMode.HALF_UP).doubleValue();
s.rtp = s.totalWin.multiply(HUNDRED).divide(s.totalBet, 4, RoundingMode.HALF_UP).doubleValue();
}
return s;
}
/**
* 实测标定系数 k = 改造后累计下注 / Σ(round_target/100 × 目标饰品价);无样本返回 0。
* <p>
* 全表聚合 + JOIN,**不能在拉取热路径上每次调用** ⇒ 5 分钟内存缓存
* (启动预热 + 后台「按历史重算」会主动刷新)。
*/
public double calibrationFactor() {
long now = System.currentTimeMillis();
if (cachedCalibrationAt > 0 && now - cachedCalibrationAt < CALIBRATION_TTL_MILLIS) {
return cachedCalibration;
}
double k = 0d;
try {
Map<String, BigDecimal> row = rewardLogMapper.sumRecordTotals();
if (row != null) {
double bet = toDouble(row.get("calBet"));
double nominal = toDouble(row.get("calNominal"));
k = (bet > 0 && nominal > 0) ? bet / nominal : 0d;
}
} catch (Exception e) {
log.warn("计算幸运升级标定系数失败", e);
}
cachedCalibration = k;
cachedCalibrationAt = now;
return k;
}
/** 实际用于推算中心的系数:样本不足时用兜底值 1.19 */
public double effectiveCalibration() {
double k = calibrationFactor();
return k > 0 ? k : DEFAULT_CALIBRATION;
}
/** 后台页面用的一份完整快照 */
public Map<String, Object> info() {
UpgradeWatermarkSettingBody setting = getSetting();
WatermarkSnapshot s = readTotals();
double k = calibrationFactor();
double target = targetWatermark();
double center = targetCenter();
Map<String, Object> m = new LinkedHashMap<>();
m.put("enabled", setting.getEnabled());
m.put("luckyValueMode", setting.getLuckyValueMode());
m.put("targetWatermark", round2(target));
m.put("gain", round2(gain()));
m.put("totalBet", s.totalBet.setScale(2, RoundingMode.HALF_UP));
m.put("totalWin", s.totalWin.setScale(2, RoundingMode.HALF_UP));
m.put("blankBet", s.blankBet.setScale(2, RoundingMode.HALF_UP));
m.put("blankWin", s.blankWin.setScale(2, RoundingMode.HALF_UP));
m.put("adjust", s.adjust.setScale(2, RoundingMode.HALF_UP));
m.put("watermark", round2(s.watermark));
m.put("rtp", round2(s.rtp));
// 与目标水位的差距:正 = 赚得比目标多(下一轮会放水),负 = 需要收紧
m.put("targetGap", round2(s.watermark - target));
// 虚拟水位:把「已抽走随机数、还没出货」的轮次预估也算进来 —— 抽随机数用的是它,不是上面的 watermark
m.put("watermarkWithPending", round2(getWatermarkWithPending()));
m.put("pendingBet", readNum(KEY_PENDING_BET).setScale(2, RoundingMode.HALF_UP));
m.put("pendingWin", readNum(KEY_PENDING_WIN).setScale(2, RoundingMode.HALF_UP));
// 实测标定系数(null = 改造后还没有出货记录,样本不足)
m.put("calibration", k > 0 ? round2(k) : null);
m.put("effectiveCalibration", round2(effectiveCalibration()));
// 按目标水位推算的随机数中心(= 长期维持目标水位所需的随机数)
m.put("targetCenter", round2(center));
// 下一轮新开会用到的随机数中心 —— 用**虚拟水位**算(含已抽走未出货的预留),与实际抽数口径一致
m.put("centerNow", round2(centerAt(getWatermarkWithPending())));
return m;
}
// ==================== 人工注水 / 抽水 ====================
/**
* 注水/抽水:写审计日志(DB 聚合出调整额),并返回操作后的水位快照。
*
* @param type 1 注水 2 抽水
*/
public Map<String, Object> adjust(int type, BigDecimal amount, String remark, String operator) {
if (type != 1 && type != 2) {
throw new IllegalArgumentException("调整类型无效");
}
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException("金额必须大于 0");
}
if (StringUtils.isEmpty(remark) || StringUtils.isEmpty(remark.trim())) {
throw new IllegalArgumentException("调整原因必填");
}
BigDecimal before = BigDecimal.valueOf(readTotals().watermark);
BigDecimal delta = type == 2 ? amount.negate() : amount;
TtUpgradeRewardLog log = TtUpgradeRewardLog.builder()
.type(type)
.amount(amount.setScale(2, RoundingMode.HALF_UP))
.delta(delta.setScale(2, RoundingMode.HALF_UP))
.watermarkBefore(before.setScale(2, RoundingMode.HALF_UP))
.operator(operator)
.remark(remark.trim())
.createTime(new Date())
.build();
rewardLogMapper.insert(log);
// 回填调整后水位(此时该条 delta 已计入 SUM)
BigDecimal after = BigDecimal.valueOf(readTotals().watermark);
log.setWatermarkAfter(after.setScale(2, RoundingMode.HALF_UP));
rewardLogMapper.updateById(log);
Map<String, Object> m = info();
m.put("watermarkAfter", round2(after.doubleValue()));
return m;
}
/** 重置人工调整:插入一条抵消当前净调整的日志,使 SUM(delta) 归零(保留全部历史可审计) */
public Map<String, Object> resetAdjust(String remark, String operator) {
if (StringUtils.isEmpty(remark) || StringUtils.isEmpty(remark.trim())) {
throw new IllegalArgumentException("调整原因必填");
}
BigDecimal net = getAdjust();
BigDecimal before = BigDecimal.valueOf(readTotals().watermark);
if (net.signum() != 0) {
TtUpgradeRewardLog log = TtUpgradeRewardLog.builder()
.type(3)
.amount(net.abs().setScale(2, RoundingMode.HALF_UP))
.delta(net.negate().setScale(2, RoundingMode.HALF_UP))
.watermarkBefore(before.setScale(2, RoundingMode.HALF_UP))
.operator(operator)
.remark(remark.trim())
.createTime(new Date())
.build();
rewardLogMapper.insert(log);
BigDecimal after = BigDecimal.valueOf(readTotals().watermark);
log.setWatermarkAfter(after.setScale(2, RoundingMode.HALF_UP));
rewardLogMapper.updateById(log);
}
Map<String, Object> m = info();
m.put("cleared", net.abs().setScale(2, RoundingMode.HALF_UP));
return m;
}
public Map<String, Object> pageLog(int page, int size) {
com.baomidou.mybatisplus.extension.plugins.pagination.Page<TtUpgradeRewardLog> p =
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TtUpgradeRewardLog> qw =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TtUpgradeRewardLog>()
.orderByDesc(TtUpgradeRewardLog::getId);
com.baomidou.mybatisplus.extension.plugins.pagination.Page<TtUpgradeRewardLog> result = rewardLogMapper.selectPage(p, qw);
Map<String, Object> m = new HashMap<>();
m.put("rows", result.getRecords());
m.put("total", result.getTotal());
return m;
}
// ==================== 内部工具 ====================
private BigDecimal readNum(String key) {
Object v = redisTemplate.opsForValue().get(key);
return BigDecimal.valueOf(toDouble(v));
}
private static double toDouble(Object v) {
if (v == null) {
return 0d;
}
try {
return Double.parseDouble(v.toString());
} catch (Exception e) {
return 0d;
}
}
private static BigDecimal round2(double v) {
return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP);
}
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
/** 策略内存缓存:拉取热路径上不能每次都查 sys_config(TTL 10s,多实例最多滞后 10s 生效) */
private static final long SETTING_TTL_MILLIS = 10_000;
private volatile UpgradeWatermarkSettingBody cachedSetting;
private volatile long cachedSettingAt;
/** 标定系数缓存(全表聚合结果,5 分钟一次足够) */
private static final long CALIBRATION_TTL_MILLIS = TimeUnit.MINUTES.toMillis(5);
private volatile double cachedCalibration = 0d;
private volatile long cachedCalibrationAt;
/** 水位快照载体 */
public static class WatermarkSnapshot {
public BigDecimal totalBet = BigDecimal.ZERO;
public BigDecimal totalWin = BigDecimal.ZERO;
public BigDecimal blankBet = BigDecimal.ZERO;
public BigDecimal blankWin = BigDecimal.ZERO;
public BigDecimal adjust = BigDecimal.ZERO;
public double watermark;
public double rtp;
}
}
@@ -16,6 +16,8 @@
tuo.ornaments_level_id,
tol.`level`,
tuo.luck_section,
tuo.probability_invested,
tuo.anchor_probability_invested,
tuo.amount_required,
tuo.amount_invested,
tuo.anchor_luck_section,
@@ -0,0 +1,47 @@
<?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.admin.mapper.TtUpgradeRewardLogMapper">
<!--
全站累计下注/返奖。水位统计口径:全局(不按饰品位)、排除白号。
白号双重判定:记录里的身份快照 user_type='06',或关联用户 is_blank_account=1。
返奖取 gain_ornaments_price(含失败安慰奖)——安慰奖同样是真实支出,必须计入返奖。
calBet / calNominal 用于「实测标定系数 k」:
名义成本 calNominal = Σ(round_target/100 × 目标饰品价),只统计带了 round_target 的出货记录;
k = calBet / calNominal,即「名义随机数 → 实际下注」的放大倍率(判定溢出导致 k > 1,实测约 1.18)。
只取 round_target 非空的记录,避免把改造前的历史数据混进来。
-->
<select id="sumRecordTotals" resultType="java.util.Map">
SELECT
COALESCE(SUM(CASE WHEN is_blank THEN 0 ELSE r.amount_consumed END), 0) AS totalBet,
COALESCE(SUM(CASE WHEN is_blank THEN 0 ELSE r.gain_ornaments_price END), 0) AS totalWin,
COALESCE(SUM(CASE WHEN is_blank THEN r.amount_consumed END, 0), 0) AS blankBet,
COALESCE(SUM(CASE WHEN is_blank THEN r.gain_ornaments_price END, 0), 0) AS blankWin,
COALESCE(SUM(CASE
WHEN NOT is_blank AND r.round_target IS NOT NULL THEN r.amount_consumed
ELSE 0 END), 0) AS calBet,
COALESCE(SUM(CASE
WHEN NOT is_blank AND r.round_target IS NOT NULL AND r.is_victory = 1
THEN r.round_target / 100 * COALESCE(r.target_ornament_price, 0)
ELSE 0 END), 0) AS calNominal
FROM (
SELECT r.amount_consumed,
r.gain_ornaments_price,
r.round_target,
r.is_victory,
r.target_ornament_price,
(COALESCE(r.user_type, '') = '06' OR COALESCE(u.is_blank_account, 0) = 1) AS is_blank
FROM tt_upgrade_record r
LEFT JOIN tt_user u ON u.user_id = r.user_id
) r
</select>
<!-- 人工调整净额:注水为正、抽水为负、重置为抵消值,直接 SUM(delta) -->
<select id="sumAdjust" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(delta), 0) FROM tt_upgrade_reward_log
</select>
</mapper>
@@ -16,6 +16,7 @@ import com.ruoyi.admin.service.TtBoxRecordsService;
import com.ruoyi.admin.service.TtUpgradeOrnamentsService;
import com.ruoyi.admin.service.TtUpgradeRecordService;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.admin.service.UpgradeWatermarkService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.StringUtils;
@@ -132,6 +133,10 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
@Autowired
private MessageService messageService;
/** 全局奖池水位:决定每轮抽到的随机数区间(水位越高 → 随机数越低),并负责水位累计记账 */
@Autowired
private UpgradeWatermarkService watermarkService;
@Override
public List<ApiLuckyOrnamentsDataVO> getOrnamentsList(ApiLuckyUpgradeBody apiLuckyUpgradeBody) {
// type 对应 tt_ornament.type 数字字典码(1匕首 2手枪 3步枪 4微型冲锋枪 5重型武器 6手套 7印花 8其它)。
@@ -348,6 +353,13 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
boolean isVictory = playResult.isWin();
// 本次拉取时的全局水位快照(落库,便于事后复盘「这批货是在什么水位下出的」)
double watermarkAtPull = watermarkService.getWatermark();
// 幸运值累加口径:默认 raw(累加前端传来的原始概率,展示口径,与历史记录一致);
// 只有策略里显式配 luckyValueMode=eff 才与判定同口径。
// 这是**展示口径**,中奖判定始终用 effectiveProbability,不受它影响。
boolean effLuckyValue = watermarkService.isLuckyValueEffMode();
// 5 同步保存本次结果。
// 快照本轮目标金额与已投金额(用于升级记录历史展示,需在累加/重置前读取)
// 本轮目标金额使用实际随机出来的目标进度(fullV),而非幸运区间固定最大值
@@ -368,57 +380,62 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
? upgradeOrnament.getBlankEffectiveAmountInvested()
: (anchorPool ? upgradeOrnament.getAnchorEffectiveAmountInvested() : upgradeOrnament.getEffectiveAmountInvested());
// 幸运值快照(累计原始概率,累加/重置前读取,按奖池类型区分)
Integer snapshotProbabilityInvested = blankPool
? upgradeOrnament.getBlankProbabilityInvested()
: (anchorPool ? upgradeOrnament.getAnchorProbabilityInvested() : upgradeOrnament.getProbabilityInvested());
int prevProbabilityInvested = snapshotProbabilityInvested == null ? 0 : snapshotProbabilityInvested;
// 本饰品当前幸运值:出货成功清 0,失败累加前端原始概率(白号在自己的池里同样累计)
int luckyValue = isVictory ? 0 : prevProbabilityInvested + rawProbability;
// 本次要累加的幸运值增量:展示口径按「用户在前端拉的那个幸运值」累加(raw=原始概率),
// 判定口径另有其人(effectiveProbability,上面已用于出货判定),两者互不影响。
int luckyDelta = effLuckyValue ? effectiveProbability : rawProbability;
// 更新本轮已投入金额(含幸运值)
// 白号维护自己的一整套字段(本轮已投金额 + 幸运值),与普通/主播同样的算法,只是不碰它们的列
// 幸运值的口径:**三个身份共享同一个累计值**(所以前端所有人看到的数一致),
// **任一身份中奖即整体归零**;中奖那一拉的增量仍计入展示值
//(玩家在出货那一刻看到的数包含本次,下一拉才从 0 重新累计)。
int prevTotal = nvl(upgradeOrnament.getProbabilityInvested())
+ nvl(upgradeOrnament.getAnchorProbabilityInvested())
+ nvl(upgradeOrnament.getBlankProbabilityInvested());
int luckyValue = prevTotal + luckyDelta;
// 更新:本轮已投金额按身份各自累计/清零;幸运值按共享口径(三列一起清)
LambdaUpdateWrapper<TtUpgradeOrnaments> investedUpdate = new LambdaUpdateWrapper<>();
investedUpdate.eq(TtUpgradeOrnaments::getId, upgradeOrnament.getId());
if (isVictory) {
// 中奖:幸运值是共享的 ⇒ 三个身份一起清 0(下一个拉取的人从 0 开始)
investedUpdate.set(TtUpgradeOrnaments::getProbabilityInvested, 0);
investedUpdate.set(TtUpgradeOrnaments::getAnchorProbabilityInvested, 0);
investedUpdate.set(TtUpgradeOrnaments::getBlankProbabilityInvested, 0);
} else {
// 失败:只往自己那一列累加(分列是为了后台能看出各身份的贡献,展示时三列相加)
if (blankPool) {
investedUpdate.setSql("blank_probability_invested = COALESCE(blank_probability_invested, 0) + " + luckyDelta);
} else if (anchorPool) {
investedUpdate.setSql("anchor_probability_invested = COALESCE(anchor_probability_invested, 0) + " + luckyDelta);
} else {
investedUpdate.setSql("probability_invested = COALESCE(probability_invested, 0) + " + luckyDelta);
}
}
if (blankPool) {
if (isVictory) {
// 成功:白号本轮进度清 0 重置,溢出不带入下一轮
// 成功:白号本轮已投金额清 0 重置,溢出不带入下一轮
investedUpdate.set(TtUpgradeOrnaments::getBlankAmountInvested, BigDecimal.ZERO);
investedUpdate.set(TtUpgradeOrnaments::getBlankEffectiveAmountInvested, BigDecimal.ZERO);
investedUpdate.set(TtUpgradeOrnaments::getBlankProbabilityInvested, 0);
} else {
// 失败:实际扣款累计 + price;真实计算口径累计 + effectiveAmount;幸运值累加原始概率
investedUpdate.setSql("blank_amount_invested = blank_amount_invested + " + price);
investedUpdate.setSql("blank_effective_amount_invested = blank_effective_amount_invested + " + effectiveAmount);
investedUpdate.setSql("blank_probability_invested = COALESCE(blank_probability_invested, 0) + " + rawProbability);
}
} else if (anchorPool) {
if (isVictory) {
// 成功:本轮进度清 0 重置,溢出不带入下一轮
investedUpdate.set(TtUpgradeOrnaments::getAnchorAmountInvested, BigDecimal.ZERO);
investedUpdate.set(TtUpgradeOrnaments::getAnchorEffectiveAmountInvested, BigDecimal.ZERO);
// 幸运值(累计原始概率)同步清 0
investedUpdate.set(TtUpgradeOrnaments::getAnchorProbabilityInvested, 0);
} else {
// 失败:实际扣款累计 + price;真实计算口径累计 + effectiveAmount
investedUpdate.setSql("anchor_amount_invested = anchor_amount_invested + " + price);
investedUpdate.setSql("anchor_effective_amount_invested = anchor_effective_amount_invested + " + effectiveAmount);
// 幸运值累加前端原始概率(如 90)
investedUpdate.setSql("anchor_probability_invested = COALESCE(anchor_probability_invested, 0) + " + rawProbability);
}
} else {
if (isVictory) {
// 成功:本轮进度清 0 重置,溢出不带入下一轮
investedUpdate.set(TtUpgradeOrnaments::getAmountInvested, BigDecimal.ZERO);
investedUpdate.set(TtUpgradeOrnaments::getEffectiveAmountInvested, BigDecimal.ZERO);
// 幸运值(累计原始概率)同步清 0
investedUpdate.set(TtUpgradeOrnaments::getProbabilityInvested, 0);
} else {
// 失败:实际扣款累计 + price;真实计算口径累计 + effectiveAmount
investedUpdate.setSql("amount_invested = amount_invested + " + price);
investedUpdate.setSql("effective_amount_invested = effective_amount_invested + " + effectiveAmount);
// 幸运值累加前端原始概率(如 90)
investedUpdate.setSql("probability_invested = COALESCE(probability_invested, 0) + " + rawProbability);
}
}
upgradeOrnamentsMapper.update(null, investedUpdate);
@@ -467,6 +484,9 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
.gainOrnamentsPrice(prizeTotal)
.watermark(BigDecimal.valueOf(watermarkAtPull).setScale(2, java.math.RoundingMode.HALF_UP))
.roundTarget(playResult.getTargetProgress())
.openTime(new Date())
.build();
@@ -528,6 +548,9 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
.gainOrnamentsPrice(prizeTotal)
.watermark(BigDecimal.valueOf(watermarkAtPull).setScale(2, java.math.RoundingMode.HALF_UP))
.roundTarget(playResult.getTargetProgress())
.openTime(new Date())
.build();
@@ -570,6 +593,10 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
ttUpgradeRecordService.save(upgradeRecord);
// 水位累计记账:本次拉取的下注/返奖入账(白号单独计,不进水位口径)。
// 本轮判定用的是本轮开始时的水位,所以这次增量只影响下一轮 —— 顺序不能反。
watermarkService.recordPull(price, upgradeRecord.getGainOrnamentsPrice(), blankPool);
// 发送邮件通知(取第一件获得的饰品)
if (!boxRecords.isEmpty()) {
TtBoxRecords record = boxRecords.get(0);
@@ -592,140 +619,133 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
}
public UpgradePlayResult playing(TtUser ttUser, TtUpgradeOrnaments upgradeOrnament, UpgradeBodyA upgradeParam, int effectiveProbability) {
Integer upgradeOrnamentId = upgradeOrnament.getId();
// 奖池类型:白号(06)走自己的进度池与幸运值,Redis key 天然按类型隔离,白号之间共享 06 池
String poolType = poolType(ttUser);
String rangeFixedKey = RedisConstants.UPGRADE_RANGE_FIXED + upgradeOrnamentId + ":" + poolType;
String rangeFloatKey = RedisConstants.UPGRADE_RANGE + upgradeOrnamentId + ":" + poolType;
// 读redis的固定概率 [x,y] x进度,y空间大小(缓存缺失或非法时返回 null)
Integer[] rangeFixed = parseRange(redisCache.getCacheObject(rangeFixedKey));
// 读redis的饰品概率区间 [begin,end]
Integer[] rangeFloat = parseRange(redisCache.getCacheObject(rangeFloatKey));
if (isValidRange(rangeFixed)) {
// 抽奖
return doUpgrade(
ttUser,
upgradeOrnament,
rangeFixed,
upgradeParam,
rangeFixedKey,
rangeFloatKey,
rangeFloat,
effectiveProbability);
/**
* 抽本轮的随机数(= 该饰品位本轮「出货所需的进度」,单位:饰品价百分比)。
* <p>
* 水位策略启用时:在该饰品位自己的区间内,围绕「按当前水位算出的中心」抖动
* (水位高于目标水位 → 中心下移、更容易出货;低于目标 → 上移、收紧)。
* 中心值由目标水位反推,不需要后台手配随机数区间。
* <p>
* 策略关闭({@code sys_config: luckyUpgradeWatermark.enabled=false})时回退到改造前的
* 「每个饰品位 × 每个身份一套固定幸运区间」均匀随机,便于出问题时逐字节回退。
* <p>
* ★ 抽数用的是**虚拟水位**(真实水位 + 已抽走但还没出货的轮次预估),并且抽完就把这一轮的
* 预估交易预留进去 —— 这样一批饰品同时开新轮时,后面的随机数不会再按前面那个"过期"的水位放水。
* 白号轮次不进水位口径,不参与预留。
*/
private int drawRoundTarget(TtUpgradeOrnaments upgradeOrnament, TtUser ttUser) {
if (watermarkService.isEnabled()) {
Integer[] range = resolveRange(upgradeOrnament, ttUser);
return watermarkService.drawTarget(
range[0], range[1], upgradeOrnament.getOrnamentPrice(), isBlankAccount(ttUser));
}
// 没有,使用redis的概率区间新建,
if (isValidRange(rangeFloat)) {
// 使用饰品概率区间创建新的固定概率(进度从 0 开始)
Integer[] newRangeFixed = createRangeFixed(rangeFloat, 0);
// 抽奖
return doUpgrade(
ttUser,
upgradeOrnament,
newRangeFixed,
upgradeParam,
rangeFixedKey,
rangeFloatKey,
rangeFloat,
effectiveProbability);
}
// 也没有概率区间,用mysql的数据(按奖池类型取:主播/白号/普通)创建固定概率,抽奖
String luckSection = resolveLuckSection(upgradeOrnament, ttUser);
if (StringUtils.isNotEmpty(luckSection)) {
rangeFloat = JSONUtil.toList(luckSection, Integer.class).toArray(new Integer[2]);
}
rangeFixed = createRangeFixed(rangeFloat, 0);
// 缓存固定概率
// redisCache.setCacheList(rangeFixedKey, Arrays.asList(rangeFixed));
// redisCache.setCacheObject(rangeFixedKey, Arrays.asList(rangeFixed));
// 缓存概率区间
redisCache.setCacheObject(rangeFloatKey, rangeFloat, 1, TimeUnit.DAYS);
// 抽奖
return doUpgrade(
ttUser,
upgradeOrnament,
rangeFixed,
upgradeParam,
rangeFixedKey,
rangeFloatKey,
rangeFloat,
effectiveProbability);
return legacyFixedTarget(upgradeOrnament, ttUser);
}
/**
* @param rangeFixed 本次的固定概率
* @param param http请求参数
* @param rangeFixedKey redis固定概率key
* @param rangeFloat 升级饰品的概率区间
* @param effectiveProbability 封顶后的有效概率(仅用于中奖判定)
* @return
* 玩家的随机数取值区间(水位驱动时用它限定落点范围):读该饰品位 × 本身份配置的区间。
* <p>
* 与旧逻辑不同,这里**直接读实体的字段**(调用方的 upgradeOrnament 已查过库),不再走 Redis 区间缓存
* —— 后台改完区间下一轮立即生效,不用清缓存。未配置 / 解析失败时用兜底区间
* {@code [80, 160]},避免区间缺失把中心的负反馈夹死。
*/
private Integer[] resolveRange(TtUpgradeOrnaments upgradeOrnament, TtUser ttUser) {
String luckSection = resolveLuckSection(upgradeOrnament, ttUser);
if (StringUtils.isNotEmpty(luckSection)) {
try {
Integer[] range = JSONUtil.toList(luckSection, Integer.class).toArray(new Integer[2]);
if (isValidRange(range) && range[0] != null && range[0] > 0 && range[0] < range[1]) {
return range;
}
} catch (Exception e) {
log.warn("解析随机数区间失败,改用兜底区间。upgradeId={}, luckSection={}",
upgradeOrnament.getId(), luckSection, e);
}
}
return new Integer[]{DEFAULT_RANGE_MIN, DEFAULT_RANGE_MAX};
}
/** 随机数兜底区间:饰品未配置区间时使用 */
private static final int DEFAULT_RANGE_MIN = 80;
private static final int DEFAULT_RANGE_MAX = 160;
/** 回退分支:改造前的固定幸运区间抽法(Redis 区间缓存 + 每身份一套 luck_section) */
private int legacyFixedTarget(TtUpgradeOrnaments upgradeOrnament, TtUser ttUser) {
String rangeFloatKey = RedisConstants.UPGRADE_RANGE + upgradeOrnament.getId() + ":" + poolType(ttUser);
Integer[] rangeFloat = parseRange(redisCache.getCacheObject(rangeFloatKey));
if (!isValidRange(rangeFloat)) {
String luckSection = resolveLuckSection(upgradeOrnament, ttUser);
if (StringUtils.isNotEmpty(luckSection)) {
try {
rangeFloat = JSONUtil.toList(luckSection, Integer.class).toArray(new Integer[2]);
} catch (Exception e) {
log.warn("解析幸运区间失败,改用默认区间。upgradeId={}, luckSection={}",
upgradeOrnament.getId(), luckSection, e);
rangeFloat = null;
}
}
if (isValidRange(rangeFloat)) {
redisCache.setCacheObject(rangeFloatKey, rangeFloat, 1, TimeUnit.DAYS);
}
}
return createRangeFixed(rangeFloat, 0)[1];
}
public UpgradePlayResult playing(TtUser ttUser, TtUpgradeOrnaments upgradeOrnament, UpgradeBodyA upgradeParam, int effectiveProbability) {
Integer upgradeOrnamentId = upgradeOrnament.getId();
// 奖池按身份分池:每个身份有自己的一份「本轮进度 + 本轮随机数」,互不影响
// (白号中奖只刷新白号自己的随机数,普通玩家的进度与随机数原样保留,不会互相污染)
String poolType = poolType(ttUser);
String rangeFixedKey = RedisConstants.UPGRADE_RANGE_FIXED + upgradeOrnamentId + ":" + poolType;
// 读 redis 的本轮状态 [已累计进度, 本轮随机数]
Integer[] rangeFixed = parseRange(redisCache.getCacheObject(rangeFixedKey));
if (isValidRange(rangeFixed) && rangeFixed[1] != null && rangeFixed[1] > 0) {
return doUpgrade(ttUser, upgradeOrnament, rangeFixed[0], rangeFixed[1], rangeFixedKey, effectiveProbability);
}
// 没有(首次 / 缓存过期 / 该身份刚中过奖):进度从 0 起,按当前水位抽一个新的随机数
int target = drawRoundTarget(upgradeOrnament, ttUser);
redisCache.setCacheObject(rangeFixedKey, new Integer[]{0, target}, 1, TimeUnit.DAYS);
return doUpgrade(ttUser, upgradeOrnament, 0, target, rangeFixedKey, effectiveProbability);
}
/**
* 一次拉取的判定。
*
* @param currentV 本轮已累计的进度(判定口径:min(概率,75) 累加)
* @param fullV 本轮随机数(= 本轮出货所需的进度)
*/
public UpgradePlayResult doUpgrade(TtUser ttUser,
TtUpgradeOrnaments upgradeOrnament,
Integer[] rangeFixed,
UpgradeBodyA param,
Integer currentV,
Integer fullV,
String rangeFixedKey,
String rangeFloatKey,
Integer[] rangeFloat,
int effectiveProbability) {
// 计算进度
Integer currentV = rangeFixed[0];
Integer fullV = rangeFixed[1];
int cur = currentV == null ? 0 : currentV;
int space = (fullV == null || fullV <= 0) ? DEFAULT_ROUND_TARGET : fullV;
// 中奖判定使用封顶后的 effectiveProbability(如 75),而非前端原始传入的 param.getProbability()(如 90)
int sub = fullV - (effectiveProbability + currentV);
// 中奖判定使用封顶后的 effectiveProbability(如 75),而非前端原始传入的 90
int sub = space - (effectiveProbability + cur);
if (sub > 0) {
// 失败,记录进度
int i = effectiveProbability + currentV;
rangeFixed[0] = i;
// redisCache.setCacheList(rangeFixedKey, Arrays.asList(rangeFixed));
redisCache.setCacheObject(rangeFixedKey, rangeFixed, 1, TimeUnit.DAYS);
return new UpgradePlayResult(false, fullV);
// 失败:累加进度,本轮随机数不变
redisCache.setCacheObject(rangeFixedKey, new Integer[]{effectiveProbability + cur, space}, 1, TimeUnit.DAYS);
return new UpgradePlayResult(false, space);
}
// 成功:先保存本轮目标进度(创建新的固定概率前)
Integer currentRoundTarget = fullV;
// 如果没有概率区间,新建
if (isValidRange(rangeFloat)) {
// 按奖池类型重新读一遍幸运区间(主播/白号/普通),后台改配置后本轮中奖即生效
String luckSection = resolveLuckSection(upgradeOrnament, ttUser);
if (StringUtils.isNotEmpty(luckSection)) {
rangeFloat = JSONUtil.toList(luckSection, Integer.class).toArray(new Integer[2]);
}
// 缓存概率区间
redisCache.setCacheObject(rangeFloatKey, rangeFloat, 1, TimeUnit.DAYS);
// rangeFixed = createRangeFixed(rangeFloat,0);
}
// 成功,随机创建新的固定概率(进度清 0 重置,溢出不带入下一轮)
Integer[] newRangeFixed = createRangeFixed(rangeFloat, 0);
// 保存新的固定概率
// redisCache.setCacheList(rangeFixedKey, Arrays.asList(newRangeFixed));
redisCache.setCacheObject(rangeFixedKey, newRangeFixed, 1, TimeUnit.DAYS);
return new UpgradePlayResult(true, currentRoundTarget);
// 成功:本轮随机数已用掉,**只刷新当前身份自己的随机数**(其他身份的进度与随机数一律不动),
// 进度清零;溢出部分不累计到下一轮
int newTarget = drawRoundTarget(upgradeOrnament, ttUser);
redisCache.setCacheObject(rangeFixedKey, new Integer[]{0, newTarget}, 1, TimeUnit.DAYS);
return new UpgradePlayResult(true, space);
}
/** 随机数兜底值:仅在 Redis 里存了非法的本轮随机数时使用(正常路径不会走到) */
private static final int DEFAULT_ROUND_TARGET = 120;
public Integer[] createRangeFixed(Integer[] rangeFloat, Integer initVal) {
@@ -804,6 +824,11 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
return range != null && range.length >= 2 && range[0] != null && range[1] != null;
}
/** null 安全取整 */
private static int nvl(Integer v) {
return v == null ? 0 : v;
}
// 扣款
public R<Map<String, BigDecimal>> upgradeAccounting(BigDecimal consumption, TtUser player) {
R<UpdateUserAccountBo> updateResult = userService.updateUserAccount(player.getUserId(), consumption.negate(),
@@ -15,9 +15,14 @@
tto.rarity_name,
tto.exterior_name,
tol.level_img,
-- 展示给前端的幸运值 = 三个身份累计之和 ⇒ 所有身份看到的数完全一致
COALESCE(tuo.probability_invested, 0)
+ COALESCE(tuo.anchor_probability_invested, 0)
+ COALESCE(tuo.blank_probability_invested, 0) AS luckyValue,
-- 查看者自己所属身份的累计(按登录身份注入的 userType),前端需要时才用
CASE WHEN #{userType} = '01' THEN COALESCE(tuo.anchor_probability_invested, 0)
WHEN #{userType} = '06' THEN COALESCE(tuo.blank_probability_invested, 0)
ELSE COALESCE(tuo.probability_invested, 0) END AS luckyValue
ELSE COALESCE(tuo.probability_invested, 0) END AS myLuckyValue
FROM tt_upgrade_ornaments tuo
LEFT JOIN tt_ornament tto ON tuo.ornaments_id = tto.id
LEFT JOIN tt_ornaments_level tol ON LEAST(tuo.ornaments_level_id, 4) = tol.id