15 KiB
幸运礼物线上落地文档
本文档按当前代码实现整理幸运礼物的服务边界、配置语义、抽奖链路、体验模拟方式和已知问题。当前幸运礼物由 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 字段落库 |
主链路
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。
多倍数配置
后台现在的核心配置不是固定概率表,而是倍率列表:
{
"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 |
服务端发布配置时会执行这些归一化:
- 如果
multiplier_ppms非空,直接用它去重、排序并过滤负数。 - 如果
multiplier_ppms为空,则从传入tiers里反推倍率。 - 如果两者都为空,则使用默认倍率列表。
- 每个倍率都会生成
novice、intermediate、advanced三个体验池各一条奖档。 - 生成奖档的
weight固定为0,真实权重由抽奖时 RTP 窗口动态计算。 multiplier_ppm >= high_multiplier * 1000000的奖档会标记high_water_only。
这意味着当前后台不能按体验池单独配置概率,也不能直接配置“新手 2x 权重大、中级 5x 权重大”这类固定权重。后台能控制的是可用倍率集合、RTP 目标、窗口大小、奖池水位、风控上限和高倍开关。
抽奖算法事实
抽奖事务按 command_id 幂等。重复请求返回同一条 lucky_draw_records,不会重复消耗奖池或 RTP。
核心步骤:
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 分位数和用户净输赢分位数。
基础命令:
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 |
验证
文档或配置语义变更后至少跑相关单元测试:
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
参数上线前跑体验模拟:
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 或余额查询为准 |