修改河南项目的管理后台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
+122
View File
@@ -6,6 +6,128 @@
--- ---
## 幸运升级·水位策略简化为「单一目标水位」:`sys_config` 配置结构变更
**背景**:上一版把水位策略做成了 **4 档波段表**(每档 = 水位下界/上界 + 随机数下界/上界),
后台要维护 16 个数、还要保证档间单调且留有重叠,运营难维护也容易配错。本次简化为**只填一个「目标水位(%)」**:
随机数的**中心**由目标水位自动反推,**取值范围**仍由每个饰品自己的「随机数区间」限定
(水位高于目标 → 落点靠近区间下限)。**口径推导、换算表、仿真数据与算例见 `docs/修改记录.md` 的 2026-09-24 条。**
**本次数据库变更 1 处**(2026-09-24 已在库 `henan` 执行):`sys_config.config_key = 'luckyUpgradeWatermark'` 的
`config_value` 由「波段表」改为「目标水位 + 灵敏度」,`bands` 字段废弃(后端已不再读取,留着不报错)。
> ⚠️ **前置依赖**:本条的 `UPDATE` 依赖下一节「幸运升级·水位驱动改造」插入的那行 `sys_config`
> —— 全新库必须先执行那一条,再执行本条。
>
> ⚠️ **同时恢复**(代码侧,非 SQL):饰品列表页的「随机数区间」编辑入口**重新启用**,
> `tt_upgrade_ornaments.luck_section` / `anchor_luck_section` / `blank_luck_section` 现在**必须配置**,
> 且要**包含按目标水位推算出的中心值**,否则中心被夹在边界上、水位停不到目标值。
> ⇒ 下一节里「上线前清空三个区间列」的说法**已作废**,不要再执行那两条清空 SQL。
**变更 SQL**:
```sql
UPDATE `sys_config`
SET `config_value` = '{"enabled":true,"targetWatermark":30,"gain":2,"luckyValueMode":"raw"}',
`remark` = '幸运升级水位策略:目标水位%(平台希望长期维持的净收益比例)、灵敏度 gain、幸运值展示口径 raw/eff。随机数中心 = 100/((1-目标水位/100)*实测标定系数),再由每个饰品的随机数区间限定取值范围;中奖判定恒用 min(概率,75)。enabled=false 逐字节回退固定区间',
`update_time` = NOW()
WHERE `config_key` = 'luckyUpgradeWatermark';
```
**字段口径**:
| 字段 | 含义 |
|---|---|
| `enabled` | `true` = 按水位动态出随机数;`false` = 逐字节回退「每个饰品位固定 `luck_section`」 |
| `targetWatermark` | **目标水位(%)**:平台希望长期维持的净收益比例。30 ⇒ 长期平均 RTP ≈ 70% |
| `gain` | 响应灵敏度:水位每偏离目标 1 个点,随机数中心反向调整 `gain`%。默认 2,后台不展示(调参用) |
| `luckyValueMode` | 幸运值**展示**口径:`raw`(默认,累加前端原始概率)/ `eff`(累加 `min(概率,75)`);**不影响判定** |
---
## 幸运升级·水位驱动改造:水位日志表、拉取记录快照列、水位策略配置与「奖池水位」菜单
**背景**:幸运升级原来每个饰品位配一套固定幸运区间,随机数永远落在区间内(如 `[100,160)`),
玩家能看出「每次都要投到 1 倍原价以上才出」。本次改为**按全局水位动态决定本轮的随机数区间**:
`水位 = (Σ下注 − Σ返奖 + 人工调整) / Σ下注`(排除白号),水位越高 → 可出的随机数越低。
同时新增后台人工注水/抽水。**方案、换算表、口径推导与算例全部汇总在 `docs/修改记录.md` 的 2026-09-23 条**(不另建单独文档)。
**本次数据库变更 4 处**(2026-09-23 已在库 `henan` 执行):
| 对象 | 变更 | 说明 |
|---|---|---|
| `tt_upgrade_reward_log` | **新建表** | 注水/抽水审计日志,`delta` 为带符号影响,水位调整额 = `SUM(delta)`(DB 为真相,Redis 只是短缓存) |
| `tt_upgrade_record` | 新增 **2 列** | `watermark`(本次拉取时的水位%)、`round_target`(本轮抽到的随机数快照)|
| `sys_config` | 新增 **1 行** | `config_id=155`、`config_key='luckyUpgradeWatermark'`,水位波段表 + 幸运值展示口径;`enabled=false` 可逐字节回退旧行为 |
| `sys_menu` / `sys_role_menu` | 各新增 **1 / 2 行** | `menu_id=3208`「幸运升级 → 奖池水位」,授权给 `role_id` 101(三恒)、103(Fdily)|
> **为什么 `menu_id` 用 `3208`**:`3200~3207` 是麻将胡了的号段,按顺序往后取,不复用已下架玩法的旧号段。
**变更 SQL**:
```sql
CREATE TABLE IF NOT EXISTS `tt_upgrade_reward_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'PK',
`type` tinyint NOT NULL COMMENT '1注水 2抽水 3重置',
`amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值,展示用)',
`delta` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '对水位的带符号影响(注水正/抽水负/重置为抵消值)',
`watermark_before` decimal(8,2) DEFAULT NULL COMMENT '调整前水位%',
`watermark_after` decimal(8,2) DEFAULT NULL COMMENT '调整后水位%',
`operator` varchar(64) DEFAULT NULL COMMENT '操作人',
`remark` varchar(200) DEFAULT NULL COMMENT '调整原因(必填,前端保证)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_create` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幸运升级奖池水位调整日志';
ALTER TABLE `tt_upgrade_record`
ADD COLUMN `watermark` decimal(8,2) NULL COMMENT '本次拉取时的水位%(1-RTP,排除白号,含人工调整)',
ADD COLUMN `round_target` int NULL COMMENT '本轮抽到的随机数 fullV 快照';
INSERT INTO `sys_config`
(`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
VALUES
(155, '幸运升级水位策略', 'luckyUpgradeWatermark',
'{"enabled":true,"luckyValueMode":"raw","bands":[{"from":-999,"to":10,"vMin":95,"vMax":130},{"from":10,"to":26,"vMin":115,"vMax":150},{"from":26,"to":36,"vMin":75,"vMax":175},{"from":36,"to":999,"vMin":85,"vMax":128}]}',
'Y', 'admin', NOW(), '', NULL, '幸运升级水位策略:波段表(水位%→随机数区间) + 幸运值展示口径 raw(原始概率)/eff(与判定同口径);中奖判定恒用 min(概率,75)。enabled=false 逐字节回退固定区间');
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
VALUES
(3208, '奖池水位', 2075, 3, 'rewardPool', 'skins/luckyUpgrade/rewardPool/index', NULL, 1, 0, 'C', '0', '0', 'skins:upgrade:pool', 'money', 'admin', NOW(), '', NULL, '幸运升级·奖池水位(全局水位/人工注水抽水/调整历史)');
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (101, 3208), (103, 3208);
```
**验证**(2026-09-23 实测结果):
```sql
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='henan' AND table_name='tt_upgrade_reward_log'; -- 期望 1,实测 1
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='henan' AND table_name='tt_upgrade_record'
AND column_name IN ('watermark','round_target'); -- 期望 2,实测 2
SELECT COUNT(*) FROM sys_config WHERE config_key='luckyUpgradeWatermark'; -- 期望 1,实测 1
SELECT COUNT(*) FROM sys_menu WHERE menu_id=3208; -- 期望 1,实测 1
SELECT COUNT(*) FROM sys_role_menu WHERE menu_id=3208; -- 期望 2,实测 2
```
后台左侧「幸运升级」目录下出现「奖池水位」页(`/luckyUpgrade/rewardPool`)。
**注意:水位策略上线后的运营动作**
> ⚠️ **本节第 1 条已被本文件顶部「水位策略简化为单一目标水位」那条取代**:
> `luck_section` / `anchor_luck_section` / `blank_luck_section` **重新成为必填项**
> (语义 = 随机数的允许取值范围),**不要清空**;而且要配成**包含「按目标水位推算出的中心值」**的区间。
> 清空后后端会用兜底区间 `[80,160]`,能用但不受控。
1. ~~两个已存在的饰品位(`tt_upgrade_ornaments.id` 18 / 25)的 `luck_section` 要清空,才能完全跟随全局波段~~
—— **已作废**,见上方提示。
2. 水位从 0 起步。`enabled=true` 时前几件货会落在收紧档(水位低 → 随机数高),属预期行为;
需要立刻放水用后台「奖池水位 → 注水」。
---
## 新增「麻将胡了」(mahjong 玩法)的后台菜单、角色授权与维护开关 ## 新增「麻将胡了」(mahjong 玩法)的后台菜单、角色授权与维护开关
**背景**:麻将胡了以「**新游戏**」类型接入,复用原玫瑰女郎(olympus 玩法)的整套统计口径 **背景**:麻将胡了以「**新游戏**」类型接入,复用原玫瑰女郎(olympus 玩法)的整套统计口径
+405
View File
@@ -7,6 +7,411 @@
--- ---
## 2026-09-24
- **幸运升级:水位配置从「4 档波段表」简化为「一个目标水位百分比」+ 饰品随机数区间恢复可编辑**
**起因**:上一版后台要维护一张 4 档波段表(水位下界/上界 × 随机数下界/上界 = 16 个数),
还要保证档间单调且留有重叠 —— 运营看不懂,也容易配出互相矛盾的表。
用户要的模型其实简单得多:**「每个饰品设一个区间,水位高的时候随机数靠近区间下限,
但水位要维持在我设定的那个值」** ⇒ 后台只该填**一个水位百分比**。
⚠️ **本条取代 `2026-09-23` 条里的「三、波段表」与「十、上线前清空旧区间」两处**,
其余口径(三身份判定隔离、幸运值共享展示、标定系数、注水抽水、水位统计口径)**全部仍然有效**。
**一、新口径(一句话)**
目标水位 → 自动反推随机数中心 → 在该饰品自己的区间内围绕中心抖动。
后台不配随机数,随机数由目标水位算出来:
```
V* = 100 / ((1 − 目标水位/100) × k) k = 实测标定系数(见第四节,样本不足按 1.19)
中心 = V* × (1 − (当前水位 − 目标水位)/100 × gain) gain 默认 2
t = (中心 − 下限) / (上限 − 下限) 中心在区间内的相对位置,夹到 [0.10, 0.90]
V = 下限 + (上限 − 下限) × u^p, p = 1/t − 1 u ~ U(0,1)
```
- **偏斜随机**:支撑集始终是**整个区间**(任何值都可能出),水位只改变**概率密度**。
水位 = 目标 ⇒ `t = 0.5`、`p = 1` ⇒ **均匀分布**(区间内等概率);
水位高于目标(放水)⇒ 中心下移、`p > 1` ⇒ 概率向**下限**堆积;低于目标则向**上限**堆积。
- `E[u^p] = 1/(p+1) = t` ⇒ `E[V] = 中心` ⇒ **水位精确收敛回目标值**(这就是「保证维持在我设定的值」)。
- ⚠️ 曾先写过「中心平移 + 固定宽度抖动,再夹取到区间边界」,**已废弃**:中心贴边时落点会被压成贴着边界的一条窄带,
且**水位偏离越大窄带越窄**,玩家一眼看出是"卡边界"。改成偏斜随机后,任何水位下支撑集都是整个区间。
- ⚠️ `t` 夹到 `[0.10, 0.90]` 决定了**中心的可表达范围 = 区间下界 + [10%, 90%] × 区间宽度**。
中心超出这个范围(水位远离目标,或区间配得太窄)⇒ 水位只能停在附近。
例:区间 `[100,160]` 的中心可表达范围是 `[106, 154]`,对应水位约 **`[20.7%, 45.4%]`**;
想把水位开到 60%,区间上限得提到 **210 以上**。
**二、目标水位 ↔ 随机数中心换算表**(`k = 1.19`,即实测系数兜底值)
| 目标水位 | 目标 RTP | 随机数中心 `V*` |
|---:|---:|---:|
| 0% | 100% | 84 |
| 10% | 90% | 93 |
| 20% | 80% | 105 |
| **30%** | **70%** | **120** |
| 40% | 60% | 140 |
| 45% | 55% | 153 |
| 50% | 50% | 168 |
| 60% | 40% | 210 |
⚠️ **`V*` 必须落在饰品的随机数区间内**(且离边界 > 10),否则中心被夹住、水位停在别处。
例:目标 60% 需要中心 210 ⇒ 饰品区间上界必须 ≥ 210。
**三、饰品随机数区间的作用(恢复了编辑入口)**
`tt_upgrade_ornaments` 的三个区间列(`luck_section` / `anchor_luck_section` / `blank_luck_section`)
重新成为**必填项**,语义从「固定随机数区间」变成「**随机数的取值范围**」:
**整个区间始终都可能出**,中心由水位决定、只控制概率往哪一端堆。区间越宽,单轮波动越大、越难被玩家归纳出规律。
⇒ **`2026-09-23` 条里「上线前清空三个区间列」的做法已作废**,不要再执行。
⇒ 区间还**限定了这台机器能维持的水位范围**(见第一节末条):区间太窄或下限太高,高水位目标就实现不了。
**四、实测标定系数 k:改为 5 分钟内存缓存**
k = 「改造后累计下注(排除白号) / Σ(`round_target/100` × 目标饰品价)」,含义与算法不变。
但它是一次**全表聚合 + `tt_user` JOIN**,而抽随机数的热路径现在也要用它
⇒ 加 **5 分钟内存缓存**(启动预热 + 后台「按历史重算」主动刷新),不能每次拉取都查一次 DB。
**五、仿真验证**(脚本 `.workbuddy/upgrade-target-watermark-sim.mjs`,每种配置 60 万次拉取,可复跑)
| 配置 | 目标水位 | 实测稳态水位 | 偏差 | 实测 RTP | p10~p90 随机数 | 随机数实际范围 | 低于 100 占比 |
|---|---:|---:|---:|---:|---:|---|---:|
| A 区间 `[100,160]` | 30% | **29.97%** | −0.03 | 70.03% | 100~148 | **`[100,160]` 全覆盖** | 0.0% |
| B 区间 `[75,175]`(宽) | 30% | **29.98%** | −0.02 | 69.99% | 81~163 | **`[75,175]` 全覆盖** | **32.8%** |
| C 区间 `[100,160]` | 45% | 43.80% | −1.20 | 56.21% | 146~159 | `[111,160]` | 0.0% |
| D 区间 `[100,160]` | 60% | 43.79% | **−16.21** | 56.21% | 146~159 | `[117,160]` | 0.0% |
| E 区间 `[80,90]`(窄) | 30% | 9.77% | **−20.23** | 90.25% | 88~90 | `[83,90]` | 100.0% |
- **A/B**:区间包含 `V*` ⇒ 稳态水位与目标值差 < 0.05 个点,**「维持在我设定的值」成立**;
且随机数的**实际出现范围就是整个区间**(不是贴着中心的一条窄带)。
- **C**:`V* = 157.4` 距区间上限 160 只差 2.6,小于 10% 区间宽度(6)⇒ 被 `t` 上限夹住,偏 1.2 个点。
- **D/E**:区间不包含 `V*` ⇒ 中心被夹死在边界,水位**到不了**目标值(偏 16~20 个点)—— 这是唯一的坑。
- **B 的 32.8%** 说明:想把「每次都要 100 以上才出」的观感彻底打掉,区间下限要低于 100(宽区间 `[75,175]`)。
**六、改动清单**
1. `UpgradeWatermarkSettingBody`:删 `bands` / `Band` 类,改为 `targetWatermark` + `gain`(后台只填目标水位)。
2. `UpgradeWatermarkService`:删波段表查表逻辑,新增 `targetCenter()` / `centerAt()` / `drawTarget(lo, hi)`;
`drawTarget` 用**偏斜随机**(`V = 下限 + 宽度 × u^p`,`p = 1/t − 1`,`t` 由中心在区间内的相对位置给出);
标定系数加 5 分钟缓存;`info()` 返回 `targetWatermark` / `targetGap` / `targetCenter` / `centerNow` /
`effectiveCalibration`(原 `bands` / `bandRows` / `bandMin` / `bandMax` 全部移除)。
3. `ApiLuckyUpgradeServiceImpl`:新增 `resolveRange()`(读该饰品位 × 本身份的区间,**直接读实体字段**,
不再走 Redis 区间缓存 ⇒ 后台改完下一轮立即生效,不用清缓存);`drawRoundTarget` 把区间交给水位服务。
未配区间时兜底 `[80,160]`。回退分支 `legacyFixedTarget()` 保持旧的 Redis 缓存写法不动。
4. 前端 `views/skins/luckyUpgrade/rewardPool/index.vue`:**删掉整张波段表**,改为一个「目标水位」输入框
+ 策略开关,**改完立即生效(页面没有保存按钮)**;实时条显示「当前水位 / 目标水位 / 离目标多少 /
下一轮随机数 / 人工调整 / 实测标定系数 / 白号 / 策略开关」。
策略卡里另有一个**「随机数区间 → 可维持水位范围」换算器**:填两个数即时反推
(`中心可表达范围 = 下限 + [10%, 90%] × 宽度`,再代回 `水位 = 1 − 100/(k × 中心)`),
目标水位不在算出的范围内会标红提示;右侧 `?` 悬浮说明「区间是什么 / 为什么它限定了水位范围 / 怎么用」。
⇒ 正文只讲**区间硬约束**,不展示「随机数中心」这类内部推算量。
5. 前端 `views/skins/ttUpgrade/index.vue`:**恢复**普通/主播/白号三列「随机数区间」编辑(列名由「幸运区间」改为
「随机数区间」);顶部水位条改为显示「目标水位 / 推算随机数中心」(原「本轮随机数区间」两栏随波段表一起删)。
**七、验证**
- `mvn -B clean install -DskipTests` 通过;`ruoyi-ui` 生产构建通过。
- 仿真 E 组反证有效:区间 `[80,90]` + 目标 30% ⇒ 稳态 9.84%、RTP 90.2%,说明夹取逻辑确实按预期生效(不是"看起来对")。
- 全仓扫 `bands` / `bandRows` / `bandMin` / `bandMax` 无残留引用。
**八、上线前必做**
1. **给每个在售饰品配随机数区间**,且区间要包含「按目标水位推算的中心值」,**中心离两端各留约 10% 区间宽度**
(后台水位页会显示该中心)。区间缺失时后端兜底 `[80,160]`,能用但不受控。
区间同时**限定了水位能调到的范围**(例:`[100,160]` 只能维持约 `[20.7%, 45.4%]` 的水位)。
2. 目标水位**默认 30%**(≈ 长期 RTP 70%);上线初期水位从 0 起步,会有一段收紧期,想立刻拉到目标值就在水位页注水。
3. 后端需**重启**才生效(策略与抽数逻辑都在 Java 侧)。
**九、虚拟水位:一批饰品同时开轮时,不会都按同一个水位放水**(2026-09-24 补充)
**问题**:真实水位只反映**已经结算**的下注/返奖。一批饰品同时开新轮时(同时上架、或几个玩家同时首次拉取),
它们会全部用同一个真实水位去算中心 —— 水位高时连着出好几个低随机数,而这批货真出完后水位其实已经变了。
**做法**:每抽一个随机数就把这一轮的**预估交易**预留进 `upgrade_pool:pending:bet` / `:pending:win`:
`预估下注 = 饰品价 × k × 随机数 / 100`(乘 k 是因为判定溢出)、`预估返奖 = 饰品价`。
抽下一个时用的是**虚拟水位**:
```
虚拟水位 = (真实bet + pending_bet − 真实win − pending_win + 调整) / (真实bet + pending_bet)
```
这一轮出货过程中产生的实际流水会在 `recordPull` 里**逐步把预留扣回去** —— 两边口径一致
(`k` 就是「实际 / 名义」的实测系数),所以真正出货时预留基本归零、不会长期挂账。
白号轮次不进水位口径,不参与预留;`enabled=false` 或没抽过数时 pending = 0,退化为真实水位。
后台「按历史重算」会把 pending 一起归零,兜住「某个轮次永远没出货、pending 长期挂账」的漂移。
**效果**(同一仿真脚本的「预留机制」段;8 轮连开后的 `E[V]`):
| 累计下注体量 | 不用预留 | 用预留(第 8 轮) | 收紧 |
|---:|---:|---:|---:|
| 2 万 | 88.0 | **108.6** | **+23.4%** |
| 10 万 | 88.0 | 90.8 | +3.2% |
| 100 万 | 88.0 | 88.0 | 0% |
⚠️ **效果与水位体量成反比,这是数学必然、不是实现问题** —— 水位是「累计比例」,
一件货(玩家成本约 1.2 倍饰品价)只占累计下注的极小一份。刚上线体量小时很灵敏,体量大后几乎可忽略。
⇒ 想让大水位下收敛更灵敏,要调的是 **`gain`**(水位每偏离 1 个点、中心移动的幅度,默认 2),不是靠预留。
⇒ 另外:中心一旦撞到区间边界(`t` 被夹到 `[0.10, 0.90]`),`E[V]` 就不再随水位变化 ——
上面 100 万那组水位明明降了 0.32 个点,`E[V]` 仍是 88,就是这个原因。
**★ 生效边界:能稳定处理的量级**(判据 `|Δ虚拟水位| ≥ 1 个点` ⇒ `E[V]` 变化 ≥ 2.4 点、约 2.7%,线上看得出来):
| 累计下注 \ 连开个数 | 3 个 | 5 个 | 10 个 | 20 个 | 50 个 |
|---:|---:|---:|---:|---:|---:|
| 1 万 | −10.0 \* | −10.8 \* | −16.1 \* | −12.6 \* | −14.0 \* |
| 3 万 | −4.1 \* | −6.6 \* | −6.4 \* | −13.1 \* | −13.2 \* |
| 10 万 | −1.4 \* | −2.2 \* | −4.0 \* | −6.3 \* | −10.6 \* |
| 30 万 | −0.5 | −0.7 | −1.4 \* | −2.7 \* | −6.0 \* |
| 100 万 | −0.1 | −0.2 | −0.3 | −0.9 | −2.0 \* |
| 500 万 | −0.03 | −0.04 | −0.1 | −0.2 | −0.4 |
(\* = 有效;饰品价按 1000、水位 45%、目标 30% 算。因撞区间边界后随机数饱和,1 万那行不再单调。)
**门槛公式**:`Δ水位 ≈ x·[q(1−r) − 1] / (1 + q·x)`,其中 `x = N·p/B`、`q = k·V/100 ≈ 1.2~1.43`、`r` = 当前水位。
⇒ **`r = 目标水位` 时分子为 0 —— 稳态下预留不起作用(本该如此)**,水位偏离目标越多才越明显。
⇒ 判据的本质是「**连开饰品的总面值 `Np` 占累计下注 `B` 的比例**」:**`Np/B ≳ 3%~5%` 才看得出来**。
**为什么和累计下注相关(而不是只看水位)** —— 水位是**比值**、流水是**绝对额**:
`水位 = 利润 / 累计下注`,一件货(饰品价 1000、随机数 120)带来「下注 +1200、返奖 +1000、净赚 +200」,
但这 200 元对水位的推动完全取决于分母:
| 累计下注 B | 加一件货后 | 水位变化 |
|---:|---|---:|
| 10 万 | 45.00% → 44.80% | **−0.20 点** |
| 100 万 | 45.00% → 44.98% | **−0.02 点** |
⇒ 同一笔钱落进 10 倍大的池子,效果就是 1/10。
⚠️ **预留不会凭空制造推动** —— 它预估的就是这批货真实产生的交易,所以效果上限 = **这批货真出完后本来就会推动的量**,
机制只是把这份推动**提前**给第 2、第 3 个随机数看到。⇒ 「连开 N 个才有效」的门槛,本质不是预留不灵,
而是**这 N 件货本来也推不动水位**。
⇒ 所以「按水位判断」和「体量影响效果」是两件事:**水位→随机数**(判断,只看水位与 `gain`)与
**流水→水位**(记账,比例反馈天然有惯性,强依赖体量)。
| 累计下注(p = 1000) | 需要连开多少个才有效 |
|---:|---|
| 1 万 ~ 3 万 | 2 ~ 3 个 |
| 10 万 | 3 个(面值占 3%) |
| 30 万 | 10 个 |
| 100 万 | 50 个(现实中不存在) |
| 500 万以上 | 不现实 |
⇒ **一句话结论:刚上线、累计下注在十万量级以内能稳定处理;滚到百万以上基本失效。**
这是「水位是累计比例」的必然结果 —— 想在大水位下也能快速纠偏,要调 **`gain`**(水位每偏离 1 个点、
中心移动的幅度;现在默认 2,暂未在后台暴露),或者直接人工注水/抽水。
---
## 2026-09-23
- **幸运升级:随机数改由「全局水位」动态决定 + 判定层三身份隔离 / 展示层共享幸运值 + 后台注水抽水页**
> ⚠️ **后续修订(2026-09-24)**:本条的「三、水位 → 随机数的映射(波段表)」与「十、上线前必做」第 1 条
> (清空三个区间列)**已被 2026-09-24 条取代** —— 水位从「4 档波段表」简化为「单一目标水位」,
> 饰品区间从"建议清空"变为"必须配置且要包含推算中心"。其余内容(水位口径、三身份判定隔离、
> 幸运值共享展示、标定系数、注水抽水、换算关系)**全部仍然有效**。
**起因**:原来每个饰品位配一套**固定**幸运区间(如 `[100,160)`),每轮从区间里均匀抽一个随机数当作出货阈值,
于是随机数永远 ≥ 区间下限 —— 玩家能看出「每次都要投到 1 倍原价以上才出」这条硬底。
同时三个身份(普通/主播/白号)各有各的区间与进度,前端展示的「幸运值」各不相同,
看主播或白号表演的观众会发现数值对不上账。
**一、换算关系(改这个玩法的参数前必看)**
幸运区间的单位是**「饰品价的百分比」**:
| 量 | 关系 |
|---|---|
| 每拉一次消耗 | `饰品价 × probability / 100` |
| 每拉一次推进 | `eff = min(probability, 75)` 点进度 |
| 每 1 点进度的成本 | `饰品价 / 100`(`probability ≤ 75` 时严格成立) |
| 出一件货需要 | 约 `fullV` 点进度 |
| **理论 RTP** | `100 / fullV` |
| **水位** | `1 − RTP = 1 − 100/fullV` |
| 目标水位 | 目标 RTP | 名义中心随机数 |
|---:|---:|---:|
| −41% | 141% | 71 |
| −20% | 120% | 83 |
| 0% | 100% | 100 |
| 10% | 90% | 111 |
| 20% | 80% | 125 |
| **30%** | **70%** | **143** |
| 40% | 60% | 167 |
| 47% | 53% | 189 |
⚠️ **水位能到多少由随机数区间的上下界决定**:想让水位能长期停在 40% 以上,某一档的随机数上界必须 > 160。
**二、最终口径(三条,都是拍过板的)**
1. **随机数 + 判定进度:按身份完全隔离**。每个身份有自己的一份「本轮进度 + 本轮随机数」,
白号中奖**只刷新白号自己的随机数**,普通玩家的进度与随机数原样不动。
2. **水位:全局**(整个幸运升级一个水位,不按饰品位拆),且**排除白号**。
3. **幸运值:判定层隔离、展示层共享** —— 见下面第 4 条改动。
**三、水位 → 随机数的映射(波段表)**
`sys_config: luckyUpgradeWatermark` 存一张波段表,命中档后在该档区间内**均匀随机**(有 10s 内存缓存):
| 水位区间 | 可出的随机数区间 |
|---|---|
| < 10% | `[95, 130]` |
| 10% ~ 26% | `[115, 150]` |
| **26% ~ 36%** | **`[75, 175]`** ← 稳态档 |
| ≥ 36% | `[85, 128]` |
保存时后端强校验:水位档升序不重叠、`0 < vMin < vMax`、**水位越高随机数中心值必须越小**(配反直接拒绝)。
**四、名义 ≠ 实测:标定系数 k**
判定是「跨过阈值即出货、最后一拉整段 eff 计入并丢弃溢出」,所以**实测等效需求是名义 `fullV` 的 118%~120%**,
波段表的随机数值**不能按 `100/fullV` 公式拍**。后台自动算标定系数:
```
k = 改造后累计下注(排除白号) / Σ(round_target/100 × 目标饰品价,仅中奖记录)
预计落点水位 = (1 − 100/(k × 中心随机数)) × 100
```
数据不足时 `k = null`,后台显示「样本不足」而不是用公式瞎估;「预计落点水位」落在该档自己的水位区间内 ⇒ 该档是**稳态档**(后台标绿)。
**五、改动清单**
(SQL 见 `docs/updata_sql.md`,接口口径见 `docs/幸运升级接口文档.md`)
1. **水位**:`水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100`,统计范围**整个幸运升级**且**排除白号**。
累计量走 Redis 增量计数(`upgrade_pool:*`),DB 只用于启动初始化与后台「按历史重算」——
不在拉取热路径上对 `tt_upgrade_record` 做全表聚合。
2. **随机数**:按当前水位查波段表,命中档后在档内均匀随机;水位越高随机数越低(放水)。
`enabled=false` ⇒ 逐字节回退到「每个饰品位固定 `luck_section`」,不发版可回滚。
新增 `UpgradeWatermarkService`(水位/波段表/标定/注水抽水)+ `TtUpgradeWatermarkController`。
3. **判定层三身份完全隔离**:进度与随机数各存 Redis `upgrade_range_fixed:{饰品位}:{身份}`
(结构与改造前一致,老缓存不用迁移),白号中奖不影响普通玩家的进度与随机数。
4. **幸运值(展示)**:累加口径**保持不变** —— 按「玩家在前端拉的那个值」累加原始 `probability`;
**中奖判定恒用 `min(概率,75)`**,两者刻意分开(75% 封顶是风控手段,不改展示数字)。
**展示值是三个身份共享的一个累计器:任一身份(含白号)中奖即整体归零,且中奖那一拉的增量仍计入**
(出货那一刻显示的是含本次的值,下一拉才从 0 重新累计);表里那三列只用来**分别记录各身份的贡献**(后台排查),展示时相加。
`getOrnamentsList.luckyValue` 返回这个共享值(所以所有人看到的数一致),另给 `myLuckyValue`。
另留 `luckyValueMode` 开关(默认 `raw`,可切 `eff`),**只影响展示值与落库快照**。
5. **后台新增「幸运升级 → 奖池水位」页**(`menu_id=3208`):水位/实测RTP 统计卡、波段表编辑器(含名义RTP 与
**按实测标定的「预计落点水位」**)、注水抽水(原因必填)、调整历史、「按历史重算」。
6. 拉取记录新增 `watermark` / `round_target` 快照列,便于事后复盘「这批货是在什么水位下出的」。
**六、仿真结论(脚本 `.workbuddy/upgrade-watermark-sim.mjs`,40 万次拉取,可复跑)**
| 方案 | 名义 target | 实测 RTP | 实测水位 | 等效需求/名义 | 随机数<100 占比 |
|---|---:|---:|---:|---:|---:|
| **A** 现状:固定 `[100,160)` | 129 | 65.1% | 34.9% | 119% | **0.0%** |
| **C** 曾设想的「共享池 + 只清中奖方」(**已否决**) | 108 | **131.8%** | **−31.9%** | 71% | 19.2% |
| **F** 水位波段表(已实现) | 125 | **66.9%** | **33.1%** | 120% | **24.6%** |
三个关键结论:
- ⚠️ **「三身份共享一个池、中奖只清中奖方」会亏钱**:残留进度会垫薄下一轮阈值,非主力身份中奖时垫得最狠还会连锁
⇒ 仿真实测 **RTP 从 62% 飙到 131.8%、水位掉到 −32%**。所以**判定层必须按身份隔离**,
「一个奖池」只体现在**幸运值的展示**上。
- ⚠️ **名义 ≠ 实测**(见第四节):波段表的随机数必须按实测标定,按 `100/fullV` 公式拍一定偏。
- **稳态随机数的均值由目标水位唯一决定**(水位 30% ⟺ 平均随机数 ≈ 121),这是数学、改不掉;
能做的只是把档内区间**做宽**(推荐跨度 ≥ 80)。改造后随机数覆盖 75~175、**24.6% 的轮次低于 100**,
而 RTP 基本不变(65.1% → 66.9%)—— 玩家不再看到「必须 100 以上才出」的硬底。
**七、一个完整算例(对着数字走一遍)**
**前提**:饰品价 1000 元;波段表如上(当前落在 26~36% 档 ⇒ 随机数取自 `[75,175]`);
起始累计(排除白号)下注 100,000 / 返奖 65,000 / 调整 0 ⇒ **水位 = 35.00%**。
| 步 | 谁 | 拉取 | 扣款 | 该池随机数 | 判定(进度 + min(概率,75)) | 结果 | 该池进度 |
|---|---|---|---|---|---|---|---|
| 1 | 普通 A | 10% | 100 | 120(本池新抽) | 0 + 10 = 10 < 120 | 失败 | 10 |
| 2 | 普通 B | 50% | 500 | 120(沿用) | 10 + 50 = 60 < 120 | 失败 | 60 |
| 3 | 主播 C | 20% | 200 | **95**(主播池自己抽) | 0 + 20 = 20 < 95 | 失败 | 20 |
| 4 | 白号 D | 90% | 900 | **88**(白号池自己抽) | 0 + **75** = 75 < 88 | 失败 | 75 |
| 5 | 白号 D | 20% | 200 | 88(沿用) | 75 + 20 = 95 ≥ 88 | **中奖** | 0 → 重抽 130 |
| 6 | 普通 A | 75% | 750 | 120(沿用) | 60 + 75 = 135 ≥ 120 | **中奖** | 0 → 重抽 105 |
**第 5 步后三个池的状态**(判定层隔离:白号中奖,普通/主播一点没动)
| 池 | 本轮随机数 | 进度 |
|---|---|---|
| 普通 | 120 | 60 |
| 主播 | 95 | 20 |
| 白号 | 130(重抽) | 0 |
**第 4 步为什么要用 75 而不是 90**:白号拉 90% 时扣款按 90% 收(900 元)、判定只按 `min(90,75)=75` 加进度,
而界面幸运值按**原始概率**加(白号那份 +90)。⇒ 那一刻白号显示「幸运值 90」、本池随机数 88,看着早该中了,
但判定进度只有 75(还差 13)—— **实际没中**;下一步再拉 20%(75+20=95 ≥ 88)才中。这就是那个分叉的实际后果。
**幸运值展示 = 三个身份共享的一个累计值**(所有人看到同一个数;任一身份中奖即整体归零)
| 时点 | 普通 | 主播 | 白号 | 展示 luckyValue | 说明 |
|---|---|---|---|---|---|
| 起始 | 0 | 0 | 0 | **0** | |
| 步 1 后 | 10 | 0 | 0 | **10** | 普通 +10 |
| 步 2 后 | 60 | 0 | 0 | **60** | 普通 +50 |
| 步 3 后 | 60 | 20 | 0 | **80** | 主播**也加进同一个数** |
| 步 4 后 | 60 | 20 | 90 | **170** | 白号 +90 |
| 步 5 后 | 0 | 0 | 0 | **190** | 白号 +20 → **中奖 → 整体归零**;显示的是中奖那一刻的 190 |
| 步 6 后 | 0 | 0 | 0 | **75** | 0 + 75 → **中奖 → 整体归零** |
**水位的变化(白号的量不算)**
| 步 | 累计下注 | 累计返奖 | 水位 |
|---|---|---|---|
| 起始 | 100,000 | 65,000 | 35.00% |
| 1(普通 +100) | 100,100 | 65,000 | 35.06% |
| 2(普通 +500) | 100,600 | 65,000 | 35.39% |
| 3(主播 +200) | 100,800 | 65,000 | 35.52% |
| 4(白号 +900) | 100,800 | 65,000 | 35.52%(**不变**) |
| 5(白号 +200 且中奖 +1000) | 100,800 | 65,000 | 35.52%(**不变**) |
| 6(普通 +750 且中奖 +1000) | 101,550 | 66,000 | 35.01% |
白号的下注/返奖进另一组计数(后台「白号」那一栏),只展示、不影响水位。
**水位什么时候生效**:只在「新的一轮开始」读一次(该饰品位该身份首次拉取 / 刚中奖)。
本轮中途水位变化**不改手里的随机数**;若水位涨到 ≥ 36%,下一次**新开的一轮**才会从 `[85,128]` 里抽。
**落库记录(`tt_upgrade_record`,节选)**
| 步 | user_type | amount_consumed | amount_required | round_target | watermark | is_victory | lucky_value |
|---|---|---|---|---|---|---|---|
| 1 | 02 | 100 | 1200 | 120 | 35.00 | 0 | 10 |
| 4 | 06 | 900 | 880 | 88 | 35.52 | 0 | 170 |
| 5 | 06 | 200 | 880 | 88 | 35.52 | 1 | **190** |
| 6 | 02 | 750 | 1200 | 120 | 35.52 | 1 | **75** |
- `amount_required = 饰品价 × round_target / 100`。
- **中奖记录的 `lucky_value` 记的是「中奖那一刻的累计值」(含本次),不是 0** —— 与玩家当下看到的一致。
- **`watermark` 记的是「本次拉取之前」的水位** —— 代码先读水位、判定出货成功后才做增量记账,顺序不能反。
**八、改动文件**
后端 `ApiLuckyUpgradeServiceImpl`、`UpgradeWatermarkService`(新)、`TtUpgradeWatermarkController`(新)、
`TtUpgradeRewardLogMapper`(新) + XML、`RedisConstants`、`ApiLuckyUpgradeMapper.xml`、`TtUpgradeOrnamentsMapper.xml`、
实体 `TtUpgradeRecord` / `TtUpgradeRewardLog`(新) / `UpgradeWatermarkSettingBody`(新) / 两个 VO;
前端 `views/skins/luckyUpgrade/rewardPool/index.vue`(新) + api、`views/skins/ttUpgrade/index.vue`。
**九、验证**
`mvn -B clean install -DskipTests` 通过,且 **javap 产物确认 `doUpgrade` 只剩新签名、无旧 `(Integer[], UpgradeBodyA)` 残留**;
`ruoyi-ui` 构建 `DONE Build complete`;`audit-palette` 离版文件/色值 **0 / 0**;`verify-dist` **ALL_OK(34 项)**;
`audit-ops-fit` 需加宽 **0 条**。新页面确认已进 `dist` chunk。
算例里的 6 个幸运值(10/60/80/170/190/75)用脚本复刻改后逻辑跑过,全部一致。
**踩坑**:① 本项目是 **Spring Boot 4(jakarta)**,`@PostConstruct` 写成 `javax.` 会编译失败;
② **并行对同一文件发多个 Edit 会静默丢改动**(回执 success 但没落盘,仿真脚本主循环那次白排查一轮)⇒ 改完必须 grep 复验;
③ 15 万元素数组用 `Math.min(...arr)` 直接 `RangeError: Maximum call stack size exceeded`,大数组统计先 sort 再取分位;
④ **口径类需求别用文字对齐**:「幸运值」到底是「三身份各自累计、各清各的」还是「一个共享累计器、谁中奖都整体归零」,
文字上两句话都解释得通 —— 最后靠「第 5 步应该显示 190、第 6 步应该显示 75」这组数字才把模型逼出来
(若是各清各的,第 5 步应为 80;若不清零,第 6 步应为 265)。
**十、上线前必做**
1. 清空两个已存在饰品位的旧固定区间
(`UPDATE tt_upgrade_ornaments SET luck_section = NULL, anchor_luck_section = NULL, blank_luck_section = NULL;`)
—— 不清的话它们仍会被旧区间夹住,仍看得见「100 以上才出」的硬底;
2. 水位从 0 起步,`enabled=true` 时前几件货会落在收紧档,属预期,需要立刻放水就在新页面注水。
## 2026-09-22 ## 2026-09-22
- **docker 配置命名统一:`changzhou` → `henan`(全套 `henan_` 前缀)** - **docker 配置命名统一:`changzhou` → `henan`(全套 `henan_` 前缀)**
+156 -31
View File
@@ -1,6 +1,12 @@
# 幸运升级接口文档(Cocos 客户端对接) # 幸运升级接口文档(Cocos 客户端对接)
> 适用版本:当前 master。所有接口路径前缀 `/api/luckyUpgrade`(另有 1 个关联接口在 `/api/userPackSack`)。 > 适用版本:当前 master。
> **2026-09-23 起随机数改由「全局水位」动态决定**(不再是每个饰品位的固定幸运区间);三身份的进度与随机数**完全隔离**;
> 幸运值展示值改为三身份合计(累加口径仍按玩家在前端拉的值,**中奖判定恒用 `min(概率,75)`**)。
> **2026-09-24 起水位配置简化为「只填一个目标水位(%)」** —— 随机数中心由它自动推算,
> 取值范围仍由每个饰品自己的区间限定(见 §1.2 / §1.3 / §6.4)。
> 方案、换算表、口径推导与算例见 `docs/修改记录.md` 的 2026-09-24 条(前情见 2026-09-23 条);数据库变更见 `docs/updata_sql.md`。
> 所有接口路径前缀 `/api/luckyUpgrade`(另有 1 个关联接口在 `/api/userPackSack`)。
> 代码位置:`skins-service/service-playingmethod/.../ApiLuckyUpgradeController.java`、`ApiLuckyUpgradeServiceImpl.java`、`ApiUpgradeRecordServiceImpl.java`。 > 代码位置:`skins-service/service-playingmethod/.../ApiLuckyUpgradeController.java`、`ApiLuckyUpgradeServiceImpl.java`、`ApiUpgradeRecordServiceImpl.java`。
--- ---
@@ -15,8 +21,8 @@
↓ 校验(身份 / 封禁 / 删除 / 未充值 703 / 余额 702) ↓ 校验(身份 / 封禁 / 删除 / 未充值 703 / 余额 702)
↓ 加锁(饰品位 + 奖池类型)→ 扣款(source=40 幸运升级消费) ↓ 加锁(饰品位 + 奖池类型)→ 扣款(source=40 幸运升级消费)
↓ 判定(见 1.2) ↓ 判定(见 1.2)
├─ 成功:获得该饰品位对应的整件饰品,进度与幸运值清 0 ├─ 成功:获得该饰品位对应的整件饰品,**该身份**的进度与随机数清 0 / 重抽(其他身份不受影响)
└─ 失败:获得后台配置的「失败奖励」饰品(安慰奖,可多件),进度与幸运值累加 └─ 失败:获得后台配置的「失败奖励」饰品(安慰奖,可多件),**该身份**的进度累加
↓ 写 tt_upgrade_record 记录(含各项快照)+ 背包 tt_box_records(source=4 幸运升级)+ 站内消息 ↓ 写 tt_upgrade_record 记录(含各项快照)+ 背包 tt_box_records(source=4 幸运升级)+ 站内消息
↓ 返回升级记录(含 gainOrnamentList,可用于结果页立即分解) ↓ 返回升级记录(含 gainOrnamentList,可用于结果页立即分解)
``` ```
@@ -27,23 +33,72 @@
|---|---| |---|---|
| 单次消耗 | `price = 饰品实时价(tt_ornament.use_price) × probability / 100`,保留 2 位小数(**前端不传价格**,由后端算,防 0 元 100% 中奖) | | 单次消耗 | `price = 饰品实时价(tt_ornament.use_price) × probability / 100`,保留 2 位小数(**前端不传价格**,由后端算,防 0 元 100% 中奖) |
| 判定概率 | `eff = min(probability, 75)`(**封顶 75**:选 90% 也只按 75% 判定,但钱按 90% 收) | | 判定概率 | `eff = min(probability, 75)`(**封顶 75**:选 90% 也只按 75% 判定,但钱按 90% 收) |
| 本轮空间 | `fullV`:从该饰品位对应奖池的幸运区间 `[min, max)` 里随机取 `nextInt(max-min) + min` | | 本轮空间 | `fullV`:由**当前全局水位**自动推算 —— 先按目标水位反推中心,再在该饰品自己的区间 `[min, max]` 内做**偏斜随机**(支撑集是整个区间,水位高时下限附近的数更频繁)。水位见 1.3 |
| 判定 | 每拉一次 `进度 += eff`;`进度 + eff >= fullV` 即中奖,中奖后 `fullV` 重新随机、进度清 0;否则失败继续累加 | | 判定 | 每拉一次 `进度 += eff`;`进度 + eff >= fullV` 即中奖,中奖后**只刷新当前身份**的 `fullV`、该身份进度清 0;**其他身份的进度与随机数一律不受影响**;否则失败继续累加 |
| 推论 | **玩家为一件饰品付出的总成本 ≈ 饰品价 × fullV / 100**。所以幸运区间 = 该池的"售价百分比区间":`[50,250]` 表示平均要付 50%~250% 原价才出;压到个位数(如 `[9,10]`)等于必中、平台必亏 | | 推论 | **玩家为一件饰品付出的总成本 ≈ 饰品价 × fullV / 100**,即 `理论 RTP = 100 / fullV`、`水位 = 1 − 100/fullV`。⇒ 想让水位能长期停在某个值,饰品的随机数区间必须**包含**该水位对应的中心随机数 |
### 1.3 幸运值与幸运区间:三套独立奖池 > ⚠️ **名义 ≠ 实测**:判定是「跨过阈值即出货、最后一拉整段 eff 计入并丢弃溢出」,实测等效需求是名义值的
> **118%~120%**。所以**不要按 `100/fullV` 公式拍参数**,按后台「奖池水位」页的实测 RTP 与标定系数来调。
`幸运值` = 该池"累计原始概率",失败累加 `probability`(原始值,不是封顶后的 75)、中奖清 0;界面与记录里展示的就是它。 ### 1.3 幸运值、水位与三个身份池
| 奖池 | 判定/显示用 userType | 幸运区间字段 | 幸运值字段 | 本轮已投金额字段 | **水位(全局,2026-09-23 起)**:
|---|---|---|---|---|
| 普通用户 | `02` | `tt_upgrade_ornaments.luck_section` | `probability_invested` | `amount_invested` / `effective_amount_invested` |
| 主播 | `01` | `anchor_luck_section` | `anchor_probability_invested` | `anchor_amount_invested` / `anchor_effective_amount_invested` |
| 白号 | `06`(`is_blank_account=1` 优先判定) | `blank_luck_section`(未配置回退 `luck_section`) | `blank_probability_invested` | `blank_amount_invested` / `blank_effective_amount_invested` |
- 三池**完全隔离**:进度缓存在 Redis `upgrade_range_fixed:{饰品位}:{池类型}` / `upgrade_range:{饰品位}:{池类型}`,互不影响、互不阻塞(锁也是按「饰品位 + 池类型」)。 ```
- 同一池内是**共享进度**:同一个池子的玩家一起把进度推到 `fullV`,谁那一拉跨过阈值谁中奖(和普通/主播现有行为一致,白号同理,白号之间共享 06 池)。 水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100 # 统计范围 = 整个幸运升级,排除白号
- 白号记录里的幸运值/已投金额读的都是白号自己的池,不再是真实玩家池的读数。 ```
水位越高说明平台已经净赚越多 → 本轮可出的随机数越低(放水);水位不足 → 随机数越高(收紧)。
后台只配一个**目标水位 T(%)**,随机数中心由它自动反推:
```
V* = 100 / ((1 − T/100) × k) k = 实测标定系数(1.18~1.20,样本不足按 1.19)
中心 = V* × (1 − (当前水位 − T)/100 × gain) gain 默认 2
t = (中心 − min) / (max − min) 中心在区间内的相对位置,夹到 [0.10, 0.90]
V = min + (max − min) × u^p, p = 1/t − 1 u ~ U(0,1)
```
**整个区间始终都可能出**,水位只改变概率重心:水位 = 目标时 `p = 1`(区间内均匀分布);
水位高于目标 ⇒ `p > 1` ⇒ 下限附近更频繁;低于目标则相反。
`E[V] = 中心` ⇒ 水位会**长期自动收敛回目标值**(仿真实测偏差 < 0.05 个点)。
⚠️ `t` 的夹取范围意味着**中心的可表达范围 = min + [10%, 90%] × 区间宽度**:
区间太窄或下限太高时,高水位目标就实现不了(例:`[100,160]` 只能维持约 `[20.7%, 45.4%]` 的水位)。
**虚拟水位(抽随机数实际用的是它,不是上面那个真实水位)**:真实水位只反映**已经结算**的下注/返奖,
一批饰品同时开新轮时若都用同一个真实水位,会连着出好几个同侧偏斜的随机数。所以每抽一个随机数,
就把这一轮的预估交易预留进 pending:
```
虚拟水位 = (真实bet + pending_bet − 真实win − pending_win + 调整) / (真实bet + pending_bet)
pending_bet += 饰品价 × k × 随机数 / 100 pending_win += 饰品价
```
出货过程中产生的实际流水会**逐步把预留扣回去**(两边口径一致 ⇒ 真正出货时归零,不会长期挂账)。
白号轮次不进水位口径,不参与预留;未抽过数时 pending = 0,退化为真实水位。
水位从 0 起步,可用后台「幸运升级 → 奖池水位」人工注水/抽水。策略与人工调整见 §6.4。
**三个身份池**:
| 池 | userType | 幸运值字段(累计进度) | 本轮已投金额字段 |
|---|---|---|---|
| 普通用户 | `02` | `probability_invested` | `amount_invested` / `effective_amount_invested` |
| 主播 | `01` | `anchor_probability_invested` | `anchor_amount_invested` / `anchor_effective_amount_invested` |
| 白号 | `06`(`is_blank_account=1` 优先判定) | `blank_probability_invested` | `blank_amount_invested` / `blank_effective_amount_invested` |
- 三池**完全隔离**:进度与随机数缓存在 Redis `upgrade_range_fixed:{饰品位}:{池类型}`,锁也是按「饰品位 + 池类型」。
**白号中奖只刷新白号自己的随机数**;普通玩家的进度和随机数原样保留 —— 一个奖池但不会互相污染。
- **随机数的中心由全局水位决定,取值范围由每个饰品自己的区间限定**:`luck_section` /
`anchor_luck_section` / `blank_luck_section` 三列仍按身份分别生效,但语义从改造前的「固定随机数区间」
变为「**允许的取值范围**」—— 区间要**包含水位推算出的中心值**,否则中心被夹在边界上、水位会停在别处。
策略关闭(`enabled=false`)时这三列恢复成改造前的「固定随机数区间」用法。
- **幸运值的累加口径**:默认按「玩家在前端拉的那个值」累加(原始 `probability`,选 90% 就 `+90`),与历史记录口径一致;
**中奖判定固定用 `min(probability, 75)`** —— 两者刻意分开(75% 封顶是风控手段,不改展示数字)。
后台开关 `luckyValueMode` 可切成 `eff`(按 `min(概率,75)` 累加),**只影响展示值与落库快照**。
- **幸运值 = 三个身份共享的一个累计器**:`getOrnamentsList.luckyValue` 就是这个值,所以所有人看到的数完全一致;
**任一身份(含白号)中奖都把它整体归零**,且**中奖那一拉的增量仍计入**(出货那一刻看到的是含本次的数,下一拉才从 0 重新累计)。
⚠️ 这与「判定进度」不是一回事:**判定进度与随机数按身份完全独立**,白号中奖不会动普通玩家的进度。
另有 `myLuckyValue` = 查看者所属身份的贡献(自上次任一身份中奖以来),排查用。
### 1.4 获得与失败奖励 ### 1.4 获得与失败奖励
@@ -132,7 +187,8 @@
| usePrice | BigDecimal | 实时价格(= 计算消耗、展示目标金额的基准) | | usePrice | BigDecimal | 实时价格(= 计算消耗、展示目标金额的基准) |
| imageUrl | String | 饰品图片 | | imageUrl | String | 饰品图片 |
| shortName / typeName / rarityName / exteriorName / levelImg | String | 简称 / 类型 / 稀有度 / 磨损度 / 品质图 | | shortName / typeName / rarityName / exteriorName / levelImg | String | 简称 / 类型 / 稀有度 / 磨损度 / 品质图 |
| luckyValue | Integer | **当前幸运值**(按登录身份取对应池:普通/主播/白号) | | luckyValue | Integer | **当前幸运值**(三个身份共享的一个累计值,所有人看到的数一致);口径见 1.3。**任一身份中奖即整体归零** |
| myLuckyValue | Integer | 查看者所属身份对共享值的贡献(自上次任一身份中奖以来);排查用,一般不用 |
### 4.2 `POST /api/luckyUpgrade/upgrade` — 拉取一次 ### 4.2 `POST /api/luckyUpgrade/upgrade` — 拉取一次
@@ -210,7 +266,7 @@
|---|---|---| |---|---|---|
| userId / avatar / nickName / vipLevel | - | 玩家信息(昵称是升级时快照) | | userId / avatar / nickName / vipLevel | - | 玩家信息(昵称是升级时快照) |
| probability | Integer | 成功率(原始概率,如 89 表示 89%) | | probability | Integer | 成功率(原始概率,如 89 表示 89%) |
| luckyValue | Integer | 幸运值快照(按该玩家所属池;白号是白号池的值) | | luckyValue | Integer | 幸运值快照(**中奖记录记的是「中奖那一刻」的值,含本次**),与 `getOrnamentsList.luckyValue` 同口径 |
| amountConsumed | BigDecimal | 消耗 | | amountConsumed | BigDecimal | 消耗 |
| gainOrnamentsPrice | BigDecimal | 获得饰品总价值 | | gainOrnamentsPrice | BigDecimal | 获得饰品总价值 |
| isVictory | Boolean | 成败:true=升级成功(前端胜负样式用) | | isVictory | Boolean | 成败:true=升级成功(前端胜负样式用) |
@@ -240,7 +296,7 @@
|---|---|---| |---|---|---|
| avatar / nickName / vipLevel | - | 拉取人信息 | | avatar / nickName / vipLevel | - | 拉取人信息 |
| probability | Integer | 本次拉取**选择的成功率**(不是中奖率) | | probability | Integer | 本次拉取**选择的成功率**(不是中奖率) |
| luckyValue | Integer | 本次拉取时的**幸运值快照**(累计原始概率;成功为 0),与 `recordPage` 同口径 | | luckyValue | Integer | 本次拉取时的**幸运值快照**(含本次的增量;中奖那一拉记的就是出货那一刻的值),与 `recordPage` 同口径 |
| isVictory | Boolean | 成败:true=升级成功 | | isVictory | Boolean | 成败:true=升级成功 |
| name / imageUrl / usePrice / exteriorName / levelImg | - | **目标饰品**("谁在拉这个饰品",不是获得物) | | name / imageUrl / usePrice / exteriorName / levelImg | - | **目标饰品**("谁在拉这个饰品",不是获得物) |
| openTime | String | 拉取时间 | | openTime | String | 拉取时间 |
@@ -291,8 +347,8 @@
1. **upgradeId 语义**:所有接口里的 `upgradeId` / `recentPulls.upgradeRecordId` 都是**可升级饰品ID**(`tt_upgrade_ornaments.id`),来自 `getOrnamentsList` 的 `id`,不是 `tt_ornament.id`,也不是升级记录ID。 1. **upgradeId 语义**:所有接口里的 `upgradeId` / `recentPulls.upgradeRecordId` 都是**可升级饰品ID**(`tt_upgrade_ornaments.id`),来自 `getOrnamentsList` 的 `id`,不是 `tt_ornament.id`,也不是升级记录ID。
2. **扣款以服务端为准**:不要传价格;展示价格用 `usePrice × probability / 100`(与后端同公式,后端保留 2 位小数)。 2. **扣款以服务端为准**:不要传价格;展示价格用 `usePrice × probability / 100`(与后端同公式,后端保留 2 位小数)。
3. **概率封顶 75**:UI 上若提供 >75% 的档位,要提示"更高概率只加钱、不提升判定"。 3. **概率封顶 75**:UI 上若提供 >75% 的档位,要提示"更高概率只加钱、不提升判定"。
4. **幸运值怎么用**:主界面实时幸运值取 `getOrnamentsList.luckyValue`;拉取成功后用 `upgrade` 响应里的 `luckyValue` 立即覆盖,不必等列表刷新。记录里的幸运值是历史快照。 4. **幸运值怎么用**:主界面实时幸运值取 `getOrnamentsList.luckyValue`(**三个身份合计的全服累计值,所有人一致**);拉取成功后用 `upgrade` 响应里的 `luckyValue` 立即覆盖,不必等列表刷新。记录里的幸运值是历史快照。
5. **分池**:幸运值/幸运区间按 普通(02) / 主播(01) / 白号(06) 三套独立,白号看到的是白号池的值;同一池内进度是共享的,别人垫的进度也算在你头上。 5. **分池与水位**:进度与随机数按 普通(02) / 主播(01) / 白号(06) 三套**完全独立** —— 别人(含白号)中奖**不会**动你的进度和随机数。随机数的**中心**由全局水位统一决定(水位越高越容易出货),**取值范围**仍由该饰品自己的随机数区间限定。
6. **分页**:`recordPage`/`myHistoryRecord` 的 `size` 上限 20;切换 tab(我的/全服、当前饰品/全部饰品)时 `page` 重置为 1。 6. **分页**:`recordPage`/`myHistoryRecord` 的 `size` 上限 20;切换 tab(我的/全服、当前饰品/全部饰品)时 `page` 重置为 1。
7. **失败记录展示**:以 `isVictory` 判断胜负,`name/imageUrl/...` 是"实际获得物"(失败=安慰奖);无安慰奖时金额为 0。 7. **失败记录展示**:以 `isVictory` 判断胜负,`name/imageUrl/...` 是"实际获得物"(失败=安慰奖);无安慰奖时金额为 0。
8. **结果页分解**:`gainOrnamentList` 里的 `id` 就是分解入参;不调用 = 饰品留在背包。 8. **结果页分解**:`gainOrnamentList` 里的 `id` 就是分解入参;不调用 = 饰品留在背包。
@@ -306,16 +362,20 @@
| 后台列 | 字段 | 说明 | | 后台列 | 字段 | 说明 |
|---|---|---| |---|---|---|
| 普通用户 / 幸运区间 | `luck_section` | 普通用户池的 `[min, max)`,即"售价百分比区间" | | 普通用户 / 随机数区间 | `luck_section` | **随机数的允许取值范围**(`[min,max]` JSON),水位推算出的中心必须落在里面 |
| 普通用户 / 本轮已投金额 | `amount_invested` | 普通池当前累计投入(运行时值,后台不可编辑回写) | | 普通用户 / 本轮已投金额 | `amount_invested` | 普通池当前累计投入(运行时值,后台不可编辑回写) |
| 主播用户 / 幸运区间 | `anchor_luck_section` | 主播池区间 | | 主播用户 / 随机数区间 | `anchor_luck_section` | 同上,主播身份独立配置 |
| 白号用户 / 幸运区间 | `blank_luck_section` | 白号池区间;为空则回退 `luck_section` | | 白号用户 / 随机数区间 | `blank_luck_section` | 同上,白号身份独立配置;未配则回退 `luck_section` |
| 白号用户 / 本轮已投金额 | `blank_amount_invested` | 白号池当前累计投入 |
| 状态 | `status` | 0=启用(=可升级、会出现在客户端列表) 1=禁用 | | 状态 | `status` | 0=启用(=可升级、会出现在客户端列表) 1=禁用 |
- 调难度就调"幸运区间":区间 = 玩家为该饰品付出的**价格百分比**。想更容易出就整体下调(如 `[80,280]`→`[60,200]`);**不要压到个位数**(如 `[9,10]` 等于必中,等于按 9% 价格卖货),也不要配成 `min == max`(会被后端防护强制改成 `[0,160]`)。 - **随机数的中心不在这里配**:中心由「目标水位」自动推算(页面顶部水位条显示 当前水位 / 目标水位 /
- **改完区间要清缓存才立即生效**:`DEL upgrade_range:{饰品位}:{池}` + `DEL upgrade_range_fixed:{饰品位}:{池}`(池=01/02/06)。不清的话当前这一轮仍按旧空间跑,等本轮中奖后自动按新配置重建。Redis 实测在 db0,密码见 `application-*.yml`。 实测 RTP / 推算随机数中心),这里只配**取值范围**。调难度请改目标水位(见 6.4)。
- 白号是独立奖池,可与普通/主播配不同的区间;「批量填货」新增饰品位时白号区间默认取普通用户的默认值。 - ⚠️ **区间必须包含推算出的中心值**,且**中心离两端各留约 10% 区间宽度**,否则中心被夹住、水位会停在附近
而不是目标值(仿真实测可偏 16~20 个点)。区间还**限定了能维持的水位范围**:`[100,160]` 只能维持约 `[20.7%, 45.4%]`。
- **整个区间始终都可能出**(偏斜随机),水位只改变概率重心;水位 = 目标时是均匀分布。
区间越宽,单轮随机数波动越大、越难被玩家归纳出规律。
- 区间改完**下一轮立即生效**,不用清 Redis(水位驱动下直接读实体字段,不走区间缓存)。
- ⚠️ 旧版本文档里「上线前清空三个区间列」的做法**已作废** —— 现在这三列是必填项。
### 6.2 「幸运升级 → 失败奖励」(`tt_upgrade_fail_ornaments`) ### 6.2 「幸运升级 → 失败奖励」(`tt_upgrade_fail_ornaments`)
@@ -328,13 +388,78 @@
| `websiteMaintenance` | `1` | 全站维护:`upgrade` 与分解接口都拦截 | | `websiteMaintenance` | `1` | 全站维护:`upgrade` 与分解接口都拦截 |
| `luckyUpgradeMaintenance` | `1` | 只拦幸运升级:`upgrade` 返回「幸运升级维护中......」 | | `luckyUpgradeMaintenance` | `1` | 只拦幸运升级:`upgrade` 返回「幸运升级维护中......」 |
### 6.4 数据落库 ### 6.4 「幸运升级 → 奖池水位」(目标水位 / 注水抽水)—— 后台接口
Base = `/admin/upgradeWatermark`,均需后台登录。
| 接口 | 说明 |
|---|---|
| `GET /info` | 当前水位快照 + 目标水位策略 + 按目标水位推算的随机数中心 |
| `POST /setting` | 保存策略:`targetWatermark`(必填)+ `gain`(可选,默认 2)+ `enabled` + `luckyValueMode` |
| `POST /adjust` | 注水 / 抽水(`type` 1注水 2抽水;调整原因必填) |
| `POST /adjustReset` | 重置人工调整(插入抵消日志,历史保留) |
| `GET /adjustLog` | 调整历史分页 |
| `POST /recalc` | 按历史流水全量重算累计量 + 刷新标定系数(纠正 Redis 计数漂移) |
`/info` 返回(节选):
| 字段 | 说明 |
|---|---|
| `enabled` | 策略开关;`false` = 回退到每个饰品位的固定随机数区间 |
| `targetWatermark` | **目标水位(%)** —— 后台唯一要配的运营参数 |
| `gain` | 响应灵敏度(水位每偏离目标 1 个点,随机数中心反向调整 gain%),默认 2 |
| `watermark` / `rtp` | 当前(真实)水位% / 实测 RTP%(均排除白号) |
| `watermarkWithPending` | **虚拟水位** —— 抽随机数实际用的那个;与 `watermark` 的差 = 已抽走未出货轮次的影响 |
| `pendingBet` / `pendingWin` | 已抽走随机数、还没出货的那些轮次的剩余预估下注 / 返奖 |
| `targetGap` | 当前水位 − 目标水位(正 = 赚得比目标多 ⇒ 下一轮放水) |
| `targetCenter` | 按目标水位推算的随机数中心 `V*`(**饰品区间要包含它**) |
| `centerNow` | 下一轮新开会用到的随机数中心(按**虚拟水位**算,与实际抽数口径一致) |
| `calibration` | 实测标定系数 k;样本不足时为 `null` |
| `effectiveCalibration` | 实际参与推算的系数(样本不足时 = 1.19 兜底) |
| `totalBet` / `totalWin` / `blankBet` / `blankWin` / `adjust` | 水位口径的累计量(白号不进水位,仅并列展示) |
**策略配置(`sys_config: luckyUpgradeWatermark`)**:
```json
{
"enabled": true,
"targetWatermark": 30,
"gain": 2,
"luckyValueMode": "raw"
}
```
| 字段 | 说明 |
|---|---|
| `enabled` | `false` = 逐字节回退改造前的固定幸运区间行为,不发版即可回滚 |
| `targetWatermark` | **目标水位(%)**:平台希望长期维持的净收益比例。30 ⇒ 长期平均 RTP ≈ 70%。合法范围 −50 ~ 95 |
| `gain` | 响应灵敏度,默认 2:水位每高出目标 1 个点,随机数中心降 2%(放水);低于目标则反之 |
| `luckyValueMode` | 幸运值**展示**口径:`raw`(默认,累加原始 `probability`)/ `eff`(累加 `min(概率,75)`)。**不影响中奖判定** |
**目标水位 → 随机数中心换算表**(按 `k = 1.19` 兜底系数算):
| 目标水位 | 0% | 10% | 20% | **30%** | 40% | 45% | 50% | 60% |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| 中心 `V*` | 84 | 93 | 105 | **120** | 140 | 153 | 168 | 210 |
保存时强校验:`targetWatermark` 必填且在 −50% ~ 95% 之间;`gain` 若填写需在 0.5 ~ 10 之间。
⚠️ **标定**:判定溢出让「实测等效需求」是名义值的 118%~120%,所以中心公式必须带上**实测标定系数**
(`V* = 100 / ((1 − 目标水位/100) × k)`),不能按 `100/(1 − 目标水位/100)` 直接算。
`k` 由系统按真实流水滚动计算(5 分钟内存缓存),样本不足时按 1.19 兜底。
⚠️ **`V*` 必须落在饰品的随机数区间内**,且**离两端各留约 10% 区间宽度**,否则中心被夹住、水位停不到目标值。
⚠️ **区间同时限定了能维持的水位范围**(中心可表达范围 = 区间下界 + `[10%, 90%]` 宽度):
例 `[100,160]` 对应水位约 `[20.7%, 45.4%]`;想把水位开到 60%,区间上限得提到 210 以上。
完整推导、仿真数据与算例见 `docs/修改记录.md` 的 2026-09-24 条。
### 6.5 数据落库
| 表 | 说明 | | 表 | 说明 |
|---|---| |---|---|
| `tt_upgrade_record` | 每条拉取记录(成败、消耗、获得总额、各项快照、`gain_ornament_list` JSON、`lucky_value`) | | `tt_upgrade_record` | 每条拉取记录(成败、消耗、获得总额、各项快照、`gain_ornament_list` JSON、`lucky_value`、`watermark` 水位快照、`round_target` 本轮随机数快照) |
| `tt_box_records` | 获得物入背包(`source=4` 幸运升级),`id` 即结果页分解入参 | | `tt_box_records` | 获得物入背包(`source=4` 幸运升级),`id` 即结果页分解入参 |
| `tt_user_blend_ercash` | 余额流水:消费 40、游戏内分解 41、背包分解 10 | | `tt_user_blend_ercash` | 余额流水:消费 40、游戏内分解 41、背包分解 10 |
| `tt_upgrade_ornaments` | 饰品位配置 + 三个池的运行进度(各池幸运值/已投金额) | | `tt_upgrade_ornaments` | 饰品位配置 + 三个身份的运行进度(各身份累计幸运值/已投金额) |
| `tt_upgrade_reward_log` | 水位人工调整日志(注水/抽水/重置;`delta` 为带符号影响,水位调整额 = `SUM(delta)`) |
字段变更历史见 `docs/updata_sql.md`(含白号三列的 ALTER)。 字段变更历史见 `docs/updata_sql.md`(含白号三列的 ALTER)。
@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 当前水位快照 + 波段表策略 + 本轮生效的随机数区间
export function getWatermarkInfo() {
return request({
url: '/admin/upgradeWatermark/info',
method: 'get'
})
}
// 保存水位波段表策略(后端带校验:区间合法 + 水位越高随机数越小)
export function saveWatermarkSetting(data) {
return request({
url: '/admin/upgradeWatermark/setting',
method: 'post',
data: data
})
}
// 注水 / 抽水(type 1注水 2抽水;调整原因必填)
export function adjustWatermark(data) {
return request({
url: '/admin/upgradeWatermark/adjust',
method: 'post',
data: data
})
}
// 重置人工调整(插入抵消日志,SUM(delta) 归零,历史保留)
export function resetWatermarkAdjust(data) {
return request({
url: '/admin/upgradeWatermark/adjustReset',
method: 'post',
data: data
})
}
// 调整历史(分页)
export function getWatermarkLogs(params) {
return request({
url: '/admin/upgradeWatermark/adjustLog',
method: 'get',
params: params
})
}
// 按历史记录全量重算累计下注/返奖(纠正 Redis 计数漂移)
export function recalcWatermark() {
return request({
url: '/admin/upgradeWatermark/recalc',
method: 'post'
})
}
@@ -0,0 +1,530 @@
<template>
<div class="app-container reward-pool-page">
<div class="page-head">
<div class="page-head-row">
<el-button type="text" size="mini" :icon="showGuide ? 'el-icon-arrow-up' : 'el-icon-question'" @click="showGuide = !showGuide">
{{ showGuide ? '收起说明' : '配置说明' }}
</el-button>
</div>
<p class="head-tip">
水位是<b>整个幸运升级</b>的全局指标:水位 = (Σ下注 − Σ返奖 + 人工调整) / Σ下注,<b>排除白号</b>(白号的量单独展示,不进水位口径)。
只需设一个<b>目标水位</b>:水位高于它 → 随机数中心下移(放水);低于它 → 上移(收紧),长期会自动收敛回这个值。
</p>
<div v-show="showGuide" class="guide-panel">
<div class="guide-col">
<h5><i class="el-icon-info" /> 随机数是什么</h5>
<ul>
<li>随机数的单位是「饰品价的百分比」,等于本轮出货所需的累计进度。</li>
<li>理论 RTP = 100 / 随机数。随机数 125 → 玩家平均要投到 1.25 倍原价才出。</li>
<li>实测 RTP 会比名义值低 15%~20%(判定最后一拉整段计入、溢出丢弃),所以<b>以页面上的实测 RTP 为准</b>。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-setting" /> 目标水位怎么定</h5>
<ul>
<li>目标水位 = 平台希望长期维持的净收益比例。定 30% ⇒ 长期平均 RTP 约 70%。</li>
<li>系统会自动把水位拉回这个值(水位高就放水、低就收紧),<b>不用手配随机数</b>。</li>
<li>水位从 0 起步,离目标值较远时前几轮会明显偏放水或偏收紧,属正常的收敛过程。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-s-grid" /> 饰品的随机数区间</h5>
<ul>
<li>随机数的取值范围由<b>每个饰品自己的区间</b>限定(在「幸运升级 → 饰品列表」里配)。</li>
<li><b>整个区间始终都可能出</b>,水位只改变概率重心:水位高 → 靠下限的数出现得更频繁;
水位正好等于目标时是均匀分布(区间内等概率)。</li>
<li>⚠️ <b>区间还限定了水位能调到的范围</b>(硬约束):区间越窄、下限越高,能开的水位上限越低。
目标水位必须落在所有在售饰品区间都能覆盖的范围内,否则水位会停在附近而不是目标值。</li>
<li>区间越宽,单轮波动越大、越不容易被玩家归纳出规律。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-s-operation" /> 注水 / 抽水</h5>
<ul>
<li>水位从 0 起步,上线初期想立刻把水位拉到目标值就注水。</li>
<li>调整原因必填,每次调整都会写入历史(可审计)。</li>
<li>「重置调整」只把人工调整归零,不影响真实流水;历史记录全部保留。</li>
<li>累计量走 Redis 增量计数,怀疑漂移时点「按历史重算」从流水全量重算。</li>
</ul>
</div>
</div>
</div>
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-terra"><i class="el-icon-odometer" /></div>
<div class="sc-body">
<small>当前水位</small>
<h3 :style="{ color: info.watermark < 0 ? '#a4485a' : '#9a5c35' }">{{ pct(info.watermark) }}</h3>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-grass"><i class="el-icon-data-line" /></div>
<div class="sc-body">
<small>实测 RTP(排除白号)</small>
<h3 :style="{ color: info.rtp > 100 ? '#a4485a' : '#457450' }">{{ pct(info.rtp) }}</h3>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-daisy"><i class="el-icon-coin" /></div>
<div class="sc-body">
<small>累计下注(排除白号)</small>
<h3>{{ fmt(info.totalBet) }}</h3>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-rose"><i class="el-icon-present" /></div>
<div class="sc-body">
<small>累计返奖(排除白号)</small>
<h3>{{ fmt(info.totalWin) }}</h3>
</div>
</div>
</el-col>
</el-row>
<div class="band-live">
<span class="bl-item">
<span class="bl-label">目标水位</span>
<b class="bl-value">{{ info.targetWatermark == null ? '—' : Number(info.targetWatermark).toFixed(1) + '%' }}</b>
</span>
<span class="bl-item">
<span class="bl-label">离目标</span>
<b class="bl-value" :style="{ color: gapColor }">{{ gapText }}</b>
</span>
<span class="bl-item">
<span class="bl-label">下一轮随机数</span>
<b class="bl-value" style="color:#9a5c35">{{ info.centerNow == null ? '—' : Number(info.centerNow).toFixed(1) }}</b>
</span>
<span class="bl-item">
<span class="bl-label">人工调整</span>
<b class="bl-value" :style="{ color: Number(info.adjust) < 0 ? '#a4485a' : (Number(info.adjust) > 0 ? '#457450' : '#4f4130') }">{{ fmt(info.adjust) }}</b>
</span>
<span class="bl-item">
<span class="bl-label">实测标定系数</span>
<b class="bl-value">{{ info.calibration == null ? '样本不足' : info.calibration }}</b>
</span>
<span class="bl-item">
<span class="bl-label">白号(不进水位口径)</span>
<b class="bl-value" style="color:#8f6418">下注 {{ fmt(info.blankBet) }} / 返奖 {{ fmt(info.blankWin) }}</b>
</span>
<span class="bl-item">
<span class="bl-label">策略开关</span>
<el-tag :type="info.enabled ? 'success' : 'info'" size="mini">{{ info.enabled ? '水位驱动已启用' : '已关闭(回退固定区间)' }}</el-tag>
</span>
</div>
<el-card shadow="never" class="op-card">
<div slot="header">
<b>水位策略</b>
<span class="sub-tip">改完<b>立即生效</b>,不需要点保存</span>
</div>
<el-form :inline="true" @submit.native.prevent>
<el-form-item>
<span class="fld-label">目标水位</span>
<el-input-number
v-model="form.targetWatermark"
:precision="1"
:step="1"
:min="-50"
:max="95"
:controls="false"
style="width:140px"
placeholder="如 30"
@change="autoSaveSetting"
/>
<span class="fld-unit">%</span>
<span class="fld-note" style="margin-left:10px">平台希望长期维持的净收益比例;定 30% ⇒ 长期平均 RTP ≈ 70%</span>
</el-form-item>
</el-form>
<div class="setting-flags">
<span class="fld-label">策略开关</span>
<el-switch v-model="form.enabled" active-color="#457450" inactive-color="#a4485a" @change="autoSaveSetting" />
<span class="fld-note" style="margin-left:8px">
{{ form.enabled ? '按水位动态出随机数' : '关闭后回退到每个饰品的固定区间(旧行为)' }}
</span>
</div>
<div class="range-calc">
<span class="fld-label">随机数区间</span>
<el-input-number v-model="form.rangeMin" :precision="0" :min="1" :controls="false" style="width:84px" placeholder="下限" />
<span class="rc-sep">~</span>
<el-input-number v-model="form.rangeMax" :precision="0" :min="2" :controls="false" style="width:84px" placeholder="上限" />
<span class="rc-arrow">⇒</span>
<span class="fld-label">可维持水位</span>
<b class="rc-result" :style="{ color: rangeResult.color }">{{ rangeResult.text }}</b>
<el-popover placement="top" width="440" trigger="hover" popper-class="wm-help">
<div slot="reference" class="help-ref">?</div>
<div class="wm-help-body">
<p><b>随机数区间</b>:该饰品每轮随机数的允许取值范围。整个区间<b>始终都可能出</b>,
水位只改变概率重心 —— 水位高于目标时,靠下限的数出现得更频繁。</p>
<p><b>为什么它决定了水位范围</b>:随机数的中心由目标水位自动推算,
但推算出的中心只能落在「下限 + 10% 区间宽度」到「上限 − 10% 区间宽度」之间(超出会被夹住)。
把这两个端点代回 <code>水位 = 1 − 100 ÷ (标定系数 × 随机数)</code>,就是左边显示的可维持范围。</p>
<p><b>怎么用</b>:目标水位要落在<b>各在售饰品区间都能覆盖</b>的范围内,否则水位会停在附近而不是目标值。
想把水位开得更高,把区间上限抬高即可 —— 上限 160 时水位最多约 45%,想到 60% 上限要到 210 以上。
(区间在「幸运升级 → 饰品列表」里按身份分别配置。)</p>
</div>
</el-popover>
</div>
</el-card>
<el-card shadow="never" class="op-card">
<div slot="header">
<b>人工注水 / 抽水</b>
<span class="sub-tip">注水 = 抬高水位(更容易出货);抽水 = 降低水位(收紧)</span>
</div>
<el-form :inline="true" @submit.native.prevent>
<el-form-item>
<span class="fld-label">金额</span>
<el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="100" :controls="false" style="width:140px" placeholder="如 1000" />
</el-form-item>
<el-form-item>
<span class="fld-label">调整原因<span class="req">*</span></span>
<el-input v-model="form.remark" maxlength="100" placeholder="必填,如:上线初期补水" style="width:220px" />
</el-form-item>
<el-form-item>
<el-popconfirm :title="'确认注水 ' + fmt(form.amount) + '?水位将上升。'" @confirm="submitAdjust(1)">
<el-button slot="reference" type="success" plain icon="el-icon-top" :loading="adjusting" :disabled="!form.amount">注水</el-button>
</el-popconfirm>
<el-popconfirm :title="'确认抽水 ' + fmt(form.amount) + '?水位将下降。'" @confirm="submitAdjust(2)">
<el-button slot="reference" type="danger" plain icon="el-icon-bottom" :loading="adjusting" :disabled="!form.amount" style="margin-left:8px">抽水</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item>
<el-popconfirm title="把人工调整累计值归零(只清人工部分,不动真实流水;历史记录保留)?" @confirm="submitReset">
<el-button slot="reference" type="warning" plain icon="el-icon-refresh-left" :loading="resetting">重置调整</el-button>
</el-popconfirm>
<el-popconfirm title="按 tt_upgrade_record 全量重算累计下注/返奖,覆盖 Redis 计数(用于纠正漂移)?" @confirm="submitRecalc">
<el-button slot="reference" plain icon="el-icon-refresh" :loading="recalcing" style="margin-left:8px">按历史重算</el-button>
</el-popconfirm>
</el-form-item>
</el-form>
</el-card>
<div class="section-head">
<h4 style="margin:0">调整历史</h4>
<el-button size="mini" type="text" icon="el-icon-refresh" @click="loadLogs">刷新</el-button>
</div>
<el-table v-loading="loading" :data="logs" border size="small">
<el-table-column prop="createTime" label="时间" min-width="150" align="center" />
<el-table-column label="类型" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.type === 1 ? 'success' : (scope.row.type === 2 ? 'danger' : 'warning')" size="mini">
{{ scope.row.type === 1 ? '注水' : (scope.row.type === 2 ? '抽水' : '重置') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" min-width="110" align="center">
<template slot-scope="scope">
<span :style="{ color: scope.row.type === 1 ? '#457450' : (scope.row.type === 2 ? '#a4485a' : '#8f6418') }">
{{ scope.row.type === 1 ? '+' : (scope.row.type === 2 ? '-' : '') }}{{ fmt(scope.row.amount) }}
</span>
</template>
</el-table-column>
<el-table-column label="调整前水位" min-width="110" align="center">
<template slot-scope="scope">{{ scope.row.watermarkBefore == null ? '—' : scope.row.watermarkBefore + '%' }}</template>
</el-table-column>
<el-table-column label="调整后水位" min-width="110" align="center">
<template slot-scope="scope">{{ scope.row.watermarkAfter == null ? '—' : scope.row.watermarkAfter + '%' }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="90" align="center" />
<el-table-column label="原因" min-width="160" align="center" show-overflow-tooltip>
<template slot-scope="scope">
<span :style="{ color: scope.row.remark ? '#4f4130' : '#8b755a' }">{{ scope.row.remark || '—' }}</span>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="query.size"
:current-page.sync="query.page"
:page-sizes="[10,20,50]"
@size-change="loadLogs"
@current-change="loadLogs"
/>
</div>
</template>
<script>
import {
getWatermarkInfo,
saveWatermarkSetting,
adjustWatermark,
resetWatermarkAdjust,
getWatermarkLogs,
recalcWatermark
} from '@/api/skins/luckyUpgrade/rewardPool/api'
/** 样本不足时的兜底标定系数,与后端 UpgradeWatermarkService.DEFAULT_CALIBRATION 保持一致 */
const FALLBACK_CALIBRATION = 1.19
export default {
name: 'LuckyUpgradeRewardPool',
data() {
return {
showGuide: false,
loading: false,
saving: false,
adjusting: false,
resetting: false,
recalcing: false,
info: {
enabled: true,
watermark: 0,
rtp: 0,
totalBet: 0,
totalWin: 0,
blankBet: 0,
blankWin: 0,
adjust: 0,
targetWatermark: 30,
gain: 2,
targetGap: 0,
targetCenter: null,
centerNow: null,
calibration: null,
effectiveCalibration: FALLBACK_CALIBRATION
},
logs: [],
total: 0,
form: {
targetWatermark: 30,
enabled: true,
gain: 2,
luckyValueMode: 'raw',
rangeMin: undefined,
rangeMax: undefined,
amount: undefined,
remark: ''
},
query: { page: 1, size: 10 }
}
},
computed: {
gapText() {
const g = Number(this.info.targetGap)
if (isNaN(g)) return '—'
const sign = g > 0 ? '+' : ''
return sign + g.toFixed(2) + '%' + (g > 0 ? '(下一轮放水)' : (g < 0 ? '(下一轮收紧)' : ''))
},
gapColor() {
const g = Number(this.info.targetGap)
if (isNaN(g) || g === 0) return '#4f4130'
return g > 0 ? '#457450' : '#a4485a'
},
/**
* 按照输入的随机数区间,反推这个区间能维持的水位范围。
* 随机数中心的可表达范围 = 下限 + [10%, 90%] × 区间宽度(超出会被夹取),
* 再代回 水位 = 1 − 100 / (标定系数 × 随机数) 即得。
*/
rangeWatermark() {
const lo = Number(this.form.rangeMin)
const hi = Number(this.form.rangeMax)
if (isNaN(lo) || isNaN(hi) || lo <= 0 || hi <= lo) return null
const k = Number(this.info.effectiveCalibration) || FALLBACK_CALIBRATION
const w = hi - lo
const watermarkAt = (center) => (1 - 100 / (k * center)) * 100
return { min: watermarkAt(lo + 0.1 * w), max: watermarkAt(lo + 0.9 * w) }
},
rangeResult() {
if (this.form.rangeMin == null || this.form.rangeMax == null) {
return { text: '—(填入区间后自动换算)', color: '#7a664e' }
}
const r = this.rangeWatermark
if (!r) return { text: '—(需满足 0 < 下限 < 上限)', color: '#7a664e' }
const text = r.min.toFixed(1) + '% ~ ' + r.max.toFixed(1) + '%'
const t = Number(this.form.targetWatermark)
if (isNaN(t)) return { text, color: '#4f4130' }
const ok = t >= r.min && t <= r.max
return {
text: ok ? text : text + ' ⚠ 目标水位 ' + t.toFixed(1) + '% 不在范围内',
color: ok ? '#457450' : '#a4485a'
}
}
},
created() {
this.loadInfo()
this.loadLogs()
},
methods: {
fmt(v) {
const n = Number(v)
return isNaN(n) ? '0.00' : n.toFixed(2)
},
pct(v) {
const n = Number(v)
return isNaN(n) ? '0.00%' : n.toFixed(2) + '%'
},
applyInfo(data) {
if (!data) return
this.info = data
this.form.targetWatermark = data.targetWatermark == null ? 30 : Number(data.targetWatermark)
this.form.enabled = data.enabled !== false
this.form.gain = data.gain == null ? 2 : Number(data.gain)
this.form.luckyValueMode = data.luckyValueMode || 'raw'
},
loadInfo() {
getWatermarkInfo().then(res => this.applyInfo(res.data))
},
loadLogs() {
this.loading = true
getWatermarkLogs(this.query)
.then(res => {
this.logs = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
})
.catch(() => {
this.loading = false
})
},
requireRemark() {
if (this.form.remark && this.form.remark.trim()) return true
this.$message.warning('请填写调整原因')
return false
},
/** 目标水位 / 策略开关改动即保存(本页没有保存按钮) */
autoSaveSetting() {
if (this.saving) return
const t = Number(this.form.targetWatermark)
if (isNaN(t) || t <= -50 || t >= 95) {
this.$message.error('目标水位需在 -50% ~ 95% 之间')
this.loadInfo()
return
}
if (t === Number(this.info.targetWatermark) && this.form.enabled === (this.info.enabled !== false)) {
return
}
this.saving = true
saveWatermarkSetting({
enabled: this.form.enabled,
luckyValueMode: this.form.luckyValueMode,
targetWatermark: t,
gain: Number(this.form.gain) || 2
})
.then(res => {
this.applyInfo(res.data)
this.$message.success('已生效')
this.saving = false
})
.catch(() => {
this.saving = false
this.loadInfo()
})
},
submitAdjust(type) {
if (!this.form.amount || this.form.amount <= 0) {
this.$message.warning('请输入大于 0 的金额')
return
}
if (!this.requireRemark()) return
this.adjusting = true
adjustWatermark({ type, amount: this.form.amount, remark: this.form.remark.trim() })
.then(res => {
this.applyInfo(res.data)
this.$message.success((type === 1 ? '注水' : '抽水') + '成功,当前水位 ' + this.pct(res.data.watermark))
this.form.amount = undefined
this.form.remark = ''
this.query.page = 1
this.loadLogs()
this.adjusting = false
})
.catch(() => {
this.adjusting = false
})
},
submitReset() {
if (!this.requireRemark()) return
this.resetting = true
resetWatermarkAdjust({ remark: this.form.remark.trim() })
.then(res => {
this.applyInfo(res.data)
this.$message.success('重置完成,清除净调整 ' + this.fmt(res.data.cleared))
this.form.remark = ''
this.query.page = 1
this.loadLogs()
this.resetting = false
})
.catch(() => {
this.resetting = false
})
},
submitRecalc() {
this.recalcing = true
recalcWatermark()
.then(res => {
this.applyInfo(res.data)
this.$message.success('已按历史流水重算累计量')
this.recalcing = false
})
.catch(() => {
this.recalcing = false
})
}
}
}
</script>
<style scoped lang="scss">
.page-head { margin-bottom: 14px; }
.page-head-row { display: flex; align-items: center; justify-content: space-between; }
.head-tip { color: #7a664e; font-size: 12px; margin: 6px 0 0; line-height: 1.8; }
.guide-panel { display: flex; gap: 16px; background: #f6efe3; border: 1px solid #eadfd0; border-radius: 8px; padding: 14px 16px; margin-top: 10px; }
.guide-col { flex: 1; min-width: 0; }
.guide-col h5 { margin: 0 0 8px; font-size: 13px; color: #4f4130; }
.guide-col h5 i { color: #9a5c35; margin-right: 4px; }
.guide-col ul { margin: 0; padding-left: 16px; }
.guide-col li { font-size: 12px; color: #5f4f3d; line-height: 1.9; }
.stat-row { margin-bottom: 12px; }
.stat-card { display: flex; align-items: center; gap: 12px; background: #fffdfa; border: 1px solid #eadfd0; border-radius: 8px; padding: 14px 16px; }
.sc-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.sc-terra { background: rgba(154, 92, 53, .22); color: #9a5c35; }
.sc-grass { background: rgba(69, 116, 80, .22); color: #457450; }
.sc-daisy { background: rgba(168, 121, 31, .24); color: #a8791f; }
.sc-rose { background: rgba(181, 116, 116, .24); color: #b57474; }
.sc-body small { color: #7a664e; display: block; }
.sc-body h3 { margin: 2px 0 0; font-size: 18px; }
.band-live { display: flex; flex-wrap: wrap; gap: 10px 26px; align-items: center; background: #f6efe3; border: 1px solid #eadfd0; border-radius: 8px; padding: 10px 16px; margin-bottom: 14px; }
.bl-item { display: flex; align-items: center; gap: 6px; }
.bl-label { color: #7a664e; font-size: 12px; }
.bl-value { color: #4f4130; font-size: 14px; }
.op-card { margin-bottom: 16px; border-radius: 8px; }
.op-card .sub-tip { color: #7a664e; font-size: 12px; margin-left: 10px; }
.fld-label { color: #5f4f3d; font-size: 13px; margin-right: 6px; }
.fld-label .req { color: #a4485a; margin-left: 2px; }
.fld-unit { color: #7a664e; font-size: 13px; margin-left: 6px; }
.fld-note { color: #7a664e; font-size: 12px; }
.fld-note b { color: #9a5c35; font-size: 14px; }
.op-card >>> .el-form-item { margin-bottom: 0; }
.setting-flags { display: flex; align-items: center; margin-top: 14px; }
.range-calc { display: flex; align-items: center; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.rc-sep { color: #7a664e; font-size: 13px; }
.rc-arrow { color: #9a5c35; font-size: 13px; margin: 0 4px; }
.rc-result { font-size: 14px; }
.help-ref {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 50%;
border: 1px solid #eadfd0; color: #9a5c35;
font-size: 12px; cursor: help; user-select: none;
}
.help-ref:hover { border-color: #9a5c35; background: #f6efe3; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 10px; }
</style>
<style lang="scss">
.wm-help .wm-help-body { font-size: 12px; line-height: 1.9; color: #5f4f3d; }
.wm-help .wm-help-body p { margin: 0 0 8px; }
.wm-help .wm-help-body p:last-child { margin-bottom: 0; }
.wm-help .wm-help-body code { background: #f6efe3; padding: 1px 4px; border-radius: 3px; color: #9a5c35; }
</style>
+61 -3
View File
@@ -44,6 +44,31 @@
</el-col> </el-col>
</el-row> </el-row>
<!-- 全局水位条:随机数中心由「目标水位」自动推算;下面各饰品自己的区间只是允许的取值范围内 -->
<div class="wm-bar">
<span class="wm-item">
<span class="wm-label">全局水位</span>
<b :style="{ color: Number(wm.watermark) < 0 ? '#a4485a' : '#9a5c35' }">{{ wm.watermark == null ? '—' : Number(wm.watermark).toFixed(2) + '%' }}</b>
</span>
<span class="wm-item">
<span class="wm-label">目标水位</span>
<b style="color:#457450">{{ wm.targetWatermark == null ? '—' : Number(wm.targetWatermark).toFixed(1) + '%' }}</b>
</span>
<span class="wm-item">
<span class="wm-label">实测RTP</span>
<b :style="{ color: Number(wm.rtp) > 100 ? '#a4485a' : '#457450' }">{{ wm.rtp == null ? '—' : Number(wm.rtp).toFixed(2) + '%' }}</b>
</span>
<span class="wm-item">
<span class="wm-label">推算随机数中心</span>
<b style="color:#9a5c35">{{ wm.targetCenter == null ? '—' : Number(wm.targetCenter).toFixed(1) }}</b>
</span>
<span class="wm-item">
<el-tag :type="wm.enabled ? 'success' : 'info'" size="mini">{{ wm.enabled ? '水位驱动中' : '已关闭(回退固定区间)' }}</el-tag>
</span>
<span class="wm-hint">随机数在其下方每个饰品自己的区间内、围绕按目标水位推算的中心波动:水位高于目标 → 落点靠近区间下限。</span>
<el-button type="text" size="mini" icon="el-icon-setting" class="wm-link" @click="goRewardPool">奖池水位 / 注水抽水</el-button>
</div>
<el-table v-loading="loading" :data="ornamentsList"> <el-table v-loading="loading" :data="ornamentsList">
<el-table-column label="ID" align="center" prop="id" /> <el-table-column label="ID" align="center" prop="id" />
<el-table-column label="名称" align="center" prop="itemName" /> <el-table-column label="名称" align="center" prop="itemName" />
@@ -89,7 +114,7 @@
</el-table-column> </el-table-column>
<el-table-column label="类型" align="center" prop="typeName" /> <el-table-column label="类型" align="center" prop="typeName" />
<el-table-column label="普通用户" align="center"> <el-table-column label="普通用户" align="center">
<el-table-column label="幸运区间(单位:%)" align="center" prop="luckSection"> <el-table-column label="随机数区间(单位:% )" align="center" prop="luckSection">
<template slot-scope="scope"> <template slot-scope="scope">
<el-popover placement="top" popper-class="layout"> <el-popover placement="top" popper-class="layout">
<div style="text-align: center;"> <div style="text-align: center;">
@@ -132,7 +157,7 @@
<el-table-column label="本轮已投金额" align="center" prop="amountInvested"></el-table-column> <el-table-column label="本轮已投金额" align="center" prop="amountInvested"></el-table-column>
</el-table-column> </el-table-column>
<el-table-column label="主播用户" align="center"> <el-table-column label="主播用户" align="center">
<el-table-column label="幸运区间(单位:%)" align="center" prop="anchorLuckSection"> <el-table-column label="随机数区间(单位:% )" align="center" prop="anchorLuckSection">
<template slot-scope="scope"> <template slot-scope="scope">
<el-popover placement="top" popper-class="layout"> <el-popover placement="top" popper-class="layout">
<div style="text-align: center;"> <div style="text-align: center;">
@@ -175,7 +200,7 @@
<el-table-column label="本轮已投金额" align="center" prop="anchorAmountInvested"></el-table-column> <el-table-column label="本轮已投金额" align="center" prop="anchorAmountInvested"></el-table-column>
</el-table-column> </el-table-column>
<el-table-column label="白号用户" align="center"> <el-table-column label="白号用户" align="center">
<el-table-column label="幸运区间(单位:%)" align="center" prop="blankLuckSection"> <el-table-column label="随机数区间(单位:% )" align="center" prop="blankLuckSection">
<template slot-scope="scope"> <template slot-scope="scope">
<el-popover placement="top" popper-class="layout"> <el-popover placement="top" popper-class="layout">
<div style="text-align: center;"> <div style="text-align: center;">
@@ -405,6 +430,7 @@ import {
} from "@/api/skins/ttUpgrade/api"; } from "@/api/skins/ttUpgrade/api";
import { listOrnaments, getWebsiteProperty } from "@/api/skins/ttOrnaments/api"; import { listOrnaments, getWebsiteProperty } from "@/api/skins/ttOrnaments/api";
import { listLevel } from "@/api/skins/ttOrnamentsLevel/api"; import { listLevel } from "@/api/skins/ttOrnamentsLevel/api";
import { getWatermarkInfo } from "@/api/skins/luckyUpgrade/rewardPool/api";
import * as echarts from "echarts"; import * as echarts from "echarts";
export default { export default {
@@ -416,6 +442,8 @@ export default {
], ],
data() { data() {
return { return {
// 全局水位快照(随机数中心由「目标水位」推算,本页只读展示;区间在本页编辑)
wm: { enabled: true, watermark: null, rtp: null, targetWatermark: null, targetCenter: null },
anchorLuckSection: { anchorLuckSection: {
maxanchorLuckSection: null, maxanchorLuckSection: null,
minanchorLuckSection: null minanchorLuckSection: null
@@ -499,8 +527,20 @@ export default {
}, },
created() { created() {
this.getList(); this.getList();
this.loadWatermark();
}, },
methods: { methods: {
/** 全局水位快照:随机数由水位决定,本页只读展示 */
loadWatermark() {
getWatermarkInfo()
.then(res => {
if (res.data) this.wm = res.data
})
.catch(() => {})
},
goRewardPool() {
this.$router.push('/luckyUpgrade/rewardPool')
},
// 数据库 rarity_color 字段为空,前端根据 rarity_name 映射 CS2 标准稀有度颜色(颜色按稀有度由低到高递进) // 数据库 rarity_color 字段为空,前端根据 rarity_name 映射 CS2 标准稀有度颜色(颜色按稀有度由低到高递进)
getRarityColor(rarityName) { getRarityColor(rarityName) {
const map = { const map = {
@@ -846,6 +886,24 @@ export default {
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 全局水位条:暖褐系浅底 + 陶土强调,与侧栏/统计卡同一套色 */
.wm-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 22px;
background: #f6efe3;
border: 1px solid #eadfd0;
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 12px;
}
.wm-item { display: flex; align-items: center; gap: 6px; }
.wm-label { color: #7a664e; font-size: 12px; }
.wm-item b { color: #4f4130; font-size: 14px; font-weight: 500; }
.wm-hint { color: #7a664e; font-size: 12px; }
.wm-link { margin-left: auto; color: #9a5c35; }
::v-deep .el-switch { ::v-deep .el-switch {
border-radius: 0px !important; border-radius: 0px !important;
height: 24px !important; height: 24px !important;
@@ -85,6 +85,13 @@ public class TtUpgradeRecord implements Serializable {
@TableField("effective_amount_invested") @TableField("effective_amount_invested")
private BigDecimal effectiveAmountInvested; private BigDecimal effectiveAmountInvested;
/** 本次拉取时的全局水位%(1−RTP,排除白号、含人工调整),用于复盘「这批货在什么水位下出的」 */
private BigDecimal watermark;
/** 本轮抽到的随机数快照(单位:饰品价百分比;水位驱动的结果) */
@TableField("round_target")
private Integer roundTarget;
/** 真实计算金额:超过 75% 按 75% 计算,小于等于 75% 按实际概率计算 */ /** 真实计算金额:超过 75% 按 75% 计算,小于等于 75% 按实际概率计算 */
@TableField("effective_amount") @TableField("effective_amount")
private BigDecimal effectiveAmount; private BigDecimal effectiveAmount;
@@ -0,0 +1,64 @@
package com.ruoyi.domain.other;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 幸运升级奖池水位调整日志 tt_upgrade_reward_log(注水/抽水/重置审计)。
* <p>
* 水位口径:水位 = (Σ下注 − Σ返奖 + 人工调整) / Σ下注,统计**全局**(整个幸运升级、所有饰品位),
* 且**排除白号**(白号是演出账号,其成本单独展示,不参与水位)。
* <p>
* 人工调整额 = 本表 {@code SUM(delta)}(DB 为真相),Redis 只做短缓存。
* {@code delta} 是带符号影响:注水为正、抽水为负、重置为抵消当前净调整的值。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Builder
@TableName("tt_upgrade_reward_log")
public class TtUpgradeRewardLog implements Serializable {
@TableField(exist = false)
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 1注水 2抽水 3重置 */
private Integer type;
/** 调整金额(绝对值,展示用) */
private BigDecimal amount;
/** 对水位的带符号影响(注水正 / 抽水负 / 重置为抵消值);水位调整额 = SUM(delta) */
private BigDecimal delta;
@TableField("watermark_before")
private BigDecimal watermarkBefore;
@TableField("watermark_after")
private BigDecimal watermarkAfter;
private String operator;
/** 调整原因(必填,前端保证) */
private String remark;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
}
@@ -0,0 +1,53 @@
package com.ruoyi.domain.other;
import lombok.Data;
/**
* 幸运升级水位策略配置(sys_config: luckyUpgradeWatermark)。
* <p>
* <b>只配一个数:目标水位(%)</b>。水位是**整个幸运升级**的全局指标,含义是「平台希望长期维持的净收益比例」,
* 由 {@code 水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100} 实时算出(排除白号)。
* <p>
* 随机数不再由后台手配区间,而是按目标水位自动推算:
* <ol>
* <li>先算出「长期出这个随机数、水位正好停在目标值」的中心值 {@code V* = 100 / ((1 − 目标水位/100) × k)},
* 其中 {@code k} 是实测标定系数(判定溢出导致实际需求约为名义值的 1.18~1.20 倍);</li>
* <li>再按当前水位相对目标值的偏离做负反馈:水位高于目标 → 中心下移(放水),低于目标 → 上移(收紧);</li>
* <li>最后在该饰品位自己的随机数区间 {@code [min, max]} 内围绕这个中心抖动,并夹取到区间边界。</li>
* </ol>
* ⇒ 后台改一个数即可,随机数的取值范围仍由每个饰品的区间控制(区间越宽,单轮波动越大)。
*/
@Data
public class UpgradeWatermarkSettingBody {
/**
* false = 逐字节回退旧行为(用每个饰品位固定 luck_section 抽随机数,不读水位)。
* 出问题时关掉即可回滚,不需要发版。
*/
private Boolean enabled;
/**
* <b>目标水位(%)</b> —— 平台希望长期维持的净收益比例。例:30 ⇒ 平均 RTP 约 70%。
* <p>
* 水位高于它 → 本轮随机数中心下调(放水,让玩家更容易出);
* 水位低于它 → 中心上调(收紧)。长期会自动收敛回这个值。
* 可为负数(负水位 = 允许先放水、后回收)。
*/
private Double targetWatermark;
/**
* 响应灵敏度:当前水位每偏离目标 1 个百分点,随机数中心反向调整 {@code gain}%。
* 默认 2(水位高出目标 10 个点 ⇒ 中心降 20%)。属调参用的高级参数,后台不展示。
*/
private Double gain;
/**
* 幸运值累加口径 —— **只影响「展示值 + 落库快照」,不影响中奖判定**:
* <ul>
* <li>{@code raw}(默认)= 累加前端传来的原始概率,与历史记录口径一致;</li>
* <li>{@code eff} = 累加 {@code min(概率,75)},与中奖判定同口径(选 &gt;75% 时展示值不会跑在判定前面)。</li>
* </ul>
* 中奖判定固定使用 {@code min(概率,75)},与本配置无关。
*/
private String luckyValueMode;
}
@@ -30,6 +30,13 @@ public class ApiLuckyOrnamentsDataVO {
private String levelImg; private String levelImg;
/** 本饰品当前幸运值(累计原始概率,出货成功清 0;按当前查看用户类型返回) */ /** 查看者所属身份对共享幸运值的贡献(自上次任一身份中奖以来),排查用;前端一般只用 luckyValue */
private Integer myLuckyValue;
/**
* 本饰品当前幸运值 = **三个身份共享的一个累计值**(所有人看到的数一致)。
* 口径:累加前端传来的原始概率(见 `luckyValueMode`);**任一身份中奖即整体归零**。
* 注意这是**展示口径**,中奖判定固定用 `min(概率, 75)`,两者刻意分开。
*/
private Integer luckyValue; private Integer luckyValue;
} }
@@ -29,6 +29,12 @@ public class TtUpgradeOrnamentsDataVO {
private String luckSection; private String luckSection;
/** 普通身份的累计幸运值(判定口径累加,出货成功清 0) */
private Integer probabilityInvested;
/** 主播身份的累计幸运值 */
private Integer anchorProbabilityInvested;
private BigDecimal amountRequired; private BigDecimal amountRequired;
private BigDecimal amountInvested; private BigDecimal amountInvested;
@@ -75,6 +75,11 @@ public class RedisConstants {
// 幸运升级 // 幸运升级
public static final String UPGRADE_RANGE = "upgrade_range:"; // 概率区间 '业务key:饰品id:用户类型' public static final String UPGRADE_RANGE = "upgrade_range:"; // 概率区间 '业务key:饰品id:用户类型'
public static final String UPGRADE_RANGE_FIXED = "upgrade_range_fixed:"; // 固定概率 '业务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, tuo.ornaments_level_id,
tol.`level`, tol.`level`,
tuo.luck_section, tuo.luck_section,
tuo.probability_invested,
tuo.anchor_probability_invested,
tuo.amount_required, tuo.amount_required,
tuo.amount_invested, tuo.amount_invested,
tuo.anchor_luck_section, tuo.anchor_luck_section,
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.admin.mapper.TtUpgradeRewardLogMapper">
<!--
全站累计下注/返奖。水位统计口径:全局(不按饰品位)、排除白号。
白号双重判定:记录里的身份快照 user_type='06',或关联用户 is_blank_account=1。
返奖取 gain_ornaments_price(含失败安慰奖)——安慰奖同样是真实支出,必须计入返奖。
calBet / calNominal 用于「实测标定系数 k」:
名义成本 calNominal = Σ(round_target/100 × 目标饰品价),只统计带了 round_target 的出货记录;
k = calBet / calNominal,即「名义随机数 → 实际下注」的放大倍率(判定溢出导致 k > 1,实测约 1.18)。
只取 round_target 非空的记录,避免把改造前的历史数据混进来。
-->
<select id="sumRecordTotals" resultType="java.util.Map">
SELECT
COALESCE(SUM(CASE WHEN is_blank THEN 0 ELSE r.amount_consumed END), 0) AS totalBet,
COALESCE(SUM(CASE WHEN is_blank THEN 0 ELSE r.gain_ornaments_price END), 0) AS totalWin,
COALESCE(SUM(CASE WHEN is_blank THEN r.amount_consumed END, 0), 0) AS blankBet,
COALESCE(SUM(CASE WHEN is_blank THEN r.gain_ornaments_price END, 0), 0) AS blankWin,
COALESCE(SUM(CASE
WHEN NOT is_blank AND r.round_target IS NOT NULL THEN r.amount_consumed
ELSE 0 END), 0) AS calBet,
COALESCE(SUM(CASE
WHEN NOT is_blank AND r.round_target IS NOT NULL AND r.is_victory = 1
THEN r.round_target / 100 * COALESCE(r.target_ornament_price, 0)
ELSE 0 END), 0) AS calNominal
FROM (
SELECT r.amount_consumed,
r.gain_ornaments_price,
r.round_target,
r.is_victory,
r.target_ornament_price,
(COALESCE(r.user_type, '') = '06' OR COALESCE(u.is_blank_account, 0) = 1) AS is_blank
FROM tt_upgrade_record r
LEFT JOIN tt_user u ON u.user_id = r.user_id
) r
</select>
<!-- 人工调整净额:注水为正、抽水为负、重置为抵消值,直接 SUM(delta) -->
<select id="sumAdjust" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(delta), 0) FROM tt_upgrade_reward_log
</select>
</mapper>
@@ -16,6 +16,7 @@ import com.ruoyi.admin.service.TtBoxRecordsService;
import com.ruoyi.admin.service.TtUpgradeOrnamentsService; import com.ruoyi.admin.service.TtUpgradeOrnamentsService;
import com.ruoyi.admin.service.TtUpgradeRecordService; import com.ruoyi.admin.service.TtUpgradeRecordService;
import com.ruoyi.admin.service.TtUserService; import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.admin.service.UpgradeWatermarkService;
import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
@@ -132,6 +133,10 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
@Autowired @Autowired
private MessageService messageService; private MessageService messageService;
/** 全局奖池水位:决定每轮抽到的随机数区间(水位越高 → 随机数越低),并负责水位累计记账 */
@Autowired
private UpgradeWatermarkService watermarkService;
@Override @Override
public List<ApiLuckyOrnamentsDataVO> getOrnamentsList(ApiLuckyUpgradeBody apiLuckyUpgradeBody) { public List<ApiLuckyOrnamentsDataVO> getOrnamentsList(ApiLuckyUpgradeBody apiLuckyUpgradeBody) {
// type 对应 tt_ornament.type 数字字典码(1匕首 2手枪 3步枪 4微型冲锋枪 5重型武器 6手套 7印花 8其它)。 // 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(); boolean isVictory = playResult.isWin();
// 本次拉取时的全局水位快照(落库,便于事后复盘「这批货是在什么水位下出的」)
double watermarkAtPull = watermarkService.getWatermark();
// 幸运值累加口径:默认 raw(累加前端传来的原始概率,展示口径,与历史记录一致);
// 只有策略里显式配 luckyValueMode=eff 才与判定同口径。
// 这是**展示口径**,中奖判定始终用 effectiveProbability,不受它影响。
boolean effLuckyValue = watermarkService.isLuckyValueEffMode();
// 5 同步保存本次结果。 // 5 同步保存本次结果。
// 快照本轮目标金额与已投金额(用于升级记录历史展示,需在累加/重置前读取) // 快照本轮目标金额与已投金额(用于升级记录历史展示,需在累加/重置前读取)
// 本轮目标金额使用实际随机出来的目标进度(fullV),而非幸运区间固定最大值 // 本轮目标金额使用实际随机出来的目标进度(fullV),而非幸运区间固定最大值
@@ -368,57 +380,62 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
? upgradeOrnament.getBlankEffectiveAmountInvested() ? upgradeOrnament.getBlankEffectiveAmountInvested()
: (anchorPool ? upgradeOrnament.getAnchorEffectiveAmountInvested() : upgradeOrnament.getEffectiveAmountInvested()); : (anchorPool ? upgradeOrnament.getAnchorEffectiveAmountInvested() : upgradeOrnament.getEffectiveAmountInvested());
// 幸运值快照(累计原始概率,累加/重置前读取,按奖池类型区分) // 本次要累加的幸运值增量:展示口径按「用户在前端拉的那个幸运值」累加(raw=原始概率),
Integer snapshotProbabilityInvested = blankPool // 判定口径另有其人(effectiveProbability,上面已用于出货判定),两者互不影响。
? upgradeOrnament.getBlankProbabilityInvested() int luckyDelta = effLuckyValue ? effectiveProbability : rawProbability;
: (anchorPool ? upgradeOrnament.getAnchorProbabilityInvested() : upgradeOrnament.getProbabilityInvested());
int prevProbabilityInvested = snapshotProbabilityInvested == null ? 0 : snapshotProbabilityInvested;
// 本饰品当前幸运值:出货成功清 0,失败累加前端原始概率(白号在自己的池里同样累计)
int luckyValue = isVictory ? 0 : prevProbabilityInvested + rawProbability;
// 更新本轮已投入金额(含幸运值) // 幸运值的口径:**三个身份共享同一个累计值**(所以前端所有人看到的数一致),
// 白号维护自己的一整套字段(本轮已投金额 + 幸运值),与普通/主播同样的算法,只是不碰它们的列 // **任一身份中奖即整体归零**;中奖那一拉的增量仍计入展示值
//(玩家在出货那一刻看到的数包含本次,下一拉才从 0 重新累计)。
int prevTotal = nvl(upgradeOrnament.getProbabilityInvested())
+ nvl(upgradeOrnament.getAnchorProbabilityInvested())
+ nvl(upgradeOrnament.getBlankProbabilityInvested());
int luckyValue = prevTotal + luckyDelta;
// 更新:本轮已投金额按身份各自累计/清零;幸运值按共享口径(三列一起清)
LambdaUpdateWrapper<TtUpgradeOrnaments> investedUpdate = new LambdaUpdateWrapper<>(); LambdaUpdateWrapper<TtUpgradeOrnaments> investedUpdate = new LambdaUpdateWrapper<>();
investedUpdate.eq(TtUpgradeOrnaments::getId, upgradeOrnament.getId()); investedUpdate.eq(TtUpgradeOrnaments::getId, upgradeOrnament.getId());
if (blankPool) {
if (isVictory) { if (isVictory) {
// 成功:白号本轮进度清 0 重置,溢出不带入下一轮 // 中奖:幸运值是共享的 ⇒ 三个身份一起清 0(下一个拉取的人从 0 开始)
investedUpdate.set(TtUpgradeOrnaments::getBlankAmountInvested, BigDecimal.ZERO); investedUpdate.set(TtUpgradeOrnaments::getProbabilityInvested, 0);
investedUpdate.set(TtUpgradeOrnaments::getBlankEffectiveAmountInvested, BigDecimal.ZERO); investedUpdate.set(TtUpgradeOrnaments::getAnchorProbabilityInvested, 0);
investedUpdate.set(TtUpgradeOrnaments::getBlankProbabilityInvested, 0); investedUpdate.set(TtUpgradeOrnaments::getBlankProbabilityInvested, 0);
} else { } else {
// 失败:实际扣款累计 + price;真实计算口径累计 + effectiveAmount;幸运值累加原始概率 // 失败:只往自己那一列累加(分列是为了后台能看出各身份的贡献,展示时三列相加)
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 重置,溢出不带入下一轮
investedUpdate.set(TtUpgradeOrnaments::getBlankAmountInvested, BigDecimal.ZERO);
investedUpdate.set(TtUpgradeOrnaments::getBlankEffectiveAmountInvested, BigDecimal.ZERO);
} else {
investedUpdate.setSql("blank_amount_invested = blank_amount_invested + " + price); investedUpdate.setSql("blank_amount_invested = blank_amount_invested + " + price);
investedUpdate.setSql("blank_effective_amount_invested = blank_effective_amount_invested + " + effectiveAmount); 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) { } else if (anchorPool) {
if (isVictory) { if (isVictory) {
// 成功:本轮进度清 0 重置,溢出不带入下一轮
investedUpdate.set(TtUpgradeOrnaments::getAnchorAmountInvested, BigDecimal.ZERO); investedUpdate.set(TtUpgradeOrnaments::getAnchorAmountInvested, BigDecimal.ZERO);
investedUpdate.set(TtUpgradeOrnaments::getAnchorEffectiveAmountInvested, BigDecimal.ZERO); investedUpdate.set(TtUpgradeOrnaments::getAnchorEffectiveAmountInvested, BigDecimal.ZERO);
// 幸运值(累计原始概率)同步清 0
investedUpdate.set(TtUpgradeOrnaments::getAnchorProbabilityInvested, 0);
} else { } else {
// 失败:实际扣款累计 + price;真实计算口径累计 + effectiveAmount
investedUpdate.setSql("anchor_amount_invested = anchor_amount_invested + " + price); investedUpdate.setSql("anchor_amount_invested = anchor_amount_invested + " + price);
investedUpdate.setSql("anchor_effective_amount_invested = anchor_effective_amount_invested + " + effectiveAmount); investedUpdate.setSql("anchor_effective_amount_invested = anchor_effective_amount_invested + " + effectiveAmount);
// 幸运值累加前端原始概率(如 90)
investedUpdate.setSql("anchor_probability_invested = COALESCE(anchor_probability_invested, 0) + " + rawProbability);
} }
} else { } else {
if (isVictory) { if (isVictory) {
// 成功:本轮进度清 0 重置,溢出不带入下一轮
investedUpdate.set(TtUpgradeOrnaments::getAmountInvested, BigDecimal.ZERO); investedUpdate.set(TtUpgradeOrnaments::getAmountInvested, BigDecimal.ZERO);
investedUpdate.set(TtUpgradeOrnaments::getEffectiveAmountInvested, BigDecimal.ZERO); investedUpdate.set(TtUpgradeOrnaments::getEffectiveAmountInvested, BigDecimal.ZERO);
// 幸运值(累计原始概率)同步清 0
investedUpdate.set(TtUpgradeOrnaments::getProbabilityInvested, 0);
} else { } else {
// 失败:实际扣款累计 + price;真实计算口径累计 + effectiveAmount
investedUpdate.setSql("amount_invested = amount_invested + " + price); investedUpdate.setSql("amount_invested = amount_invested + " + price);
investedUpdate.setSql("effective_amount_invested = effective_amount_invested + " + effectiveAmount); investedUpdate.setSql("effective_amount_invested = effective_amount_invested + " + effectiveAmount);
// 幸运值累加前端原始概率(如 90)
investedUpdate.setSql("probability_invested = COALESCE(probability_invested, 0) + " + rawProbability);
} }
} }
upgradeOrnamentsMapper.update(null, investedUpdate); upgradeOrnamentsMapper.update(null, investedUpdate);
@@ -467,6 +484,9 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
.gainOrnamentsPrice(prizeTotal) .gainOrnamentsPrice(prizeTotal)
.watermark(BigDecimal.valueOf(watermarkAtPull).setScale(2, java.math.RoundingMode.HALF_UP))
.roundTarget(playResult.getTargetProgress())
.openTime(new Date()) .openTime(new Date())
.build(); .build();
@@ -528,6 +548,9 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
.gainOrnamentsPrice(prizeTotal) .gainOrnamentsPrice(prizeTotal)
.watermark(BigDecimal.valueOf(watermarkAtPull).setScale(2, java.math.RoundingMode.HALF_UP))
.roundTarget(playResult.getTargetProgress())
.openTime(new Date()) .openTime(new Date())
.build(); .build();
@@ -570,6 +593,10 @@ public class ApiLuckyUpgradeServiceImpl implements ApiLuckyUpgradeService {
ttUpgradeRecordService.save(upgradeRecord); ttUpgradeRecordService.save(upgradeRecord);
// 水位累计记账:本次拉取的下注/返奖入账(白号单独计,不进水位口径)。
// 本轮判定用的是本轮开始时的水位,所以这次增量只影响下一轮 —— 顺序不能反。
watermarkService.recordPull(price, upgradeRecord.getGainOrnamentsPrice(), blankPool);
// 发送邮件通知(取第一件获得的饰品) // 发送邮件通知(取第一件获得的饰品)
if (!boxRecords.isEmpty()) { if (!boxRecords.isEmpty()) {
TtBoxRecords record = boxRecords.get(0); 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(); * <p>
// 奖池类型:白号(06)走自己的进度池与幸运值,Redis key 天然按类型隔离,白号之间共享 06 池 * 水位策略启用时:在该饰品位自己的区间内,围绕「按当前水位算出的中心」抖动
String poolType = poolType(ttUser); * (水位高于目标水位 → 中心下移、更容易出货;低于目标 → 上移、收紧)。
* 中心值由目标水位反推,不需要后台手配随机数区间。
String rangeFixedKey = RedisConstants.UPGRADE_RANGE_FIXED + upgradeOrnamentId + ":" + poolType; * <p>
String rangeFloatKey = RedisConstants.UPGRADE_RANGE + upgradeOrnamentId + ":" + poolType; * 策略关闭({@code sys_config: luckyUpgradeWatermark.enabled=false})时回退到改造前的
* 「每个饰品位 × 每个身份一套固定幸运区间」均匀随机,便于出问题时逐字节回退。
// 读redis的固定概率 [x,y] x进度,y空间大小(缓存缺失或非法时返回 null) * <p>
Integer[] rangeFixed = parseRange(redisCache.getCacheObject(rangeFixedKey)); * ★ 抽数用的是**虚拟水位**(真实水位 + 已抽走但还没出货的轮次预估),并且抽完就把这一轮的
// 读redis的饰品概率区间 [begin,end] * 预估交易预留进去 —— 这样一批饰品同时开新轮时,后面的随机数不会再按前面那个"过期"的水位放水。
Integer[] rangeFloat = parseRange(redisCache.getCacheObject(rangeFloatKey)); * 白号轮次不进水位口径,不参与预留。
*/
if (isValidRange(rangeFixed)) { private int drawRoundTarget(TtUpgradeOrnaments upgradeOrnament, TtUser ttUser) {
// 抽奖 if (watermarkService.isEnabled()) {
return doUpgrade( Integer[] range = resolveRange(upgradeOrnament, ttUser);
ttUser, return watermarkService.drawTarget(
upgradeOrnament, range[0], range[1], upgradeOrnament.getOrnamentPrice(), isBlankAccount(ttUser));
rangeFixed,
upgradeParam,
rangeFixedKey,
rangeFloatKey,
rangeFloat,
effectiveProbability);
} }
return legacyFixedTarget(upgradeOrnament, 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);
} }
/** /**
* @param rangeFixed 本次的固定概率 * 玩家的随机数取值区间(水位驱动时用它限定落点范围):读该饰品位 × 本身份配置的区间。
* @param param http请求参数 * <p>
* @param rangeFixedKey redis固定概率key * 与旧逻辑不同,这里**直接读实体的字段**(调用方的 upgradeOrnament 已查过库),不再走 Redis 区间缓存
* @param rangeFloat 升级饰品的概率区间 * —— 后台改完区间下一轮立即生效,不用清缓存。未配置 / 解析失败时用兜底区间
* @param effectiveProbability 封顶后的有效概率(仅用于中奖判定) * {@code [80, 160]},避免区间缺失把中心的负反馈夹死。
* @return */
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, public UpgradePlayResult doUpgrade(TtUser ttUser,
TtUpgradeOrnaments upgradeOrnament, TtUpgradeOrnaments upgradeOrnament,
Integer[] rangeFixed, Integer currentV,
UpgradeBodyA param, Integer fullV,
String rangeFixedKey, String rangeFixedKey,
String rangeFloatKey,
Integer[] rangeFloat,
int effectiveProbability) { int effectiveProbability) {
// 计算进度 int cur = currentV == null ? 0 : currentV;
Integer currentV = rangeFixed[0]; int space = (fullV == null || fullV <= 0) ? DEFAULT_ROUND_TARGET : fullV;
Integer fullV = rangeFixed[1];
// 中奖判定使用封顶后的 effectiveProbability(如 75),而非前端原始传入的 param.getProbability()(如 90) // 中奖判定使用封顶后的 effectiveProbability(如 75),而非前端原始传入的 90
int sub = fullV - (effectiveProbability + currentV); int sub = space - (effectiveProbability + cur);
if (sub > 0) { if (sub > 0) {
// 失败,记录进度 // 失败:累加进度,本轮随机数不变
int i = effectiveProbability + currentV; redisCache.setCacheObject(rangeFixedKey, new Integer[]{effectiveProbability + cur, space}, 1, TimeUnit.DAYS);
rangeFixed[0] = i; return new UpgradePlayResult(false, space);
// redisCache.setCacheList(rangeFixedKey, Arrays.asList(rangeFixed));
redisCache.setCacheObject(rangeFixedKey, rangeFixed, 1, TimeUnit.DAYS);
return new UpgradePlayResult(false, fullV);
} }
// 成功:先保存本轮目标进度(创建新的固定概率前) // 成功:本轮随机数已用掉,**只刷新当前身份自己的随机数**(其他身份的进度与随机数一律不动),
Integer currentRoundTarget = fullV; // 进度清零;溢出部分不累计到下一轮
int newTarget = drawRoundTarget(upgradeOrnament, ttUser);
// 如果没有概率区间,新建 redisCache.setCacheObject(rangeFixedKey, new Integer[]{0, newTarget}, 1, TimeUnit.DAYS);
if (isValidRange(rangeFloat)) { return new UpgradePlayResult(true, space);
// 按奖池类型重新读一遍幸运区间(主播/白号/普通),后台改配置后本轮中奖即生效
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);
} }
/** 随机数兜底值:仅在 Redis 里存了非法的本轮随机数时使用(正常路径不会走到) */
private static final int DEFAULT_ROUND_TARGET = 120;
public Integer[] createRangeFixed(Integer[] rangeFloat, Integer initVal) { 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; 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) { public R<Map<String, BigDecimal>> upgradeAccounting(BigDecimal consumption, TtUser player) {
R<UpdateUserAccountBo> updateResult = userService.updateUserAccount(player.getUserId(), consumption.negate(), R<UpdateUserAccountBo> updateResult = userService.updateUserAccount(player.getUserId(), consumption.negate(),
@@ -15,9 +15,14 @@
tto.rarity_name, tto.rarity_name,
tto.exterior_name, tto.exterior_name,
tol.level_img, 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) CASE WHEN #{userType} = '01' THEN COALESCE(tuo.anchor_probability_invested, 0)
WHEN #{userType} = '06' THEN COALESCE(tuo.blank_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 FROM tt_upgrade_ornaments tuo
LEFT JOIN tt_ornament tto ON tuo.ornaments_id = tto.id 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 LEFT JOIN tt_ornaments_level tol ON LEAST(tuo.ornaments_level_id, 4) = tol.id