# 幸运礼物重构方案 本文档从 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`。