# 幸运礼物线上落地文档 本文档按当前代码实现整理幸运礼物的服务边界、配置语义、抽奖链路、体验模拟方式和已知问题。当前幸运礼物由 `activity-service` 持有规则、RTP 窗口、奖池、风控计数、抽奖事实和异步副作用 outbox;金币扣费与返奖入账仍归 `wallet-service`,房间状态、热度、榜单和送礼消息仍归 `room-service`。 ## 相关代码 | 模块 | 文件 | 当前职责 | | --- | --- | --- | | protobuf 契约 | `api/proto/activity/v1/activity.proto` | `LuckyGiftConfig`、`multiplier_ppms`、`LuckyGiftDrawResult.multiplier_ppm`、后台配置和审计 RPC | | 房间送礼主链路 | `services/room-service/internal/room/service/gift.go` | 送礼前可检查奖池,扣费成功后同步调用 `ExecuteLuckyGiftDraw`,把 `lucky_gift` 放入 `SendGiftResponse` | | 幸运礼物领域模型 | `services/activity-service/internal/domain/luckygift/lucky_gift.go` | 规则、奖档、Check/Draw 命令、抽奖结果和审计查询结构 | | 规则归一化 | `services/activity-service/internal/service/luckygift/config.go` | 后台输入 `multiplier_ppms` 后生成三档体验池奖档;权重不是后台配置项 | | 抽奖服务 | `services/activity-service/internal/service/luckygift/service.go` | Check、Draw、outbox worker、返奖入账、房间 IM、区域播报 | | MySQL 抽奖事务 | `services/activity-service/internal/storage/mysql/lucky_gift_repository.go` | 规则锁、RTP 窗口、三层池、风控计数、候选过滤、随机、draw fact、outbox | | activity schema | `services/activity-service/deploy/mysql/initdb/001_activity_service.sql` | `lucky_gift_rules`、`lucky_rtp_windows`、`lucky_pools`、`lucky_user_states`、`lucky_draw_records` 等表 | | gateway 检查接口 | `services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go` | `POST /api/v1/activities/lucky-gifts/check`,当前要求 `room_id/gift_id/pool_id` 都非空 | | gateway 送礼接口 | `services/gateway-service/internal/transport/http/roomapi/room_handler.go` | `POST /api/v1/rooms/gift/send` 透传 `pool_id` 和 `command_id` | | admin 配置接口 | `server/admin/internal/modules/luckygift/handler.go` | 后台读写 `pool_id` 维度的规则,支持 `multiplier_ppms` | | 体验模拟工具 | `tools/lucky-gift-v2-demo/main.go` | 离线模拟用户 RTP 分布、奖池水位、风控限制和高倍命中 | ## 当前范围 | 能力 | 状态 | 代码事实 | | --- | --- | --- | | 送礼前检查 | 已实现 | gateway check 要求 `pool_id`;activity 按 `pool_id` 读规则,未配置返回 `not_configured` | | 扣费后抽奖 | 已实现 | `room-service` 先调 `wallet-service.DebitGift`,扣费成功后调 `activity-service.ExecuteLuckyGiftDraw` | | 同步返回抽奖结果 | 已实现 | `SendGiftResponse.lucky_gift` 直接返回给送礼用户 | | 后台多奖池 | 已实现 | 规则主键实际是 `(app_code, gift_id)`,但当前 `gift_id` 列保存的是 `pool_id` | | 多倍数配置 | 已实现 | 后台配置 `multiplier_ppms`,服务端生成每个体验池的候选倍率 | | RTP 双窗口 | 已实现 | 当前窗口 scope 是 `pool` 和 `pool_gift`,都按 `pool_id` 隔离 | | 三层基础奖池 | 已实现 | platform / room / gift 三层池按 `pool_id` 隔离,room 池再加 `room_id` | | 风控计数 | 已实现 | user hour/day、device day、room hour、anchor day | | 房间气氛池 | 已实现 | 只在基础 RTP 不需要强制追平时参与候选 | | 活动补贴池 | 已实现 | 只在基础 RTP 不需要强制追平时参与候选 | | 返奖入账 | 已实现 | 抽奖事务写 `activity_outbox`,worker 调 `wallet-service.CreditLuckyGiftReward` | | 房间中奖 IM | 已实现 | worker 投递 `lucky_gift_drawn` 群消息 | | 区域中奖播报 | 已实现 | `multiplier_ppm >= 1x` 且有 `visible_region_id` 时写区域播报 outbox | | 完整候选审计 | 未完整 | `candidate_tiers_json` 当前只保存命中候选和限制标记,不保存完整过滤后候选表 | | 随机摘要审计 | 未实现 | 当前没有 `random_digest` 字段落库 | ## 主链路 ```text Client -> gateway POST /api/v1/rooms/gift/send -> room-service SendGift -> 可选 CheckLuckyGift(pool_id 非空时送礼前检查) -> wallet-service DebitGift -> activity-service ExecuteLuckyGiftDraw -> activity-service 本地事务写 draw fact + activity_outbox -> room-service 更新 Room Cell 热度、榜单、礼物 outbox -> gateway 返回 data.lucky_gift -> lucky gift worker 补偿返奖入账和 lucky_gift_drawn IM ``` 当前有两个触发入口: | 入口 | 行为 | | --- | --- | | 请求显式传 `pool_id` | `room-service` 会在扣费前调用 `CheckLuckyGift`,规则未启用则不扣费 | | 请求未传 `pool_id` 但钱包返回 `gift_type_code=lucky/super_lucky` | `room-service` 会在扣费后按默认奖池抽奖,`activity-service` 把空 `pool_id` 归一化为 `default` | 客户端 check 接口当前要求 `pool_id` 必填,所以 App 不应依赖“空 `pool_id` 默认奖池”的隐式路径。礼物面板需要下发明确的 `pool_id`。 ## 多倍数配置 后台现在的核心配置不是固定概率表,而是倍率列表: ```json { "pool_id": "lucky", "gift_price": 100, "target_rtp_ppm": 950000, "pool_rate_ppm": 950000, "high_multiplier": 100, "large_tier_enabled": true, "multiplier_ppms": [ 0, 200000, 500000, 1000000, 2000000, 5000000, 10000000, 20000000, 50000000, 100000000, 500000000 ] } ``` 倍率单位是 ppm: | 值 | 含义 | `gift_price=100` 时基础返奖 | | ---: | --- | ---: | | `0` | 未中奖 | `0` | | `200000` | `0.2x` | `20` | | `500000` | `0.5x` | `50` | | `1000000` | `1x` | `100` | | `2000000` | `2x` | `200` | | `100000000` | `100x` | `10000` | | `500000000` | `500x` | `50000` | 服务端发布配置时会执行这些归一化: 1. 如果 `multiplier_ppms` 非空,直接用它去重、排序并过滤负数。 2. 如果 `multiplier_ppms` 为空,则从传入 `tiers` 里反推倍率。 3. 如果两者都为空,则使用默认倍率列表。 4. 每个倍率都会生成 `novice`、`intermediate`、`advanced` 三个体验池各一条奖档。 5. 生成奖档的 `weight` 固定为 `0`,真实权重由抽奖时 RTP 窗口动态计算。 6. `multiplier_ppm >= high_multiplier * 1000000` 的奖档会标记 `high_water_only`。 这意味着当前后台不能按体验池单独配置概率,也不能直接配置“新手 2x 权重大、中级 5x 权重大”这类固定权重。后台能控制的是可用倍率集合、RTP 目标、窗口大小、奖池水位、风控上限和高倍开关。 ## 抽奖算法事实 抽奖事务按 `command_id` 幂等。重复请求返回同一条 `lucky_draw_records`,不会重复消耗奖池或 RTP。 核心步骤: ```text 1. 锁定 command_id 对应 draw fact;存在则直接返回 2. 按 pool_id 锁定幸运礼物规则;未启用则失败 3. 用真实 coin_spent 替换配置里的参考 gift_price,并等比缩放奖档和风控上限 4. 锁定用户在该 pool_id 下的累计付费抽数,选择 novice/intermediate/advanced 5. 锁定 pool RTP 窗口和 pool_gift RTP 子窗口 6. 锁定 platform、room、gift 三层基础奖池 7. 锁定房间气氛池、活动预算和风控计数 8. 计算本次基础 RTP 最小必付额 min_required 9. 过滤低于 min_required、超过奖池容量、超过风控容量或高水位不足的倍率 10. 从可支付基础候选中按 RTP 缺口自动生成 1 个或 2 个带权候选 11. 当 min_required=0 时,额外加入房间气氛、活动补贴和阶段反馈候选 12. 使用 crypto/rand 做一次加权随机 13. 更新 RTP 窗口、三层池、气氛池、活动预算、风控计数、用户抽数 14. 写 lucky_draw_records;有返奖或展示副作用时写 activity_outbox ``` 运行时基础候选权重不是后台配置权重。`luckyAutoWeightedBaseCandidates` 会按当前窗口缺口选择: | RTP 状态 | 当前行为 | | --- | --- | | 没有基础 RTP 缺口 | 优先选择 `0x` 候选;如果倍率列表没有 `0`,会选择最低可用倍率 | | 缺口正好落在某个倍率以下 | 只给最接近且可支付的低档或高档候选分配 `1000000` 权重 | | 缺口落在两个倍率之间 | 只保留相邻两个倍率,权重按线性插值分配 | | 没有可用候选但必须追平 | 生成 `rtp_balance` 修正候选;如果超过容量则返回冲突 | 因此,多倍数列表不是“所有倍率每次都按固定概率参与随机”。它是 RTP 控制器可选择的离散返奖点。 ## 用户体验模拟 体验模拟工具用于评估参数是否会带来可接受的用户体感分布。它输出全局 RTP、有效 RTP、100k/1m 窗口偏差、奖池水位、用户 RTP 分位数和用户净输赢分位数。 基础命令: ```bash go run ./tools/lucky-gift-v2-demo \ -users 1000 \ -devices 900 \ -rooms 50 \ -draws-min 0 \ -draws-max 100000 \ -unique-draws \ -base-rtp 95 \ -cost 500 \ -global-window 100000 \ -gift-window 100000 \ -pool-rate-ppm 950000 \ -high-multiplier 100 \ -high-water-pool-multiple 2 \ -user-csv /tmp/lucky-gift-users.csv ``` 重点看这些输出: | 输出 | 判断方式 | | --- | --- | | `base_rtp` | 基础成本口径,应贴近 `base-rtp` | | `effective_rtp` | 用户可见返还,允许因气氛池和活动补贴高于基础 RTP | | `100k_blocks max_abs_deviation` | 应小于 `1pp` | | `1m_blocks max_abs_deviation` | 应小于 `0.5pp` | | `high_multiplier_hits` | 评估高倍实际出现频率是否符合运营预期 | | `risk_cap_limited_draws` | 过高说明风控上限压得太低,会牺牲 RTP 追平能力 | | `pool_cap_limited_draws` | 过高说明初始奖池、入池比例或 reserve 配置不健康 | | `user_effective_rtp_p05/median/p95` | 用户体验分布;单用户不会收敛到全局 RTP | | `user_net_p05/median/p95` | 用户净输赢体感;用于判断投诉风险和高倍刺激强度 | 当前模拟工具的限制: | 限制 | 影响 | | --- | --- | | 不能直接输入 `multiplier_ppms` | 它使用内置新手/中级/高级 tier table,不能完全复现后台任意倍率列表 | | 使用伪随机 `math/rand` | 便于复现实验,不等同线上 `crypto/rand` | | 不连接真实 MySQL | 不验证行锁、幂等、outbox 和钱包补偿 | | 不模拟真实礼物面板 | 只模拟付费抽数、成本、奖池和用户结果分布 | 使用模拟结论上线前,仍需用真实 `activity-service` MySQL 测试覆盖配置发布、抽奖事务、重复 `command_id`、outbox 返奖和 IM 投递。 ## 当前问题 | 问题 | 影响 | 建议动作 | | --- | --- | --- | | `lucky_gift_rules.gift_id` 实际保存 `pool_id` | 文档、后台和审计容易把真实礼物 ID 与奖池 ID 混淆 | 后续 DB 字段改名为 `pool_id`,或在所有接口文档明确“规则表 gift_id 是历史列名” | | 后台传入 `tiers.weight` 不生效 | 运营以为配置了固定概率,但线上按 RTP 缺口自动权重抽 | 后台隐藏或只读展示 `tiers.weight`,主编辑项改为 `multiplier_ppms` | | `multiplier_ppms` 可以不含 `0` | 没有 RTP 缺口时最低正倍率可能被强制选中,用户体感和成本都会异常 | 后台校验建议强制包含 `0`,除非明确做“每抽必返”奖池 | | 三个体验池使用同一倍率集合 | 无法做到新手池低倍密集、高级池高倍更多的差异化倍率表 | 增加按 pool 分组的倍率配置,或保留统一倍率但明确只能靠阶段和 RTP 控制差异 | | 当前服务不使用后台固定概率 | “概率表版本”“权重表”类文档与代码不一致 | 文档统一改为“倍率列表 + 运行时 RTP 自动权重” | | `candidate_tiers_json` 不保存完整候选表 | 后台审计无法复盘每次过滤后的完整随机空间 | 落库完整候选列表、过滤原因、总权重和随机落点 | | 没有 `random_digest` | 不能证明随机材料与候选表一致 | 增加随机摘要或 seed commit-reveal 方案 | | 同步返回通常是 `pending` | 客户端如果看到返奖就展示“已到账”,会早于钱包入账事实 | 客户端只在钱包 IM 或余额刷新后更新余额;HTTP 只展示“中奖/处理中” | | check 接口要求 `pool_id`,送礼链路又支持空 `pool_id` 默认奖池 | App 行为和服务端隐式兜底不一致 | 礼物面板必须明确下发 `pool_id`,客户端幸运礼物送礼必传 | | 区域播报阈值硬编码为 `>= 1x` | 后台不能独立控制哪些中奖进入区域飘屏 | 增加 `broadcast_min_multiplier_ppm` 或 `broadcast_min_reward_coins` | | 模拟工具不能输入多倍数列表 | 不能验证“多个倍数配置”对实际体感的影响 | 给 `tools/lucky-gift-v2-demo` 增加 `-multipliers-ppm` 参数,或新增基于线上 `Config` 的模拟入口 | ## 后台配置建议 | 目标 | 建议 | | --- | --- | | 常规幸运礼物 | `multiplier_ppms` 包含 `0/0.2x/0.5x/1x/2x/5x/10x/20x`,高倍关闭或保持较高水位门槛 | | 超级幸运礼物 | 在常规列表基础上增加 `50x/100x/500x`,同时提高初始池、水位倍数和风控 cap | | 新手体验 | 保留 `0.2x/0.5x/1x/2x`,不要只配置 `1x` 以上倍率 | | 降低投诉 | 降低 `effective_rtp` 波动,增加小倍率数量,减少 `500x` 等极高倍 | | 控制成本 | 降低 `target_rtp_ppm` 或补贴预算,不要用过低风控 cap 硬压基础 RTP | 高风险配置示例: | 配置 | 风险 | | --- | --- | | `multiplier_ppms=[1000000,2000000]` | 没有未中奖档,低缺口时仍可能返 `1x` | | `target_rtp_ppm=950000` 但最大倍率低于 `1x` | 发布会被校验拒绝,无法追平 RTP | | `large_tier_enabled=true` 但初始池很低 | 高倍长期被过滤,用户看到配置但实际不出 | | `user_hourly_payout_cap` 低于小时期望返奖 | 风控会阻断 RTP 追平,触发 `cannot be settled` | ## 验证 文档或配置语义变更后至少跑相关单元测试: ```bash go test ./services/activity-service/internal/service/luckygift go test ./services/activity-service/internal/storage/mysql go test ./services/room-service/internal/room/service go test ./services/gateway-service/internal/transport/http/activityapi ``` 参数上线前跑体验模拟: ```bash go run ./tools/lucky-gift-v2-demo \ -users 1000 \ -devices 900 \ -rooms 50 \ -draws-min 0 \ -draws-max 100000 \ -unique-draws \ -base-rtp 95 \ -cost 500 \ -global-window 100000 \ -gift-window 100000 \ -user-csv /tmp/lucky-gift-users.csv ``` 验收口径: | 项 | 要求 | | --- | --- | | `100000` paid draws | 基础 RTP 偏差 `< 1pp` | | `1000000` paid draws | 基础 RTP 偏差 `< 0.5pp` | | pool safety | 三层池和气氛池不低于 reserve | | idempotency | 重复 `command_id` 返回同一 draw | | reward status | 有返奖时先 `pending`,worker 成功后 `granted` | | Flutter 体验 | 不本地加余额;余额以 `WalletBalanceChanged` 或余额查询为准 |