hyapp-server/docs/幸运礼物重构方案.md
2026-05-27 22:12:16 +08:00

457 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 幸运礼物重构方案
本文档从 0 重新定义幸运礼物玩法和实现方案。目标是在严格控制长期返奖率的前提下,让用户在小额礼物、高频送礼和不同阶段里获得稳定、可解释、可调优的体验。
当前线上实现的问题不是单个 bug而是玩法模型不完整它把 RTP 控制器当成了主随机逻辑。新方案必须改成“体验概率表决定用户看到的结果RTP 控制器只做边界约束和温和纠偏”。
## 目标
| 目标 | 说明 |
| --- | --- |
| 严格控制成本 | 基础返奖长期贴近配置 RTP不能被补贴、房间气氛或运营表现污染 |
| 用户体验稳定 | 小额礼物不能长期只出 `0x`;不同阶段要有可配置的中奖率、小奖率和高倍率 |
| 配置可理解 | 后台使用百分比和倍数,服务端内部再转换成 ppm避免 `95` 被当成 `0.0095%` |
| 抽奖可审计 | 每次抽奖保存完整候选、过滤原因、权重、随机落点和控制模式 |
| 失败可收敛 | 钱包返奖和房间 IM 通过 outbox 补偿,客户端不能提前本地加余额 |
## 非目标
| 非目标 | 边界 |
| --- | --- |
| 不承诺单用户盈利 | RTP 是奖池级成本目标,不是单用户保底 |
| 不在 room-service 做抽奖 | Room Cell 只负责房间状态、送礼表现、热度和榜单 |
| 不让客户端参与概率 | 客户端只能提交送礼命令和 `pool_id`,不能提交价格、概率、倍率或奖励 |
| 不用风控硬压体验 | 风控是最后防线,不应该替代概率和 RTP 设计 |
## 当前问题
| 问题 | 现象 | 根因 |
| --- | --- | --- |
| RTP 单位容易错 | 后台填 `95` 后 100 金币一直 `0x` | 后端字段是 `target_rtp_ppm`,需要 `950000` 表示 `95%` |
| 倍率没有概率 | 配了多个倍率,但不是按这些倍率随机 | 当前服务把倍率当 RTP 离散候选点,权重运行时自动生成 |
| RTP 控制过强 | 没有缺口时优先 `0x`,缺口出现时只在 1 到 2 个倍率间抽 | RTP 控制器替代了体验概率表 |
| 新手/中级/高级体验弱 | 三个阶段使用同一倍率集合 | 后台不能分别配置各阶段中奖率和倍率分布 |
| 审计不足 | 后台难以解释“为什么这次是 0x” | 当前只保存命中候选和部分限制标记,不保存完整候选表 |
| 小额礼物 rounding 明显 | 100 金币这类小额更容易被整数截断影响 | 目标返奖、carry 和倍率候选没有按小额场景单独设计 |
## 核心模型
幸运礼物拆成四层:
| 层 | 职责 | 是否影响基础 RTP |
| --- | --- | --- |
| 体验概率层 | 决定用户看到的倍率分布、中奖率、小奖率、高倍率 | 是,基础返奖档计入 |
| RTP 控制层 | 监控长期返奖率,按控制模式微调候选权重或过滤高倍 | 是 |
| 奖池与风控层 | 判断当前是否有钱支付、是否超过用户/设备/房间/主播上限 | 是,硬约束 |
| 表现补贴层 | 阶段反馈、气氛特效、房间播报、活动补贴 | 补贴不计入基础 RTP |
新逻辑中用户体验由配置表决定RTP 控制器只在返奖率偏离目标时介入,不能每一抽都把候选压成 `0x` 或两个档位。
## 单位规则
后台展示和服务端存储必须分离:
| 后台展示 | 服务端存储 | 示例 |
| --- | --- | --- |
| RTP 百分比 | `target_rtp_ppm` | `95% -> 950000` |
| 入池百分比 | `pool_rate_ppm` | `96% -> 960000` |
| 倍数 | `multiplier_ppm` | `0.3x -> 300000``1x -> 1000000` |
| 概率百分比 | `weight_ppm` 或权重 | `12.5% -> 125000` |
后台 API 推荐接收更安全的字段:
```json
{
"target_rtp_percent": 95,
"pool_rate_percent": 96,
"tiers": [
{"multiplier": 0, "probability_percent": 45},
{"multiplier": 0.3, "probability_percent": 18},
{"multiplier": 0.5, "probability_percent": 14}
]
}
```
服务端内部保存:
```json
{
"target_rtp_ppm": 950000,
"pool_rate_ppm": 960000,
"tiers": [
{"multiplier_ppm": 0, "weight_ppm": 450000},
{"multiplier_ppm": 300000, "weight_ppm": 180000},
{"multiplier_ppm": 500000, "weight_ppm": 140000}
]
}
```
服务端必须增加防错校验:
| 校验 | 规则 |
| --- | --- |
| RTP 下限 | `target_rtp_ppm >= 100000`,防止把 `95` 当 ppm |
| RTP 上限 | `target_rtp_ppm <= 990000`,超过需明确运营审批 |
| 入池比例 | `pool_rate_ppm >= target_rtp_ppm` |
| 倍率 | `multiplier_ppm >= 0`,高倍需要单独开关 |
| 概率 | 同一体验阶段的基础概率总和必须等于 `1000000` |
## 配置结构
一个幸运礼物奖池配置由 7 类参数组成。
### 基础配置
| 字段 | 说明 |
| --- | --- |
| `pool_id` | 奖池 ID例如 `lucky_100``super_lucky` |
| `enabled` | 是否启用 |
| `target_rtp_ppm` | 基础返奖目标 |
| `pool_rate_ppm` | 每笔扣费进入基础奖池的比例 |
| `settlement_window_wager` | RTP 结算窗口,建议按金币流水,不只按抽数 |
| `control_band_ppm` | 正常波动带,例如 `10000` 表示 `±1%` |
| `gift_price_reference` | 配置参考价格,真实抽奖按 `coin_spent` 等比缩放 |
### 体验阶段
| 阶段 | 进入规则 | 体验目标 |
| --- | --- | --- |
| `novice` | 用户在该 `pool_id` 下前 N 次付费抽 | 高频小反馈,降低连续空感 |
| `normal` | 常规用户 | 稳定小奖、中低倍为主 |
| `advanced` | 高频或高价用户 | 高波动,允许低频高倍 |
体验阶段只影响概率表和表现,不改变奖池 owner。
### 奖档配置
每个阶段独立配置奖档:
```json
{
"stage": "novice",
"tiers": [
{
"tier_id": "novice_none",
"multiplier_ppm": 0,
"base_weight_ppm": 350000,
"reward_source": "base_rtp",
"max_control_mode": "normal"
},
{
"tier_id": "novice_0_3x",
"multiplier_ppm": 300000,
"base_weight_ppm": 220000,
"reward_source": "base_rtp"
},
{
"tier_id": "novice_1x",
"multiplier_ppm": 1000000,
"base_weight_ppm": 90000,
"reward_source": "base_rtp"
}
]
}
```
字段语义:
| 字段 | 说明 |
| --- | --- |
| `multiplier_ppm` | 返奖倍率,`reward = coin_spent * multiplier_ppm / 1000000` |
| `base_weight_ppm` | 正常模式下的体验概率 |
| `reward_source` | `base_rtp``activity_subsidy``presentation_only` |
| `high_water_only` | 是否只在高水位开放 |
| `min_pool_capacity` | 该档最低奖池容量 |
| `broadcast_level` | 不播报、房间播报、区域播报 |
### 100 金币普通幸运礼物建议表
这是体验样例,不是固定线上参数:
| 阶段 | 倍率 | 正常权重 | 100 金币返奖 | 体验作用 |
| --- | ---: | ---: | ---: | --- |
| novice | `0x` | 30% | 0 | 保留成本空间 |
| novice | `0.3x` | 22% | 30 | 小反馈 |
| novice | `0.5x` | 18% | 50 | 小反馈 |
| novice | `0.8x` | 12% | 80 | 接近返还 |
| novice | `1x` | 9% | 100 | 回本 |
| novice | `1.3x` | 5% | 130 | 小惊喜 |
| novice | `1.5x` | 3% | 150 | 小惊喜 |
| novice | `2x` | 1% | 200 | 中反馈 |
这个表的期望返奖约为:
```text
0*30% + 0.3*22% + 0.5*18% + 0.8*12% + 1*9% + 1.3*5% + 1.5*3% + 2*1%
= 0.382
```
它只表示新手阶段的基础体验期望,不等于最终奖池 RTP。最终 RTP 还需要 normal/advanced 阶段、高倍档、补贴和控制器共同校验。如果目标是 `95%`,常规阶段或周期纠偏必须承担更多返奖;如果不想提高返奖成本,则新手表不能承诺太高中奖体验。
## RTP 控制方式
RTP 控制器只做状态判断和权重调整,不直接替代概率表。
### 统计口径
基础 RTP
```text
base_rtp = sum(base_reward_coins) / sum(coin_spent)
```
有效 RTP
```text
effective_rtp = sum(base_reward_coins + activity_subsidy_coins + room_atmosphere_reward_coins) / sum(coin_spent)
```
成本控制只看 `base_rtp`。活动补贴和房间气氛不能用来补平基础 RTP。
### 控制窗口
建议使用流水窗口,而不是只用抽数窗口:
| 窗口 | 作用 |
| --- | --- |
| `short_window_wager` | 观察短期波动,不强行追平 |
| `settlement_window_wager` | 基础 RTP 结算窗口,到窗口末尾必须收敛 |
| `gift_window_wager` | 单奖池或单礼物成本健康度 |
窗口目标:
```text
target_payout = floor((wager_coins * target_rtp_ppm + carry_ppm) / 1000000)
```
### 控制模式
| 模式 | 触发 | 处理 |
| --- | --- | --- |
| `normal` | 实际 RTP 在目标波动带内 | 使用基础概率表 |
| `boost` | 实际 RTP 低于目标下沿 | 提高小奖和中低奖权重,降低 `0x` 权重 |
| `cooldown` | 实际 RTP 高于目标上沿 | 降低高倍和中倍权重,提高 `0x` 和表现型反馈 |
| `high_lock` | 高倍水位不足 | 过滤高倍,保留小奖 |
| `hard_lock` | 奖池或风控不足以支付基础小奖 | 停止抽奖或只允许普通送礼,不允许先扣费后给不可解释结果 |
控制器不能做的事:
| 禁止行为 | 原因 |
| --- | --- |
| 没有缺口时强制只选 `0x` | 用户体验极差,尤其小额高频 |
| 缺口出现时只保留两个倍率 | 配置表失去意义,运营无法设计体验 |
| 随机后发现超预算再改结果 | 不可审计,不可信 |
| 用活动补贴补基础 RTP | 成本口径混乱 |
### 权重调整
基础概率表先生成候选,再按控制模式调整权重。
示例:
```text
normal:
使用 base_weight_ppm
boost:
0x 权重 * 0.7
0.3x~1.5x 权重 * 1.2
高倍不因 boost 自动开放
cooldown:
0x 权重 * 1.3
0.3x~1x 权重 * 0.9
2x 以上权重 * 0.5
```
调整后必须重新归一化到 `1000000`。如果调整后预计会突破窗口上限,继续过滤高倍,而不是改随机结果。
## 用户体验约束
用户体验不能只靠金币返奖解决。需要区分金币命中和表现反馈。
| 体验项 | 规则 |
| --- | --- |
| 最大连续无反馈 | 可以用 `presentation_only` 打断,成本为 0 |
| 最大连续无金币 | 如果要配置,必须纳入基础概率和 RTP 成本模拟 |
| 阶段反馈 | 默认只做表现,不发金币;发金币必须占预算 |
| 高倍动效 | 只在真实命中高倍后播放 |
| 余额变化 | 只以 `WalletBalanceChanged` 或余额查询为准 |
推荐策略:
| 场景 | 处理 |
| --- | --- |
| 小额高频礼物 | 提高低倍小奖和表现反馈频率,降低超高倍 |
| 高价礼物 | 降低中奖频率,提高中高倍表现 |
| 新手期 | 多小奖、多表现、少高倍 |
| 高频用户 | 进入更稳定的 normal/advanced 概率,不做单用户保底 RTP |
## 抽奖流程
新版本抽奖按以下顺序执行:
```text
1. room-service 校验人在房间、目标合法
2. room-service 调 wallet-service 扣费
3. wallet-service 返回 coin_spent、gift_type、price_version
4. room-service 调 activity-service ExecuteLuckyGiftDraw
5. activity-service 用 command_id 做幂等
6. 锁定 active rule、用户阶段、RTP 窗口、奖池、风控计数
7. 根据用户阶段读取基础概率表
8. 根据当前 RTP 状态选择 control_mode
9. 生成候选列表
10. 过滤奖池不足、风控不足、高水位不足的档位
11. 按 control_mode 调整权重并归一化
12. 写完整候选快照
13. 使用安全随机选择结果
14. 更新 RTP、奖池、风控、用户状态和 draw fact
15. 写 activity_outbox
16. 提交事务
17. outbox worker 调 wallet-service 返奖
18. outbox worker 投递 room lucky_gift_drawn IM 和区域播报
```
事务内禁止调用钱包返奖、房间 RPC 或腾讯云 IM。
## 数据表调整
建议新增 v2 表,不直接在旧表上打补丁。
### `lucky_gift_rule_versions`
| 字段 | 说明 |
| --- | --- |
| `app_code` | App |
| `pool_id` | 奖池 ID |
| `rule_version` | 规则版本 |
| `enabled` | 是否启用 |
| `target_rtp_ppm` | 基础 RTP |
| `pool_rate_ppm` | 入池比例 |
| `control_band_ppm` | RTP 正常波动带 |
| `settlement_window_wager` | 结算窗口流水 |
| `effective_from_ms` | 生效时间 |
| `created_at_ms` | 创建时间 |
### `lucky_gift_stage_tiers`
| 字段 | 说明 |
| --- | --- |
| `pool_id` | 奖池 ID |
| `rule_version` | 规则版本 |
| `stage` | `novice/normal/advanced` |
| `tier_id` | 奖档 ID |
| `multiplier_ppm` | 倍率 |
| `base_weight_ppm` | 基础概率 |
| `reward_source` | 预算来源 |
| `high_water_only` | 高水位开关 |
| `enabled` | 是否启用 |
### `lucky_draw_records_v2`
必须保存:
| 字段 | 说明 |
| --- | --- |
| `draw_id` | 抽奖 ID |
| `command_id` | 幂等键 |
| `pool_id` | 奖池 |
| `gift_id` | 实际礼物 |
| `coin_spent` | 实际扣费 |
| `stage` | 用户阶段 |
| `control_mode` | RTP 控制模式 |
| `selected_tier_id` | 命中奖档 |
| `multiplier_ppm` | 命中倍率 |
| `candidate_table_json` | 完整候选、权重、过滤原因 |
| `random_index` | 随机落点 |
| `random_digest` | 随机摘要 |
| `rtp_snapshot_json` | RTP 快照 |
| `pool_snapshot_json` | 奖池快照 |
| `risk_snapshot_json` | 风控快照 |
## 后台发布校验
后台保存前必须模拟和校验。
| 校验 | 失败处理 |
| --- | --- |
| RTP 单位合法 | 拒绝保存 |
| 每个阶段权重和为 100% | 拒绝保存 |
| 期望基础 RTP 与目标差距过大 | 警告或拒绝 |
| 高倍超过单次上限 | 拒绝保存 |
| 风控 cap 小于期望返奖 | 拒绝保存 |
| 连续无反馈超过阈值 | 警告 |
| 100k / 1m 模拟偏差超标 | 拒绝发布 |
发布必须生成模拟报告:
| 指标 | 要求 |
| --- | --- |
| `base_rtp` | 贴近目标 |
| `effective_rtp` | 可高于基础 RTP但必须展示补贴成本 |
| `hit_rate` | 符合产品配置 |
| `zero_streak_p95` | 不超过体验阈值 |
| `tier_hit_rate` | 各倍率命中率可解释 |
| `pool_min_balance` | 不低于 reserve |
| `risk_reject_count` | 不应成为主要控制手段 |
## 实施步骤
### 第 1 步:止血
1. 后台把 `RTP=95` 转成 `target_rtp_ppm=950000`
2. 后台把 `0.3x` 转成 `multiplier_ppm=300000`
3. 后端拒绝 `target_rtp_ppm < 100000` 的配置。
4. 后端拒绝 `pool_rate_ppm < target_rtp_ppm`
5. 文档和后台提示统一说明百分比、ppm、倍数转换。
### 第 2 步:配置 v2
1. 新增按阶段配置的 tier 表。
2. 后台支持 `novice/normal/advanced` 三套概率。
3. 后台展示概率百分比,不展示 ppm。
4. 保存时生成 `rule_version`,历史版本不可变。
### 第 3 步:模拟器
1. 模拟器读取真实配置 JSON。
2. 支持 100 金币小额礼物场景。
3. 输出 RTP、命中率、连续 0、用户分位数、奖池水位。
4. 后台发布前必须跑模拟。
### 第 4 步:抽奖引擎 v2
1. 实现候选生成。
2. 实现 control_mode。
3. 实现完整候选审计。
4. 实现随机摘要。
5. 保留旧引擎开关,按 `pool_id` 灰度。
### 第 5 步:影子验证
1. 线上送礼仍走旧逻辑。
2. 同步用 v2 引擎 shadow draw不返奖、不发 IM。
3. 对比旧结果、v2 模拟成本和用户体验指标。
4. 连续至少一个完整结算窗口稳定后再切流。
### 第 6 步:灰度上线
1. 内部房间启用。
2. 低价礼物启用。
3. 单区域小流量启用。
4. 观察 pending outbox、base RTP、连续 0、投诉率。
5. 再开放高倍和高价礼物。
## 结论
新的幸运礼物不应该让 RTP 控制器直接决定用户每次看到什么。正确结构是:
```text
体验概率表决定玩法
RTP 控制器决定边界和纠偏
奖池风控决定能不能支付
outbox 决定返奖和房间表现最终一致
```
这样才能同时做到两个目标:平台能严格控制长期返奖率,用户在 100 金币这类小额幸运礼物上也不会长期只看到 `0x`