hyapp-server/docs/幸运礼物线上落地文档.md

272 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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