修改河南项目的管理后台ui
This commit is contained in:
@@ -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:";
|
||||
|
||||
|
||||
/**
|
||||
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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();
|
||||
}
|
||||
+678
@@ -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 > 1} ⇒ 概率向**下限**堆积;
|
||||
* 水位低于目标(收紧)⇒ {@code p < 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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+47
@@ -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>
|
||||
+167
-142
@@ -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(),
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user