206 lines
7.0 KiB
Markdown
206 lines
7.0 KiB
Markdown
# 幸运礼物重构第二步:抽奖引擎与模拟器
|
||
|
||
本文档定义幸运礼物重构第二步。第二步基于第一步的新配置契约,实现 v2 抽奖引擎和真实配置模拟器。该步骤不兼容旧抽奖逻辑,不再使用“RTP 缺口直接压缩成 1 到 2 个倍率候选”的模型。
|
||
|
||
## 目标
|
||
|
||
| 目标 | 说明 |
|
||
| --- | --- |
|
||
| 体验概率表为主 | 用户看到的倍率分布首先来自后台阶段概率表 |
|
||
| RTP 控制为约束 | RTP 只做模式切换、权重调整和硬边界过滤 |
|
||
| 奖池不透支 | platform / room / gift 三层基础池都不能低于 reserve |
|
||
| 风控不后置 | 用户、设备、房间、主播上限必须在随机前过滤 |
|
||
| 完整审计 | 每次抽奖保存候选、过滤、权重、随机落点和快照 |
|
||
| 模拟先行 | 后台发布前能用同一套规则跑用户体验和成本模拟 |
|
||
|
||
## 新抽奖原则
|
||
|
||
| 原则 | 说明 |
|
||
| --- | --- |
|
||
| 先候选后过滤 | 先按阶段概率表生成候选,再按奖池/RTP/风控过滤 |
|
||
| 先过滤后随机 | 不能随机后发现超预算再改结果 |
|
||
| 正常模式不用强纠偏 | 实际 RTP 在波动带内时按基础概率表抽 |
|
||
| 纠偏只调权重 | `boost/cooldown` 只调整权重,不直接指定结果 |
|
||
| 高倍要水位 | 高倍必须满足 RTP headroom 和三层池容量 |
|
||
| 补贴分口径 | 活动补贴和房间气氛不计入基础 RTP |
|
||
|
||
## 控制模式
|
||
|
||
抽奖前根据当前窗口计算 `control_mode`:
|
||
|
||
```text
|
||
actual_rtp_ppm = actual_base_payout * 1000000 / wager_coins
|
||
lower = target_rtp_ppm - control_band_ppm
|
||
upper = target_rtp_ppm + control_band_ppm
|
||
```
|
||
|
||
| 模式 | 触发条件 | 行为 |
|
||
| --- | --- | --- |
|
||
| `normal` | `lower <= actual_rtp_ppm <= upper` | 使用基础概率表 |
|
||
| `boost` | `actual_rtp_ppm < lower` | 降低 `0x`,提高小奖和中低奖 |
|
||
| `cooldown` | `actual_rtp_ppm > upper` | 提高 `0x`,降低中高奖 |
|
||
| `high_lock` | 高倍水位不足 | 过滤高倍,保留小奖 |
|
||
| `hard_lock` | 基础小奖都不可支付 | 拒绝幸运抽奖,不允许先扣费后给不可解释结果 |
|
||
|
||
## 候选生成
|
||
|
||
输入:
|
||
|
||
| 输入 | 来源 |
|
||
| --- | --- |
|
||
| `pool_id` | room-service 请求 |
|
||
| `coin_spent` | wallet-service 扣费结果 |
|
||
| `stage` | 用户在该 pool 下累计付费抽数 |
|
||
| `rule_version` | active lucky rule |
|
||
| `tier table` | 第一阶段配置 |
|
||
| `rtp windows` | activity-service MySQL |
|
||
| `pools` | activity-service MySQL |
|
||
| `risk counters` | activity-service MySQL |
|
||
|
||
候选计算:
|
||
|
||
```text
|
||
reward_coins = floor(coin_spent * multiplier_ppm / 1000000)
|
||
```
|
||
|
||
候选结构:
|
||
|
||
```json
|
||
{
|
||
"tier_id": "normal_0_5x",
|
||
"multiplier_ppm": 500000,
|
||
"base_reward_coins": 50,
|
||
"source": "base_rtp",
|
||
"base_weight_ppm": 120000,
|
||
"adjusted_weight_ppm": 135000,
|
||
"enabled": true,
|
||
"filtered_reason": ""
|
||
}
|
||
```
|
||
|
||
## 权重调整
|
||
|
||
基础概率表是主体验,控制模式只能做有限调整。
|
||
|
||
建议第一版调整系数:
|
||
|
||
| 倍率区间 | `boost` | `normal` | `cooldown` |
|
||
| --- | ---: | ---: | ---: |
|
||
| `0x` | `0.70` | `1.00` | `1.30` |
|
||
| `0x < x <= 1x` | `1.20` | `1.00` | `0.90` |
|
||
| `1x < x <= 5x` | `1.10` | `1.00` | `0.60` |
|
||
| `5x+` | `1.00` | `1.00` | `0.30` |
|
||
|
||
调整后重新归一化:
|
||
|
||
```text
|
||
adjusted_weight_ppm = adjusted_weight * 1000000 / sum(adjusted_weight)
|
||
```
|
||
|
||
如果某个奖档被过滤,它的权重可以进入 `0x`,但必须记录 `filtered_reason`。
|
||
|
||
## 过滤规则
|
||
|
||
随机前必须过滤:
|
||
|
||
| 过滤项 | 规则 |
|
||
| --- | --- |
|
||
| 单次上限 | `reward_coins <= max_single_payout` |
|
||
| 用户小时/日上限 | 不超过剩余额度 |
|
||
| 设备日上限 | 不超过剩余额度 |
|
||
| 房间小时上限 | 不超过剩余额度 |
|
||
| 主播日上限 | 不超过剩余额度 |
|
||
| 三层基础池 | platform / room / gift 扣减后不低于 reserve |
|
||
| RTP 上限 | `cooldown` 下过滤中高倍 |
|
||
| 高倍水位 | 高倍必须满足 `high_water_only` 条件 |
|
||
| 活动预算 | 补贴档不超过总预算和 UTC 日预算 |
|
||
|
||
过滤后如果只有 `0x`:
|
||
|
||
| 场景 | 行为 |
|
||
| --- | --- |
|
||
| `normal/cooldown` 且配置允许 | 可继续抽,但审计记录“只剩 0x” |
|
||
| `boost` 仍只剩 `0x` | 说明奖池或风控配置不健康,返回 `hard_lock` 或降级普通礼物 |
|
||
| 连续多次只剩 `0x` | 后台告警,禁止继续发布该规则 |
|
||
|
||
## 随机与审计
|
||
|
||
随机使用安全随机:
|
||
|
||
```text
|
||
random_index = crypto_rand_int(sum(adjusted_weight))
|
||
```
|
||
|
||
每次 draw 必须保存:
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `candidate_table_json` | 完整候选列表、调整前后权重、过滤原因 |
|
||
| `random_index` | 随机落点 |
|
||
| `random_digest` | 随机材料摘要 |
|
||
| `control_mode` | `normal/boost/cooldown/high_lock/hard_lock` |
|
||
| `rtp_snapshot_json` | 抽前 RTP |
|
||
| `pool_snapshot_json` | 抽前奖池 |
|
||
| `risk_snapshot_json` | 抽前风控剩余额度 |
|
||
|
||
## 模拟器
|
||
|
||
模拟器必须读取第一步的真实配置 JSON,不再使用写死的倍率表。
|
||
|
||
输入:
|
||
|
||
```bash
|
||
go run ./tools/lucky-gift-v2-sim \
|
||
-config ./tmp/lucky_100_rule.json \
|
||
-users 10000 \
|
||
-rooms 200 \
|
||
-draws-min 0 \
|
||
-draws-max 1000 \
|
||
-gift-price 100 \
|
||
-seed 20260527
|
||
```
|
||
|
||
输出指标:
|
||
|
||
| 指标 | 说明 |
|
||
| --- | --- |
|
||
| `base_rtp` | 基础返奖率 |
|
||
| `effective_rtp` | 用户可见返奖率 |
|
||
| `hit_rate` | 金币命中率 |
|
||
| `zero_rate` | `0x` 比例 |
|
||
| `zero_streak_p50/p95/p99` | 连续 `0x` 分布 |
|
||
| `tier_hit_rate` | 各倍率命中率 |
|
||
| `control_mode_count` | 各控制模式出现次数 |
|
||
| `pool_min_balance` | 最低奖池水位 |
|
||
| `risk_filter_count` | 风控过滤次数 |
|
||
| `user_net_p05/median/p95` | 用户净输赢体感 |
|
||
|
||
## 实施任务
|
||
|
||
1. 新增 v2 抽奖引擎 package,和旧引擎隔离。
|
||
2. 实现 stage 选择。
|
||
3. 实现 control mode。
|
||
4. 实现候选生成、过滤、权重调整和归一化。
|
||
5. 实现完整审计落库。
|
||
6. 实现 v2 模拟器读取真实配置。
|
||
7. 用模拟器接入后台发布校验。
|
||
8. 增加单元测试、并发事务测试和模拟快照测试。
|
||
|
||
## 验收标准
|
||
|
||
| 验收项 | 标准 |
|
||
| --- | --- |
|
||
| 正常模式 | RTP 在波动带内时,候选权重等于或接近基础概率表 |
|
||
| boost 模式 | RTP 低于下沿时,`0x` 权重下降,小奖权重上升 |
|
||
| cooldown 模式 | RTP 高于上沿时,中高奖权重下降 |
|
||
| 高倍过滤 | 高水位不足时,高倍不会进入随机候选 |
|
||
| 先过滤后随机 | 所有被随机选中的候选都必须可支付 |
|
||
| 完整审计 | `candidate_table_json` 可解释每个奖档是否可选、为何过滤、最终权重 |
|
||
| 随机可审计 | 每条 draw 有 `random_index` 和 `random_digest` |
|
||
| 小额体验 | 100 金币配置在模拟中不能长期只出 `0x`,`zero_streak_p95` 达到配置阈值 |
|
||
| RTP 收敛 | 100k 流水窗口内基础 RTP 偏差不超过配置波动带;1m 流水窗口内更稳定 |
|
||
| 奖池安全 | 任一模拟场景下三层池不低于 reserve |
|
||
| 测试 | `go test ./services/activity-service/internal/service/luckygift ./services/activity-service/internal/storage/mysql ./tools/lucky-gift-v2-sim` 通过 |
|
||
|
||
第二步完成后,v2 引擎可以离线模拟和 shadow draw,但还不直接替换线上送礼主链路。
|