diff --git a/docs/幸运礼物重构方案.md b/docs/幸运礼物重构方案.md new file mode 100644 index 00000000..1ffc1cf2 --- /dev/null +++ b/docs/幸运礼物重构方案.md @@ -0,0 +1,456 @@ +# 幸运礼物重构方案 + +本文档从 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`。 diff --git a/docs/幸运礼物重构第一步-配置契约与单位.md b/docs/幸运礼物重构第一步-配置契约与单位.md new file mode 100644 index 00000000..9fc0e33b --- /dev/null +++ b/docs/幸运礼物重构第一步-配置契约与单位.md @@ -0,0 +1,156 @@ +# 幸运礼物重构第一步:配置契约与单位 + +本文档定义幸运礼物重构第一步。第一步只解决配置、单位、规则版本和后台发布校验,不实现新抽奖算法。当前处于开发阶段,不兼容旧代码,不保留旧的 `target_rtp_ppm=95`、旧 `tiers.weight=0` 自动权重模型,也不继续把 `lucky_gift_rules.gift_id` 当作奖池 ID 使用。 + +## 目标 + +| 目标 | 说明 | +| --- | --- | +| 消灭单位歧义 | 后台展示百分比和倍数,服务端持久化 ppm | +| 明确规则 owner | `activity-service` 拥有幸运礼物规则、奖档、RTP 参数和版本 | +| 按阶段配置体验 | `novice/normal/advanced` 各自拥有独立奖档概率 | +| 发布前校验 | 配置无法满足成本或体验边界时拒绝发布 | +| 为第二步抽奖引擎铺路 | 输出可直接被 v2 抽奖引擎读取的规则快照 | + +## 删除旧模型 + +| 旧模型 | 新处理 | +| --- | --- | +| 后台直接提交 `target_rtp_ppm=95` | 改为后台提交 `target_rtp_percent=95`,服务端转换为 `950000` | +| 后台提交倍数 `0.3` 后直接存 `0.3` | 服务端保存 `multiplier_ppm=300000` | +| `lucky_gift_rules.gift_id` 表示奖池 | 新表使用显式 `pool_id` | +| 单一 `multiplier_ppms` 生成三个阶段相同奖档 | 三个阶段分别配置奖档和权重 | +| `tiers.weight=0`,运行时自动权重 | 后台必须配置基础概率,抽奖引擎只做控制调整 | + +## 新配置字段 + +### 规则主表 + +建议新建 `lucky_gift_rule_versions`。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `app_code` | string | App 编码 | +| `pool_id` | string | 幸运礼物奖池 ID | +| `rule_version` | int64 | 规则版本,每次发布递增 | +| `enabled` | bool | 是否启用 | +| `target_rtp_ppm` | int64 | 基础返奖目标,`95% = 950000` | +| `pool_rate_ppm` | int64 | 每笔扣费进入基础奖池比例 | +| `settlement_window_wager` | int64 | RTP 结算窗口流水,单位金币 | +| `control_band_ppm` | int64 | 正常波动带,`10000 = ±1%` | +| `gift_price_reference` | int64 | 配置参考礼物价格 | +| `effective_from_ms` | int64 | 生效时间,UTC epoch ms | +| `created_by_admin_id` | int64 | 发布人 | +| `created_at_ms` | int64 | 创建时间 | + +### 阶段奖档表 + +建议新建 `lucky_gift_stage_tiers`。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `app_code` | string | App 编码 | +| `pool_id` | string | 奖池 ID | +| `rule_version` | int64 | 规则版本 | +| `stage` | string | `novice/normal/advanced` | +| `tier_id` | string | 奖档 ID | +| `multiplier_ppm` | int64 | 倍率,`1x = 1000000` | +| `base_weight_ppm` | int64 | 正常模式基础概率 | +| `reward_source` | string | `base_rtp/activity_subsidy/presentation_only` | +| `high_water_only` | bool | 是否高水位才开放 | +| `broadcast_level` | string | `none/room/region/global` | +| `enabled` | bool | 是否启用 | + +## 后台输入格式 + +后台对运营展示和提交应使用易懂单位: + +```json +{ + "pool_id": "lucky_100", + "enabled": true, + "target_rtp_percent": 95, + "pool_rate_percent": 96, + "settlement_window_wager": 1000000, + "control_band_percent": 1, + "gift_price_reference": 100, + "stages": [ + { + "stage": "novice", + "tiers": [ + {"tier_id": "novice_none", "multiplier": 0, "probability_percent": 30}, + {"tier_id": "novice_0_3x", "multiplier": 0.3, "probability_percent": 22}, + {"tier_id": "novice_0_5x", "multiplier": 0.5, "probability_percent": 18} + ] + } + ] +} +``` + +服务端保存前转换: + +```text +target_rtp_percent=95 -> target_rtp_ppm=950000 +pool_rate_percent=96 -> pool_rate_ppm=960000 +control_band_percent=1 -> control_band_ppm=10000 +multiplier=0.3 -> multiplier_ppm=300000 +probability_percent=22 -> base_weight_ppm=220000 +``` + +## 发布校验 + +| 校验项 | 规则 | 失败处理 | +| --- | --- | --- | +| RTP 单位 | `100000 <= target_rtp_ppm <= 990000` | 拒绝发布 | +| 入池比例 | `pool_rate_ppm >= target_rtp_ppm` 且 `pool_rate_ppm <= 1000000` | 拒绝发布 | +| 波动带 | `0 < control_band_ppm <= 50000` | 拒绝发布 | +| 参考价格 | `gift_price_reference > 0` | 拒绝发布 | +| 阶段完整 | 必须有 `novice/normal/advanced` 三个阶段 | 拒绝发布 | +| 阶段概率 | 每个阶段 `sum(base_weight_ppm) == 1000000` | 拒绝发布 | +| 倍率合法 | `multiplier_ppm >= 0` | 拒绝发布 | +| 基础奖档 | 每个阶段至少有一个 `base_rtp` 奖档 | 拒绝发布 | +| 未中奖档 | 每个阶段必须显式配置 `0x`,除非标记为“每抽必返” | 拒绝发布或强确认 | +| 高倍档 | `high_water_only=true` 或有明确单次上限 | 拒绝发布 | + +## API 调整 + +后台接口只暴露业务单位: + +| 接口 | 行为 | +| --- | --- | +| `GET /api/v1/admin/activity/lucky-gifts/config?pool_id=...` | 返回百分比、倍数和阶段奖档 | +| `PUT /api/v1/admin/activity/lucky-gifts/config?pool_id=...` | 发布新版本,服务端转换 ppm 并校验 | +| `GET /api/v1/admin/activity/lucky-gifts/configs` | 列出所有奖池和当前版本 | + +内部 gRPC 可以继续使用 ppm,但字段名必须明确: + +| 字段 | 说明 | +| --- | --- | +| `target_rtp_ppm` | 内部字段,后台 HTTP 不直接要求运营填写 | +| `pool_rate_ppm` | 内部字段 | +| `multiplier_ppm` | 内部字段 | +| `base_weight_ppm` | 内部字段 | + +## 实施任务 + +1. 新增 v2 proto:规则、阶段、奖档、发布请求、读取响应。 +2. 新增 MySQL v2 表。 +3. 改后台 lucky gift handler,只接收百分比和倍数。 +4. 在 `activity-service` 增加配置转换和发布校验。 +5. 删除或旁路旧 `multiplier_ppms -> tiers(weight=0)` 归一化模型。 +6. 增加配置单元测试和 schema 初始化。 + +## 验收标准 + +| 验收项 | 标准 | +| --- | --- | +| 单位转换 | 后台提交 `target_rtp_percent=95` 后,DB 保存 `target_rtp_ppm=950000` | +| 倍率转换 | 后台提交 `multiplier=0.3` 后,DB 保存 `multiplier_ppm=300000` | +| 概率转换 | 后台提交 `probability_percent=22` 后,DB 保存 `base_weight_ppm=220000` | +| 错误 RTP | 后台提交等价 `target_rtp_ppm=95` 的配置必须被拒绝 | +| 阶段概率 | 任一阶段概率和不是 100% 时必须被拒绝 | +| 旧字段隔离 | 新规则表不再使用 `gift_id` 表示 `pool_id` | +| 版本不可变 | 发布新配置只新增 `rule_version`,不覆盖历史版本 | +| 测试 | `go test ./services/activity-service/internal/service/luckygift ./services/activity-service/internal/storage/mysql ./server/admin/internal/modules/luckygift` 通过 | + +第一步完成后,系统只具备“正确配置和发布规则”的能力,不要求新抽奖引擎上线。 diff --git a/docs/幸运礼物重构第三步-链路接入与上线验收.md b/docs/幸运礼物重构第三步-链路接入与上线验收.md new file mode 100644 index 00000000..a7a16f9f --- /dev/null +++ b/docs/幸运礼物重构第三步-链路接入与上线验收.md @@ -0,0 +1,210 @@ +# 幸运礼物重构第三步:链路接入与上线验收 + +本文档定义幸运礼物重构第三步。第三步把 v2 配置和 v2 抽奖引擎接回房间送礼主链路,完成返奖、IM、后台审计、灰度上线和旧逻辑删除。当前不兼容旧代码,切换完成后旧幸运礼物抽奖入口、旧规则表和旧模拟器都应下线。 + +## 目标 + +| 目标 | 说明 | +| --- | --- | +| 主链路接入 | `room-service` 扣费后调用 v2 抽奖 | +| 返奖一致 | `activity_outbox` 调 `wallet-service` 入账,钱包 outbox 通知余额 | +| 房间表现一致 | `lucky_gift_drawn` IM 和区域播报由 outbox 补偿 | +| 后台可审计 | 后台能查 draw、候选表、控制模式、RTP、奖池和风控快照 | +| 可灰度回滚 | 按 `pool_id/room_id/region` 控制 v2 开关 | +| 删除旧逻辑 | 验收完成后移除旧 v1 抽奖和旧配置入口 | + +## 主链路 + +```text +Client + -> gateway POST /api/v1/rooms/gift/send + -> room-service 校验房间和目标 + -> wallet-service DebitGift + -> room-service 调 activity-service ExecuteLuckyGiftDrawV2 + -> activity-service 本地事务写 draw + outbox + -> room-service 落 Room Cell 礼物、热度、榜单 + -> gateway 返回 lucky_gift + -> activity worker 返奖入账 + -> activity worker 投递 lucky_gift_drawn IM + -> wallet/notice 投递 WalletBalanceChanged +``` + +## 接口要求 + +### App 送礼请求 + +幸运礼物必须传明确 `pool_id`: + +```json +{ + "room_id": "room_1", + "command_id": "cmd_001", + "target_type": "user", + "target_user_ids": [10002], + "gift_id": "lucky_star_100", + "gift_count": 1, + "pool_id": "lucky_100" +} +``` + +不再支持“礼物类型是 lucky 但不传 pool_id 时走 default”的隐式路径。 + +### 送礼响应 + +```json +{ + "lucky_gift": { + "enabled": true, + "draw_id": "lucky_draw_xxx", + "command_id": "cmd_001", + "pool_id": "lucky_100", + "rule_version": 12, + "stage": "normal", + "control_mode": "normal", + "selected_tier_id": "normal_0_5x", + "multiplier_ppm": 500000, + "base_reward_coins": 50, + "activity_subsidy_coins": 0, + "effective_reward_coins": 50, + "reward_status": "pending", + "created_at_ms": 1779259000000 + } +} +``` + +客户端规则: + +| 场景 | 处理 | +| --- | --- | +| `reward_status=pending` | 展示中奖结果和“奖励处理中”,不本地加余额 | +| 收到 `WalletBalanceChanged` | 更新余额 | +| 收到同一 `draw_id` 的 IM | 去重,不重复播放 | +| `lucky_gift` 为空 | 按普通礼物处理 | + +## outbox 要求 + +抽奖事务只写事实和 outbox: + +| outbox | 作用 | +| --- | --- | +| `LuckyGiftRewardCredit` | 调钱包返奖 | +| `LuckyGiftDrawnRoomIM` | 投递房间开奖结果 | +| `LuckyGiftBigWinBroadcast` | 投递区域或全站播报 | + +顺序要求: + +1. 先返奖入账。 +2. 再投递房间开奖结果。 +3. 最后标记 draw 为 `granted`。 + +如果返奖已成功但 IM 失败: + +| 状态 | 处理 | +| --- | --- | +| draw | 不能标记 `failed`,避免覆盖账务事实 | +| outbox | 继续重试 IM | +| 后台 | 展示“返奖成功,表现补偿中” | + +## 后台审计 + +后台必须支持: + +| 页面 | 能力 | +| --- | --- | +| 抽奖列表 | 按 `pool_id/user_id/room_id/status/control_mode` 查询 | +| 抽奖详情 | 展示候选表、过滤原因、权重、随机落点 | +| RTP 看板 | 展示目标 RTP、实际 RTP、控制模式分布 | +| 奖池看板 | 展示 platform / room / gift 水位 | +| 风控看板 | 展示用户、设备、房间、主播过滤次数 | +| outbox 看板 | 展示 pending/retryable/failed | + +抽奖详情必须能回答: + +```text +为什么这次是 0x? +为什么 100x 被过滤? +为什么进入 boost/cooldown? +这次随机落点在哪里? +返奖是否到账? +房间 IM 是否投递? +``` + +## 灰度策略 + +| 阶段 | 范围 | 要求 | +| --- | --- | --- | +| shadow | 只计算 v2,不返奖、不发 IM | 和模拟结果接近 | +| internal | 内部房间、小额礼物 | outbox 和钱包稳定 | +| room whitelist | 白名单房间 | 用户体验指标正常 | +| region canary | 单区域小流量 | RTP 和投诉稳定 | +| full | 全量开放 | 旧逻辑可删除 | + +灰度开关: + +| 开关 | 作用 | +| --- | --- | +| `lucky_gift_v2_enabled` | 全局 v2 开关 | +| `pool_v2_enabled` | 单奖池开关 | +| `room_whitelist` | 房间白名单 | +| `region_whitelist` | 区域白名单 | +| `disable_high_tier` | 临时关闭高倍 | +| `reward_credit_enabled` | 钱包事故时暂停返奖 dispatch,draw 保持 pending | + +## 监控指标 + +| 指标 | 告警 | +| --- | --- | +| `base_rtp` | 超出目标波动带 | +| `effective_rtp` | 高于预算预期 | +| `zero_rate` | 高于配置预期 | +| `zero_streak_p95` | 超过体验阈值 | +| `control_mode_boost_ratio` | 长期过高说明概率或奖池不健康 | +| `control_mode_cooldown_ratio` | 长期过高说明派奖过多 | +| `pool_balance_min` | 接近 reserve | +| `risk_filter_count` | 过高说明风控在替代概率控制 | +| `outbox_pending_count` | 堆积 | +| `reward_credit_latency` | 到账延迟 | +| `draw_error_count` | 抽奖事务失败 | + +## 旧逻辑删除 + +全量稳定后删除: + +| 旧内容 | 删除条件 | +| --- | --- | +| 旧 `lucky_gift_rules` 写入口 | v2 后台发布稳定 | +| 旧 `multiplier_ppms -> tiers(weight=0)` 归一化 | v2 引擎全量 | +| 旧 `selectLuckyCandidate` 自动权重模型 | v2 引擎全量 | +| 旧 `tools/lucky-gift-v2-demo` 写死倍率模拟 | 新模拟器覆盖 | +| 空 `pool_id` 默认奖池路径 | App 全部传明确 `pool_id` | + +## 实施任务 + +1. 增加 `ExecuteLuckyGiftDrawV2` 内部接口。 +2. `room-service` 送礼链路改调 v2。 +3. gateway 响应增加 `stage/control_mode`。 +4. activity outbox 拆分返奖、房间 IM、区域播报事件。 +5. 后台增加抽奖详情和候选表审计。 +6. 增加灰度开关和监控指标。 +7. shadow draw 跑满一个结算窗口。 +8. 灰度切流。 +9. 全量后删除旧逻辑。 + +## 验收标准 + +| 验收项 | 标准 | +| --- | --- | +| App 参数 | 幸运礼物不传 `pool_id` 必须拒绝,不走 default | +| 扣费顺序 | 钱包扣费成功后才调用 v2 抽奖 | +| 事务边界 | 抽奖事务内不调用钱包返奖或 IM | +| 同步响应 | `lucky_gift` 返回 `stage/control_mode/multiplier_ppm/reward_status` | +| 余额一致 | 客户端余额只由 `WalletBalanceChanged` 或余额查询更新 | +| outbox 幂等 | 重试不会重复返奖、重复 IM 可由 `event_id/draw_id` 去重 | +| 后台审计 | 任一 draw 可查看完整候选表和过滤原因 | +| shadow 验证 | shadow 结果的 RTP、命中率、连续 0 分布与模拟报告一致 | +| 灰度指标 | 灰度期间 base RTP 在控制带内,outbox 无持续堆积 | +| 体验指标 | 100 金币礼物 `zero_streak_p95` 不超过配置阈值 | +| 删除旧逻辑 | 全量稳定后旧配置入口、旧抽奖引擎、旧默认奖池路径已删除 | +| 全量测试 | `make test` 通过 | + +第三步完成后,幸运礼物 v2 才算真正上线。前两步只完成规则和引擎,不能直接对外开放。 diff --git a/docs/幸运礼物重构第二步-抽奖引擎与模拟器.md b/docs/幸运礼物重构第二步-抽奖引擎与模拟器.md new file mode 100644 index 00000000..838d90a8 --- /dev/null +++ b/docs/幸运礼物重构第二步-抽奖引擎与模拟器.md @@ -0,0 +1,205 @@ +# 幸运礼物重构第二步:抽奖引擎与模拟器 + +本文档定义幸运礼物重构第二步。第二步基于第一步的新配置契约,实现 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,但还不直接替换线上送礼主链路。 diff --git a/tools/lucky-gift-test-server.mjs b/tools/lucky-gift-test-server.mjs new file mode 100644 index 00000000..981b42e4 --- /dev/null +++ b/tools/lucky-gift-test-server.mjs @@ -0,0 +1,79 @@ +import http from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const htmlPath = join(__dirname, "lucky-gift-test.html"); +const port = Number(process.env.PORT || 8788); +const upstreamBase = (process.env.LUCKY_GIFT_API_BASE || "http://127.0.0.1:13000").replace(/\/+$/, ""); + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function copyProxyHeaders(req) { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (!value) continue; + const lower = key.toLowerCase(); + if (["host", "origin", "referer", "connection", "content-length", "accept-encoding"].includes(lower)) continue; + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + headers.set("Accept-Encoding", "identity"); + return headers; +} + +async function proxyAPI(req, res) { + const body = req.method === "GET" || req.method === "HEAD" ? undefined : await readBody(req); + const upstream = await fetch(`${upstreamBase}${req.url}`, { + method: req.method, + headers: copyProxyHeaders(req), + body, + redirect: "manual", + }); + const responseBody = Buffer.from(await upstream.arrayBuffer()); + res.writeHead(upstream.status, { + "Content-Type": upstream.headers.get("content-type") || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(responseBody); +} + +const server = http.createServer(async (req, res) => { + try { + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + if (req.url === "/" || req.url === "/lucky-gift-test.html") { + const html = await readFile(htmlPath); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(html); + return; + } + if (req.url?.startsWith("/api/")) { + await proxyAPI(req, res); + return; + } + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("not found"); + } catch (err) { + res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(err instanceof Error ? err.message : String(err)); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Lucky gift test page: http://127.0.0.1:${port}`); + console.log(`Proxy upstream: ${upstreamBase}`); +}); diff --git a/tools/lucky-gift-test.html b/tools/lucky-gift-test.html new file mode 100644 index 00000000..13b5de23 --- /dev/null +++ b/tools/lucky-gift-test.html @@ -0,0 +1,769 @@ + + + + + + 幸运礼物测试页 + + + +
+
+

幸运礼物测试页

+
本地默认网关:http://127.0.0.1:13000;用随页 server 打开时,API Base URL 留空即可走本地代理。
+
+ +
+

1. API 与账号

+
+ + + + +
+ +
+ + + +
+
+ +
+

2. 房间、礼物与 IM

+
+ + + + + + +
+
+ + + + + + +
+ +
+ +
+

3. 发幸运礼物

+
+ + + +
+
+
+
是否中奖
+
未发送
+
+
+
中奖倍数
+
-
+
+
+
中奖金额
+
-
+
+
+
到账状态
+
-
+
+
+
+ +
+

4. HTTP 与 IM 日志

+
+
+
+ + + +