272 lines
15 KiB
Markdown
272 lines
15 KiB
Markdown
# 幸运礼物线上落地文档
|
||
|
||
本文档按当前代码实现整理幸运礼物的服务边界、配置语义、抽奖链路、体验模拟方式和已知问题。当前幸运礼物由 `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` 或余额查询为准 |
|