幸运礼物重构

This commit is contained in:
zhx 2026-05-27 22:12:16 +08:00
parent 31f686bd68
commit c93f46b191
6 changed files with 1875 additions and 0 deletions

View File

@ -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`

View File

@ -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` 通过 |
第一步完成后,系统只具备“正确配置和发布规则”的能力,不要求新抽奖引擎上线。

View File

@ -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` | 钱包事故时暂停返奖 dispatchdraw 保持 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 才算真正上线。前两步只完成规则和引擎,不能直接对外开放。

View File

@ -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但还不直接替换线上送礼主链路。

View File

@ -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}`);
});

769
tools/lucky-gift-test.html Normal file
View File

@ -0,0 +1,769 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>幸运礼物测试页</title>
<style>
:root {
color-scheme: light;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f4f7fb;
color: #172033;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; }
main { max-width: 1180px; margin: 0 auto; display: grid; gap: 16px; }
h1 { margin: 0; font-size: 24px; }
h2 { margin: 0 0 12px; font-size: 16px; }
section {
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
padding: 16px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
label { display: grid; gap: 6px; color: #53627a; font-size: 13px; font-weight: 700; }
input, select, textarea {
width: 100%;
border: 1px solid #cad6e6;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
color: #172033;
background: #fbfdff;
}
textarea {
min-height: 84px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
line-height: 1.45;
}
button {
border: 1px solid #2563eb;
border-radius: 8px;
padding: 10px 14px;
font: inherit;
font-weight: 800;
color: #fff;
background: #2563eb;
cursor: pointer;
}
button.secondary { color: #2563eb; background: #fff; }
button.danger { border-color: #dc2626; background: #dc2626; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
code { color: #1d4ed8; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.muted { color: #697891; font-size: 13px; }
.status { min-height: 22px; color: #334155; font-size: 13px; }
.status.error { color: #dc2626; }
.status.ok { color: #16a34a; }
.result {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric {
border: 1px solid #d9e2ef;
border-radius: 8px;
padding: 12px;
background: #fbfdff;
min-height: 76px;
}
.metric .name { color: #697891; font-size: 12px; font-weight: 800; }
.metric .value { margin-top: 8px; font-size: 22px; font-weight: 900; overflow-wrap: anywhere; }
.win .value { color: #dc2626; }
.lose .value { color: #334155; }
.log {
display: grid;
gap: 8px;
max-height: 360px;
overflow: auto;
padding-right: 4px;
}
.log-item {
border: 1px solid #d9e2ef;
border-radius: 8px;
padding: 10px;
background: #fbfdff;
}
.log-title { display: flex; gap: 8px; justify-content: space-between; font-size: 13px; font-weight: 900; }
.log-body { margin-top: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #334155; }
@media (max-width: 820px) {
body { padding: 12px; }
.grid, .grid.three, .result { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<header>
<h1>幸运礼物测试页</h1>
<div class="muted">本地默认网关:<code>http://127.0.0.1:13000</code>;用随页 server 打开时API Base URL 留空即可走本地代理。</div>
</header>
<section>
<h2>1. API 与账号</h2>
<div class="grid">
<label>
API Base URL
<input id="apiBase" placeholder="例如 http://127.0.0.1:13000代理模式留空" />
</label>
<label>
X-App-Code
<input id="appCode" value="lalu" />
</label>
<label>
账号 / UID
<input id="account" placeholder="用于账号密码登录" />
</label>
<label>
密码
<input id="password" type="password" placeholder="用于登录或快捷创建" />
</label>
</div>
<label style="margin-top:12px">
Access Token
<textarea id="token" placeholder="可以粘贴 App access_token也可以点击登录/快捷创建自动填充"></textarea>
</label>
<div class="actions" style="margin-top:12px">
<button id="loginBtn" type="button">账号密码登录</button>
<button id="quickCreateBtn" class="secondary" type="button">快捷创建测试账号</button>
<span id="authStatus" class="status"></span>
</div>
</section>
<section>
<h2>2. 房间、礼物与 IM</h2>
<div class="grid three">
<label>
room_id
<input id="roomId" value="test_room_1" />
</label>
<label>
target_user_id
<input id="targetUserId" inputmode="numeric" placeholder="收礼人 user_id" />
</label>
<label>
IM group_id
<input id="imGroupId" placeholder="进房后自动填充;为空时默认 room_id" />
</label>
<label>
gift_id
<input id="giftId" value="lucky_star_100" />
</label>
<label>
pool_id
<input id="poolId" value="lucky" />
</label>
<label>
gift_count
<input id="giftCount" inputmode="numeric" value="1" />
</label>
</div>
<div class="actions" style="margin-top:12px">
<button id="randomReadyBtn" type="button">随机房间和礼物</button>
<button id="loadPanelBtn" type="button">读取礼物面板</button>
<button id="checkBtn" class="secondary" type="button">检查幸运礼物</button>
<button id="imBtn" type="button">登录 IM</button>
<button id="joinBtn" class="secondary" type="button">进房并加入 IM 群</button>
<span id="roomStatus" class="status"></span>
</div>
<label style="margin-top:12px">
礼物面板幸运礼物
<select id="giftSelect">
<option value="">未读取</option>
</select>
</label>
</section>
<section>
<h2>3. 发幸运礼物</h2>
<div class="actions">
<button id="sendBtn" type="button">发礼物</button>
<button id="clearBtn" class="danger" type="button">清空日志</button>
<span id="sendStatus" class="status"></span>
</div>
<div class="result" style="margin-top:12px">
<div id="winMetric" class="metric lose">
<div class="name">是否中奖</div>
<div id="winValue" class="value">未发送</div>
</div>
<div class="metric">
<div class="name">中奖倍数</div>
<div id="multiplierValue" class="value">-</div>
</div>
<div class="metric">
<div class="name">中奖金额</div>
<div id="rewardValue" class="value">-</div>
</div>
<div class="metric">
<div class="name">到账状态</div>
<div id="rewardStatusValue" class="value">-</div>
</div>
</div>
</section>
<section>
<h2>4. HTTP 与 IM 日志</h2>
<div id="log" class="log"></div>
</section>
</main>
<script type="module">
const SDK_URL = "https://cdn.jsdelivr.net/npm/@tencentcloud/chat@3.6.6/index.es.js";
const defaultApiBase = "http://127.0.0.1:13000";
const proxyHostnames = new Set(["localhost", "127.0.0.1", "::1"]);
const state = {
chat: null,
chatSDK: null,
imReady: false,
loginUserID: "",
selfUserID: "",
joinedGroupID: "",
seenEventIDs: new Set(),
};
const $ = (id) => document.getElementById(id);
const isProxyPage = proxyHostnames.has(window.location.hostname) && window.location.port === "8788";
$("apiBase").value = localStorage.getItem("lucky_gift_api_base") || (isProxyPage ? "" : defaultApiBase);
$("appCode").value = localStorage.getItem("lucky_gift_app_code") || "lalu";
$("account").value = localStorage.getItem("lucky_gift_account") || "";
$("password").value = localStorage.getItem("lucky_gift_password") || "";
$("token").value = localStorage.getItem("lucky_gift_access_token") || "";
$("roomId").value = localStorage.getItem("lucky_gift_room_id") || $("roomId").value;
$("targetUserId").value = localStorage.getItem("lucky_gift_target_user_id") || "";
$("imGroupId").value = localStorage.getItem("lucky_gift_im_group_id") || "";
$("giftId").value = localStorage.getItem("lucky_gift_gift_id") || $("giftId").value;
$("poolId").value = "lucky";
$("giftCount").value = localStorage.getItem("lucky_gift_count") || $("giftCount").value;
function apiBase() {
return $("apiBase").value.trim().replace(/\/+$/, "");
}
function appCode() {
return $("appCode").value.trim() || "lalu";
}
function token() {
return $("token").value.trim();
}
function rememberForm() {
localStorage.setItem("lucky_gift_api_base", apiBase());
localStorage.setItem("lucky_gift_app_code", appCode());
localStorage.setItem("lucky_gift_access_token", token());
localStorage.setItem("lucky_gift_account", $("account").value.trim());
localStorage.setItem("lucky_gift_password", $("password").value);
localStorage.setItem("lucky_gift_room_id", $("roomId").value.trim());
localStorage.setItem("lucky_gift_target_user_id", $("targetUserId").value.trim());
localStorage.setItem("lucky_gift_im_group_id", $("imGroupId").value.trim());
localStorage.setItem("lucky_gift_gift_id", $("giftId").value.trim());
localStorage.setItem("lucky_gift_pool_id", $("poolId").value.trim());
localStorage.setItem("lucky_gift_count", $("giftCount").value.trim());
}
async function request(path, options = {}) {
rememberForm();
const headers = {
"Content-Type": "application/json",
"X-App-Code": appCode(),
"X-App-Platform": "web",
"X-App-Language": navigator.language || "zh-CN",
...(options.headers || {}),
};
if (options.auth !== false && token()) {
headers.Authorization = `Bearer ${token()}`;
}
const response = await fetch(`${apiBase()}${path}`, {
method: options.method || "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const text = await response.text();
const payload = parseJSON(text) || { message: text };
if (!response.ok || !isSuccessCode(payload.code)) {
throw new Error(payload.message || response.statusText || `HTTP ${response.status}`);
}
return payload.data;
}
function isSuccessCode(code) {
return code === undefined || code === null || code === 0 || code === "0" || code === 200 || code === "200" || code === "OK";
}
function parseJSON(raw) {
if (typeof raw !== "string") return null;
try {
return raw ? JSON.parse(raw) : {};
} catch {
return null;
}
}
function setStatus(id, message, type = "") {
const el = $(id);
el.textContent = message || "";
el.className = ["status", type].filter(Boolean).join(" ");
}
function addLog(title, body, type = "") {
const item = document.createElement("div");
item.className = "log-item";
item.innerHTML = `
<div class="log-title">
<span>${escapeHtml(title)}</span>
<span class="muted">${escapeHtml(new Date().toLocaleTimeString())}</span>
</div>
<div class="log-body">${escapeHtml(typeof body === "string" ? body : JSON.stringify(body, null, 2))}</div>
`;
if (type === "error") item.style.borderColor = "#fecaca";
$("log").prepend(item);
}
function saveToken(data) {
const accessToken = data?.access_token || data?.token?.access_token || "";
if (!accessToken) throw new Error("登录成功但响应里没有 access_token");
$("token").value = accessToken;
if (data.account || data.uid) $("account").value = data.account || data.uid;
state.selfUserID = String(data.uid || data.user_id || data.account || jwtUserID(accessToken) || "").trim();
if (state.selfUserID) $("targetUserId").value = state.selfUserID;
$("poolId").value = "lucky";
rememberForm();
}
async function login() {
setStatus("authStatus", "登录中...");
try {
const account = $("account").value.trim();
const password = $("password").value;
if (!account || !password) throw new Error("请填写账号/UID 和密码");
const data = await request("/api/v1/auth/account/login", {
method: "POST",
auth: false,
body: loginMeta({ account, password }),
});
saveToken(data);
addLog("账号登录成功", data);
setStatus("authStatus", "登录成功token 已填入", "ok");
await prepareRandomRoomAndGift();
} catch (err) {
addLog("账号登录失败", errorMessage(err), "error");
setStatus("authStatus", errorMessage(err), "error");
}
}
async function quickCreate() {
setStatus("authStatus", "创建中...");
try {
const password = $("password").value || "Test@123456";
const data = await request("/api/v1/auth/account/quick-create", {
method: "POST",
auth: false,
body: loginMeta({ password }),
});
$("password").value = password;
saveToken(data);
addLog("快捷创建成功", data);
setStatus("authStatus", `创建成功:${data.account || data.uid || ""}`, "ok");
await prepareRandomRoomAndGift();
} catch (err) {
addLog("快捷创建失败", errorMessage(err), "error");
setStatus("authStatus", errorMessage(err), "error");
}
}
function loginMeta(extra) {
return {
...extra,
device_id: "lucky-gift-html-test",
platform: "web",
language: navigator.language || "zh-CN",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
};
}
async function loadGiftPanel() {
setStatus("roomStatus", "读取礼物面板...");
try {
const roomID = $("roomId").value.trim();
if (!roomID) throw new Error("请填写 room_id");
const data = await request(`/api/v1/rooms/${encodeURIComponent(roomID)}/gift-panel`);
const gifts = Array.isArray(data?.gifts) ? data.gifts : [];
const luckyGifts = gifts.filter((gift) => ["lucky", "super_lucky"].includes(String(gift.gift_type_code || "")));
renderGiftOptions(luckyGifts, { random: false });
setSelfRecipient();
addLog("礼物面板", data);
setStatus("roomStatus", `读取成功:${luckyGifts.length} 个幸运礼物`, "ok");
} catch (err) {
addLog("读取礼物面板失败", errorMessage(err), "error");
setStatus("roomStatus", errorMessage(err), "error");
}
}
async function prepareRandomRoomAndGift() {
setStatus("roomStatus", "随机获取本地房间...");
try {
setSelfRecipient();
$("poolId").value = "lucky";
const room = await randomLocalRoom();
$("roomId").value = room.room_id || room.roomId || "";
$("imGroupId").value = room.im_group_id || room.imGroupId || $("roomId").value.trim();
rememberForm();
addLog("随机选中房间", room);
await joinRoomAndGroup();
await loadRandomLuckyGift();
setStatus("roomStatus", `已准备:房间 ${$("roomId").value},礼物 ${$("giftId").value}`, "ok");
} catch (err) {
addLog("随机准备失败", errorMessage(err), "error");
setStatus("roomStatus", errorMessage(err), "error");
}
}
async function randomLocalRoom() {
const data = await request("/api/v1/rooms?tab=hot&limit=50");
const rooms = (Array.isArray(data?.rooms) ? data.rooms : []).filter((room) => room?.room_id && room.status === "active" && !room.locked);
if (!rooms.length) throw new Error("本地房间列表没有可加入的 active 非锁房房间");
return randomItem(rooms);
}
async function loadRandomLuckyGift() {
const roomID = $("roomId").value.trim();
if (!roomID) throw new Error("请先选中 room_id");
const data = await request(`/api/v1/rooms/${encodeURIComponent(roomID)}/gift-panel`);
const gifts = Array.isArray(data?.gifts) ? data.gifts : [];
const luckyGifts = gifts.filter((gift) => ["lucky", "super_lucky"].includes(String(gift.gift_type_code || "")));
if (!luckyGifts.length) throw new Error("当前房间礼物面板没有 lucky / super_lucky 礼物");
renderGiftOptions(luckyGifts, { random: true });
setSelfRecipient();
$("poolId").value = "lucky";
rememberForm();
addLog("随机选中幸运礼物", { gift_id: $("giftId").value, pool_id: $("poolId").value });
}
function renderGiftOptions(gifts, options = {}) {
const select = $("giftSelect");
select.innerHTML = "";
if (!gifts.length) {
select.append(new Option("没有找到 lucky / super_lucky 礼物", ""));
return;
}
for (const gift of gifts) {
const poolID = "lucky";
const label = `${gift.name || gift.gift_id} / ${gift.gift_id} / pool=${poolID || "-"}`;
const option = new Option(label, JSON.stringify({ gift_id: gift.gift_id, pool_id: poolID, coin_price: gift.coin_price }));
select.append(option);
}
if (options.random) select.selectedIndex = Math.floor(Math.random() * select.options.length);
applySelectedGift();
}
function luckyPoolID(gift) {
const presentation = parseJSON(gift?.presentation_json || "") || {};
return String(gift?.lucky_pool_id || presentation.lucky_pool_id || presentation.pool_id || "").trim();
}
function firstRecipientID(recipients) {
if (!Array.isArray(recipients)) return "";
const item = recipients.find((recipient) => recipient?.user_id || recipient?.target_user_id);
return item?.user_id || item?.target_user_id || "";
}
function applySelectedGift() {
const value = $("giftSelect").value;
if (!value) return;
const gift = parseJSON(value);
if (!gift) return;
$("giftId").value = gift.gift_id || $("giftId").value;
$("poolId").value = "lucky";
rememberForm();
}
function setSelfRecipient() {
const selfID = state.selfUserID || state.loginUserID || jwtUserID(token()) || $("account").value.trim();
if (selfID) {
state.selfUserID = String(selfID);
$("targetUserId").value = String(selfID);
}
}
async function checkLuckyGift() {
setStatus("roomStatus", "检查幸运礼物...");
try {
const body = giftBaseBody();
const data = await request("/api/v1/activities/lucky-gifts/check", {
method: "POST",
body: {
room_id: body.room_id,
gift_id: body.gift_id,
pool_id: body.pool_id,
},
});
addLog("幸运礼物检查结果", data);
setStatus("roomStatus", data.enabled ? "幸运礼物可用" : `不可用:${data.reason || "-"}`, data.enabled ? "ok" : "error");
} catch (err) {
addLog("幸运礼物检查失败", errorMessage(err), "error");
setStatus("roomStatus", errorMessage(err), "error");
}
}
async function loginIM() {
setStatus("roomStatus", "登录 IM...");
try {
if (!token()) throw new Error("请先登录或填写 access_token");
const sig = await request("/api/v1/im/usersig");
const SDK = await loadChatSDK();
const sdkAppID = Number(sig.sdk_app_id || sig.sdkAppID || sig.SDKAppID);
const userID = String(sig.user_id || sig.userID || "");
const userSig = sig.user_sig || sig.userSig || "";
if (!sdkAppID || !userID || !userSig) throw new Error("usersig 响应缺少 sdk_app_id/user_id/user_sig");
state.loginUserID = userID;
state.selfUserID = state.selfUserID || userID;
$("targetUserId").value = state.selfUserID;
state.chat = SDK.create({ SDKAppID: sdkAppID });
state.chat.setLogLevel?.(1);
bindIMEvents(SDK);
await state.chat.login({ userID, userSig });
await waitIMReady();
addLog("IM 登录成功", { sdk_app_id: sdkAppID, user_id: userID, expire_at_ms: sig.expire_at_ms });
setStatus("roomStatus", `IM 已登录:${userID}`, "ok");
} catch (err) {
addLog("IM 登录失败", errorMessage(err), "error");
setStatus("roomStatus", errorMessage(err), "error");
}
}
async function loadChatSDK() {
if (state.chatSDK) return state.chatSDK;
const mod = await import(SDK_URL);
state.chatSDK = mod.default || mod.TencentCloudChat || mod;
return state.chatSDK;
}
function bindIMEvents(SDK) {
state.imReady = state.chat?.isReady?.() === true;
state.chat.on(SDK.EVENT.SDK_READY, () => {
state.imReady = true;
addLog("IM SDK_READY", { ready: true });
});
state.chat.on(SDK.EVENT.SDK_NOT_READY, () => {
state.imReady = false;
addLog("IM SDK_NOT_READY", { ready: false });
});
state.chat.on(SDK.EVENT.MESSAGE_RECEIVED, (event) => {
for (const message of event.data || []) handleIMMessage(message);
});
state.chat.on(SDK.EVENT.KICKED_OUT, (event) => {
state.imReady = false;
addLog("IM 被踢下线", event.data || event, "error");
});
}
function waitIMReady() {
if (state.imReady || state.chat?.isReady?.()) return Promise.resolve();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("IM SDK_READY 等待超时")), 12000);
const timer = setInterval(() => {
if (state.imReady || state.chat?.isReady?.()) {
clearInterval(timer);
clearTimeout(timeout);
resolve();
}
}, 200);
});
}
async function joinRoomAndGroup() {
setStatus("roomStatus", "进房并加入 IM 群...");
try {
const roomID = $("roomId").value.trim();
if (!roomID) throw new Error("请填写 room_id");
const data = await request("/api/v1/rooms/join", {
method: "POST",
body: {
room_id: roomID,
command_id: commandID("join"),
role: "audience",
},
});
const groupID = data?.im?.group_id || data?.room?.im_group_id || roomID;
$("imGroupId").value = groupID;
rememberForm();
addLog("业务进房成功", data);
if (!state.chat) await loginIM();
await waitIMReady();
await state.chat.joinGroup({ groupID });
state.joinedGroupID = groupID;
addLog("IM 加群成功", { group_id: groupID });
setStatus("roomStatus", `已进房并加入 IM 群:${groupID}`, "ok");
} catch (err) {
addLog("进房或加群失败", errorMessage(err), "error");
setStatus("roomStatus", errorMessage(err), "error");
}
}
async function sendLuckyGift() {
setStatus("sendStatus", "发礼物中...");
try {
const body = {
...giftBaseBody(),
command_id: commandID("gift"),
target_type: "user",
target_user_ids: [requiredInt("targetUserId", "target_user_id")],
gift_count: requiredInt("giftCount", "gift_count"),
};
const data = await request("/api/v1/rooms/gift/send", {
method: "POST",
body,
});
addLog("发礼物 HTTP 响应", data);
if (data?.lucky_gift) {
renderLuckyResult("HTTP", data.lucky_gift);
} else {
renderEmptyResult("HTTP 成功,响应无 lucky_gift");
}
setStatus("sendStatus", "发礼物成功,等待 IM 同步", "ok");
} catch (err) {
addLog("发礼物失败", errorMessage(err), "error");
setStatus("sendStatus", errorMessage(err), "error");
}
}
function giftBaseBody() {
const roomID = $("roomId").value.trim();
const giftID = $("giftId").value.trim();
const poolID = $("poolId").value.trim();
if (!roomID) throw new Error("请填写 room_id");
if (!giftID) throw new Error("请填写 gift_id");
if (!poolID) throw new Error("请填写 pool_id");
return { room_id: roomID, gift_id: giftID, pool_id: poolID };
}
function requiredInt(id, label) {
const value = Number.parseInt($(id).value.trim(), 10);
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`请填写有效的 ${label}`);
return value;
}
function commandID(prefix) {
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}_${Date.now()}_${random}`;
}
function randomItem(items) {
return items[Math.floor(Math.random() * items.length)];
}
function jwtUserID(accessToken) {
const payload = parseJWTPayload(accessToken);
return payload?.user_id || payload?.uid || payload?.sub || "";
}
function parseJWTPayload(accessToken) {
const part = String(accessToken || "").split(".")[1];
if (!part) return null;
try {
const normalized = part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "=");
return JSON.parse(decodeURIComponent(escape(atob(normalized))));
} catch {
return null;
}
}
function handleIMMessage(message) {
const decoded = decodeCustomMessage(message);
addLog(`IM 收到 ${decoded.desc || decoded.payload?.event_type || message.type || ""}`, decoded);
if (!decoded.payload) return;
const eventID = decoded.payload.event_id || decoded.payload.draw_id || "";
if (eventID && state.seenEventIDs.has(eventID)) return;
if (eventID) state.seenEventIDs.add(eventID);
if (decoded.desc === "lucky_gift_drawn" || decoded.payload.event_type === "lucky_gift_drawn") {
renderLuckyResult("IM", decoded.payload);
}
}
function decodeCustomMessage(message) {
const payload = message?.payload || {};
const raw = payload.data || payload.Data || message?.cloudCustomData || payload.cloudCustomData || "";
const parsed = typeof raw === "string" ? parseJSON(raw) : raw;
return {
type: message?.type || "",
conversationID: message?.conversationID || "",
from: message?.from || message?.nick || "",
desc: payload.description || payload.desc || message?.description || "",
extension: payload.extension || message?.extension || "",
raw,
payload: parsed || null,
};
}
function renderLuckyResult(source, luckyGift) {
const reward = Number(luckyGift.effective_reward_coins || 0);
const multiplierPPM = Number(luckyGift.multiplier_ppm || 0);
const isWin = reward > 0;
$("winMetric").className = `metric ${isWin ? "win" : "lose"}`;
$("winValue").textContent = isWin ? "中奖" : "未中奖";
$("multiplierValue").textContent = multiplierLabel(multiplierPPM);
$("rewardValue").textContent = `${reward} 金币`;
$("rewardStatusValue").textContent = luckyGift.reward_status || "-";
addLog(`${source} 开奖结果:${isWin ? "中奖" : "未中奖"}`, {
draw_id: luckyGift.draw_id,
command_id: luckyGift.command_id,
pool_id: luckyGift.pool_id,
gift_id: luckyGift.gift_id,
multiplier: multiplierLabel(multiplierPPM),
multiplier_ppm: multiplierPPM,
effective_reward_coins: reward,
base_reward_coins: Number(luckyGift.base_reward_coins || 0),
reward_status: luckyGift.reward_status || "",
high_multiplier: Boolean(luckyGift.high_multiplier),
});
}
function renderEmptyResult(reason) {
$("winMetric").className = "metric lose";
$("winValue").textContent = "无抽奖";
$("multiplierValue").textContent = "-";
$("rewardValue").textContent = "-";
$("rewardStatusValue").textContent = reason;
}
function multiplierLabel(ppm) {
if (!Number.isFinite(ppm) || ppm <= 0) return "0x";
const value = ppm / 1000000;
return `${Number.isInteger(value) ? value : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}x`;
}
function errorMessage(err) {
return err instanceof Error ? err.message : String(err);
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
$("loginBtn").onclick = login;
$("quickCreateBtn").onclick = quickCreate;
$("randomReadyBtn").onclick = prepareRandomRoomAndGift;
$("loadPanelBtn").onclick = loadGiftPanel;
$("checkBtn").onclick = checkLuckyGift;
$("imBtn").onclick = loginIM;
$("joinBtn").onclick = joinRoomAndGroup;
$("sendBtn").onclick = sendLuckyGift;
$("clearBtn").onclick = () => {
$("log").innerHTML = "";
state.seenEventIDs.clear();
renderEmptyResult("已清空");
};
$("giftSelect").onchange = applySelectedGift;
</script>
</body>
</html>