修改河南项目的管理后台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>
|
||||
Reference in New Issue
Block a user