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

15 KiB
Raw Blame History

幸运礼物重构方案

本文档从 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 -> 3000001x -> 1000000
概率百分比 weight_ppm 或权重 12.5% -> 125000

后台 API 推荐接收更安全的字段:

{
  "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}
  ]
}

服务端内部保存:

{
  "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_100super_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。

奖档配置

每个阶段独立配置奖档:

{
  "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_rtpactivity_subsidypresentation_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 中反馈

这个表的期望返奖约为:

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

base_rtp = sum(base_reward_coins) / sum(coin_spent)

有效 RTP

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 单奖池或单礼物成本健康度

窗口目标:

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 成本口径混乱

权重调整

基础概率表先生成候选,再按控制模式调整权重。

示例:

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

抽奖流程

新版本抽奖按以下顺序执行:

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 控制器直接决定用户每次看到什么。正确结构是:

体验概率表决定玩法
RTP 控制器决定边界和纠偏
奖池风控决定能不能支付
outbox 决定返奖和房间表现最终一致

这样才能同时做到两个目标:平台能严格控制长期返奖率,用户在 100 金币这类小额幸运礼物上也不会长期只看到 0x