修改河南项目的管理后台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>