幸运礼物重构
This commit is contained in:
parent
31f686bd68
commit
c93f46b191
456
docs/幸运礼物重构方案.md
Normal file
456
docs/幸运礼物重构方案.md
Normal 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`。
|
||||
156
docs/幸运礼物重构第一步-配置契约与单位.md
Normal file
156
docs/幸运礼物重构第一步-配置契约与单位.md
Normal 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` 通过 |
|
||||
|
||||
第一步完成后,系统只具备“正确配置和发布规则”的能力,不要求新抽奖引擎上线。
|
||||
210
docs/幸运礼物重构第三步-链路接入与上线验收.md
Normal file
210
docs/幸运礼物重构第三步-链路接入与上线验收.md
Normal 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` | 钱包事故时暂停返奖 dispatch,draw 保持 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 才算真正上线。前两步只完成规则和引擎,不能直接对外开放。
|
||||
205
docs/幸运礼物重构第二步-抽奖引擎与模拟器.md
Normal file
205
docs/幸运礼物重构第二步-抽奖引擎与模拟器.md
Normal 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,但还不直接替换线上送礼主链路。
|
||||
79
tools/lucky-gift-test-server.mjs
Normal file
79
tools/lucky-gift-test-server.mjs
Normal 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
769
tools/lucky-gift-test.html
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
$("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>
|
||||
Loading…
x
Reference in New Issue
Block a user