幸运礼物V2

This commit is contained in:
zhx 2026-05-29 12:52:58 +08:00
parent 09fb0c3c81
commit 226362c9d9
30 changed files with 2550 additions and 3909 deletions

View File

@ -10695,10 +10695,18 @@ type LuckyGiftRuleConfig struct {
SettlementWindowWager int64 `protobuf:"varint,7,opt,name=settlement_window_wager,json=settlementWindowWager,proto3" json:"settlement_window_wager,omitempty"`
ControlBandPpm int64 `protobuf:"varint,8,opt,name=control_band_ppm,json=controlBandPpm,proto3" json:"control_band_ppm,omitempty"`
GiftPriceReference int64 `protobuf:"varint,9,opt,name=gift_price_reference,json=giftPriceReference,proto3" json:"gift_price_reference,omitempty"`
EffectiveFromMs int64 `protobuf:"varint,10,opt,name=effective_from_ms,json=effectiveFromMs,proto3" json:"effective_from_ms,omitempty"`
CreatedByAdminId int64 `protobuf:"varint,11,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"`
CreatedAtMs int64 `protobuf:"varint,12,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
Stages []*LuckyGiftRuleStage `protobuf:"bytes,13,rep,name=stages,proto3" json:"stages,omitempty"`
NoviceMaxEquivalentDraws int64 `protobuf:"varint,10,opt,name=novice_max_equivalent_draws,json=noviceMaxEquivalentDraws,proto3" json:"novice_max_equivalent_draws,omitempty"`
NormalMaxEquivalentDraws int64 `protobuf:"varint,11,opt,name=normal_max_equivalent_draws,json=normalMaxEquivalentDraws,proto3" json:"normal_max_equivalent_draws,omitempty"`
EffectiveFromMs int64 `protobuf:"varint,12,opt,name=effective_from_ms,json=effectiveFromMs,proto3" json:"effective_from_ms,omitempty"`
CreatedByAdminId int64 `protobuf:"varint,13,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"`
CreatedAtMs int64 `protobuf:"varint,14,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
MaxSinglePayout int64 `protobuf:"varint,15,opt,name=max_single_payout,json=maxSinglePayout,proto3" json:"max_single_payout,omitempty"`
UserHourlyPayoutCap int64 `protobuf:"varint,16,opt,name=user_hourly_payout_cap,json=userHourlyPayoutCap,proto3" json:"user_hourly_payout_cap,omitempty"`
UserDailyPayoutCap int64 `protobuf:"varint,17,opt,name=user_daily_payout_cap,json=userDailyPayoutCap,proto3" json:"user_daily_payout_cap,omitempty"`
DeviceDailyPayoutCap int64 `protobuf:"varint,18,opt,name=device_daily_payout_cap,json=deviceDailyPayoutCap,proto3" json:"device_daily_payout_cap,omitempty"`
RoomHourlyPayoutCap int64 `protobuf:"varint,19,opt,name=room_hourly_payout_cap,json=roomHourlyPayoutCap,proto3" json:"room_hourly_payout_cap,omitempty"`
AnchorDailyPayoutCap int64 `protobuf:"varint,20,opt,name=anchor_daily_payout_cap,json=anchorDailyPayoutCap,proto3" json:"anchor_daily_payout_cap,omitempty"`
Stages []*LuckyGiftRuleStage `protobuf:"bytes,21,rep,name=stages,proto3" json:"stages,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -10796,6 +10804,20 @@ func (x *LuckyGiftRuleConfig) GetGiftPriceReference() int64 {
return 0
}
func (x *LuckyGiftRuleConfig) GetNoviceMaxEquivalentDraws() int64 {
if x != nil {
return x.NoviceMaxEquivalentDraws
}
return 0
}
func (x *LuckyGiftRuleConfig) GetNormalMaxEquivalentDraws() int64 {
if x != nil {
return x.NormalMaxEquivalentDraws
}
return 0
}
func (x *LuckyGiftRuleConfig) GetEffectiveFromMs() int64 {
if x != nil {
return x.EffectiveFromMs
@ -10817,6 +10839,48 @@ func (x *LuckyGiftRuleConfig) GetCreatedAtMs() int64 {
return 0
}
func (x *LuckyGiftRuleConfig) GetMaxSinglePayout() int64 {
if x != nil {
return x.MaxSinglePayout
}
return 0
}
func (x *LuckyGiftRuleConfig) GetUserHourlyPayoutCap() int64 {
if x != nil {
return x.UserHourlyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetUserDailyPayoutCap() int64 {
if x != nil {
return x.UserDailyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetDeviceDailyPayoutCap() int64 {
if x != nil {
return x.DeviceDailyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetRoomHourlyPayoutCap() int64 {
if x != nil {
return x.RoomHourlyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetAnchorDailyPayoutCap() int64 {
if x != nil {
return x.AnchorDailyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetStages() []*LuckyGiftRuleStage {
if x != nil {
return x.Stages
@ -12998,7 +13062,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\aenabled\x18\b \x01(\bR\aenabled\"f\n" +
"\x12LuckyGiftRuleStage\x12\x14\n" +
"\x05stage\x18\x01 \x01(\tR\x05stage\x12:\n" +
"\x05tiers\x18\x02 \x03(\v2$.hyapp.activity.v1.LuckyGiftRuleTierR\x05tiers\"\xa2\x04\n" +
"\x05tiers\x18\x02 \x03(\v2$.hyapp.activity.v1.LuckyGiftRuleTierR\x05tiers\"\xd7\a\n" +
"\x13LuckyGiftRuleConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12!\n" +
@ -13008,12 +13072,20 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\rpool_rate_ppm\x18\x06 \x01(\x03R\vpoolRatePpm\x126\n" +
"\x17settlement_window_wager\x18\a \x01(\x03R\x15settlementWindowWager\x12(\n" +
"\x10control_band_ppm\x18\b \x01(\x03R\x0econtrolBandPpm\x120\n" +
"\x14gift_price_reference\x18\t \x01(\x03R\x12giftPriceReference\x12*\n" +
"\x11effective_from_ms\x18\n" +
" \x01(\x03R\x0feffectiveFromMs\x12-\n" +
"\x13created_by_admin_id\x18\v \x01(\x03R\x10createdByAdminId\x12\"\n" +
"\rcreated_at_ms\x18\f \x01(\x03R\vcreatedAtMs\x12=\n" +
"\x06stages\x18\r \x03(\v2%.hyapp.activity.v1.LuckyGiftRuleStageR\x06stages\"\xaf\x01\n" +
"\x14gift_price_reference\x18\t \x01(\x03R\x12giftPriceReference\x12=\n" +
"\x1bnovice_max_equivalent_draws\x18\n" +
" \x01(\x03R\x18noviceMaxEquivalentDraws\x12=\n" +
"\x1bnormal_max_equivalent_draws\x18\v \x01(\x03R\x18normalMaxEquivalentDraws\x12*\n" +
"\x11effective_from_ms\x18\f \x01(\x03R\x0feffectiveFromMs\x12-\n" +
"\x13created_by_admin_id\x18\r \x01(\x03R\x10createdByAdminId\x12\"\n" +
"\rcreated_at_ms\x18\x0e \x01(\x03R\vcreatedAtMs\x12*\n" +
"\x11max_single_payout\x18\x0f \x01(\x03R\x0fmaxSinglePayout\x123\n" +
"\x16user_hourly_payout_cap\x18\x10 \x01(\x03R\x13userHourlyPayoutCap\x121\n" +
"\x15user_daily_payout_cap\x18\x11 \x01(\x03R\x12userDailyPayoutCap\x125\n" +
"\x17device_daily_payout_cap\x18\x12 \x01(\x03R\x14deviceDailyPayoutCap\x123\n" +
"\x16room_hourly_payout_cap\x18\x13 \x01(\x03R\x13roomHourlyPayoutCap\x125\n" +
"\x17anchor_daily_payout_cap\x18\x14 \x01(\x03R\x14anchorDailyPayoutCap\x12=\n" +
"\x06stages\x18\x15 \x03(\v2%.hyapp.activity.v1.LuckyGiftRuleStageR\x06stages\"\xaf\x01\n" +
"\x15CheckLuckyGiftRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +

View File

@ -1208,10 +1208,18 @@ message LuckyGiftRuleConfig {
int64 settlement_window_wager = 7;
int64 control_band_ppm = 8;
int64 gift_price_reference = 9;
int64 effective_from_ms = 10;
int64 created_by_admin_id = 11;
int64 created_at_ms = 12;
repeated LuckyGiftRuleStage stages = 13;
int64 novice_max_equivalent_draws = 10;
int64 normal_max_equivalent_draws = 11;
int64 effective_from_ms = 12;
int64 created_by_admin_id = 13;
int64 created_at_ms = 14;
int64 max_single_payout = 15;
int64 user_hourly_payout_cap = 16;
int64 user_daily_payout_cap = 17;
int64 device_daily_payout_cap = 18;
int64 room_hourly_payout_cap = 19;
int64 anchor_daily_payout_cap = 20;
repeated LuckyGiftRuleStage stages = 21;
}
message CheckLuckyGiftRequest {

View File

@ -1,584 +0,0 @@
# 幸运礼物 Flutter 对接
本文描述 Flutter App 对接幸运礼物所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息。幸运礼物支持多个互相独立的奖池App 只负责传入 `pool_id`,不计算 RTP、倍率概率、奖池水位或中奖结果。
## 基础约定
本地开发地址:
```text
http://127.0.0.1:13000
```
业务接口统一响应 envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
| 字段 | 说明 |
| --- | --- |
| `code` | 成功固定 `OK`;失败为稳定错误码。 |
| `message` | 短错误文案,不用于客户端分支判断。 |
| `request_id` | 服务端链路追踪 ID客户端日志要记录。 |
| `data` | 成功业务数据;失败时通常不存在。 |
通用请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>`。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
| `Content-Type` | POST 必填 | `application/json`。 |
所有 `*_ms` 都是 Unix epoch milliseconds。金额单位都是整数金币。
## 核心概念
| 概念 | 说明 |
| --- | --- |
| `gift_id` | 用户实际送出的幸运礼物 ID例如某个 100 金币、5000 金币或 500000 金币礼物。 |
| `pool_id` | 后台配置的幸运礼物奖池 ID例如 `pool_95``pool_98`。不同 `pool_id` 的 RTP、奖池、风控和用户阶段完全隔离。 |
| `command_id` | 本次送礼动作幂等键。用户同一次点击产生一个值HTTP 超时或重试必须复用。 |
| `coin_spent` | 服务端按礼物价格和数量计算的实际扣费金额App 不上传该字段。 |
| `multiplier_ppm` | 倍率的 ppm 表达,`1x=1000000``0.5x=500000``100x=100000000`。 |
| `base_reward_coins` | 基础 RTP 返奖,计入平台成本控制。 |
| `effective_reward_coins` | 用户可见总奖励,等于基础返奖、房间气氛奖励、活动补贴之和。 |
App 不要展示后台 RTP 数值,不要展示实时概率,不要本地推算“下一次该中”。客户端只展示服务端返回的结果。
## 当前实现状态
当前仓库已经具备:
| 能力 | 状态 |
| --- | --- |
| `POST /api/v1/rooms/gift/send` 支持 `pool_id` 入参 | 已有 |
| 房间送礼 IM `room_gift_sent` 可携带 `pool_id` | 已有 |
| `POST /api/v1/activities/lucky-gifts/check` | 已有 |
| `activity-service` 内部 gRPC `CheckLuckyGift` / `ExecuteLuckyGiftDraw` | 已有 |
| 后台按 `pool_id` 配置多奖池 | 已有 |
| 送礼响应直接返回抽奖结果 `data.lucky_gift` | 已有 |
| 房间 IM `lucky_gift_drawn` | 已有 |
| 中奖返奖入账并触发 `WalletBalanceChanged` | 已有 |
简洁版接入清单见 [幸运礼物接口闭环Flutter对接.md](/Users/hy/Documents/hy/hyapp-server/docs/flutter对接/幸运礼物接口闭环Flutter对接.md)。
## 客户端流程
1. 进入房间后调用 `GET /api/v1/rooms/{room_id}/gift-panel` 获取礼物面板、金币余额和收礼人。
2. 找到 `gift_type_code=lucky``gift_type_code=super_lucky` 的礼物,读取该礼物对应的 `pool_id`。当前可从 `presentation_json.lucky_pool_id` 下发,后续建议提升为顶层字段 `lucky_pool_id`
3. 用户点击幸运礼物前,可调用 `POST /api/v1/activities/lucky-gifts/check` 判断奖池是否启用;当前 check 接口要求 `pool_id` 必填。
4. 用户确认送礼时调用 `POST /api/v1/rooms/gift/send`,请求体带上 `pool_id`
5. 成功响应里如果有 `lucky_gift`,立即播放中奖或未中奖反馈。
6. 房间内监听 `room_gift_sent``lucky_gift_drawn` IM给其他用户同步展示。
7. 金币余额以 `WalletBalanceChanged` 私有 IM 或 `GET /api/v1/wallet/me/balances` 刷新为准,不在本地推算。
## 获取礼物面板
```http
GET /api/v1/rooms/{room_id}/gift-panel
Authorization: Bearer <access_token>
X-App-Code: lalu
```
路径参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前语音房 ID。 |
成功响应节选:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"coin_balance": 1000000,
"recipients": [],
"tabs": [],
"gifts": [
{
"gift_id": "lucky_star_100",
"name": "幸运星",
"gift_type_code": "lucky",
"charge_asset_type": "COIN",
"coin_price": 100,
"presentation_json": "{\"lucky_pool_id\":\"pool_95\"}",
"resource": {
"asset_url": "https://cdn.example/lucky_star.png",
"animation_url": "https://cdn.example/lucky_star.svga"
}
}
],
"quantity_presets": [1, 9, 99, 999]
}
}
```
幸运礼物识别规则:
| 字段 | 说明 |
| --- | --- |
| `gift_type_code=lucky` | 普通幸运礼物。 |
| `gift_type_code=super_lucky` | 超级幸运礼物。 |
| `presentation_json.lucky_pool_id` | 建议下发的奖池 ID为空时 App 传 `default` 或不传,由服务端按默认奖池处理。 |
| `coin_price` | 单个礼物价格;实际扣费仍以服务端钱包价格为准。 |
## 检查幸运礼物状态
已实现 App HTTP 接口:
```http
POST /api/v1/activities/lucky-gifts/check
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```
请求体:
```json
{
"room_id": "lalu_xxx",
"gift_id": "lucky_star_100",
"pool_id": "pool_95"
}
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `gift_id` | string | 是 | 用户准备送出的幸运礼物 ID。 |
| `pool_id` | string | 是 | 奖池 ID当前 gateway check 接口必填。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"enabled": true,
"reason": "enabled",
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"rule_version": 12,
"experience_pool": "novice",
"server_time_ms": 1779259000000
}
}
```
返回字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否允许当前用户在当前房间抽该奖池。 |
| `reason` | string | 服务端原因,客户端只做日志或灰度排查。 |
| `pool_id` | string | 实际使用的奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `rule_version` | int64 | 当前奖池规则版本。 |
| `experience_pool` | string | `novice``intermediate``advanced`,只影响展示文案或动效强度。 |
| `server_time_ms` | int64 | 服务端当前时间。 |
客户端处理:
| 场景 | 处理 |
| --- | --- |
| `enabled=true` | 允许点击送礼。 |
| `enabled=false` | 置灰幸运抽奖入口,但普通礼物展示可继续。 |
| 接口失败 | 不在本地兜底开奖;可以提示稍后重试或隐藏幸运玩法。 |
注意:`room-service` 内部仍会把空 `pool_id` 归一化为默认奖池,但 App check 接口不接受空值。客户端必须从礼物面板拿到明确 `pool_id` 后再走幸运礼物流程。
## 送礼并抽奖
已存在 App HTTP 入口:
```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```
请求体:
```json
{
"room_id": "lalu_xxx",
"command_id": "gift_1779259000000_10001_01",
"target_type": "user",
"target_user_ids": [10002],
"gift_id": "lucky_star_100",
"gift_count": 1,
"pool_id": "pool_95"
}
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `command_id` | string | 是 | 本次用户动作幂等键;同一次重试必须复用。 |
| `target_type` | string | 否 | 当前主流程使用 `user`;为空按 `user`。 |
| `target_user_ids` | int64[] | 是 | 收礼用户 ID`target_type=user` 时必须且只能 1 个。 |
| `target_user_id` | int64 | 否 | 单目标兼容字段;新代码优先用 `target_user_ids`。 |
| `gift_id` | string | 是 | 礼物 ID。 |
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
| `pool_id` | string | 幸运礼物必填 | 幸运礼物奖池 ID不同奖池完全隔离。普通礼物可不传。 |
成功响应节选:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"result": {
"applied": true,
"room_version": 14,
"server_time_ms": 1779259000000
},
"billing_receipt_id": "br_xxx",
"room_heat": 123456,
"gift_rank": [],
"room": {},
"treasure": {},
"lucky_gift": {
"enabled": true,
"draw_id": "lucky_draw_xxx",
"command_id": "gift_1779259000000_10001_01",
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"rule_version": 12,
"experience_pool": "novice",
"selected_tier_id": "novice_2x",
"multiplier_ppm": 2000000,
"base_reward_coins": 200,
"room_atmosphere_reward_coins": 0,
"activity_subsidy_coins": 0,
"effective_reward_coins": 200,
"reward_status": "granted",
"stage_feedback": false,
"high_multiplier": false,
"created_at_ms": 1779259000000
}
}
}
```
`lucky_gift` 字段已实现。普通礼物或未执行幸运礼物抽奖时可以为空。
`data` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `result.applied` | bool | 本次命令是否实际落地;幂等重放可能为 `false`。 |
| `result.room_version` | int64 | 房间版本。 |
| `result.server_time_ms` | int64 | 服务端时间。 |
| `billing_receipt_id` | string | 钱包扣费回执;存在表示送礼扣费成功。 |
| `room_heat` | int64 | 房间热度。 |
| `gift_rank` | array | 房间礼物榜快照。 |
| `room` | object | 房间快照。 |
| `treasure` | object | 宝箱状态;如果房间宝箱开启可直接更新。 |
| `lucky_gift` | object | 幸运礼物抽奖结果;已实现。 |
`lucky_gift` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 本次是否执行了幸运礼物抽奖。 |
| `draw_id` | string | 抽奖记录 ID。 |
| `command_id` | string | 和送礼请求一致的幂等键。 |
| `pool_id` | string | 本次使用的奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `rule_version` | int64 | 抽奖时使用的规则版本。 |
| `experience_pool` | string | `novice``intermediate``advanced`。 |
| `selected_tier_id` | string | 服务端选中的奖档 ID。 |
| `multiplier_ppm` | int64 | 已返回;用于直接展示倍率。客户端不要从 `selected_tier_id` 解析倍率。 |
| `base_reward_coins` | int64 | 基础 RTP 返奖。 |
| `room_atmosphere_reward_coins` | int64 | 房间气氛补贴奖励。 |
| `activity_subsidy_coins` | int64 | 活动补贴奖励。 |
| `effective_reward_coins` | int64 | 用户可见总奖励。 |
| `reward_status` | string | `pending``granted``failed`。 |
| `stage_feedback` | bool | 是否触发阶段反馈。可能没有金币奖励。 |
| `high_multiplier` | bool | 是否属于高倍结果,可用于强化动效。 |
| `created_at_ms` | int64 | 抽奖结果生成时间。 |
展示规则:
| 场景 | 处理 |
| --- | --- |
| `lucky_gift` 为空 | 按普通礼物成功展示。 |
| `effective_reward_coins=0``stage_feedback=false` | 展示未中奖或普通送礼效果。 |
| `stage_feedback=true` | 展示阶段进度、幸运值或轻量动画;不要本地加金币。 |
| `effective_reward_coins>0``reward_status=granted` | 展示中奖金额和到账效果;余额仍以钱包 IM 为准。 |
| `effective_reward_coins>0``reward_status=pending` | 展示“奖励处理中”,等待钱包 IM 或余额刷新。 |
| `reward_status=failed` | 不本地加金币;提示稍后刷新或走补偿查询。 |
| `high_multiplier=true` | 播放高倍中奖动效,并等待房间 IM 同步其他用户。 |
## 错误处理
通用错误:
| HTTP | `code` | 说明 | Flutter 处理 |
| --- | --- | --- | --- |
| 400 | `INVALID_JSON` | JSON 解析失败。 | 修正客户端请求体。 |
| 400 | `INVALID_ARGUMENT` | 参数不合法,例如缺 `room_id``gift_id``command_id`。 | 不重试,提示操作失败并打点。 |
| 401 | `UNAUTHORIZED` | token 无效或过期。 | 走登录刷新或重新登录。 |
| 403 | `PROFILE_REQUIRED` | 用户未完成资料。 | 引导补全资料。 |
| 403 | `PERMISSION_DENIED` | 没有权限。 | 提示不可操作。 |
| 409 | `INSUFFICIENT_BALANCE` | 金币不足。 | 弹充值或余额不足提示。 |
| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出或刷新房间状态。 |
| 409 | `RULE_NOT_ACTIVE` | 幸运礼物奖池未启用。 | 置灰幸运玩法,不本地开奖。 |
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 对应的请求内容不一致。 | 生成新的 `command_id` 重新发起新动作;旧动作不再重放。 |
| 409 | `CONFLICT` | 规则、奖池、水位或风控状态冲突。 | 提示稍后再试,必要时刷新面板。 |
| 502 | `UPSTREAM_ERROR` | room、wallet、activity 等依赖暂不可用。 | 可保留同一个 `command_id` 重试同一次送礼。 |
| 500 | `INTERNAL_ERROR` | 服务端内部异常。 | 记录 `request_id`,提示稍后重试。 |
幂等和重试规则:
| 场景 | 处理 |
| --- | --- |
| HTTP 超时、断网、502 | 使用同一个 `command_id` 重试。 |
| 409 `IDEMPOTENCY_CONFLICT` | 不要复用该 `command_id`;说明客户端把同一个幂等键用于不同请求。 |
| 收到成功响应且有 `billing_receipt_id` | 扣费已成功,不要再次生成新命令补发。 |
| 成功响应丢失但钱包余额已变化 | 以钱包余额和房间 IM 为准,避免重复扣费。 |
| App 进程重启 | 本地持久化最近未完成的 `command_id`,优先用同一键查询或重试。 |
## IM房间送礼消息
已存在房间 IM
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `room_gift_sent` |
| `Ext` / `extension` | `room_system_message` |
| 群 ID | 当前 `room_id` |
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |
Payload 示例:
```json
{
"event_id": "evt_xxx",
"room_id": "lalu_xxx",
"event_type": "room_gift_sent",
"actor_user_id": 10001,
"target_user_id": 10002,
"gift_value": 100,
"room_heat": 123456,
"room_version": 14,
"attributes": {
"gift_id": "lucky_star_100",
"pool_id": "pool_95",
"gift_count": "1",
"billing_receipt_id": "br_xxx",
"coin_spent": "100",
"gift_point_added": "10",
"price_version": "pv_xxx"
}
}
```
处理规则:
| 场景 | 处理 |
| --- | --- |
| `Ext` 不是 `room_system_message` | 忽略。 |
| `event_type` 不是 `room_gift_sent` | 交给其他房间消息处理器。 |
| `event_id` 已处理 | 忽略。 |
| `room_version` 小于本地当前版本 | 忽略或只展示轻量动画,不回滚房间状态。 |
| `attributes.pool_id` 为空 | 按普通礼物处理。 |
| `attributes.pool_id` 非空 | 展示幸运礼物送出效果,但中奖结果仍等 `lucky_gift_drawn` 或 HTTP `lucky_gift`。 |
## IM幸运礼物开奖结果
已实现房间 IM
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `lucky_gift_drawn` |
| `Ext` / `extension` | `room_system_message` |
| 群 ID | 当前 `room_id` |
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |
Payload 示例:
```json
{
"event_id": "lucky_draw_event_xxx",
"room_id": "lalu_xxx",
"event_type": "lucky_gift_drawn",
"room_version": 14,
"draw_id": "lucky_draw_xxx",
"command_id": "gift_1779259000000_10001_01",
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"gift_count": 1,
"sender_user_id": 10001,
"target_user_id": 10002,
"coin_spent": 100,
"selected_tier_id": "novice_2x",
"multiplier_ppm": 2000000,
"effective_reward_coins": 200,
"reward_status": "granted",
"stage_feedback": false,
"high_multiplier": false,
"created_at_ms": 1779259000000
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | IM 事件幂等键。 |
| `event_type` | string | 固定 `lucky_gift_drawn`。 |
| `room_id` | string | 房间 ID。 |
| `room_version` | int64 | 房间版本。 |
| `draw_id` | string | 抽奖记录 ID。 |
| `command_id` | string | 送礼幂等键。 |
| `pool_id` | string | 奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `gift_count` | int32 | 礼物数量。 |
| `sender_user_id` | int64 | 送礼用户。 |
| `target_user_id` | int64 | 收礼用户。 |
| `coin_spent` | int64 | 本次实际扣费。 |
| `selected_tier_id` | string | 中奖奖档。 |
| `multiplier_ppm` | int64 | 中奖倍率,已返回。 |
| `effective_reward_coins` | int64 | 用户可见奖励金币。 |
| `reward_status` | string | `pending``granted``failed`。 |
| `stage_feedback` | bool | 是否阶段反馈。 |
| `high_multiplier` | bool | 是否高倍结果。 |
| `created_at_ms` | int64 | 事件创建时间。 |
展示规则:
| 场景 | 处理 |
| --- | --- |
| 当前用户是 `sender_user_id` | 如果 HTTP 已展示过同一 `draw_id`,只更新状态,不重复播中奖动画。 |
| 当前用户不是 `sender_user_id` | 展示房间内中奖横幅、飘屏或公屏提示。 |
| `effective_reward_coins=0` | 不展示大奖飘屏,只保留普通送礼效果或阶段反馈。 |
| `high_multiplier=true` | 展示高倍动画。 |
| `reward_status=pending` | 文案使用“奖励处理中”,不要展示已到账。 |
| JSON 解析失败或字段缺失 | 忽略并记录客户端日志。 |
## IM钱包余额变更
幸运礼物扣费和返奖都应通过钱包私有 IM 同步余额。该 IM 已在钱包文档中定义。
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `WalletBalanceChanged` |
| `Ext` / `extension` | `wallet_notice` |
| 通道 | C2C 私有消息 |
Payload 示例:
```json
{
"event_type": "WalletBalanceChanged",
"event_id": "wev_xxx",
"app_code": "lalu",
"transaction_id": "tx_xxx",
"command_id": "gift_1779259000000_10001_01",
"user_id": "10001",
"asset_type": "COIN",
"available_delta": -100,
"frozen_delta": 0,
"available_after": 999900,
"frozen_after": 0,
"balance_version": 18,
"created_at_ms": 1779259000000,
"source_created_at_ms": 1779259000000,
"metadata": {
"biz_type": "gift_debit",
"gift_id": "lucky_star_100",
"pool_id": "pool_95"
}
}
```
返奖到账时 `available_delta` 为正数,`metadata.biz_type` 建议使用 `lucky_gift_reward`
处理规则:
| 场景 | 处理 |
| --- | --- |
| `Ext` 不是 `wallet_notice` | 忽略。 |
| `event_type` 不是 `WalletBalanceChanged` | 忽略。 |
| `event_id` 已处理 | 忽略。 |
| 本地 `asset_type` 的版本 `>= balance_version` | 忽略。 |
| 长时间没有收到余额 IM | 调 `GET /api/v1/wallet/me/balances` 兜底刷新。 |
## IM高倍全站或区域播报
已实现区域播报 IM用于高倍中奖或运营配置的大额中奖展示当前服务端触发阈值是 `multiplier_ppm >= 1000000` 且有 `visible_region_id`
| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `lucky_gift_big_win` |
| `Ext` / `extension` | `im_broadcast` |
| 群 ID | 全站播报群或区域播报群 |
Payload 示例:
```json
{
"event_id": "lucky_big_win_xxx",
"broadcast_type": "lucky_gift_big_win",
"scope": "region",
"app_code": "lalu",
"region_id": 1001,
"room_id": "lalu_xxx",
"sender_user_id": 10001,
"target_user_id": 10002,
"pool_id": "pool_95",
"gift_id": "lucky_star_100",
"multiplier_ppm": 100000000,
"effective_reward_coins": 10000,
"sent_at_ms": 1779259000000,
"action": {
"type": "enter_room",
"room_id": "lalu_xxx"
}
}
```
处理规则:
| 场景 | 处理 |
| --- | --- |
| 当前用户已在该房间且已收到 `lucky_gift_drawn` | 可以只展示一次,避免重复刷屏。 |
| 当前用户不在该房间 | 展示全站或区域飘屏,点击 `action` 进房。 |
| `event_id` 已处理 | 忽略。 |
## App 最小对接顺序
1. 登录后调用 `GET /api/v1/im/usersig`,登录腾讯云 IM。
2. 进入房间后加入房间 IM 群,并调用 `GET /api/v1/rooms/{room_id}/gift-panel`
3. 从礼物面板识别幸运礼物和对应 `pool_id`
4. 用户点击送礼时生成 `command_id`,调用 `POST /api/v1/rooms/gift/send`
5. 使用 HTTP 响应里的 `lucky_gift` 做当前用户即时反馈。
6. 监听房间 IM `room_gift_sent``lucky_gift_drawn`,同步其他用户看到的表现。
7. 监听 C2C IM `WalletBalanceChanged`,更新金币余额。
8. 任意 IM 丢失、页面重进或余额不一致时,重新拉取礼物面板和钱包余额纠正状态。

View File

@ -1,187 +0,0 @@
# 幸运礼物 Flutter 对接
本文只描述 App 需要接入的 HTTP、送礼返回、错误处理和腾讯云 IM 自定义消息。所有接口响应都使用 gateway 业务 envelope
```json
{
"code": "OK",
"message": "",
"request_id": "req_xxx",
"data": {}
}
```
## 1. 送礼前检查
`POST /api/v1/activities/lucky-gifts/check`
请求头:
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` |
| `X-App-Code` | 否 | 不传时使用默认 App |
请求体:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID |
| `gift_id` | string | 是 | 实际送出的礼物 ID例如 `rose` |
| `pool_id` | string | 是 | 幸运礼物奖池 ID例如 `lucky``super_lucky`;当前 check 接口必填 |
返回 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否可按幸运礼物发送 |
| `reason` | string | `enabled``not_configured``disabled` |
| `room_id` | string | 请求房间 ID |
| `gift_id` | string | 请求礼物 ID |
| `pool_id` | string | 生效奖池 ID |
| `gift_price` | int | 单次抽奖价格,金币 |
| `rule_version` | int | 后端规则版本 |
| `target_rtp_ppm` | int | RTP 目标ppm |
| `experience_pool` | string | `novice``intermediate``advanced` |
客户端处理:
- `enabled=true`:允许用户继续点击送礼。
- `enabled=false`:不要走幸运礼物送礼;可降级普通礼物或提示当前不可用。
- 检查结果只用于展示和拦截,最终仍以送礼接口为准。
## 2. 发送礼物
`POST /api/v1/rooms/gift/send`
请求体关键字段:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID |
| `command_id` | string | 是 | 本次送礼动作幂等键;同一次超时重试必须复用 |
| `target_user_id` | int/string | 兼容 | 旧单目标字段;新客户端优先使用 `target_user_ids` |
| `target_user_ids` | int[] | 是 | 当前 `target_type=user` 时必须且只能 1 个 |
| `target_type` | string | 否 | 当前只支持 `user`,为空按 `user` |
| `gift_id` | string | 是 | 礼物 ID例如 `rose` |
| `gift_count` | int | 是 | 数量,必须大于 0 |
| `pool_id` | string | 幸运礼物必填 | `lucky``super_lucky` |
返回 `data.lucky_gift`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `draw_id` | string | 抽奖 ID客户端去重锚点 |
| `command_id` | string | 送礼命令 ID |
| `pool_id` | string | 生效奖池 |
| `gift_id` | string | 礼物 ID |
| `rule_version` | int | 抽奖规则版本 |
| `experience_pool` | string | 体验池 |
| `selected_tier_id` | string | 命中奖档 |
| `multiplier_ppm` | int | 倍率,`2000000` 表示 2 倍 |
| `base_reward_coins` | int | RTP 基础返奖 |
| `room_atmosphere_reward_coins` | int | 房间气氛池返奖 |
| `activity_subsidy_coins` | int | 活动补贴返奖 |
| `effective_reward_coins` | int | 用户实际获得金币 |
| `reward_status` | string | 有返奖或阶段反馈时初始通常为 `pending`,到账和房间 IM 由后端 outbox 补偿 |
| `stage_feedback` | bool | 是否阶段反馈,可播放轻量中奖表现 |
| `high_multiplier` | bool | 是否高倍 |
| `created_at_ms` | int | UTC epoch ms |
客户端处理:
- `data.lucky_gift != null`:立即调用 `_applyLuckyGiftResult()`,播放中奖或阶段反馈表现。
- `effective_reward_coins > 0 && reward_status == "pending"`:展示中奖金额和“奖励处理中”,余额不要本地相加。
- `effective_reward_coins > 0 && reward_status == "granted"`:可以展示已到账状态;余额仍以 `WalletBalanceChanged` 或余额刷新为准。
- `data.lucky_gift == null`:按普通礼物表现处理。
## 3. 错误处理
| code | 场景 | 客户端处理 |
| --- | --- | --- |
| `INVALID_ARGUMENT` | 参数缺失、数量非法、`pool_id` 缺失、`target_user_ids` 非单目标 | 停止提交,提示用户重试或刷新礼物面板 |
| `UNAUTHENTICATED` | token 失效 | 重新登录 |
| `INSUFFICIENT_BALANCE` | 金币不足 | 打开充值入口或提示余额不足 |
| `RULE_NOT_ACTIVE` | 幸运礼物规则未启用 | 降级普通礼物或刷新礼物配置 |
| `UPSTREAM_ERROR`/`UNAVAILABLE` | 内部服务或 IM/钱包临时不可用 | 不重复扣本地余额;允许用户稍后重试 |
| `IDEMPOTENCY_CONFLICT`/`CONFLICT` | 同一 `command_id` 对应的请求参数变化,或规则/奖池/风控冲突 | 参数变化时重新生成 `command_id`;规则冲突时刷新礼物面板 |
送礼失败时后端不会返回 `lucky_gift`,客户端不要播放中奖 UI。送礼成功但返奖到账延迟时房间 IM 和钱包 IM 会继续补偿。
幂等规则:
- HTTP 超时、断网、502同一次用户动作使用原 `command_id` 重试。
- 已收到成功响应或钱包余额已扣减:不要生成新 `command_id` 再发同一笔礼物。
- 需要重新点击送礼:生成新的 `command_id`
## 4. 房间 IM
腾讯云 IM 群自定义消息:
| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `Desc` | `lucky_gift_drawn` |
| `Ext` | `room_system_message` |
| `Data` | JSON 字符串 |
`Data` 示例:
```json
{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_xxx",
"draw_id": "lucky_draw_xxx",
"command_id": "cmd_xxx",
"pool_id": "super_lucky",
"room_id": "room_1",
"gift_id": "rose",
"gift_count": 1,
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"coin_spent": 100,
"rule_version": 1,
"experience_pool": "novice",
"selected_tier_id": "novice_2x",
"multiplier_ppm": 2000000,
"base_reward_coins": 200,
"room_atmosphere_reward_coins": 0,
"activity_subsidy_coins": 0,
"effective_reward_coins": 200,
"stage_feedback": true,
"high_multiplier": false,
"created_at_ms": 1760000000000
}
```
处理规则:
- 只处理 `Desc == "lucky_gift_drawn"``event_type == "lucky_gift_drawn"`
- 用 `event_id``draw_id` 去重;同一抽奖不要重复播放。
- 当前用户已通过 HTTP 送礼响应播放过同一 `draw_id` 时,收到 IM 只更新状态,不重复播放。
- `effective_reward_coins > 0` 但还没有收到钱包余额 IM 时,只展示中奖结果和处理中状态,不本地加金币。
## 5. 钱包余额 IM
中奖金币到账后wallet-service 会写 `WalletBalanceChanged`notice-service 通过腾讯云 IM C2C 投递。
| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `Desc` | `WalletBalanceChanged` |
| `Ext` | `wallet_notice` |
客户端处理:
- 只处理 `Desc == "WalletBalanceChanged"`
- 用 `event_id` 去重。
- 当 `asset_type == "COIN"` 时,用 `available_after` 更新金币余额;字段缺失时调用 `GET /api/v1/wallet/me/balances` 刷新。
## 6. 接入顺序
1. 礼物面板拿到 `pool_id` 后调用 check。
2. check 可用后调用送礼接口,并把 `pool_id` 放入请求。
3. 送礼响应含 `data.lucky_gift` 时立即播放本机中奖 UI。
4. 房间内监听 `lucky_gift_drawn`,给其他用户同步中奖 UI。
5. 私聊监听 `WalletBalanceChanged`,更新金币余额。

View File

@ -1,748 +0,0 @@
# Lucky Gift Activity Service Development Guide
本文档定义幸运礼物在 `activity-service` 中的开发落地。产品规则见 `docs/幸运礼物产品方案.md`
当前代码已经落地到 `api/proto/activity/v1/activity.proto``services/activity-service/internal/service/luckygift``services/activity-service/internal/storage/mysql/lucky_gift_repository.go`。线上事实以 `docs/幸运礼物线上落地文档.md` 为准;本文件保留开发阶段设计背景,涉及“建议新增 proto”“固定概率表”“后台权重表”的段落不再代表当前实现。当前实现的核心配置是 `pool_id + multiplier_ppms`,后台配置多个倍率,服务端在抽奖时按 RTP 窗口、奖池和风控自动生成运行时权重。
## Ownership
幸运礼物归属 `activity-service`,但金币账务仍归属 `wallet-service`,房间状态仍归属 `room-service`
| Capability | Owner | Notes |
| --- | --- | --- |
| 幸运礼物规则配置 | `activity-service` | 概率表、奖档、启停、版本 |
| 三层奖池 | `activity-service` | 平台、房间、礼物资金水位 |
| 体验池 | `activity-service` | 新手、中级、高级候选奖档和权重 |
| 全站 RTP 控制窗口 | `activity-service` | 控全站基础返奖总成本 |
| 礼物 RTP 子窗口 | `activity-service` | 控单个幸运礼物基础返奖成本 |
| 房间气氛池 | `activity-service` | 控房间爆点、共享奖励和表现预算 |
| 活动预算池 | `activity-service` | 控限时活动补贴,不污染基础 RTP |
| 抽奖决策 | `activity-service` | 先过滤奖档,再执行随机 |
| 风控阈值 | `activity-service` | 用户、设备、房间、主播、活动周期 |
| 金币扣费 | `wallet-service` | `room-service` 送礼前同步扣费 |
| 金币返还 | `wallet-service` | 需要新增幂等入账 RPC |
| 房间热度和榜单 | `room-service` | Room Cell 内串行变更 |
| 房间消息投递 | 腾讯云 IM | 承载客户端群消息和历史 |
`activity-service` 不直接修改用户金币余额,不直接写 Room Cell不直接保存 WebSocket 连接。
## Target Flow
幸运礼物在普通 `SendGift` 链路上增加一次活动决策。
```mermaid
sequenceDiagram
participant C as Client
participant G as gateway-service
participant R as room-service
participant W as wallet-service
participant A as activity-service
participant I as Tencent Cloud IM
C->>G: HTTP SendGift
G->>R: gRPC SendGift
R->>A: CheckLuckyGift
A-->>R: enabled + rule_version
R->>W: DebitGift
W-->>R: billing_receipt_id
R->>A: ExecuteLuckyGiftDraw
A->>A: lock pools + filter tiers + draw + write log
A->>W: CreditLuckyReward
W-->>A: credit_receipt_id
A-->>R: draw_result
R->>R: Room Cell apply gift + lucky presentation
R->>I: PublishRoomEvent via REST
R-->>G: SendGiftResponse
G-->>C: JSON result
```
如果 `CreditLuckyReward` 暂时失败,`activity-service` 返回 `reward_status=PENDING`,并由本服务 outbox 重试入账。`room-service` 不能广播“已到账”,只能广播普通礼物或“奖励处理中”。
## RPC Contracts
建议新增 `api/proto/activity/v1/lucky_gift.proto`
```protobuf
syntax = "proto3";
package hyapp.activity.v1;
option go_package = "hyapp/api/proto/activity/v1;activityv1";
message LuckyGiftMeta {
string request_id = 1;
string command_id = 2;
string room_id = 3;
int64 sender_user_id = 4;
int64 target_user_id = 5;
string device_id = 6;
int64 sent_at_ms = 7;
}
message CheckLuckyGiftRequest {
LuckyGiftMeta meta = 1;
string gift_id = 2;
int32 gift_count = 3;
int64 coin_spent = 4;
int64 heat_value = 5;
}
message CheckLuckyGiftResponse {
bool enabled = 1;
string rule_version = 2;
string reject_reason = 3;
}
message ExecuteLuckyGiftDrawRequest {
LuckyGiftMeta meta = 1;
string gift_id = 2;
int32 gift_count = 3;
int64 coin_spent = 4;
int64 heat_value = 5;
string billing_receipt_id = 6;
string rule_version = 7;
}
message RewardTierResult {
string tier_id = 1;
int64 base_reward_coins = 2;
int64 room_atmosphere_reward_coins = 3;
int64 activity_subsidy_coins = 4;
int64 effective_reward_coins = 5;
string tier_type = 6;
string budget_sources_json = 7;
}
message ExecuteLuckyGiftDrawResponse {
string draw_id = 1;
string rule_version = 2;
bool hit = 3;
RewardTierResult tier = 4;
string reward_status = 5;
string credit_receipt_id = 6;
string reject_reason = 7;
}
service LuckyGiftService {
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
}
```
`wallet-service` 需要新增金币返还接口,避免 `activity-service` 直接写账务库。
```protobuf
message CreditLuckyRewardRequest {
string reward_id = 1;
string command_id = 2;
string draw_id = 3;
string room_id = 4;
int64 user_id = 5;
int64 effective_reward_coins = 6;
int64 base_reward_coins = 7;
int64 room_atmosphere_reward_coins = 8;
int64 activity_subsidy_coins = 9;
string reason = 10;
string budget_sources_json = 11;
}
message CreditLuckyRewardResponse {
string credit_receipt_id = 1;
int64 balance_after = 2;
}
```
## Database Tables
`activity-service` 需要独立 MySQL schema。Redis 只用于短期计数和热缓存,不能作为抽奖结果的唯一存储。
### `lucky_gift_rules`
保存幸运礼物主规则。
| Column | Type | Notes |
| --- | --- | --- |
| `id` | bigint | primary key |
| `gift_id` | varchar | unique with `rule_version` |
| `rule_version` | varchar | immutable after publish |
| `gift_price` | bigint | single gift price in coins |
| `pool_rate_ppm` | int | pool inflow rate, ppm |
| `target_payout_rate_ppm` | int | expected long-term payout |
| `target_rtp_ppm` | int | controlled RTP target, e.g. 950000 for 95% |
| `control_window_draws` | int | paid draws per RTP control window |
| `gift_rtp_window_draws` | int | paid draws per gift-level RTP sub-window |
| `novice_draw_limit` | bigint | inclusive max paid draw count for novice pool |
| `intermediate_draw_limit` | bigint | inclusive max paid draw count for intermediate pool |
| `room_atmosphere_rule_json` | json | room burst, shared reward, and presentation policy |
| `activity_budget_id` | varchar | optional active subsidy budget |
| `max_single_payout` | bigint | max reward per draw |
| `max_user_daily_payout` | bigint | user daily cap |
| `max_room_hourly_payout` | bigint | room hourly cap |
| `status` | varchar | draft, active, paused, retired |
| `effective_at_ms` | bigint | version effective time |
| `created_at_ms` | bigint | creation time |
| `updated_at_ms` | bigint | update time |
### `lucky_reward_tiers`
保存概率表奖档。
| Column | Type | Notes |
| --- | --- | --- |
| `id` | bigint | primary key |
| `gift_id` | varchar | gift id |
| `rule_version` | varchar | rule version |
| `experience_pool` | varchar | novice, intermediate, advanced |
| `tier_id` | varchar | none, small_1x, medium_5x |
| `reward_type` | varchar | none, fixed, multiplier |
| `reward_value` | bigint | fixed coins or multiplier base |
| `base_weight` | int | relative selection weight inside the experience pool |
| `budget_source` | varchar | base_rtp, room_atmosphere, activity_subsidy, presentation_only |
| `presentation_type` | varchar | none, room_message, floating_screen, shared_drop |
| `shared_reward_policy_json` | json | optional shared reward split for room atmosphere |
| `min_pool_balance` | bigint | activation waterline |
| `enabled` | bool | tier switch |
All tier weights in one `(gift_id, rule_version, experience_pool)` group should be positive and internally normalizable. They are not the source of truth for total RTP; they only shape the visible distribution inside the RTP control window.
### `lucky_rtp_windows`
保存 RTP 控制窗口。窗口行必须在抽奖事务内和三层奖池一起锁定,避免并发抽奖同时消费同一段目标支出。
| Column | Type | Notes |
| --- | --- | --- |
| `scope_type` | varchar | global, gift |
| `scope_id` | varchar | global or gift_id |
| `window_index` | bigint | monotonic index by paid draw count |
| `target_rtp_ppm` | int | immutable target for this window |
| `control_window_draws` | bigint | expected paid draws in this window |
| `paid_draws` | bigint | paid draws already assigned to this window |
| `wager_coins` | bigint | accumulated cost in this window |
| `target_payout_coins` | bigint | integer payout target for this window |
| `actual_base_payout_coins` | bigint | base RTP rewards selected in this window |
| `carry_ppm` | bigint | fractional target remainder carried into next window |
| `status` | varchar | open, closed, underpaid |
| `updated_at_ms` | bigint | update time |
The primary key is `(scope_type, scope_id, window_index)`.
### `lucky_pools`
保存三层奖池水位。
| Column | Type | Notes |
| --- | --- | --- |
| `scope_type` | varchar | platform, room, gift |
| `scope_id` | varchar | global, room_id, gift_id |
| `balance` | bigint | available coins |
| `reserve_floor` | bigint | minimum safe waterline |
| `total_in` | bigint | historical inflow |
| `total_out` | bigint | historical payout |
| `version` | bigint | optimistic version |
| `updated_at_ms` | bigint | update time |
The primary key is `(scope_type, scope_id)`.
### `lucky_room_atmosphere_pools`
保存房间气氛预算。它控制房间爆点和共享奖励,不拥有 Room Cell 核心状态。
| Column | Type | Notes |
| --- | --- | --- |
| `room_id` | varchar | room id |
| `balance` | bigint | available room atmosphere coins |
| `reserve_floor` | bigint | minimum balance before shared rewards stop |
| `lucky_heat` | bigint | non-coin room atmosphere score |
| `burst_level` | int | current presentation intensity |
| `hourly_shared_payout_limit` | bigint | hourly shared reward cap |
| `daily_shared_payout_limit` | bigint | daily shared reward cap |
| `version` | bigint | optimistic version |
| `updated_at_ms` | bigint | update time |
The primary key is `room_id`.
### `lucky_activity_budgets`
保存活动补贴预算。活动补贴提高用户看到的 `effective_rtp`,但不计入基础 RTP。
| Column | Type | Notes |
| --- | --- | --- |
| `budget_id` | varchar | primary key |
| `activity_id` | varchar | activity id |
| `gift_id` | varchar | optional gift scope, empty means all lucky gifts |
| `budget_coins` | bigint | total subsidy budget |
| `spent_coins` | bigint | granted subsidy coins |
| `daily_spend_limit` | bigint | daily subsidy cap |
| `status` | varchar | draft, active, paused, exhausted, retired |
| `effective_from_ms` | bigint | UTC start time |
| `effective_to_ms` | bigint | UTC end time |
| `updated_at_ms` | bigint | update time |
### `lucky_draw_records`
保存每次抽奖事实。
| Column | Type | Notes |
| --- | --- | --- |
| `draw_id` | varchar | primary key |
| `command_id` | varchar | unique idempotency key |
| `billing_receipt_id` | varchar | wallet debit receipt |
| `room_id` | varchar | room id |
| `sender_user_id` | bigint | payer |
| `target_user_id` | bigint | gift receiver |
| `gift_id` | varchar | gift id |
| `gift_count` | int | count |
| `coin_spent` | bigint | paid coins |
| `heat_value` | bigint | room heat contributed by this gift |
| `rule_version` | varchar | immutable rule version |
| `experience_pool` | varchar | novice, intermediate, advanced |
| `rtp_scope_type` | varchar | scope that controlled this draw |
| `rtp_scope_id` | varchar | concrete scope id |
| `rtp_window_index` | bigint | RTP control window |
| `gift_rtp_window_index` | bigint | gift-level RTP sub-window |
| `candidate_tiers_json` | json | tiers after filtering |
| `selected_tier_id` | varchar | final selected tier |
| `base_reward_coins` | bigint | reward counted in global and gift RTP |
| `room_atmosphere_reward_coins` | bigint | shared or room-burst reward outside base RTP |
| `activity_subsidy_coins` | bigint | activity subsidy outside base RTP |
| `effective_reward_coins` | bigint | total user-visible coin reward |
| `budget_sources_json` | json | exact budget sources consumed by this draw |
| `random_digest` | varchar | audit digest |
| `pool_snapshot_json` | json | balances before draw |
| `rtp_snapshot_json` | json | global and gift RTP window snapshots |
| `budget_snapshot_json` | json | room atmosphere and activity budget snapshots |
| `risk_snapshot_json` | json | counters before draw |
| `reward_status` | varchar | none, pending, granted, failed |
| `credit_receipt_id` | varchar | wallet credit receipt |
| `created_at_ms` | bigint | draw time |
| `updated_at_ms` | bigint | update time |
### `lucky_risk_counters`
保存可审计的风控计数。Redis 可以做热计数MySQL 必须能重建关键窗口。
| Column | Type | Notes |
| --- | --- | --- |
| `counter_key` | varchar | primary key |
| `scope_type` | varchar | user, device, room, anchor, activity |
| `scope_id` | varchar | concrete id |
| `window_type` | varchar | hourly, daily, period |
| `payout_coins` | bigint | reward total |
| `draw_count` | bigint | draw total |
| `hit_count` | bigint | hit total |
| `window_start_ms` | bigint | window start |
| `window_end_ms` | bigint | window end |
### `lucky_outbox`
保存需要补偿的外部动作。
| Column | Type | Notes |
| --- | --- | --- |
| `event_id` | varchar | primary key |
| `event_type` | varchar | reward_credit, room_event, activity_event |
| `aggregate_id` | varchar | draw_id |
| `payload` | blob | protobuf bytes |
| `status` | varchar | pending, processing, done, failed |
| `retry_count` | int | retry count |
| `next_retry_at_ms` | bigint | backoff time |
| `created_at_ms` | bigint | creation time |
| `updated_at_ms` | bigint | update time |
## Draw Algorithm
All money-like values use integer coins. Percentages use ppm to avoid floating point drift. V2 separates base RTP payout from entertainment subsidies.
### 1. Experience Pool
```text
paid_draw_number = user_lucky_paid_draw_count + 1
if paid_draw_number <= novice_draw_limit:
experience_pool = novice
else if paid_draw_number <= intermediate_draw_limit:
experience_pool = intermediate
else:
experience_pool = advanced
```
Stage feedback is calculated from the same paid draw number. It is a presentation state unless the selected candidate explicitly carries `base_rtp` or `activity_subsidy` budget.
```text
novice_feedback_marks = [1, novice_draw_limit * 25%, novice_draw_limit * 50%, novice_draw_limit * 75%, novice_draw_limit]
stage_feedback_enabled = paid_draw_number in novice_feedback_marks
```
Feedback cannot credit coins after selection. If a milestone should pay coins, that payout must be present in the pre-random candidate set and must pass all RTP, pool, and risk filters.
### 2. Pool Inflow And Capacity
```text
pool_in = floor(coin_spent * pool_rate_ppm / 1000000)
platform_in = floor(pool_in * platform_weight_ppm / 1000000)
room_in = floor(pool_in * room_weight_ppm / 1000000)
gift_in = pool_in - platform_in - room_in
platform_capacity = platform_balance + platform_in - platform_reserve_floor
room_capacity = room_balance + room_in - room_reserve_floor
gift_capacity = gift_balance + gift_in - gift_reserve_floor
```
Risk remains a hard cap:
```text
risk_capacity = min(
max_single_payout,
user_daily_remaining,
device_daily_remaining,
room_hourly_remaining,
anchor_daily_remaining,
activity_period_remaining
)
```
Time-window caps must be configured above the expected payout for that window. Otherwise the transaction can satisfy the user risk cap or the RTP target, but not both.
```text
expected_window_payout = paid_draw_capacity
* gift_price
* target_rtp_ppm
/ 1000000
hourly_cap >= expected_hourly_payout * volatility_factor
daily_cap >= expected_daily_payout * volatility_factor
```
Use a larger `volatility_factor` for higher variance pools. The default demo model uses `2.0` for hourly and `1.5` for daily caps.
### 3. Global And Gift RTP Windows
Every draw enters two RTP windows:
```text
global_window_target = floor((global_window_wager * global_target_rtp_ppm + global_carry_ppm) / 1000000)
gift_window_target = floor((gift_window_wager * gift_target_rtp_ppm + gift_carry_ppm) / 1000000)
global_remaining_payout = global_window_target - global_window_base_payout
gift_remaining_payout = gift_window_target - gift_window_base_payout
global_remaining_draws = global_window_draws - global_window_paid_draws
gift_remaining_draws = gift_window_draws - gift_window_paid_draws
```
High multiplier tiers are disabled until both RTP windows have positive payout headroom large enough to pay the tier:
```text
rtp_headroom = remaining_payout
- remaining_draws * gift_price * target_rtp_ppm / 1000000
high_multiplier_enabled = global_rtp_headroom >= tier_reward
&& gift_rtp_headroom >= tier_reward
```
The full production filter must additionally require platform, room, and gift pool headroom above the same tier reward plus reserve floors.
The base payout capacity is the strict minimum:
```text
base_capacity = min(
platform_capacity,
room_capacity,
gift_capacity,
risk_capacity,
global_remaining_payout,
gift_remaining_payout
)
```
### 4. Candidate Construction
Build candidates from three independent sources:
| Source | Candidate Fields | Accounting |
| --- | --- | --- |
| `base_rtp` | tier id, base reward, experience pool weight | counts in global and gift RTP windows |
| `room_atmosphere` | room burst, shared reward, presentation | consumes room atmosphere budget only |
| `activity_subsidy` | subsidy reward, activity id, display tag | consumes activity budget only |
Base candidates must preserve both RTP windows:
```text
base_reward_coins <= base_capacity
global_remaining_payout - base_reward_coins
<= global_min_future_max_reward * (global_remaining_draws - 1)
gift_remaining_payout - base_reward_coins
<= gift_min_future_max_reward * (gift_remaining_draws - 1)
```
Room atmosphere and activity candidates cannot be used to satisfy these formulas. They only increase `effective_reward_coins`.
### 5. Selection
```text
candidate_set = filtered_base_candidates
+ filtered_room_atmosphere_candidates
+ filtered_activity_subsidy_candidates
selected = weighted_random(candidate_set, secure_random)
```
Selection rules:
| Rule | Behavior |
| --- | --- |
| base reward exceeds base capacity | base tier disabled |
| base reward breaks either RTP window feasibility | base tier disabled |
| high multiplier lacks global, gift, or pool headroom | high tier disabled and its weight moves to `none` |
| room atmosphere reward exceeds room atmosphere budget | room candidate disabled |
| activity subsidy exceeds active budget or daily limit | activity candidate disabled |
| risk cap would be exceeded | coin-paying candidate disabled |
| disabled tier weight | moved to `none` only inside the same source |
The final random selection runs only on the filtered table. It must never select a tier that cannot be paid.
### 6. Accounting
```text
base_reward_coins = selected.base_reward_coins
room_atmosphere_reward_coins = selected.room_atmosphere_reward_coins
activity_subsidy_coins = selected.activity_subsidy_coins
effective_reward_coins = base_reward_coins
+ room_atmosphere_reward_coins
+ activity_subsidy_coins
```
Only `base_reward_coins` updates global and gift RTP windows. Room atmosphere and activity subsidies update their own budgets and are reported separately in metrics.
## Transaction Boundary
`ExecuteLuckyGiftDraw` uses one local MySQL transaction for the draw truth.
```text
1. read idempotency by command_id
2. lock lucky_gift_rules active version
3. lock global lucky_rtp_windows row
4. lock gift lucky_rtp_windows row
5. lock platform, room, gift pool rows with SELECT FOR UPDATE
6. lock lucky_room_atmosphere_pools row
7. lock active lucky_activity_budgets row when configured
8. load or lock risk counters for current windows
9. choose novice/intermediate/advanced experience pool
10. calculate stage feedback state from the user's paid draw number
11. calculate base RTP capacity, room atmosphere capacity, and activity subsidy capacity
12. calculate single, hourly, daily, device, room, anchor, and activity risk remaining
13. calculate high multiplier headroom from RTP windows and three-layer pools
14. build and filter base, room atmosphere, and activity subsidy candidates
15. execute weighted random selection once
16. update platform, room, gift pool balances for base reward only
17. update global and gift RTP windows for base_reward_coins only
18. update room atmosphere and activity budget spending separately
19. update risk counters for all coin-paying rewards
20. insert lucky_draw_records with separated reward fields and snapshots
21. insert lucky_outbox if effective_reward_coins > 0 or room presentation is required
22. commit
```
After commit, `activity-service` may synchronously dispatch the wallet credit for low latency. If dispatch fails, it must keep the outbox pending and retry.
Do not call `wallet-service` while holding MySQL pool locks. External RPC inside the pool transaction can block all draws for the same room or gift.
## Idempotency
`command_id` is the global idempotency key.
| Case | Required Behavior |
| --- | --- |
| duplicate `CheckLuckyGift` | return current enabled rule |
| duplicate `ExecuteLuckyGiftDraw` before commit | one succeeds, one waits or fails retryably |
| duplicate `ExecuteLuckyGiftDraw` after commit | return stored draw result |
| duplicate wallet credit | wallet returns same `credit_receipt_id` |
| duplicate outbox dispatch | consumer ignores completed `event_id` |
`draw_id` should be derived from `command_id` or stored under a unique `command_id` constraint.
## Randomness and Audit
V2 should use server-side cryptographically secure randomness. Every draw record must keep enough audit material to prove the result came from the recorded candidate table.
Recommended audit fields:
| Field | Meaning |
| --- | --- |
| `random_digest` | HMAC or hash of random source and draw context |
| `candidate_tiers_json` | exact filtered probability table |
| `pool_snapshot_json` | pool balances before selection |
| `risk_snapshot_json` | caps and remaining quota |
| `rule_version` | immutable probability version |
如果后续需要更强公信力,可以升级为 daily seed commit-reveal每天提前公布 `hash(seed)`,开奖后公布 `seed`,用户可复算结果。
## Failure Handling
| Failure | Behavior |
| --- | --- |
| `CheckLuckyGift` unavailable before debit | reject lucky gift send, do not debit |
| `DebitGift` failed | do not call draw |
| `ExecuteLuckyGiftDraw` timeout after debit | room gift can land as pending lucky draw; retry by `command_id` |
| draw transaction committed but wallet credit failed | keep `reward_status=PENDING`, retry outbox |
| wallet credit succeeded but response lost | retry credit by `reward_id`, wallet returns same receipt |
| im broadcast failed | room-service outbox handles room event compensation |
| activity outbox exhausted retries | mark failed and alert; do not delete record |
The product response must distinguish `GRANTED` and `PENDING`. Only `GRANTED` can show “金币已到账”。
## Service Modules
Recommended `activity-service` layout:
```text
services/activity-service/
internal/
luckygift/
service/
service.go
draw.go
risk.go
idempotency.go
rule/
repository.go
validator.go
pool/
repository.go
allocator.go
rtp/
window.go
controller.go
atmosphere/
room_pool.go
presenter.go
budget/
activity_budget.go
reward/
wallet_client.go
dispatcher.go
outbox/
store.go
worker.go
audit/
random.go
digest.go
storage/
mysql/
redis/
transport/
grpc/
```
Keep the draw logic in one service package. Do not split probability, pool, and risk into separate network services in V1.
## Integration Events
`activity-service` should emit protobuf events for downstream statistics and audit.
Suggested event types:
| Event | Payload |
| --- | --- |
| `LuckyGiftDrawn` | draw id, gift id, rule version, tier, reward coins |
| `LuckyRewardGranted` | reward id, draw id, user id, credit receipt |
| `LuckyRewardPending` | reward id, draw id, retry reason |
| `LuckyPoolChanged` | scope, delta in, delta out, balance |
| `LuckyRiskRejected` | scope, threshold, reason |
These events should live under `api/proto/events/activity/v1` when implementation starts.
## Config
`services/activity-service/configs/config.yaml` should eventually include:
```yaml
grpc_addr: ":13006"
mysql_dsn: "root:root@tcp(mysql:3306)/hyapp_activity?parseTime=true"
redis_addr: "redis:6379"
redis_password: ""
wallet_grpc_addr: "wallet-service:13004"
outbox_batch_size: 100
outbox_poll_interval: "500ms"
draw_timeout: "300ms"
wallet_credit_timeout: "500ms"
```
Local Docker uses MySQL and Redis from `docker-compose.yml`; production uses Tencent Cloud Redis and TencentDB for MySQL with the same config keys.
## Testing
Required tests:
| Test | Assertion |
| --- | --- |
| experience pool validation | novice, intermediate, advanced tier weights are positive and usable |
| RTP window validation | `100000` paid draws deviate from target RTP by `<1%` |
| RTP million validation | `1000000` paid draws deviate from target RTP by `<0.5%` |
| gift RTP sub-window validation | one gift cannot overpay while global RTP is still healthy |
| subsidy isolation | room atmosphere and activity subsidy do not update base RTP windows |
| low-water filtering | unavailable tier weight moves to `none` |
| pool transaction | platform, room, gift balances update atomically |
| RTP transaction | concurrent draws cannot double-spend the same window remaining payout |
| room atmosphere budget | shared rewards stop when room atmosphere budget reaches floor |
| activity budget exhaustion | subsidy candidates are disabled after budget or daily cap is exhausted |
| user daily cap | reward tier over cap is filtered before draw |
| idempotent draw | duplicate `command_id` returns same result |
| wallet credit retry | pending reward eventually becomes granted |
| draw timeout retry | no duplicate pool payout |
| concurrent room draws | row locks prevent negative pool balance |
| audit replay | stored candidate table reproduces selected tier check |
Load tests should verify the hottest room path because room pool row locks can become the first bottleneck.
Production simulation must cover the full online control surface before service implementation starts:
```bash
go run ./tools/lucky-gift-v2-demo \
-users 1000 \
-devices 900 \
-rooms 50 \
-draws 100000 \
-base-rtp 95 \
-initial 1000000000 \
-cost 500 \
-global-window 100000 \
-gift-window 100000
```
The expected pass condition is:
| Check | Required Result |
| --- | --- |
| `base_rtp` | remains within the configured global and gift RTP windows |
| `effective_rtp` | may be higher than `base_rtp` only through room atmosphere or activity subsidy |
| platform, room, gift pools | no pool pays below its reserve floor |
| user, device, room, anchor caps | all coin-paying candidates are filtered before selection |
| high multiplier | disabled unless RTP windows and three-layer pools have enough high-water headroom |
## Rollout
V1 rollout order:
| Step | Scope |
| --- | --- |
| 1 | Add proto and DB schema behind disabled config |
| 2 | Implement `CheckLuckyGift` and rule validation |
| 3 | Implement pool transaction and draw records with reward disabled |
| 4 | Add wallet `CreditLuckyReward` and pending reward outbox |
| 5 | Connect `room-service SendGift` for selected lucky gifts |
| 6 | Enable one low-price gift in internal room |
| 7 | Enable room-level metrics and alerting |
| 8 | Expand to higher-price gifts only after payout rate is stable |
Operational kill switches:
| Switch | Effect |
| --- | --- |
| `global_lucky_gift_enabled=false` | all lucky gifts reject before debit |
| `gift_rule.status=paused` | single gift stops lucky draw |
| `large_tier_enabled=false` | disables high-risk tiers |
| `wallet_credit_dispatch=false` | rewards stay pending; used only during wallet incident |
| `room_scope_paused` | disables lucky draw for one room |

View File

@ -1,467 +0,0 @@
# Lucky Gift Product Design
本文档定义语音房幸运礼物的产品规则。幸运礼物使用金币赠送,奖励也返还金币
当前代码实现以 `docs/幸运礼物线上落地文档.md` 为准。已落地版本不是固定概率表模型,而是后台配置 `multiplier_ppms` 多个倍率,服务端按 RTP 窗口、三层奖池和风控容量在运行时自动生成候选权重;因此本文中“概率表/权重表”只保留为产品抽象,不代表后台可以直接配置固定命中概率。
## Positioning
| Object | Meaning |
| ------ | ---------------------------------------------- |
| 用户 | 用金币赠送幸运礼物,获得房间表现和概率金币奖励 |
| 主播 | 正常获得礼物贡献和房间热度,不参与控制开奖结果 |
| 房间 | 获得飘屏、热度、本地榜、互动气氛 |
| 平台 | 用奖池和风控控制整体返还率,不控制单个用户输赢 |
核心原则:
| Rule | Decision |
| ------------ | -------------------------------------------------- |
| 先扣费再抽奖 | 只有金币扣费成功后才进入幸运礼物抽奖 |
| 先过滤再随机 | 先根据奖池水位和风控阈值过滤奖档,再执行随机 |
| 抽后不改结果 | 随机结果生成后必须落日志,不允许后台强改 |
| 整体控阈值 | 控制平台、房间、礼物、用户维度的风险敞口 |
| 规则可解释 | 前台展示基础概率、奖励范围、低水位规则和上限 |
| 结果可审计 | 每次抽奖保存规则版本、奖池快照、随机材料和发奖状态 |
## User Experience
用户在礼物面板选择幸运礼物,支付金币后出现三类结果:
| Result | User Feedback |
| ------------ | -------------------------------------------------- |
| 未命中奖励 | 礼物正常送出,房间展示普通幸运礼物消息 |
| 命中小额奖励 | 礼物正常送出,返还金币到账,房间展示轻量提示 |
| 命中大奖励 | 礼物正常送出,返还金币到账,房间展示飘屏或强化特效 |
金币奖励必须以实际到账为准。若账务补偿中,客户端展示“奖励处理中”,不能提前展示“已到账”。
## Three Pool Model
三层奖池仍然是资金水位和风险敞口模型,不能被新手、中级、高级体验池替代。体验池只决定同一次抽奖可见的奖档分布,实际能不能发奖仍然由平台、房间、礼物三层水位以及全站/礼物基础 RTP 窗口共同决定。
### Platform Pool
平台总奖池控制全站幸运礼物的整体返还率。
| Field | Meaning |
| ----------------------------- | ------------------------ |
| `platform_pool_balance` | 当前全站可用幸运奖励金币 |
| `platform_reserve_floor` | 平台最低安全水位 |
| `platform_target_payout_rate` | 平台目标返还率 |
| `platform_daily_payout_limit` | 平台每日最大奖励支出 |
平台奖池低于安全水位时,大额奖档不可参与随机;被过滤奖档的概率质量进入未中奖奖档,不重新分配给其他奖档。
### Room Pool
房间奖池控制单房间爆奖能力。热门房可以积累更高水位,冷房不能打穿平台预算。
| Field | Meaning |
| -------------------------- | ------------------------ |
| `room_pool_balance` | 房间当前可用幸运奖励金币 |
| `room_reserve_floor` | 房间最低安全水位 |
| `room_hourly_payout_limit` | 房间小时最大奖励支出 |
| `room_daily_payout_limit` | 房间每日最大奖励支出 |
房间奖池只影响本房间,不允许直接挪用其他房间水位。需要运营活动加成时,必须通过平台奖池或活动预算显式补贴。
### Gift Pool
礼物奖池控制不同幸运礼物的成本结构。
| Field | Meaning |
| ------------------------- | ---------------------------- |
| `gift_pool_balance` | 某个幸运礼物当前可用奖励金币 |
| `gift_reserve_floor` | 礼物最低安全水位 |
| `gift_target_payout_rate` | 礼物目标返还率 |
| `gift_max_single_payout` | 礼物单次最高奖励 |
低价礼物适合高频小奖,高价礼物适合低频大奖。不同礼物必须使用独立概率表和独立水位,不要共用一个不可解释的全局概率。
## Five Layer Control Model
幸运礼物 V2 使用五层控制模型。三层资金水位继续负责“能不能付”,五层控制负责“钱怎么算、体验怎么呈现、补贴归谁”。
| Layer | Product Role | Cost Accounting | Hard Boundary |
| --- | --- | --- | --- |
| 全站 RTP 控制窗口 | 保证全站基础返奖成本 | 计入 `base_reward_coins`,进入全站基础 RTP | 不能被房间气氛、活动补贴污染 |
| 礼物 RTP 子窗口 | 控制单个幸运礼物成本结构 | 同一笔 `base_reward_coins` 同时进入礼物基础 RTP | 单礼物不能长期借其他礼物的 RTP |
| 用户体验池 | 新手、中级、高级奖档和节奏 | 不单独记成本,只选择基础候选奖档 | 不承诺单用户 RTP |
| 房间气氛池 | 控制房间爆点、共享奖励、飘屏强度 | 计入 `room_atmosphere_reward_coins` 或表现预算 | 不回写 Room Cell 核心状态 |
| 活动预算池 | 节日、榜单、运营补贴 | 计入 `activity_subsidy_coins` | 独立预算,不能冲抵基础 RTP |
用户看到的总返奖拆成三类:
```text
effective_reward_coins = base_reward_coins
+ room_atmosphere_reward_coins
+ activity_subsidy_coins
```
成本口径必须分开:
| Metric | Formula | Meaning |
| --- | --- | --- |
| `base_rtp` | `sum(base_reward_coins) / sum(coin_spent)` | 平台承诺和风控使用的基础 RTP |
| `gift_base_rtp` | `sum(base_reward_coins by gift_id) / sum(coin_spent by gift_id)` | 单礼物成本健康度 |
| `effective_rtp` | `sum(effective_reward_coins) / sum(coin_spent)` | 用户实际感受到的总返还 |
| `subsidy_cost` | `sum(room_atmosphere_reward_coins + activity_subsidy_coins)` | 运营和房间气氛额外成本 |
运营要提高娱乐性时,优先调整体验池、房间气氛池和活动预算池;不要直接把基础 RTP 拉高。基础 RTP 是平台成本底线,不是用户体感的唯一来源。
## Experience Pools
新手池、中级池、高级池是同一个幸运礼物规则下的三个体验配置。它们不单独拥有金币余额,不直接绕过平台、房间、礼物三层奖池,不承诺单个用户固定 RTP。
| Pool | Entry Rule | Product Shape | Payout Boundary |
| --- | --- | --- | --- |
| `novice` | 用户当前幸运礼物累计付费抽数较低,或处于新手保护期 | 高频小奖、少量返还、禁止超高倍大奖 | 单次奖励上限最低,避免新用户误解为高波动博彩 |
| `intermediate` | 用户已经完成基础体验但未进入高频阶段 | 小奖和中等奖混合,中奖频率下降 | 允许中等奖,不允许消耗高级奖池预算 |
| `advanced` | 高频幸运礼物用户或高价幸运礼物 | 低频高倍、强调飘屏和房间表现 | 单次奖励上限最高,但必须被总 RTP、三层水位和风控同时约束 |
默认分池建议按付费抽数切换,服务端只使用 UTC 计数,不使用客户端本地时间:
| Pool | Default Draw Range | Notes |
| --- | ---: | --- |
| `novice` | `[1, 2000]` | 首次体验阶段,奖档更密但倍数更低 |
| `intermediate` | `[2001, 20000]` | 常规用户阶段,保留中等奖表现 |
| `advanced` | `[20001, +∞)` | 高频阶段,大奖概率更低但上限更高 |
分池只影响基础候选奖档和表现节奏。总返还率必须由全站 RTP 控制窗口和礼物 RTP 子窗口共同收敛,不能依赖三个体验池各自“碰运气”。
### Stage Feedback
阶段反馈是体验反馈,不是单用户保底。默认在新手池内按付费抽数进度触发:`1``25%``50%``75%``100%` of `novice_draw_limit`
| Feedback Type | Cost Accounting | Allowed Behavior |
| --- | --- | --- |
| 进度反馈 | `presentation_only` | 展示阶段、进度、动画、房间幸运值,不发金币 |
| 小额反馈奖 | `base_rtp` or `activity_subsidy` | 如果发金币,必须抽前进入基础候选或活动预算候选 |
| 阶段完成提示 | `presentation_only` | 告知用户已离开新手池或进入常规池 |
阶段反馈不能绕过 RTP、三层水位和风控上限。任何“第 N 抽必给金币”的设计都必须先占用明确预算,否则会把基础 RTP 控制窗口打穿。
## RTP Control Model
单纯固定概率表不能保证 `10w` 次上下浮动不超过 `1%``100w` 次上下浮动不超过 `0.5%`,尤其是高级池包含高倍奖时。幸运礼物必须使用“概率体验 + 整数 RTP 控制窗口”的组合模型。
控制规则:
| Rule | Decision |
| --- | --- |
| 控制口径 | 每次抽奖同时进入全站 RTP 窗口和礼物 RTP 子窗口 |
| 控制窗口 | 默认 `global_window_draws=100000`,礼物子窗口可按流量配置更大窗口 |
| RTP 输入 | 使用 `target_rtp_ppm` 保存,例如 `95% = 950000` |
| 目标支出 | `window_target_payout = floor((window_wager * target_rtp_ppm + carry_ppm) / 1000000)` |
| 余数处理 | 除不尽的 ppm 余数进入下一个窗口的 `carry_ppm` |
| 候选过滤 | 每次抽奖前过滤三层水位、风控、体验池奖档和窗口剩余可支付额 |
| 随机范围 | 只在过滤后的候选奖档中随机,随机后不允许后台改结果 |
| 窗口补偿 | 全站或礼物窗口剩余抽数不足以自然追平目标时,抽前启用基础 RTP 补偿候选奖档 |
窗口内每次抽奖都维护两个整数:
```text
global_remaining_draws = global_window_draws - global_window_paid_draws
global_remaining_payout = global_window_target_payout - global_window_base_payout
gift_remaining_draws = gift_window_draws - gift_window_paid_draws
gift_remaining_payout = gift_window_target_payout - gift_window_base_payout
```
候选奖档必须满足:
```text
base_reward_coins <= global_remaining_payout
base_reward_coins <= gift_remaining_payout
global_remaining_payout - base_reward_coins
<= global_min_future_max_reward * (global_remaining_draws - 1)
gift_remaining_payout - base_reward_coins
<= gift_min_future_max_reward * (gift_remaining_draws - 1)
```
其中 `*_min_future_max_reward` 是对应窗口后续任意体验池至少能支付的单次最大基础返奖。这个约束保证窗口不会因为前面连续未中奖而在最后几抽无法追平基础 RTP。
达成条件:
| Sample Size | Required Result | Control Requirement |
| ---: | --- | --- |
| `100000` paid draws | 全站基础 RTP 与目标 RTP 绝对偏差 `< 1%` | 一个完整全站控制窗口内目标支出按整数配额结清 |
| `1000000` paid draws | 全站基础 RTP 与目标 RTP 绝对偏差 `< 0.5%` | 十个完整全站控制窗口累计偏差只剩整数余数 |
| 单礼物流量达到配置窗口 | 礼物基础 RTP 收敛到礼物目标 | 礼物子窗口独立结清,不能长期靠全站窗口兜底 |
这个保证只适用于控制口径内的总 paid draws不适用于单个用户、单个房间或单个很小流量分桶。单个用户仍然可能亏损或盈利平台只能承诺全站和礼物基础 RTP 收敛。
如果三层奖池水位、风控硬上限或钱包入账失败导致基础返奖无法支付,系统必须记录 `rtp_underpay` 风险事件并降低基础 RTP不能透支奖池或抽后篡改结果。房间气氛和活动补贴也不能用于“补平”基础 RTP只能提高用户看到的 `effective_rtp`
## Risk Layer
风控层不在抽奖后篡改结果,只在抽奖前决定是否允许参与、是否降低可用奖档、是否进入人工观察。
| Threshold | Product Meaning |
| --------------------------------- | ------------------------ |
| `max_single_payout` | 单次最大奖励金币 |
| `max_user_daily_payout` | 单用户每日最大奖励金币 |
| `max_device_daily_payout` | 单设备每日最大奖励金币 |
| `max_room_hourly_payout` | 单房间每小时最大奖励金币 |
| `max_anchor_daily_related_payout` | 单主播关联奖励风险上限 |
| `max_activity_period_payout` | 活动周期最大奖励金币 |
| `max_user_hit_count_window` | 单用户窗口期最大命中次数 |
上限制定依据:
```text
expected_payout(window) = paid_draw_capacity(window)
* gift_price
* target_rtp_ppm
/ 1000000
single_cap <= min(gift_sub_window_target_payout, single_event_risk_budget)
hourly_cap >= expected_payout(user_hour_window) * volatility_factor
daily_cap >= expected_payout(user_day_window) * volatility_factor
```
默认建议:
| Limit | Base Formula | Why |
| --- | --- | --- |
| 单次上限 | `gift_price * max_allowed_multiplier` | 控制单事件最大曝光,直接决定是否允许 100x/500x |
| 小时上限 | `expected_hourly_payout * 1.5~3.0` | 覆盖正常波动,同时限制短时异常套利 |
| 日上限 | `expected_daily_payout * 1.2~2.0` | 覆盖高活跃用户正常体验,同时保护单日预算 |
| 高水位开高倍 | `min(global_headroom, gift_headroom, pool_headroom) >= high_reward` | 只有此前少付或奖池积累足够时,才允许高倍进入候选 |
如果小时或日上限低于对应窗口的期望基础返奖,系统将无法同时满足用户上限和全站 RTP。此时必须降低目标 RTP、降低用户可抽容量、提高上限或把额外体验放到活动预算池而不是让基础 RTP 窗口透支。
高水位定义:
```text
rtp_headroom = remaining_base_payout
- remaining_draws * gift_price * target_rtp_ppm / 1000000
high_multiplier_enabled = global_rtp_headroom >= tier_reward
&& gift_rtp_headroom >= tier_reward
&& platform_pool_headroom >= tier_reward
&& room_pool_headroom >= tier_reward
&& gift_pool_headroom >= tier_reward
```
基础最小版本暂时只验证 `global_rtp_headroom``gift_rtp_headroom`。接入真实奖池后,必须同时校验平台、房间、礼物三层水位。
线上标准强制口径:
| Control | Required Behavior |
| --- | --- |
| 全站 RTP 窗口 | 每一笔付费抽奖都进入,`base_reward_coins` 必须按窗口整数配额结清 |
| 礼物 RTP 子窗口 | 同一笔基础返奖同时进入礼物窗口,单礼物不能借其他礼物的成本 |
| 平台/房间/礼物三层池 | 每抽先按 `pool_rate_ppm` 入池,再按责任权重支付基础返奖;任一层低于 reserve floor 时过滤对应奖档 |
| 新手/中级/高级体验池 | 只控制候选奖档和节奏,不持有预算,不承诺单用户 RTP |
| 单次/小时/日/设备/房间/主播上限 | 抽前过滤所有会超过剩余额度的付费候选,抽后只更新计数 |
| 高水位高倍 | 只有全站窗口、礼物窗口、三层池水位同时满足高倍奖励时100x/500x 才能进候选 |
| 房间气氛池 | 独立预算,只提升房间爆点和共享奖励,不写入基础 RTP |
| 活动预算池 | 独立补贴,只提高 `effective_rtp`,不能冲抵基础 RTP 欠付 |
| 钱包和房间表现 | 扣费必须先成功;返奖和房间表现通过 outbox 重试,不能在持有奖池锁时调用外部 RPC |
触发阈值后的产品动作:
| Action | When |
| ---------- | ------------------------------------------ |
| 拒绝参与 | 用户、设备、房间或活动已达到硬上限 |
| 低水位模式 | 奖池不足以支付完整奖档 |
| 仅小奖模式 | 大奖风险超出当前窗口预算 |
| 人工观察 | 主播、用户、设备、支付来源存在明显关联异常 |
| 活动暂停 | 平台总返还率或投诉风险超过运营阈值 |
## Reward Configuration
每个幸运礼物最小配置:
| Field | Meaning |
| --------------------------- | -------------------------- |
| `gift_id` | 幸运礼物 ID |
| `gift_price` | 单个礼物金币价格 |
| `pool_rate` | 每笔礼物进入幸运奖池的比例 |
| `target_payout_rate` | 长期目标返还率 |
| `target_rtp_ppm` | RTP 控制窗口使用的目标返还率 |
| `control_window_draws` | RTP 控制窗口 paid draws 数 |
| `gift_rtp_window_draws` | 单礼物 RTP 子窗口 paid draws 数 |
| `experience_pool_rules` | 新手、中级、高级分池进入规则 |
| `room_atmosphere_rule` | 房间爆点、共享奖励和表现规则 |
| `activity_budget_id` | 当前可用活动补贴预算 ID |
| `reserve_floor` | 最低安全水位 |
| `max_single_payout` | 单次最高奖励金币 |
| `max_user_daily_payout` | 单用户每日最高奖励金币 |
| `max_room_hourly_payout` | 单房间小时最高奖励金币 |
| `probability_table_version` | 概率表版本 |
| `enabled_reward_tiers` | 当前启用奖档 |
示例权重表只表达结构,不作为正式运营参数。体验池权重用于表现节奏,不直接决定总 RTP。
| Pool | Tier | Reward | Base Weight | Availability |
| --- | --- | ---: | ---: | --- |
| `novice` | `none` | `0` | `270000` | always |
| `novice` | `rebate_0_5x` | `gift_price * 0.5` | `200000` | pool safe |
| `novice` | `rebate_1x` | `gift_price * 1` | `350000` | pool safe |
| `novice` | `small_2x` | `gift_price * 2` | `140000` | pool safe |
| `novice` | `small_5x` | `gift_price * 5` | `35000` | pool safe |
| `novice` | `small_10x` | `gift_price * 10` | `5000` | pool safe |
| `intermediate` | `none` | `0` | `760000` | always |
| `intermediate` | `rebate_1x` | `gift_price * 1` | `100000` | pool safe |
| `intermediate` | `small_2x` | `gift_price * 2` | `80000` | pool safe |
| `intermediate` | `medium_5x` | `gift_price * 5` | `45000` | pool safe and user limit safe |
| `intermediate` | `medium_20x` | `gift_price * 20` | `13000` | high water only |
| `intermediate` | `medium_50x` | `gift_price * 50` | `2000` | high water only |
| `advanced` | `none` | `0` | `900000` | always |
| `advanced` | `small_2x` | `gift_price * 2` | `50000` | pool safe |
| `advanced` | `medium_5x` | `gift_price * 5` | `30000` | pool safe |
| `advanced` | `large_20x` | `gift_price * 20` | `15000` | high water only |
| `advanced` | `large_100x` | `gift_price * 100` | `4500` | high water only |
| `advanced` | `jackpot_500x` | `gift_price * 500` | `500` | high water and risk safe |
奖池低水位时,如果 `large_100x` 不可用,它的权重必须进入 `none`不能转给其他奖档。RTP 控制窗口需要追平目标时,只能在抽前选择当前可支付的补偿奖档,不能在随机后把未中奖改成中奖。
## V2 Draw Algorithm
V2 抽奖算法把“基础返奖”和“娱乐补贴”分开算。基础返奖负责 RTP房间气氛和活动补贴负责体验。
```text
input:
command_id, user_id, room_id, gift_id, gift_price, paid_at_ms
state:
global_rtp_window
gift_rtp_window(gift_id)
platform_pool, room_pool(room_id), gift_pool(gift_id)
user_experience_state(user_id, gift_id)
room_atmosphere_pool(room_id)
activity_budget(optional)
risk_counters
```
标准算法:
```text
1. 用 command_id 做幂等查询,已有结果直接返回
2. 选择用户体验池novice / intermediate / advanced
3. 计算阶段反馈点;反馈只产生展示事件或已预算候选,不直接发未预算金币
4. 锁定全站 RTP 窗口和礼物 RTP 子窗口
5. 锁定平台、房间、礼物三层资金水位
6. 锁定房间气氛池和可用活动预算
7. 读取风险计数和用户窗口状态
8. 计算基础返奖容量:
global_base_capacity
gift_base_capacity
platform_capacity
room_capacity
gift_capacity
risk_capacity
9. 计算单次、小时、日、设备、房间、主播和活动周期剩余额度
10. 计算高倍奖档水位;只有全站、礼物和三层奖池高水位都足够时才开放高倍
11. 从体验池生成基础候选奖档
12. 过滤所有无法同时满足全站 RTP、礼物 RTP、三层水位和风控的基础奖档
13. 生成房间气氛候选:飘屏、共享红包、房间幸运值、表现强化
14. 生成活动补贴候选:限时加奖、榜单补贴、节日额外返奖
15. 合并候选并执行一次随机
16. 写入抽奖事实、窗口快照、预算快照和随机审计材料
17. 更新全站 RTP 窗口和礼物 RTP 子窗口,仅写入 base_reward_coins
18. 更新房间气氛池和活动预算,仅写入各自补贴支出
19. 提交事务
20. 事务后通过 wallet-service 发金币,通过 room-service/腾讯云 IM 做房间表现
```
候选奖档必须带预算来源:
| Budget Source | Pays Coins | Counts In Base RTP | Typical Use |
| --- | --- | --- | --- |
| `base_rtp` | yes | yes | 常规小奖、中奖、大奖 |
| `room_atmosphere` | optional | no | 房间红包、共享奖励、飘屏强化 |
| `activity_subsidy` | yes | no | 节日加奖、榜单补贴、限时返利 |
| `presentation_only` | no | no | 动画、称号、房间幸运值 |
随机前必须完成过滤。随机后只能落账、补偿或返回 pending不能把结果改成另一个奖档。
## Draw Flow
标准流程:
```text
读取规则版本
按用户累计付费抽数选择 novice/intermediate/advanced 体验池
读取平台、房间、礼物奖池水位
读取或创建全站 RTP 窗口和礼物 RTP 子窗口
读取房间气氛池和活动预算
读取用户、设备、房间、主播、活动风控计数
过滤不可支付、不可触发或会破坏基础 RTP 窗口追平能力的基础奖档
生成基础返奖、房间气氛、活动补贴候选奖档
执行随机
写入抽奖结果日志
发放金币奖励
广播房间表现
```
不可接受流程:
```text
先随机
发现超预算
后台改成未中奖
```
## Room Presentation
幸运礼物产生两类房间表现:
| Event | Owner |
| -------------- | ---------------------------------------------------- |
| 礼物送出消息 | `room-service` |
| 房间热度变化 | `room-service` |
| 房间本地礼物榜 | `room-service` |
| 幸运奖励结果 | `activity-service` 决策,`room-service` 转成房间表现 |
| 房间消息投递 | 腾讯云 IM |
`activity-service` 不直接持有 Room Cell不修改麦位、禁言、在线态和房间榜。它只返回幸运礼物决策结果并通过事件让房间系统表现出来。
## Compliance Baseline
产品默认遵守以下红线:
| Red Line | Decision |
| -------------------- | ------------------------------------ |
| 不允许空奖池承诺大奖 | 当前奖池无法支付时,大奖档必须不可用 |
| 不删除抽奖记录 | 抽奖、奖池、发奖记录按审计周期保存 |
## Metrics
上线后必须监控这些指标:
| Metric | Reason |
| ----------------------- | -------------------- |
| `lucky_gift_gmv` | 幸运礼物金币消耗 |
| `lucky_base_reward_payout` | 基础 RTP 金币奖励支出 |
| `room_atmosphere_payout` | 房间气氛和共享奖励支出 |
| `activity_subsidy_payout` | 活动补贴支出 |
| `base_rtp` | 基础返还率,平台成本口径 |
| `effective_rtp` | 用户可见返还率,含补贴 |
| `gift_base_rtp` | 单礼物基础返还率 |
| `pool_balance_by_scope` | 平台、房间、礼物水位 |
| `room_atmosphere_balance` | 房间气氛预算水位 |
| `activity_budget_balance` | 活动补贴预算水位 |
| `hit_rate_by_tier` | 各奖档命中率 |
| `reward_credit_latency` | 金币到账延迟 |
| `risk_reject_count` | 风控拒绝次数 |
| `pending_reward_count` | 待补偿发奖数量 |
| `complaint_rate` | 用户争议风险 |
## V1 Scope
首版只做:
| Included | Excluded |
| ------------------ | ---------------------- |
| 三层奖池 | 主备热同步 |
| 固定概率表版本 | 个性化概率 |
| 抽前风控阈值 | 抽后改结果 |
| 金币奖励 | 现金、实物、提现 |
| 房间飘屏和系统消息 | 跨房 PK 大奖池 |
| 抽奖日志和补偿 | 后台人工指定某用户中奖 |

View File

@ -0,0 +1,184 @@
# 幸运礼物实现文档
本文档描述当前代码里的幸运礼物实现。旧的产品方案、重构分步文档、Flutter 单独对接文档和早期 activity-service 开发文档已经下线;后续以本文档为唯一实现说明。
当前实现不兼容旧玩法假设。幸运礼物以 `pool_id` 为规则维度,以 `activity-service` 为规则、抽奖、奖池、RTP 窗口、风控、抽奖事实和 outbox owner扣费和返奖入账属于 `wallet-service`;房间状态、热度、礼物榜和房间 outbox 属于 `room-service`
## 一、核心原则
| 功能点 | 当前规则 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 服务边界 | `activity-service` 只做幸运礼物规则和开奖结果,不改 Room Cell不直接改钱包余额 | `services/activity-service/internal/service/luckygift.Service.Draw``services/activity-service/internal/service/luckygift.Service.processDrawOutbox``services/room-service/internal/room/service.Service.SendGift` | 已验收:抽奖在扣费后执行,返奖通过 wallet gRPC房间表现通过 IM/outbox |
| 规则维度 | 线上规则按 `pool_id` 读取最新版本,不按真实 `gift_id` 读取旧规则 | `Repository.GetLuckyGiftRuleConfig``Repository.getLuckyGiftRuleConfig``luckyPoolID` | 已验收:代码读取 `lucky_gift_rule_versions``lucky_gift_stage_tiers` |
| 返奖率控制 | 后台发布时校验阶段期望 RTP 落在 `target_rtp_ppm ± control_band_ppm`,运行时不按 RTP 缺口改概率 | `validateRuleConfig``validateRuleStageExpectedRTP``selectLuckyCandidate` | 已验收:发布校验函数存在,候选随机只过滤不可支付档 |
| 用户体验 | 用户阶段按累计金币流水折算等价抽数,避免不同价格礼物用真实抽数误判阶段 | `luckyEquivalentDraws``luckyExperiencePool``getLuckyUserStateForUpdate` | 已验收:状态表维护 `cumulative_wager_coins``equivalent_draws``loss_streak` |
| 批量抽奖 | `gift_count=N` 表示抽 N 次;每一抽按单份金币计算奖励,响应返回总中奖倍数和总奖励 | `executeLuckyGiftDrawBatch``luckyDrawUnitSpend``stageLuckyBatchDraw``luckyAggregateDrawResults` | 已验收:单测覆盖 10 抽返回总倍数 11x 的聚合逻辑 |
| 聚合发放 | 批量抽奖可以有 N 条抽奖明细,但只写 1 条 `LuckyGiftDrawn` outbox钱包只加币 1 次 | `insertLuckyAggregateDrawOutbox``decodeDrawOutboxPayload``MarkLuckyGiftDrawsGranted` | 已验收worker 单测覆盖 `draw_ids` 批量 granted |
## 二、配置模型
### 2.1 Proto 契约
| 功能点 | 字段或 RPC | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| App/房间抽奖元信息 | `LuckyGiftMeta`,包含 `command_id``pool_id``gift_count``coin_spent``paid_at_ms``visible_region_id` | `api/proto/activity/v1/activity.proto` `LuckyGiftMeta``LuckyGiftServer.ExecuteLuckyGiftDraw` | 已验收:房间送礼只把 wallet 已结算 `coin_spent` 传给 activity |
| 抽奖结果 | `LuckyGiftDrawResult`,包含 `multiplier_ppm``effective_reward_coins``reward_status`、RTP 窗口和阶段字段 | `luckyDrawResultToProto``luckyGiftResultFromProto` | 已验收room 返回 `LuckyGift` 字段给送礼用户 |
| 后台规则 | `LuckyGiftRuleConfig``LuckyGiftRuleStage``LuckyGiftRuleTier` | `luckyRuleConfigFromProto``luckyRuleConfigToProto``configToProto``configFromProto` | 已验收:后台百分比和倍率在 handler 内转成 ppm |
| 后台审计 | `ListLuckyGiftDraws``GetLuckyGiftDrawSummary` | `AdminLuckyGiftServer.ListLuckyGiftDraws``AdminLuckyGiftServer.GetLuckyGiftDrawSummary` | 已验收:按 `pool_id/gift_id/user_id/room_id/status` 查询 |
### 2.2 配置字段语义
| 字段 | 业务含义 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| `target_rtp_ppm` | 基础返奖目标,例如 950000 表示 95% | `validateRuleConfig``luckyRuntimeConfigFromRuleConfig` | 已验收:发布入口限制 10% 到 99% |
| `pool_rate_ppm` | 每笔扣费进入基础奖池的比例,必须不低于目标 RTP | `validateRuleConfig``luckyBasePoolIn``creditLuckyBasePools` | 已验收:低于目标 RTP 会拒绝发布 |
| `settlement_window_wager` | RTP 结算窗口金币流水,运行时按参考价格折算窗口抽数 | `luckyRuntimeConfigFromRuleConfig``luckyCeilDiv` | 已验收:窗口抽数由 `settlement_window_wager / gift_price_reference` 得到 |
| `control_band_ppm` | 发布时允许阶段期望 RTP 波动带 | `validateRuleStageExpectedRTP` | 已验收:阶段期望 RTP 不在区间内会返回 InvalidArgument |
| `gift_price_reference` | 阶段阈值和奖档金额的配置参考价格 | `luckyRuntimeConfigFromRuleConfig``luckyRuntimeConfig` | 已验收:真实抽奖按单抽 `coin_spent` 等比缩放奖档金额 |
| `novice_max_equivalent_draws` | 新手阶段最大等价抽数 | `luckyExperiencePool``DefaultRuleConfig` | 已验收:小额和高价礼物统一按累计流水折算阶段 |
| `normal_max_equivalent_draws` | 正常阶段最大等价抽数,超过后进入高级阶段 | `luckyExperiencePool``DefaultRuleConfig` | 已验收:正常阈值必须大于等于新手阈值 |
| 风控上限字段 | 单次、用户小时、用户日、设备日、房间小时、主播日返奖上限 | `validateRuleConfig``luckyRiskCapacity``updateLuckyRiskCounters` | 已验收:所有上限必须为正数,候选过滤时统一参与 |
| `stages[].tiers[]` | 三个阶段的基础候选概率和倍率 | `validateRuleStage``validateRuleStageExpectedRTP``luckyRuntimeTiersFromRuleStages` | 已验收:每阶段启用概率和必须为 1000000且必须有 0x 档和基础 RTP 档 |
### 2.3 默认草稿和发布
| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 默认草稿 | 后台读取未配置奖池时返回 disabled 草稿,不会自动生效 | `Service.GetConfig``DefaultRuleConfig` | 已验收:未找到配置时返回草稿,运行抽奖仍要求已发布启用规则 |
| 参数归一化 | 补齐 stage、reward_source、broadcast_level清理空格 | `normalizeRuleConfig` | 已验收:后台提交的空 `reward_source` 默认 `base_rtp` |
| 发布校验 | 校验 RTP、入池比例、窗口、阶段、概率、风控和高倍水位 | `validateRuleConfig``validateRuleStage``validRuleStage``validRewardSource``validBroadcastLevel` | 已验收service 层发布前执行完整校验 |
| 不可变版本 | 每次发布新增 `rule_version`,不覆盖历史版本 | `Repository.PublishLuckyGiftRuleConfig` | 已验收:事务里锁最新版本并写入 `lucky_gift_rule_versions``lucky_gift_stage_tiers` |
| 最新版本读取 | 后台和运行时都读取指定 `pool_id` 的最新版本 | `Repository.GetLuckyGiftRuleConfig``Repository.ListLuckyGiftRuleConfigs``Repository.getLuckyGiftRuleStages` | 已验收:列表按每个 pool 的最大 `rule_version` 返回 |
## 三、送礼链路
### 3.1 送礼前检查
| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| App 检查接口 | `POST /api/v1/activities/lucky-gifts/check` 只做资格检查,不扣费、不抽奖 | `activityapi.Handler.checkLuckyGift` | 已验收:接口要求 `room_id/gift_id/pool_id` 必填 |
| gRPC 检查 | gateway 或 room-service 通过 gRPC 查奖池是否启用 | `LuckyGiftServer.CheckLuckyGift``Service.Check` | 已验收:入参缺失返回 InvalidArgument |
| 规则读取 | 按 `pool_id` 查 v2 最新规则,未配置返回 disabled | `Repository.CheckLuckyGift``Repository.getLuckyGiftRuleConfig` | 已验收:不再从旧 `lucky_gift_rules` 作为运行规则来源 |
| 阶段展示 | 检查响应返回当前用户所在体验池 | `getLuckyUserState``luckyExperiencePool` | 已验收:响应包含 `experience_pool` |
### 3.2 扣费后抽奖
| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 幸运礼物识别 | 显式传 `pool_id` 或钱包礼物类型为 `lucky/super_lucky` 时触发抽奖 | `Service.shouldDrawLuckyGift` | 已验收:旧客户端可由礼物类型触发,新客户端应传明确 `pool_id` |
| 先扣费 | 房间链路先调用 wallet 扣费,成功后才调用 activity 抽奖 | `Service.SendGift``wallet.DebitGift` 调用段 | 已验收:抽奖使用 `billing.GetCoinSpent()`,不信任客户端价格 |
| 抽奖命令 | 使用房间命令 ID 作为幂等根 ID | `Service.SendGift``LuckyGiftServer.ExecuteLuckyGiftDraw``Service.Draw` | 已验收:重复命令由抽奖记录幂等返回 |
| 设备和主播 scope | 设备优先取 session_id主播优先取房主 ID | `luckyGiftDeviceID``luckyGiftAnchorID` | 已验收scope 不为空,风控计数可落表 |
| 同步响应 | 当前送礼用户立即拿到抽奖结果 | `luckyGiftResultFromProto``roomv1.SendGiftResponse.LuckyGift` | 已验收room response 带 `lucky_gift` |
| 房间事实 | 送礼本身仍写 Room Cell、热度、榜单和 room outbox | `Service.SendGift``outbox.Build` | 已验收:幸运礼物不替代普通送礼事实 |
## 四、抽奖事务
### 4.1 入口和幂等
| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| service 入参校验 | command、用户、设备、房间、主播、礼物、金额、数量必须有效 | `Service.Draw` | 已验收:`gift_count <= 0` 会被拒绝 |
| MySQL 事务 | 抽奖事实、奖池、窗口、风控、用户状态和 outbox 在一个事务内提交 | `Repository.ExecuteLuckyGiftDraw` | 已验收:事务提交前不调用外部 RPC |
| 单抽幂等 | 单抽按原始 `command_id` 查已有 draw | `executeSingleLuckyGiftDraw``getLuckyDrawByCommand` | 已验收:已存在时直接返回已有结果 |
| 批量幂等 | 批量按 `command_id#序号` 查子抽;部分存在时报冲突 | `executeLuckyGiftDrawBatch``collectLuckyBatchDrawsByCommand``luckyDrawSubCommandID` | 已验收:避免重试补写导致重复返奖 |
### 4.2 单抽路径
| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 规则锁定 | 抽奖时锁定当前 `pool_id` 最新规则版本 | `executeSingleLuckyGiftDraw``getLuckyGiftRuleConfig(... forUpdate=true)` | 已验收:规则未启用返回 RuleNotActive |
| 运行配置转换 | v2 阶段奖档转换为运行候选,窗口流水转等价抽数 | `luckyRuntimeConfigFromRuleConfig``luckyRuntimeTiersFromRuleStages``luckyRuntimePoolFromRuleStage` | 已验收:只读取 `SourceBaseRTP` 作为基础候选 |
| 实际金额缩放 | 同一奖池可服务不同价格礼物,奖励按本次 `coin_spent` 缩放 | `luckyRuntimeConfig``luckyScaleReward` | 已验收:风险上限保持绝对金币,不随小额礼物缩小 |
| 阶段推进 | 累计流水折算等价抽数并选择阶段 | `luckyEquivalentDraws``luckyExperiencePool``luckyStageFeedback` | 已验收:阶段反馈只是表现,不强制发币 |
| RTP 窗口 | 记录 wager、target payout、actual base payout 和 carry | `getOpenLuckyRTPWindow``luckyRTPWindow.withCurrentWager``createLuckyRTPWindow` | 已验收RTP 只观察结算,不直接改随机概率 |
| 三层基础奖池 | 平台、房间、礼物三层共同承担基础返奖,任一层不足都会过滤奖档 | `getOrCreateLuckyPool``creditLuckyBasePools``debitLuckyPool``luckyWeightedPoolCapacity` | 已验收:候选过滤前先入池,容量按责任权重反推 |
| 房间气氛池 | 额外体验预算独立于基础 RTP | `getOrCreateLuckyAtmosphere``creditLuckyAtmosphere` | 已验收:气氛奖励不写入基础 RTP payout |
| 活动预算 | 活动补贴预算按 UTC 日切分 | `getLuckyActivityRemaining``getOrCreateLuckyActivityTotalRemaining` | 已验收:预算不足时不会产生活动补贴候选 |
| 风控计数 | 单次、用户、设备、房间、主播上限统一过滤可支付候选 | `getLuckyRiskCounters``luckyRiskCapacity``updateLuckyRiskCounters` | 已验收:并发抽奖通过 FOR UPDATE 锁计数行 |
| 候选随机 | 只使用后台阶段概率,过滤不可支付档,不注入 RTP 修正档 | `selectLuckyCandidate``luckyConfiguredWeightCandidates``luckySecureIndex` | 已验收:候选为空时落 `no_payable_tier` 0 倍,不透支 |
| 事实落库 | 写入 draw 明细和必要 outbox | `applyLuckyDraw``luckyInitialRewardStatus``luckyDrawNeedsOutbox` | 已验收:无奖励且无反馈时直接 `granted` |
### 4.3 批量路径
| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 单份金额拆分 | `coin_spent``gift_count` 拆成 N 份,余数分配给前几抽 | `luckyDrawUnitSpend` | 已验收:单测覆盖 1001 金币拆成 101 + 9 个 100 |
| 循环外锁共享状态 | 规则、用户状态、RTP 窗口、奖池、气氛池、活动预算、风控计数只读取和锁定一次 | `executeOptimizedLuckyGiftDrawBatch` | 已验收:批量入口不再循环调用完整 `executeSingleLuckyGiftDraw` |
| 循环内只做业务推进 | 每一抽在内存里推进阶段、窗口、入池、候选、出池、风控和 draw 明细 | `stageLuckyBatchDraw``creditLuckyPoolInMemory``debitLuckyPoolInMemory``incrementLuckyRiskCountersInMemory` | 已验收:避免 777 抽产生 777 轮 SQL 状态写入 |
| 批量落库 | 窗口、奖池、气氛池、风控、用户状态按本批净变化写一次 | `persistLuckyBatchDraw``updateLuckyPoolNet``updateLuckyAtmosphereNet` | 已验收:写入顺序保证 outbox 指向已存在 draw records |
| 明细保留 | 每一抽仍保留一条 `lucky_draw_records`,用于审计和统计抽奖次数 | `insertLuckyDrawRecords``luckyDrawRecordArgs``luckyDrawRecordSnapshots` | 已验收:批量 INSERT每 200 条一批 |
| 聚合响应 | 批量响应把金额、倍数、阶段反馈和高倍标记聚合 | `luckyAggregateDrawResults``luckyAggregateBudgetSourcesJSON` | 已验收:总倍数为各命中倍数相加,不是平均倍率 |
| 聚合发放 | 整批只写一条 outboxpayload 里有聚合 `draw_id` 和全部 `draw_ids` | `insertLuckyAggregateDrawOutbox` | 已验收:待发放队列按送礼命令增长,不按中奖子抽增长 |
## 五、返奖和房间表现
| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| worker 循环 | 持续 claim `LuckyGiftDrawn` outbox | `Service.RunWorker``Service.ProcessPendingDrawOutbox``Repository.ClaimPendingLuckyGiftOutbox` | 已验收claim 使用 `delivering` 锁和 lock TTL |
| payload 兼容 | 新 payload 支持 `draw_ids`,老 payload 只有单个 `draw_id` 时回填 | `decodeDrawOutboxPayload` | 已验收:单抽旧 payload 仍能发放 |
| 钱包返奖 | 用聚合 `draw_id` 生成钱包幂等 command只加币一次 | `Service.processDrawOutbox``walletv1.CreditLuckyGiftReward` 调用 | 已验收:批量 worker 单测覆盖一次钱包请求 |
| 房间开奖结果 IM | worker 投递 `lucky_gift_drawn` 房间群消息 | `publishLuckyGiftDrawn` | 已验收:发布失败时 outbox 可重试,不提前 granted |
| 区域播报 | 符合条件的中奖写区域播报 outbox | `shouldPublishLuckyGiftRegionBroadcast``publishLuckyGiftRegionBroadcast``luckyGiftRegionBroadcastPayload` | 已验收:当前阈值为有区域、有效奖励、聚合倍数不低于 1x |
| 成功收敛 | 钱包、区域播报、房间 IM 都成功后批量标记 draw records 为 granted | `MarkLuckyGiftDrawsGranted``markLuckyGiftDraws``MarkLuckyGiftOutboxDelivered` | 已验收:批量更新按 500 个 draw_id 分片 |
| 失败收敛 | 未返奖且达到最大重试时,批量标记 pending draw failed | `markOutboxFailed``MarkLuckyGiftDrawsFailed` | 已验收:已返奖但 IM 失败不会覆盖账务事实 |
| 重试退避 | outbox 失败按指数退避重试 | `luckyGiftBackoff``MarkLuckyGiftOutboxRetryable` | 已验收:错误保存在 `activity_outbox.last_error` |
## 六、后台配置和审计
| 功能点 | HTTP 入口 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 获取单个奖池配置 | `GET /admin/activity/lucky-gifts/config?pool_id=` | `Handler.GetLuckyGiftConfig``AdminLuckyGiftServer.GetLuckyGiftConfig``Service.GetConfig` | 已验收:未配置返回 disabled 草稿 |
| 发布配置 | `PUT /admin/activity/lucky-gifts/config` | `Handler.UpsertLuckyGiftConfig``configToProto``Service.UpsertConfig``Repository.PublishLuckyGiftRuleConfig` | 已验收:百分比/倍率转换为 ppm 后发布 |
| 配置列表 | `GET /admin/activity/lucky-gifts/configs` | `Handler.ListLuckyGiftConfigs``Service.ListConfigs``Repository.ListLuckyGiftRuleConfigs` | 已验收:每个奖池只返回最新版本 |
| 抽奖明细 | `GET /admin/activity/lucky-gifts/draws` | `Handler.ListLuckyGiftDraws``Service.ListDraws``Repository.ListLuckyGiftDraws``luckyDrawWhereClause` | 已验收:分页上限由 service 控制,支持状态筛选 |
| 抽奖汇总 | `GET /admin/activity/lucky-gifts/draw-summary` | `Handler.GetLuckyGiftDrawSummary``Service.GetDrawSummary``Repository.GetLuckyGiftDrawSummary` | 已验收统计总抽数、参与用户、消耗、返奖、pending/granted/failed |
| 后台路由权限 | 所有后台入口需要 `lucky-gift:view``lucky-gift:update` | `RegisterRoutes` | 已验收:路由注册在后台模块内 |
## 七、数据表
| 表 | 用途 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| `lucky_gift_rule_versions` | v2 不可变规则版本 | `PublishLuckyGiftRuleConfig``getLuckyGiftRuleConfig` | 已验收schema 在 `001_activity_service.sql` 和迁移兜底中存在 |
| `lucky_gift_stage_tiers` | 每个规则版本的阶段奖档概率 | `PublishLuckyGiftRuleConfig``getLuckyGiftRuleStages` | 已验收:按 stage 和倍率顺序读取 |
| `lucky_rtp_windows` | RTP 观察窗口 | `getOpenLuckyRTPWindow``createLuckyRTPWindow``persistLuckyBatchDraw``applyLuckyDraw` | 已验收:窗口关闭后创建新窗口 |
| `lucky_pools` | 平台、房间、礼物三层基础奖池 | `getOrCreateLuckyPool``updateLuckyPoolNet``creditLuckyPool``debitLuckyPool` | 已验收:容量过滤使用 reserve floor |
| `lucky_room_atmosphere_pools` | 房间气氛池 | `getOrCreateLuckyAtmosphere``updateLuckyAtmosphereNet``creditLuckyAtmosphere` | 已验收:独立于 Room Cell |
| `lucky_activity_budgets` | 活动补贴预算 | `ensureLuckyActivityBudget``getLuckyActivityRemaining` | 已验收:总预算和 UTC 日预算都支持 |
| `lucky_user_states` | 用户阶段、累计流水、等价抽数和连亏 | `getLuckyUserState``getLuckyUserStateForUpdate``persistLuckyBatchDraw``applyLuckyDraw` | 已验收:包含 `cumulative_wager_coins``equivalent_draws``loss_streak` |
| `lucky_risk_counters` | 风控窗口累计返奖 | `getLuckyRiskCounters``getOrCreateLuckyRiskCounter``updateLuckyRiskCounters` | 已验收:用户小时、用户日、设备日、房间小时、主播日五类 scope |
| `lucky_draw_records` | 单抽审计事实 | `insertLuckyDrawRecords``applyLuckyDraw``ListLuckyGiftDraws` | 已验收:批量送礼仍保留单抽粒度 |
| `activity_outbox` | 幸运礼物返奖和表现副作用 | `insertLuckyAggregateDrawOutbox``ClaimPendingLuckyGiftOutbox``MarkLuckyGiftOutboxDelivered` | 已验收:批量送礼只写一条 `LuckyGiftDrawn` outbox |
## 八、运行边界
| 场景 | 当前处理 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 奖池未启用 | 送礼前检查或扣费后抽奖都会失败,不开奖 | `CheckLuckyGift``ExecuteLuckyGiftDraw``Service.Check``Service.Draw` | 已验收:未启用返回 RuleNotActive 或 enabled=false |
| 所有正奖档不可支付 | 本抽返回 0 倍 `no_payable_tier`,不透支奖池和风控 | `selectLuckyCandidate` | 已验收:单测覆盖全正奖不可支付回退 0 倍 |
| 小额礼物 | 阶段按累计流水折算,单抽奖励按单份金额缩放,风控上限不被缩小 | `luckyRuntimeConfig``luckyEquivalentDraws``luckyRiskCapacity` | 已验收:单测覆盖大额后小额仍可中小奖 |
| 高倍奖 | 发布校验要求高倍档 `HighWaterOnly`,运行时仍受奖池和风控过滤 | `validateRuleStage``selectLuckyCandidate` | 已验收:高倍率未标高水位会拒绝发布 |
| 批量 777 份 | 抽 777 次,保留 777 条明细,状态写入和 outbox 聚合 | `executeOptimizedLuckyGiftDrawBatch``stageLuckyBatchDraw``persistLuckyBatchDraw` | 已验收:代码结构已移出循环内 SQL 状态写入 |
| worker 重试 | 钱包用聚合 `draw_id` 幂等outbox 失败可重试 | `processDrawOutbox``MarkLuckyGiftOutboxRetryable` | 已验收:成功后 delivered不重复加币 |
| 已返奖但 IM 失败 | 不把 draw 标 failed只让 outbox failed 并保留告警空间 | `markOutboxFailed` | 已验收:`alreadyCredited` 分支不覆盖 draw 状态 |
## 九、代码验收清单
| 验收项 | 命令或检查 | 当前结果 |
| --- | --- | --- |
| 旧专属文档删除 | `test ! -e docs/幸运礼物Activity服务开发.md` 等 9 个旧文档 | 通过 |
| 新文档唯一入口 | `test -f docs/幸运礼物实现文档.md` | 通过 |
| 函数引用存在 | `rg -n "executeOptimizedLuckyGiftDrawBatch|stageLuckyBatchDraw|insertLuckyAggregateDrawOutbox|MarkLuckyGiftDrawsGranted"` | 通过 |
| Activity 幸运礼物服务测试 | `go test ./services/activity-service/internal/service/luckygift` | 通过 |
| Activity MySQL 抽奖测试 | `go test ./services/activity-service/internal/storage/mysql` | 通过 |
| Room 送礼链路测试 | `go test ./services/room-service/internal/room/service` | 通过 |
| Gateway 检查接口测试 | `go test ./services/gateway-service/internal/transport/http/activityapi` | 通过 |
| Admin 幸运礼物模块测试 | `cd server/admin && go test ./internal/modules/luckygift` | 通过 |
## 十、后续维护规则
1. 后续幸运礼物规则、字段、算法或接口变化,先改代码,再同步本文档对应功能点和验收项。
2. 新增后台字段必须同时标明:后台 DTO、proto 字段、domain 字段、发布校验、运行读取、数据库字段。
3. 新增抽奖行为必须写明是否影响候选概率、奖池水位、RTP 观察、风控计数、draw record、outbox payload、客户端同步响应。
4. 批量抽奖不能重新引入“每抽一条发奖 outbox”。抽奖可以是 N 次,钱包发奖必须按一次送礼命令聚合。
5. 不允许用 RTP 缺口在运行时动态改概率。RTP 只在发布校验和窗口观察中使用;运行时只能过滤不可支付奖档。

View File

@ -1,271 +0,0 @@
# 幸运礼物线上落地文档
本文档按当前代码实现整理幸运礼物的服务边界、配置语义、抽奖链路、体验模拟方式和已知问题。当前幸运礼物由 `activity-service` 持有规则、RTP 窗口、奖池、风控计数、抽奖事实和异步副作用 outbox金币扣费与返奖入账仍归 `wallet-service`,房间状态、热度、榜单和送礼消息仍归 `room-service`
## 相关代码
| 模块 | 文件 | 当前职责 |
| --- | --- | --- |
| protobuf 契约 | `api/proto/activity/v1/activity.proto` | `LuckyGiftConfig``multiplier_ppms``LuckyGiftDrawResult.multiplier_ppm`、后台配置和审计 RPC |
| 房间送礼主链路 | `services/room-service/internal/room/service/gift.go` | 送礼前可检查奖池,扣费成功后同步调用 `ExecuteLuckyGiftDraw`,把 `lucky_gift` 放入 `SendGiftResponse` |
| 幸运礼物领域模型 | `services/activity-service/internal/domain/luckygift/lucky_gift.go` | 规则、奖档、Check/Draw 命令、抽奖结果和审计查询结构 |
| 规则归一化 | `services/activity-service/internal/service/luckygift/config.go` | 后台输入 `multiplier_ppms` 后生成三档体验池奖档;权重不是后台配置项 |
| 抽奖服务 | `services/activity-service/internal/service/luckygift/service.go` | Check、Draw、outbox worker、返奖入账、房间 IM、区域播报 |
| MySQL 抽奖事务 | `services/activity-service/internal/storage/mysql/lucky_gift_repository.go` | 规则锁、RTP 窗口、三层池、风控计数、候选过滤、随机、draw fact、outbox |
| activity schema | `services/activity-service/deploy/mysql/initdb/001_activity_service.sql` | `lucky_gift_rules``lucky_rtp_windows``lucky_pools``lucky_user_states``lucky_draw_records` 等表 |
| gateway 检查接口 | `services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go` | `POST /api/v1/activities/lucky-gifts/check`,当前要求 `room_id/gift_id/pool_id` 都非空 |
| gateway 送礼接口 | `services/gateway-service/internal/transport/http/roomapi/room_handler.go` | `POST /api/v1/rooms/gift/send` 透传 `pool_id``command_id` |
| admin 配置接口 | `server/admin/internal/modules/luckygift/handler.go` | 后台读写 `pool_id` 维度的规则,支持 `multiplier_ppms` |
| 体验模拟工具 | `tools/lucky-gift-v2-demo/main.go` | 离线模拟用户 RTP 分布、奖池水位、风控限制和高倍命中 |
## 当前范围
| 能力 | 状态 | 代码事实 |
| --- | --- | --- |
| 送礼前检查 | 已实现 | gateway check 要求 `pool_id`activity 按 `pool_id` 读规则,未配置返回 `not_configured` |
| 扣费后抽奖 | 已实现 | `room-service` 先调 `wallet-service.DebitGift`,扣费成功后调 `activity-service.ExecuteLuckyGiftDraw` |
| 同步返回抽奖结果 | 已实现 | `SendGiftResponse.lucky_gift` 直接返回给送礼用户 |
| 后台多奖池 | 已实现 | 规则主键实际是 `(app_code, gift_id)`,但当前 `gift_id` 列保存的是 `pool_id` |
| 多倍数配置 | 已实现 | 后台配置 `multiplier_ppms`,服务端生成每个体验池的候选倍率 |
| RTP 双窗口 | 已实现 | 当前窗口 scope 是 `pool``pool_gift`,都按 `pool_id` 隔离 |
| 三层基础奖池 | 已实现 | platform / room / gift 三层池按 `pool_id` 隔离room 池再加 `room_id` |
| 风控计数 | 已实现 | user hour/day、device day、room hour、anchor day |
| 房间气氛池 | 已实现 | 只在基础 RTP 不需要强制追平时参与候选 |
| 活动补贴池 | 已实现 | 只在基础 RTP 不需要强制追平时参与候选 |
| 返奖入账 | 已实现 | 抽奖事务写 `activity_outbox`worker 调 `wallet-service.CreditLuckyGiftReward` |
| 房间中奖 IM | 已实现 | worker 投递 `lucky_gift_drawn` 群消息 |
| 区域中奖播报 | 已实现 | `multiplier_ppm >= 1x` 且有 `visible_region_id` 时写区域播报 outbox |
| 完整候选审计 | 未完整 | `candidate_tiers_json` 当前只保存命中候选和限制标记,不保存完整过滤后候选表 |
| 随机摘要审计 | 未实现 | 当前没有 `random_digest` 字段落库 |
## 主链路
```text
Client
-> gateway POST /api/v1/rooms/gift/send
-> room-service SendGift
-> 可选 CheckLuckyGift(pool_id 非空时送礼前检查)
-> wallet-service DebitGift
-> activity-service ExecuteLuckyGiftDraw
-> activity-service 本地事务写 draw fact + activity_outbox
-> room-service 更新 Room Cell 热度、榜单、礼物 outbox
-> gateway 返回 data.lucky_gift
-> lucky gift worker 补偿返奖入账和 lucky_gift_drawn IM
```
当前有两个触发入口:
| 入口 | 行为 |
| --- | --- |
| 请求显式传 `pool_id` | `room-service` 会在扣费前调用 `CheckLuckyGift`,规则未启用则不扣费 |
| 请求未传 `pool_id` 但钱包返回 `gift_type_code=lucky/super_lucky` | `room-service` 会在扣费后按默认奖池抽奖,`activity-service` 把空 `pool_id` 归一化为 `default` |
客户端 check 接口当前要求 `pool_id` 必填,所以 App 不应依赖“空 `pool_id` 默认奖池”的隐式路径。礼物面板需要下发明确的 `pool_id`
## 多倍数配置
后台现在的核心配置不是固定概率表,而是倍率列表:
```json
{
"pool_id": "lucky",
"gift_price": 100,
"target_rtp_ppm": 950000,
"pool_rate_ppm": 950000,
"high_multiplier": 100,
"large_tier_enabled": true,
"multiplier_ppms": [
0,
200000,
500000,
1000000,
2000000,
5000000,
10000000,
20000000,
50000000,
100000000,
500000000
]
}
```
倍率单位是 ppm
| 值 | 含义 | `gift_price=100` 时基础返奖 |
| ---: | --- | ---: |
| `0` | 未中奖 | `0` |
| `200000` | `0.2x` | `20` |
| `500000` | `0.5x` | `50` |
| `1000000` | `1x` | `100` |
| `2000000` | `2x` | `200` |
| `100000000` | `100x` | `10000` |
| `500000000` | `500x` | `50000` |
服务端发布配置时会执行这些归一化:
1. 如果 `multiplier_ppms` 非空,直接用它去重、排序并过滤负数。
2. 如果 `multiplier_ppms` 为空,则从传入 `tiers` 里反推倍率。
3. 如果两者都为空,则使用默认倍率列表。
4. 每个倍率都会生成 `novice``intermediate``advanced` 三个体验池各一条奖档。
5. 生成奖档的 `weight` 固定为 `0`,真实权重由抽奖时 RTP 窗口动态计算。
6. `multiplier_ppm >= high_multiplier * 1000000` 的奖档会标记 `high_water_only`
这意味着当前后台不能按体验池单独配置概率,也不能直接配置“新手 2x 权重大、中级 5x 权重大”这类固定权重。后台能控制的是可用倍率集合、RTP 目标、窗口大小、奖池水位、风控上限和高倍开关。
## 抽奖算法事实
抽奖事务按 `command_id` 幂等。重复请求返回同一条 `lucky_draw_records`,不会重复消耗奖池或 RTP。
核心步骤:
```text
1. 锁定 command_id 对应 draw fact存在则直接返回
2. 按 pool_id 锁定幸运礼物规则;未启用则失败
3. 用真实 coin_spent 替换配置里的参考 gift_price并等比缩放奖档和风控上限
4. 锁定用户在该 pool_id 下的累计付费抽数,选择 novice/intermediate/advanced
5. 锁定 pool RTP 窗口和 pool_gift RTP 子窗口
6. 锁定 platform、room、gift 三层基础奖池
7. 锁定房间气氛池、活动预算和风控计数
8. 计算本次基础 RTP 最小必付额 min_required
9. 过滤低于 min_required、超过奖池容量、超过风控容量或高水位不足的倍率
10. 从可支付基础候选中按 RTP 缺口自动生成 1 个或 2 个带权候选
11. 当 min_required=0 时,额外加入房间气氛、活动补贴和阶段反馈候选
12. 使用 crypto/rand 做一次加权随机
13. 更新 RTP 窗口、三层池、气氛池、活动预算、风控计数、用户抽数
14. 写 lucky_draw_records有返奖或展示副作用时写 activity_outbox
```
运行时基础候选权重不是后台配置权重。`luckyAutoWeightedBaseCandidates` 会按当前窗口缺口选择:
| RTP 状态 | 当前行为 |
| --- | --- |
| 没有基础 RTP 缺口 | 优先选择 `0x` 候选;如果倍率列表没有 `0`,会选择最低可用倍率 |
| 缺口正好落在某个倍率以下 | 只给最接近且可支付的低档或高档候选分配 `1000000` 权重 |
| 缺口落在两个倍率之间 | 只保留相邻两个倍率,权重按线性插值分配 |
| 没有可用候选但必须追平 | 生成 `rtp_balance` 修正候选;如果超过容量则返回冲突 |
因此,多倍数列表不是“所有倍率每次都按固定概率参与随机”。它是 RTP 控制器可选择的离散返奖点。
## 用户体验模拟
体验模拟工具用于评估参数是否会带来可接受的用户体感分布。它输出全局 RTP、有效 RTP、100k/1m 窗口偏差、奖池水位、用户 RTP 分位数和用户净输赢分位数。
基础命令:
```bash
go run ./tools/lucky-gift-v2-demo \
-users 1000 \
-devices 900 \
-rooms 50 \
-draws-min 0 \
-draws-max 100000 \
-unique-draws \
-base-rtp 95 \
-cost 500 \
-global-window 100000 \
-gift-window 100000 \
-pool-rate-ppm 950000 \
-high-multiplier 100 \
-high-water-pool-multiple 2 \
-user-csv /tmp/lucky-gift-users.csv
```
重点看这些输出:
| 输出 | 判断方式 |
| --- | --- |
| `base_rtp` | 基础成本口径,应贴近 `base-rtp` |
| `effective_rtp` | 用户可见返还,允许因气氛池和活动补贴高于基础 RTP |
| `100k_blocks max_abs_deviation` | 应小于 `1pp` |
| `1m_blocks max_abs_deviation` | 应小于 `0.5pp` |
| `high_multiplier_hits` | 评估高倍实际出现频率是否符合运营预期 |
| `risk_cap_limited_draws` | 过高说明风控上限压得太低,会牺牲 RTP 追平能力 |
| `pool_cap_limited_draws` | 过高说明初始奖池、入池比例或 reserve 配置不健康 |
| `user_effective_rtp_p05/median/p95` | 用户体验分布;单用户不会收敛到全局 RTP |
| `user_net_p05/median/p95` | 用户净输赢体感;用于判断投诉风险和高倍刺激强度 |
当前模拟工具的限制:
| 限制 | 影响 |
| --- | --- |
| 不能直接输入 `multiplier_ppms` | 它使用内置新手/中级/高级 tier table不能完全复现后台任意倍率列表 |
| 使用伪随机 `math/rand` | 便于复现实验,不等同线上 `crypto/rand` |
| 不连接真实 MySQL | 不验证行锁、幂等、outbox 和钱包补偿 |
| 不模拟真实礼物面板 | 只模拟付费抽数、成本、奖池和用户结果分布 |
使用模拟结论上线前,仍需用真实 `activity-service` MySQL 测试覆盖配置发布、抽奖事务、重复 `command_id`、outbox 返奖和 IM 投递。
## 当前问题
| 问题 | 影响 | 建议动作 |
| --- | --- | --- |
| `lucky_gift_rules.gift_id` 实际保存 `pool_id` | 文档、后台和审计容易把真实礼物 ID 与奖池 ID 混淆 | 后续 DB 字段改名为 `pool_id`,或在所有接口文档明确“规则表 gift_id 是历史列名” |
| 后台传入 `tiers.weight` 不生效 | 运营以为配置了固定概率,但线上按 RTP 缺口自动权重抽 | 后台隐藏或只读展示 `tiers.weight`,主编辑项改为 `multiplier_ppms` |
| `multiplier_ppms` 可以不含 `0` | 没有 RTP 缺口时最低正倍率可能被强制选中,用户体感和成本都会异常 | 后台校验建议强制包含 `0`,除非明确做“每抽必返”奖池 |
| 三个体验池使用同一倍率集合 | 无法做到新手池低倍密集、高级池高倍更多的差异化倍率表 | 增加按 pool 分组的倍率配置,或保留统一倍率但明确只能靠阶段和 RTP 控制差异 |
| 当前服务不使用后台固定概率 | “概率表版本”“权重表”类文档与代码不一致 | 文档统一改为“倍率列表 + 运行时 RTP 自动权重” |
| `candidate_tiers_json` 不保存完整候选表 | 后台审计无法复盘每次过滤后的完整随机空间 | 落库完整候选列表、过滤原因、总权重和随机落点 |
| 没有 `random_digest` | 不能证明随机材料与候选表一致 | 增加随机摘要或 seed commit-reveal 方案 |
| 同步返回通常是 `pending` | 客户端如果看到返奖就展示“已到账”,会早于钱包入账事实 | 客户端只在钱包 IM 或余额刷新后更新余额HTTP 只展示“中奖/处理中” |
| check 接口要求 `pool_id`,送礼链路又支持空 `pool_id` 默认奖池 | App 行为和服务端隐式兜底不一致 | 礼物面板必须明确下发 `pool_id`,客户端幸运礼物送礼必传 |
| 区域播报阈值硬编码为 `>= 1x` | 后台不能独立控制哪些中奖进入区域飘屏 | 增加 `broadcast_min_multiplier_ppm``broadcast_min_reward_coins` |
| 模拟工具不能输入多倍数列表 | 不能验证“多个倍数配置”对实际体感的影响 | 给 `tools/lucky-gift-v2-demo` 增加 `-multipliers-ppm` 参数,或新增基于线上 `Config` 的模拟入口 |
## 后台配置建议
| 目标 | 建议 |
| --- | --- |
| 常规幸运礼物 | `multiplier_ppms` 包含 `0/0.2x/0.5x/1x/2x/5x/10x/20x`,高倍关闭或保持较高水位门槛 |
| 超级幸运礼物 | 在常规列表基础上增加 `50x/100x/500x`,同时提高初始池、水位倍数和风控 cap |
| 新手体验 | 保留 `0.2x/0.5x/1x/2x`,不要只配置 `1x` 以上倍率 |
| 降低投诉 | 降低 `effective_rtp` 波动,增加小倍率数量,减少 `500x` 等极高倍 |
| 控制成本 | 降低 `target_rtp_ppm` 或补贴预算,不要用过低风控 cap 硬压基础 RTP |
高风险配置示例:
| 配置 | 风险 |
| --- | --- |
| `multiplier_ppms=[1000000,2000000]` | 没有未中奖档,低缺口时仍可能返 `1x` |
| `target_rtp_ppm=950000` 但最大倍率低于 `1x` | 发布会被校验拒绝,无法追平 RTP |
| `large_tier_enabled=true` 但初始池很低 | 高倍长期被过滤,用户看到配置但实际不出 |
| `user_hourly_payout_cap` 低于小时期望返奖 | 风控会阻断 RTP 追平,触发 `cannot be settled` |
## 验证
文档或配置语义变更后至少跑相关单元测试:
```bash
go test ./services/activity-service/internal/service/luckygift
go test ./services/activity-service/internal/storage/mysql
go test ./services/room-service/internal/room/service
go test ./services/gateway-service/internal/transport/http/activityapi
```
参数上线前跑体验模拟:
```bash
go run ./tools/lucky-gift-v2-demo \
-users 1000 \
-devices 900 \
-rooms 50 \
-draws-min 0 \
-draws-max 100000 \
-unique-draws \
-base-rtp 95 \
-cost 500 \
-global-window 100000 \
-gift-window 100000 \
-user-csv /tmp/lucky-gift-users.csv
```
验收口径:
| 项 | 要求 |
| --- | --- |
| `100000` paid draws | 基础 RTP 偏差 `< 1pp` |
| `1000000` paid draws | 基础 RTP 偏差 `< 0.5pp` |
| pool safety | 三层池和气氛池不低于 reserve |
| idempotency | 重复 `command_id` 返回同一 draw |
| reward status | 有返奖时先 `pending`worker 成功后 `granted` |
| Flutter 体验 | 不本地加余额;余额以 `WalletBalanceChanged` 或余额查询为准 |

View File

@ -1,456 +0,0 @@
# 幸运礼物重构方案
本文档从 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

@ -1,156 +0,0 @@
# 幸运礼物重构第一步:配置契约与单位
本文档定义幸运礼物重构第一步。第一步只解决配置、单位、规则版本和后台发布校验,不实现新抽奖算法。当前处于开发阶段,不兼容旧代码,不保留旧的 `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

@ -1,210 +0,0 @@
# 幸运礼物重构第三步:链路接入与上线验收
本文档定义幸运礼物重构第三步。第三步把 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

@ -1,205 +0,0 @@
# 幸运礼物重构第二步:抽奖引擎与模拟器
本文档定义幸运礼物重构第二步。第二步基于第一步的新配置契约,实现 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

@ -144,12 +144,12 @@
| --- | --- | --- | --- |
| activity-service 底座 | `PARTIAL` | gRPC Ping/GetActivityStatus、MySQL activity/outbox 表存在 | 具体活动规则和奖励结算未实现 |
| room outbox 消费边界 | `DONE` | 本地 direct 投递 activity-service线上可发布 `hyapp_room_outbox`activity 使用 `hyapp-activity-room-outbox` 独立消费位点 | 后台排障页和消费积压告警需补 |
| 幸运礼物开发文档 | `DONE` | `docs/幸运礼物Activity服务开发.md` | 按文档实现 proto、pool、risk、draw |
| 幸运礼物实现文档 | `DONE` | `docs/幸运礼物实现文档.md` | 后续幸运礼物变更同步该唯一文档 |
| 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 |
| LuckyGift Check | `TODO` | 文档有设计proto/code 未实现 | `CheckLuckyGift` RPC 和规则校验 |
| LuckyGift Draw | `TODO` | 文档有设计proto/code 未实现 | 三层奖池、一层风控、幂等 draw |
| 奖励入账 | `TODO` | wallet 只有 DebitGift无 CreditLuckyReward | wallet credit RPC、activity outbox、重试 |
| 幸运礼物审计 | `TODO` | 文档有 draw/pool/risk 审计设计 | 表结构和查询入口未实现 |
| LuckyGift Check | `DONE` | `CheckLuckyGift` RPC、gateway 检查接口和 room-service 扣费前检查已实现 | App 礼物面板按 `pool_id` 联调 |
| LuckyGift Draw | `DONE` | `ExecuteLuckyGiftDraw`、三层奖池、RTP 窗口、风控计数、批量抽奖和幂等已实现 | 运营配置和长压测继续补 |
| 奖励入账 | `DONE` | activity outbox worker 调 wallet `CreditLuckyGiftReward`,批量抽奖按一次送礼聚合发放 | 线上监控和积压告警需补 |
| 幸运礼物审计 | `DONE` | `lucky_draw_records`、后台 draws 和 draw-summary 查询已实现 | 后台可视化维度继续补 |
## Messaging Product Surface

View File

@ -32,6 +32,14 @@ type configRequest struct {
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
EffectiveFromMS int64 `json:"effective_from_ms"`
Stages []stageDTO `json:"stages"`
}
@ -226,6 +234,14 @@ func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
EffectiveFromMs: req.EffectiveFromMS,
Stages: stages,
}
@ -268,6 +284,14 @@ func configFromProto(config *activityv1.LuckyGiftRuleConfig) configDTO {
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
EffectiveFromMS: config.GetEffectiveFromMs(),
Stages: stages,
},

View File

@ -15,6 +15,8 @@ func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
SettlementWindowWager: 1_000_000,
ControlBandPercent: 1,
GiftPriceReference: 100,
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
Stages: []stageDTO{
{
Stage: " novice ",
@ -44,6 +46,9 @@ func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
if config.GetControlBandPpm() != 10_000 {
t.Fatalf("control band ppm = %d, want 10000", config.GetControlBandPpm())
}
if config.GetNoviceMaxEquivalentDraws() != 2_000 || config.GetNormalMaxEquivalentDraws() != 20_000 {
t.Fatalf("equivalent draw thresholds mismatch: novice=%d normal=%d", config.GetNoviceMaxEquivalentDraws(), config.GetNormalMaxEquivalentDraws())
}
tier := config.GetStages()[0].GetTiers()[0]
if tier.GetStage() != "novice" {
t.Fatalf("tier stage = %q, want novice", tier.GetStage())
@ -70,6 +75,14 @@ func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) {
SettlementWindowWager: 1_000_000,
ControlBandPpm: 10_000,
GiftPriceReference: 100,
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
MaxSinglePayout: 50_000,
UserHourlyPayoutCap: 34_200,
UserDailyPayoutCap: 615_600,
DeviceDailyPayoutCap: 1_026_000,
RoomHourlyPayoutCap: 684_000,
AnchorDailyPayoutCap: 12_312_000,
CreatedByAdminId: 12,
CreatedAtMs: 1_700_000_000_000,
Stages: []*activityv1.LuckyGiftRuleStage{
@ -98,6 +111,12 @@ func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) {
if config.ControlBandPercent != 1 {
t.Fatalf("control band percent = %v, want 1", config.ControlBandPercent)
}
if config.RoomHourlyPayoutCap != 684_000 || config.AnchorDailyPayoutCap != 12_312_000 {
t.Fatalf("risk caps were not copied from proto: room=%d anchor=%d", config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap)
}
if config.NoviceMaxEquivalentDraws != 2_000 || config.NormalMaxEquivalentDraws != 20_000 {
t.Fatalf("equivalent draw thresholds were not copied from proto: novice=%d normal=%d", config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws)
}
if config.Stages[0].Tiers[0].Multiplier != 0.3 {
t.Fatalf("multiplier = %v, want 0.3", config.Stages[0].Tiers[0].Multiplier)
}

View File

@ -1,5 +1,21 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 本迁移同时覆盖两种开发阶段状态:
-- 1. 老库已有 admin_app_configs但由早期 AutoMigrate 建表,缺少描述和创建时间列;
-- 2. 新本地库先跑 SQL migrations 再 AutoMigrate此时表还不存在后续 ALTER 会直接失败。
CREATE TABLE IF NOT EXISTS admin_app_configs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`group` VARCHAR(64) NOT NULL,
`key` VARCHAR(100) NOT NULL,
value TEXT,
description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '描述信息',
created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (id),
UNIQUE KEY idx_admin_app_config_group_key (`group`, `key`),
KEY idx_admin_app_configs_group (`group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 老生产库曾由早期 AutoMigrate 建表,缺少当前模型依赖的描述和创建时间列。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_configs' AND COLUMN_NAME = 'description') = 0,

View File

@ -100,6 +100,14 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币',
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm',
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
novice_max_equivalent_draws BIGINT NOT NULL DEFAULT 2000 COMMENT '新手阶段截止等价抽数,按累计金币/参考价格折算',
normal_max_equivalent_draws BIGINT NOT NULL DEFAULT 20000 COMMENT '正常阶段截止等价抽数,超过后进入高阶阶段',
max_single_payout BIGINT NOT NULL DEFAULT 50000 COMMENT '单次基础返奖上限,按参考价格配置',
user_hourly_payout_cap BIGINT NOT NULL DEFAULT 34200 COMMENT '单用户每小时基础返奖上限,按参考价格配置',
user_daily_payout_cap BIGINT NOT NULL DEFAULT 615600 COMMENT '单用户每日基础返奖上限,按参考价格配置',
device_daily_payout_cap BIGINT NOT NULL DEFAULT 1026000 COMMENT '单设备每日基础返奖上限,按参考价格配置',
room_hourly_payout_cap BIGINT NOT NULL DEFAULT 684000 COMMENT '单房间每小时基础返奖上限,按参考价格配置',
anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 12312000 COMMENT '单主播每日基础返奖上限,按参考价格配置',
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -187,6 +195,9 @@ CREATE TABLE IF NOT EXISTS lucky_user_states (
user_id BIGINT NOT NULL COMMENT '用户 ID',
gift_id VARCHAR(96) NOT NULL COMMENT '奖池 ID当前列名沿用旧内部作用域名',
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '该用户幸运礼物累计付费抽数',
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, gift_id)

View File

@ -51,10 +51,11 @@ type Config struct {
GiftPrice int64 `json:"gift_price"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
ControlBandPPM int64 `json:"control_band_ppm"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceDrawLimit int64 `json:"novice_draw_limit"`
IntermediateDrawLimit int64 `json:"intermediate_draw_limit"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
PlatformPoolWeightPPM int64 `json:"platform_pool_weight_ppm"`
@ -114,9 +115,17 @@ type RuleConfig struct {
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []RuleStage `json:"stages"`
}

View File

@ -26,10 +26,11 @@ func DefaultConfig(appCode, poolID string) domain.Config {
GiftPrice: giftPrice,
TargetRTPPPM: 950_000,
PoolRatePPM: 950_000,
ControlBandPPM: 10_000,
GlobalWindowDraws: 100_000,
GiftWindowDraws: 100_000,
NoviceDrawLimit: 2_000,
IntermediateDrawLimit: 20_000,
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
HighMultiplier: 100,
HighWaterPoolMultiple: 2,
PlatformPoolWeightPPM: 200_000,
@ -70,24 +71,34 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
SettlementWindowWager: 1_000_000,
ControlBandPPM: 10_000,
GiftPriceReference: 100,
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
// v2 风控上限是绝对金币额,运行时只累计真实返奖;小额礼物不能把窗口上限按比例缩小。
MaxSinglePayout: 50_000,
UserHourlyPayoutCap: 34_200,
UserDailyPayoutCap: 615_600,
DeviceDailyPayoutCap: 1_026_000,
RoomHourlyPayoutCap: 684_000,
AnchorDailyPayoutCap: 12_312_000,
Stages: []domain.RuleStage{
defaultRuleStage(domain.StageNovice, []ruleTierSeed{
{id: "novice_none", multiplierPPM: 0, weightPPM: 300_000},
{id: "novice_0_3x", multiplierPPM: 300_000, weightPPM: 220_000},
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 180_000},
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 300_000},
{id: "novice_none", multiplierPPM: 0, weightPPM: 100_000},
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
{id: "novice_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
}),
defaultRuleStage(domain.StageNormal, []ruleTierSeed{
{id: "normal_none", multiplierPPM: 0, weightPPM: 400_000},
{id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 250_000},
{id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 250_000},
{id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 100_000},
{id: "normal_none", multiplierPPM: 0, weightPPM: 100_000},
{id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
{id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
{id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
}),
defaultRuleStage(domain.StageAdvanced, []ruleTierSeed{
{id: "advanced_none", multiplierPPM: 0, weightPPM: 500_000},
{id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 300_000},
{id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
{id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 50_000},
{id: "advanced_none", multiplierPPM: 0, weightPPM: 270_000},
{id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 600_000},
{id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 100_000},
{id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 30_000},
}),
},
}
@ -160,6 +171,14 @@ func validateRuleConfig(config domain.RuleConfig) error {
if config.GiftPriceReference <= 0 {
return xerr.New(xerr.InvalidArgument, "gift price reference must be positive")
}
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid")
}
// 风控上限是 v2 抽奖候选被 risk_cap 剪掉的直接来源,必须随规则版本固化,避免运行时继续吃代码硬编码。
if config.MaxSinglePayout <= 0 || config.UserHourlyPayoutCap <= 0 || config.UserDailyPayoutCap <= 0 ||
config.DeviceDailyPayoutCap <= 0 || config.RoomHourlyPayoutCap <= 0 || config.AnchorDailyPayoutCap <= 0 {
return xerr.New(xerr.InvalidArgument, "risk payout caps must be positive")
}
stageMap := make(map[string]domain.RuleStage, len(config.Stages))
for _, stage := range config.Stages {
if !validRuleStage(stage.Stage) {
@ -178,6 +197,9 @@ func validateRuleConfig(config domain.RuleConfig) error {
if err := validateRuleStage(stage); err != nil {
return err
}
if err := validateRuleStageExpectedRTP(stage, config.TargetRTPPPM, config.ControlBandPPM); err != nil {
return err
}
}
return nil
}
@ -226,6 +248,27 @@ func validateRuleStage(stage domain.RuleStage) error {
return nil
}
func validateRuleStageExpectedRTP(stage domain.RuleStage, targetRTPPPM int64, controlBandPPM int64) error {
var expectedPPM int64
for _, tier := range stage.Tiers {
if !tier.Enabled || tier.RewardSource != domain.SourceBaseRTP {
continue
}
expectedPPM += tier.MultiplierPPM * tier.BaseWeightPPM / ppmScale
}
lower := targetRTPPPM - controlBandPPM
upper := targetRTPPPM + controlBandPPM
if expectedPPM < lower || expectedPPM > upper {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage expected RTP %.2f%% must be within target band %.2f%%-%.2f%%",
stage.Stage,
float64(expectedPPM)/10_000,
float64(lower)/10_000,
float64(upper)/10_000,
))
}
return nil
}
func validRuleStage(stage string) bool {
return stage == domain.StageNovice || stage == domain.StageNormal || stage == domain.StageAdvanced
}
@ -260,8 +303,8 @@ func validateConfig(config domain.Config) error {
if config.GlobalWindowDraws <= 0 || config.GiftWindowDraws <= 0 {
return xerr.New(xerr.InvalidArgument, "RTP window draws must be positive")
}
if config.NoviceDrawLimit < 0 || config.IntermediateDrawLimit < config.NoviceDrawLimit {
return xerr.New(xerr.InvalidArgument, "experience pool draw limits are invalid")
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
return xerr.New(xerr.InvalidArgument, "experience pool equivalent draw limits are invalid")
}
if config.HighMultiplier <= 0 || config.HighWaterPoolMultiple <= 0 {
return xerr.New(xerr.InvalidArgument, "high multiplier config is invalid")
@ -345,7 +388,7 @@ func buildMultiplierTiers(cost, highMultiplier int64, multipliers []int64) []dom
TierID: pool + "_" + multiplierTierID(multiplierPPM),
RewardCoins: cost * multiplierPPM / ppmScale,
MultiplierPPM: multiplierPPM,
// 权重固定为 0 是有意设计:线上概率由 RTP 缺口、奖池容量和风控容量运行时生成
// 旧 multiplier_ppms 入口没有显式概率;运行侧会退化为等权随机,正式 v2 发布应使用阶段奖档概率
Weight: 0,
HighWaterOnly: highMultiplier > 0 && multiplierPPM >= highMultiplier*ppmScale,
Enabled: true,

View File

@ -91,6 +91,24 @@ func TestValidateRuleConfigRejectsStageWeightMismatch(t *testing.T) {
}
}
func TestValidateRuleConfigRejectsMissingRiskCaps(t *testing.T) {
config := DefaultRuleConfig("hyapp", "pool_bad_cap")
config.RoomHourlyPayoutCap = 0
if err := validateRuleConfig(config); err == nil {
t.Fatalf("expected missing v2 risk cap to be rejected")
}
}
func TestValidateRuleConfigRejectsInvalidEquivalentDrawThresholds(t *testing.T) {
config := DefaultRuleConfig("hyapp", "pool_bad_stage_threshold")
config.NormalMaxEquivalentDraws = config.NoviceMaxEquivalentDraws - 1
if err := validateRuleConfig(config); err == nil {
t.Fatalf("expected invalid equivalent draw thresholds to be rejected")
}
}
func TestValidateRuleConfigRequiresExplicitZeroTier(t *testing.T) {
config := DefaultRuleConfig("hyapp", "pool_no_zero")
config.Stages[1].Tiers = config.Stages[1].Tiers[1:]

View File

@ -8,8 +8,6 @@ import (
"strings"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
@ -17,6 +15,9 @@ import (
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
domain "hyapp/services/activity-service/internal/domain/luckygift"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
// Repository 是幸运礼物唯一持久化边界RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
@ -34,6 +35,9 @@ type Repository interface {
MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error
MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error
MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
// 批量抽奖只产生一条聚合 outboxworker 处理后需要用 draw_ids 一次性收敛所有明细状态。
MarkLuckyGiftDrawsGranted(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string, nowMS int64) error
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
}
// WalletClient 是幸运礼物返奖唯一账务依赖;实际余额变更通知继续由 wallet outbox -> notice-service 投递。
@ -124,7 +128,7 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
}
if cmd.TargetUserID < 0 || cmd.GiftCount < 0 {
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
if cmd.PaidAtMS <= 0 {
@ -194,7 +198,7 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
if s.wallet == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift wallet client is not configured"), false)
}
// 返奖 command_id 派生自 draw_id钱包侧可用它做幂等worker 重试不会重复加币
// 返奖 command_id 派生自聚合 draw_id钱包侧可用它做幂等批量抽奖不按子抽逐条加币避免待发放和钱包流水膨胀
resp, err := s.wallet.CreditLuckyGiftReward(eventCtx, &walletv1.CreditLuckyGiftRewardRequest{
CommandId: "lucky_reward:" + payload.DrawID,
AppCode: event.AppCode,
@ -229,8 +233,9 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
nowMS := s.now().UTC().UnixMilli()
// 所有外部副作用都成功后再把 draw 从 pending 收敛到 granted客户端据此区分“处理中”和“已完成”。
if err := s.repository.MarkLuckyGiftDrawGranted(eventCtx, event.AppCode, payload.DrawID, walletTransactionID, nowMS); err != nil {
// 所有外部副作用都成功后再把整批 draw 从 pending 收敛到 granted。
// 这一步必须晚于钱包和 IM否则客户端可能看到“已发放”但余额或房间表现还没落地。
if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil {
return err
}
return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS)
@ -275,8 +280,8 @@ func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox,
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetry {
if !alreadyCredited {
// 未返奖的 draw 可以标记 failed已返奖但 IM 失败时不能覆盖账务事实,只停 outbox 并告警。
_ = s.repository.MarkLuckyGiftDrawFailed(ctx, event.AppCode, drawIDFromPayload(event.PayloadJSON), cause.Error(), nowMS)
// 未返奖的 draw 可以整批标记 failed已返奖但 IM 失败时不能覆盖账务事实,只停 outbox 并告警。
_ = s.repository.MarkLuckyGiftDrawsFailed(ctx, event.AppCode, drawIDsFromPayload(event.PayloadJSON), cause.Error(), nowMS)
}
return s.repository.MarkLuckyGiftOutboxFailed(ctx, event, nextRetryCount, cause.Error(), nowMS)
}
@ -365,6 +370,8 @@ type luckyGiftDrawnPayload struct {
EventID string `json:"event_id"`
AppCode string `json:"app_code"`
DrawID string `json:"draw_id"`
// DrawIDs 是聚合发放的明细集合;老 outbox 没有该字段时 decode 会回填为单个 DrawID。
DrawIDs []string `json:"draw_ids,omitempty"`
CommandID string `json:"command_id"`
PoolID string `json:"pool_id"`
RoomID string `json:"room_id"`
@ -440,6 +447,13 @@ func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error)
if payload.EventType == "" {
payload.EventType = "lucky_gift_drawn"
}
// 兼容两类 outbox老单抽 payload 只有 draw_id新批量 payload 同时有聚合 draw_id 和 draw_ids。
if payload.DrawID == "" && len(payload.DrawIDs) > 0 {
payload.DrawID = payload.DrawIDs[0]
}
if len(payload.DrawIDs) == 0 && payload.DrawID != "" {
payload.DrawIDs = []string{payload.DrawID}
}
if payload.EventID == "" && payload.DrawID != "" {
payload.EventID = "lucky_gift_drawn:" + payload.DrawID
}
@ -453,12 +467,13 @@ func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error)
return payload, nil
}
func drawIDFromPayload(payloadJSON string) string {
// drawIDsFromPayload 只用于失败收敛;解析失败时返回 nil让仓储层保持 no-op避免误更新未知 draw。
func drawIDsFromPayload(payloadJSON string) []string {
payload, err := decodeDrawOutboxPayload(payloadJSON)
if err != nil {
return ""
return nil
}
return payload.DrawID
return payload.DrawIDs
}
func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {

View File

@ -3,6 +3,7 @@ package luckygift
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@ -108,12 +109,61 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomIM(t *testing.T) {
}
}
func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_batch_1",
"app_code": "lalu",
"draw_id": "lucky_draw_batch_1",
"draw_ids": []string{"lucky_draw_batch_1", "lucky_draw_batch_2", "lucky_draw_batch_3"},
"command_id": "cmd-gift-batch",
"pool_id": "lucky_100",
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"room_id": "room-1",
"gift_id": "rose",
"gift_count": 3,
"coin_spent": 300,
"effective_reward_coins": 800,
"multiplier_ppm": 8000000,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
outbox: []domain.DrawOutbox{{
AppCode: "lalu",
OutboxID: "lucky_lucky_draw_batch_1",
EventType: "LuckyGiftDrawn",
PayloadJSON: string(payload),
}},
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
}
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" {
t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
}
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
t.Fatalf("all sub draws should be granted together, got %s", got)
}
}
type fakeLuckyGiftRepository struct {
getConfigCalls int
upserted domain.RuleConfig
outbox []domain.DrawOutbox
grantedDrawID string
grantedDrawIDs []string
deliveredOutboxID string
}
@ -173,6 +223,18 @@ func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawFailed(context.Context, strin
return nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawsGranted(_ context.Context, _ string, drawIDs []string, _ string, _ int64) error {
r.grantedDrawIDs = append([]string(nil), drawIDs...)
if len(drawIDs) > 0 {
r.grantedDrawID = drawIDs[0]
}
return nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawsFailed(context.Context, string, []string, string, int64) error {
return nil
}
type fakeLuckyGiftWallet struct {
last *walletv1.CreditLuckyGiftRewardRequest
}

View File

@ -1,12 +1,13 @@
package mysql
import (
"strings"
"testing"
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
func TestLuckyRuntimeConfigScalesTierRewardsByActualSpend(t *testing.T) {
func TestLuckyRuntimeConfigScalesTierRewardsByActualSpendButKeepsRiskCapsAbsolute(t *testing.T) {
config := domain.Config{
GiftID: domain.DefaultPoolID,
PoolID: domain.DefaultPoolID,
@ -27,14 +28,194 @@ func TestLuckyRuntimeConfigScalesTierRewardsByActualSpend(t *testing.T) {
if runtimeConfig.Tiers[0].RewardCoins != 100_000 {
t.Fatalf("expected tier reward to scale by spend, got %d", runtimeConfig.Tiers[0].RewardCoins)
}
if runtimeConfig.MaxSinglePayout != 500_000 || runtimeConfig.UserHourlyPayoutCap != 1_000_000 {
t.Fatalf("expected risk caps to scale by spend, got single=%d hourly=%d", runtimeConfig.MaxSinglePayout, runtimeConfig.UserHourlyPayoutCap)
if runtimeConfig.MaxSinglePayout != 250_000 || runtimeConfig.UserHourlyPayoutCap != 500_000 {
t.Fatalf("expected risk caps to stay absolute, got single=%d hourly=%d", runtimeConfig.MaxSinglePayout, runtimeConfig.UserHourlyPayoutCap)
}
if config.Tiers[0].RewardCoins != 50_000 {
t.Fatalf("runtime scaling mutated source config")
}
}
func TestLuckyRuntimeConfigFromRuleConfigUsesV2PoolAndStageTiers(t *testing.T) {
ruleConfig := domain.RuleConfig{
AppCode: "lalu",
PoolID: "lucky",
RuleVersion: 7,
Enabled: true,
TargetRTPPPM: 950_000,
PoolRatePPM: 960_000,
SettlementWindowWager: 1_000_001,
GiftPriceReference: 100,
MaxSinglePayout: 9_999,
UserHourlyPayoutCap: 10_001,
UserDailyPayoutCap: 10_002,
DeviceDailyPayoutCap: 10_003,
RoomHourlyPayoutCap: 10_004,
AnchorDailyPayoutCap: 10_005,
CreatedByAdminID: 42,
CreatedAtMS: 123456,
Stages: []domain.RuleStage{
{Stage: domain.StageNovice, Tiers: []domain.RuleTier{
{Stage: domain.StageNovice, TierID: "novice_none", MultiplierPPM: 0, BaseWeightPPM: 300_000, RewardSource: domain.SourceBaseRTP, Enabled: true},
{Stage: domain.StageNovice, TierID: "novice_0_3x", MultiplierPPM: 300_000, BaseWeightPPM: 220_000, RewardSource: domain.SourceBaseRTP, Enabled: true},
}},
{Stage: domain.StageNormal, Tiers: []domain.RuleTier{
{Stage: domain.StageNormal, TierID: "normal_2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 100_000, RewardSource: domain.SourceBaseRTP, Enabled: true},
}},
{Stage: domain.StageAdvanced, Tiers: []domain.RuleTier{
{Stage: domain.StageAdvanced, TierID: "advanced_5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 50_000, RewardSource: domain.SourceBaseRTP, HighWaterOnly: true, Enabled: true},
}},
},
}
config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
if err != nil {
t.Fatalf("convert v2 rule config failed: %v", err)
}
if config.GiftID != "lucky" || config.PoolID != "lucky" || config.RuleVersion != 7 {
t.Fatalf("runtime identity should come from pool version, got pool=%s gift=%s version=%d", config.PoolID, config.GiftID, config.RuleVersion)
}
if config.GlobalWindowDraws != 10_001 || config.GiftWindowDraws != 10_001 {
t.Fatalf("expected wager window to be converted to equivalent draw window, got global=%d gift=%d", config.GlobalWindowDraws, config.GiftWindowDraws)
}
if config.UserHourlyPayoutCap != 10_001 || config.RoomHourlyPayoutCap != 10_004 {
t.Fatalf("expected runtime risk caps to come from v2 config, got user_hour=%d room_hour=%d", config.UserHourlyPayoutCap, config.RoomHourlyPayoutCap)
}
expected := map[string]struct {
pool string
rewardCoins int64
weight int64
highWaterOnly bool
}{
"novice_0_3x": {pool: domain.PoolNovice, rewardCoins: 30, weight: 220_000},
"normal_2x": {pool: domain.PoolIntermediate, rewardCoins: 200, weight: 100_000},
"advanced_5x": {pool: domain.PoolAdvanced, rewardCoins: 500, weight: 50_000, highWaterOnly: true},
}
for _, tier := range config.Tiers {
want, exists := expected[tier.TierID]
if !exists {
continue
}
if tier.Pool != want.pool || tier.RewardCoins != want.rewardCoins || tier.Weight != want.weight || tier.HighWaterOnly != want.highWaterOnly {
t.Fatalf("tier %s mismatch: got %#v want %#v", tier.TierID, tier, want)
}
delete(expected, tier.TierID)
}
if len(expected) != 0 {
t.Fatalf("missing converted v2 tiers: %#v", expected)
}
}
func TestLuckyExperiencePoolUsesEquivalentDrawsFromCumulativeWager(t *testing.T) {
config := domain.Config{
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
}
cases := []struct {
name string
cumulativeWager int64
referencePrice int64
wantDraws int64
wantPool string
}{
{name: "small gift advances fractionally by wager", cumulativeWager: 199_900, referencePrice: 100, wantDraws: 1_999, wantPool: domain.PoolNovice},
{name: "novice boundary includes exact threshold", cumulativeWager: 200_000, referencePrice: 100, wantDraws: 2_000, wantPool: domain.PoolNovice},
{name: "large gift exits novice by value not send count", cumulativeWager: 200_100, referencePrice: 100, wantDraws: 2_001, wantPool: domain.PoolIntermediate},
{name: "advanced starts after normal threshold", cumulativeWager: 2_000_100, referencePrice: 100, wantDraws: 20_001, wantPool: domain.PoolAdvanced},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
draws := luckyEquivalentDraws(tt.cumulativeWager, tt.referencePrice)
if draws != tt.wantDraws {
t.Fatalf("equivalent draws = %d, want %d", draws, tt.wantDraws)
}
if pool := luckyExperiencePool(draws, config); pool != tt.wantPool {
t.Fatalf("experience pool = %s, want %s", pool, tt.wantPool)
}
})
}
}
func TestLuckyDrawUnitSpendSplitsBatchSpendByGiftCount(t *testing.T) {
var total int64
for index := int32(1); index <= 10; index++ {
spend := luckyDrawUnitSpend(1_001, 10, index)
total += spend
if index == 1 && spend != 101 {
t.Fatalf("expected remainder coin to be assigned to first draw, got %d", spend)
}
if index > 1 && spend != 100 {
t.Fatalf("expected normal unit spend to be 100, got index=%d spend=%d", index, spend)
}
}
if total != 1_001 {
t.Fatalf("unit spends must preserve total spend, got %d", total)
}
if spend := luckyDrawUnitSpend(1_000, 10, 7); spend != 100 {
t.Fatalf("expected 1000 coins / 10 draws to use 100 per draw, got %d", spend)
}
}
func TestLuckyDrawSubCommandIDIsStableAndBounded(t *testing.T) {
if got := luckyDrawSubCommandID("gift_cmd", 3, 10); got != "gift_cmd#000003" {
t.Fatalf("short sub command id mismatch: %s", got)
}
longCommand := strings.Repeat("x", 160)
first := luckyDrawSubCommandID(longCommand, 12, 20)
second := luckyDrawSubCommandID(longCommand, 12, 20)
if first != second {
t.Fatalf("sub command id must be stable, got %s and %s", first, second)
}
if len(first) > 128 || !strings.HasSuffix(first, "#000012") {
t.Fatalf("sub command id must fit db column and keep draw index, got len=%d id=%s", len(first), first)
}
}
func TestLuckyAggregateDrawResultsSummarizesBatchReward(t *testing.T) {
cmd := domain.DrawCommand{CommandID: "gift_cmd", CoinSpent: 1_000}
results := []domain.DrawResult{
{DrawID: "draw-1", CommandID: "gift_cmd#000001", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "none", BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusGranted},
{DrawID: "draw-2", CommandID: "gift_cmd#000002", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "five", BaseRewardCoins: 500, EffectiveRewardCoins: 500, MultiplierPPM: 5_000_000, BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusPending, StageFeedback: true, HighMultiplier: true, CreatedAtMS: 123},
{DrawID: "draw-3", CommandID: "gift_cmd#000003", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "two", BaseRewardCoins: 200, EffectiveRewardCoins: 200, MultiplierPPM: 2_000_000, BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusPending, CreatedAtMS: 123},
}
for index := 0; index < 4; index++ {
results = append(results, domain.DrawResult{
DrawID: "draw-one",
CommandID: "gift_cmd#00000x",
PoolID: "lucky",
GiftID: "gift-1",
RuleVersion: 7,
ExperiencePool: domain.PoolNovice,
SelectedTierID: "one",
BaseRewardCoins: 100,
EffectiveRewardCoins: 100,
MultiplierPPM: 1_000_000,
BudgetSourcesJSON: `{"sources":["base_rtp"]}`,
RewardStatus: domain.StatusPending,
CreatedAtMS: 123,
})
}
for len(results) < 10 {
results = append(results, domain.DrawResult{DrawID: "draw-none", CommandID: "gift_cmd#00000n", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "none", BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusGranted})
}
aggregate := luckyAggregateDrawResults(cmd, results)
if aggregate.CommandID != "gift_cmd" || aggregate.SelectedTierID != "batch" {
t.Fatalf("aggregate identity mismatch: %#v", aggregate)
}
if aggregate.EffectiveRewardCoins != 1_100 || aggregate.BaseRewardCoins != 1_100 || aggregate.MultiplierPPM != 11_000_000 {
t.Fatalf("aggregate reward mismatch: reward=%d base=%d multiplier=%d", aggregate.EffectiveRewardCoins, aggregate.BaseRewardCoins, aggregate.MultiplierPPM)
}
if aggregate.RewardStatus != domain.StatusPending || !aggregate.StageFeedback || !aggregate.HighMultiplier {
t.Fatalf("aggregate flags mismatch: %#v", aggregate)
}
if !strings.Contains(aggregate.BudgetSourcesJSON, `"draw_count":10`) || !strings.Contains(aggregate.BudgetSourcesJSON, domain.SourceBaseRTP) {
t.Fatalf("aggregate budget json mismatch: %s", aggregate.BudgetSourcesJSON)
}
}
func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) {
window := luckyRTPWindow{
ControlDraws: 100,
@ -58,65 +239,20 @@ func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) {
}
}
func TestLuckyAutoWeightedBaseCandidatesUsesCurrentWindowDeficit(t *testing.T) {
func TestLuckyConfiguredWeightCandidatesUsesConfiguredWeightsWithoutForcedSettlement(t *testing.T) {
candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0},
{TierID: "half", BaseReward: 500},
{TierID: "one", BaseReward: 1000},
}
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 950}
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
if len(weighted) != 2 {
t.Fatalf("expected two generated probability points, got %#v", weighted)
}
if weighted[0].TierID != "half" || weighted[0].Weight != 100_000 {
t.Fatalf("expected 0.5x to carry 10%% probability, got %#v", weighted[0])
}
if weighted[1].TierID != "one" || weighted[1].Weight != 900_000 {
t.Fatalf("expected 1x to carry 90%% probability, got %#v", weighted[1])
}
{TierID: "none", BaseReward: 0, Weight: 300_000},
{TierID: "one", BaseReward: 100, Weight: 600_000},
{TierID: "two", BaseReward: 200, Weight: 100_000},
}
func TestLuckyAutoWeightedBaseCandidatesDoesNotDiluteDeficitAcrossLargeWindow(t *testing.T) {
candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0},
{TierID: "rebate_0_5x", BaseReward: 5},
{TierID: "rebate_1x", BaseReward: 10},
}
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 9}
weighted := luckyConfiguredWeightCandidates(candidates)
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
if len(weighted) != 2 {
t.Fatalf("expected deficit to interpolate between reward tiers, got %#v", weighted)
if len(weighted) != 3 {
t.Fatalf("expected configured candidates inside control band, got %#v", weighted)
}
if weighted[0].TierID != "rebate_0_5x" || weighted[0].Weight != 200_000 {
t.Fatalf("expected 0.5x to carry 20%% probability, got %#v", weighted[0])
}
if weighted[1].TierID != "rebate_1x" || weighted[1].Weight != 800_000 {
t.Fatalf("expected 1x to carry 80%% probability, got %#v", weighted[1])
}
}
func TestLuckyAutoWeightedBaseCandidatesInterpolatesSingleDrawBelowOneX(t *testing.T) {
candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0},
{TierID: "rebate_1x", BaseReward: 100},
}
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 95}
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
if len(weighted) != 2 {
t.Fatalf("expected 0x and 1x interpolation candidates, got %#v", weighted)
}
if weighted[0].TierID != "none" || weighted[0].Weight != 50_000 {
t.Fatalf("expected 0x to keep 5%% probability, got %#v", weighted[0])
}
if weighted[1].TierID != "rebate_1x" || weighted[1].Weight != 950_000 {
t.Fatalf("expected 1x to carry 95%% probability, got %#v", weighted[1])
if weighted[0].TierID != "none" || weighted[0].Weight != 300_000 || weighted[2].TierID != "two" || weighted[2].Weight != 100_000 {
t.Fatalf("expected configured weights to be preserved, got %#v", weighted)
}
}
@ -142,7 +278,6 @@ func TestSelectLuckyCandidateDoesNotInjectNoneTier(t *testing.T) {
{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Enabled: true},
},
}
window := luckyRTPWindow{ControlDraws: 100, TargetRTPPPM: 950_000}
pool := luckyPool{Balance: 1_000_000}
counters := map[string]luckyCounter{
luckyCounterKey("user", "hour"): {},
@ -155,16 +290,10 @@ func TestSelectLuckyCandidateDoesNotInjectNoneTier(t *testing.T) {
candidate, _, err := (&Repository{}).selectLuckyCandidate(
config,
domain.PoolAdvanced,
false,
window,
window,
pool,
pool,
pool,
luckyPool{},
0,
counters,
20,
)
if err != nil {
t.Fatalf("select positive-only candidate failed: %v", err)
@ -174,6 +303,98 @@ func TestSelectLuckyCandidateDoesNotInjectNoneTier(t *testing.T) {
}
}
func TestSelectLuckyCandidateKeepsSmallGiftEligibleAfterLargeWagerWhenAbsoluteCapAllows(t *testing.T) {
config := domain.Config{
GiftID: domain.DefaultPoolID,
PoolID: domain.DefaultPoolID,
GiftPrice: 10,
TargetRTPPPM: 950_000,
HighMultiplier: 100,
LargeTierEnabled: true,
PlatformPoolWeightPPM: 200_000,
RoomPoolWeightPPM: 300_000,
GiftPoolWeightPPM: 500_000,
MaxSinglePayout: 1_000_000,
UserHourlyPayoutCap: 1_000_000,
UserDailyPayoutCap: 1_000_000,
DeviceDailyPayoutCap: 1_000_000,
RoomHourlyPayoutCap: 1_000_000,
AnchorDailyPayoutCap: 1_000_000,
Tiers: []domain.Tier{
{Pool: domain.PoolAdvanced, TierID: "advanced_none", RewardCoins: 0, MultiplierPPM: 0, Weight: 500_000, Enabled: true},
{Pool: domain.PoolAdvanced, TierID: "advanced_1x", RewardCoins: 10, MultiplierPPM: 1_000_000, Weight: 300_000, Enabled: true},
{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Weight: 200_000, Enabled: true},
},
}
pool := luckyPool{Balance: 1_000_000}
counters := map[string]luckyCounter{
luckyCounterKey("user", "hour"): {Payout: 999_970},
luckyCounterKey("user", "day"): {Payout: 999_970},
luckyCounterKey("device", "day"): {Payout: 999_970},
luckyCounterKey("room", "hour"): {Payout: 999_970},
luckyCounterKey("anchor", "day"): {Payout: 999_970},
}
seenPositive := false
for i := 0; i < 100; i++ {
candidate, limited, err := (&Repository{}).selectLuckyCandidate(config, domain.PoolAdvanced, pool, pool, pool, counters)
if err != nil {
t.Fatalf("select small gift candidate failed: %v", err)
}
if limited["risk_cap"] {
t.Fatalf("small payable tiers should not be risk-filtered when absolute cap still has room")
}
if candidate.BaseReward > 0 {
seenPositive = true
break
}
}
if !seenPositive {
t.Fatalf("expected positive small-gift tier to remain in random candidate set")
}
}
func TestSelectLuckyCandidateFallsBackToZeroWhenAllPositiveTiersAreUnpayable(t *testing.T) {
config := domain.Config{
GiftID: domain.DefaultPoolID,
PoolID: domain.DefaultPoolID,
GiftPrice: 10,
HighMultiplier: 100,
LargeTierEnabled: true,
PlatformPoolWeightPPM: 200_000,
RoomPoolWeightPPM: 300_000,
GiftPoolWeightPPM: 500_000,
MaxSinglePayout: 10,
UserHourlyPayoutCap: 10,
UserDailyPayoutCap: 10,
DeviceDailyPayoutCap: 10,
RoomHourlyPayoutCap: 10,
AnchorDailyPayoutCap: 10,
Tiers: []domain.Tier{
{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Weight: 1_000_000, Enabled: true},
},
}
pool := luckyPool{Balance: 1_000_000}
counters := map[string]luckyCounter{
luckyCounterKey("user", "hour"): {},
luckyCounterKey("user", "day"): {},
luckyCounterKey("device", "day"): {},
luckyCounterKey("room", "hour"): {},
luckyCounterKey("anchor", "day"): {},
}
candidate, limited, err := (&Repository{}).selectLuckyCandidate(config, domain.PoolAdvanced, pool, pool, pool, counters)
if err != nil {
t.Fatalf("select unpayable candidate failed: %v", err)
}
if candidate.TierID != "no_payable_tier" || candidate.effectiveReward() != 0 {
t.Fatalf("expected no-payable fallback, got %#v", candidate)
}
if !limited["risk_cap"] {
t.Fatalf("expected risk_cap to explain filtered positive tier")
}
}
func TestLuckyInitialRewardStatusGrantsZeroRewardWithoutOutbox(t *testing.T) {
if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted {
t.Fatalf("zero reward without presentation should be granted, got %s", status)

View File

@ -62,10 +62,15 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
INSERT INTO lucky_gift_rule_versions (
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference,
effective_from_ms, created_by_admin_id, created_at_ms
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
novice_max_equivalent_draws, normal_max_equivalent_draws,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap,
room_hourly_payout_cap, anchor_daily_payout_cap, effective_from_ms, created_by_admin_id, created_at_ms
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.TargetRTPPPM, config.PoolRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap, config.DeviceDailyPayoutCap,
config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS,
); err != nil {
return domain.RuleConfig{}, err
@ -99,6 +104,9 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.Rul
rows, err := r.db.QueryContext(ctx, `
SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.target_rtp_ppm, v.pool_rate_ppm,
v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference,
v.novice_max_equivalent_draws, v.normal_max_equivalent_draws,
v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap, v.device_daily_payout_cap,
v.room_hourly_payout_cap, v.anchor_daily_payout_cap,
v.effective_from_ms, v.created_by_admin_id, v.created_at_ms
FROM lucky_gift_rule_versions v
INNER JOIN (
@ -141,6 +149,9 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
row := q.QueryRowContext(ctx, `
SELECT app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference,
novice_max_equivalent_draws, normal_max_equivalent_draws,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap,
room_hourly_payout_cap, anchor_daily_payout_cap,
effective_from_ms, created_by_admin_id, created_at_ms
FROM lucky_gift_rule_versions
WHERE app_code = ? AND pool_id = ?
@ -205,6 +216,14 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
&config.SettlementWindowWager,
&config.ControlBandPPM,
&config.GiftPriceReference,
&config.NoviceMaxEquivalentDraws,
&config.NormalMaxEquivalentDraws,
&config.MaxSinglePayout,
&config.UserHourlyPayoutCap,
&config.UserDailyPayoutCap,
&config.DeviceDailyPayoutCap,
&config.RoomHourlyPayoutCap,
&config.AnchorDailyPayoutCap,
&config.EffectiveFromMS,
&config.CreatedByAdminID,
&config.CreatedAtMS,

View File

@ -54,12 +54,39 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
return err
}
if err := r.ensureLuckyUserStateFlowColumns(ctx); err != nil {
return err
}
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
return err
}
return nil
}
func (r *Repository) ensureLuckyUserStateFlowColumns(ctx context.Context) error {
// 用户阶段改按金币流水折算等价抽数,状态表必须持久保存累计流水和连续未中奖次数,不能再只依赖 paid_draws。
additions := []struct {
name string
sql string
}{
{"cumulative_wager_coins", `ALTER TABLE lucky_user_states ADD COLUMN cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币' AFTER paid_draws`},
{"equivalent_draws", `ALTER TABLE lucky_user_states ADD COLUMN equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数' AFTER cumulative_wager_coins`},
{"loss_streak", `ALTER TABLE lucky_user_states ADD COLUMN loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零' AFTER equivalent_draws`},
}
for _, addition := range additions {
exists, err := r.columnExists(ctx, "lucky_user_states", addition.name)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
return nil
}
// Close 释放 MySQL 连接池。
func (r *Repository) Close() error {
if r == nil || r.db == nil {
@ -163,6 +190,14 @@ func (r *Repository) ensureLuckyGiftRuleVersionTables(ctx context.Context) error
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水单位金币',
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm',
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
novice_max_equivalent_draws BIGINT NOT NULL DEFAULT 2000 COMMENT '新手阶段截止等价抽数按累计金币/参考价格折算',
normal_max_equivalent_draws BIGINT NOT NULL DEFAULT 20000 COMMENT '正常阶段截止等价抽数超过后进入高阶阶段',
max_single_payout BIGINT NOT NULL DEFAULT 50000 COMMENT '单次基础返奖上限按参考价格配置',
user_hourly_payout_cap BIGINT NOT NULL DEFAULT 34200 COMMENT '单用户每小时基础返奖上限按参考价格配置',
user_daily_payout_cap BIGINT NOT NULL DEFAULT 615600 COMMENT '单用户每日基础返奖上限按参考价格配置',
device_daily_payout_cap BIGINT NOT NULL DEFAULT 1026000 COMMENT '单设备每日基础返奖上限按参考价格配置',
room_hourly_payout_cap BIGINT NOT NULL DEFAULT 684000 COMMENT '单房间每小时基础返奖上限按参考价格配置',
anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 12312000 COMMENT '单主播每日基础返奖上限按参考价格配置',
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -191,6 +226,62 @@ func (r *Repository) ensureLuckyGiftRuleVersionTables(ctx context.Context) error
return err
}
}
if err := r.ensureLuckyGiftRuleVersionStageThresholdColumns(ctx); err != nil {
return err
}
if err := r.ensureLuckyGiftRuleVersionRiskCapColumns(ctx); err != nil {
return err
}
return nil
}
func (r *Repository) ensureLuckyGiftRuleVersionStageThresholdColumns(ctx context.Context) error {
// 阶段阈值从硬编码抽数升级为“累计金币流水折算等价抽数”;旧开发库重启后必须自动补列,避免线上逻辑读到零阈值。
additions := []struct {
name string
sql string
}{
{"novice_max_equivalent_draws", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN novice_max_equivalent_draws BIGINT NOT NULL DEFAULT 2000 COMMENT '新手阶段截止等价抽数,按累计金币/参考价格折算' AFTER gift_price_reference`},
{"normal_max_equivalent_draws", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN normal_max_equivalent_draws BIGINT NOT NULL DEFAULT 20000 COMMENT '正常阶段截止等价抽数,超过后进入高阶阶段' AFTER novice_max_equivalent_draws`},
}
for _, addition := range additions {
exists, err := r.columnExists(ctx, "lucky_gift_rule_versions", addition.name)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) ensureLuckyGiftRuleVersionRiskCapColumns(ctx context.Context) error {
// 开发库可能已经存在旧 v2 表;这里做幂等补列,保证服务重启后后台立即可以读写风控上限。
additions := []struct {
name string
sql string
}{
{"max_single_payout", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_single_payout BIGINT NOT NULL DEFAULT 50000 COMMENT '单次基础返奖上限,按参考价格配置' AFTER gift_price_reference`},
{"user_hourly_payout_cap", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_hourly_payout_cap BIGINT NOT NULL DEFAULT 34200 COMMENT '单用户每小时基础返奖上限,按参考价格配置' AFTER max_single_payout`},
{"user_daily_payout_cap", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_daily_payout_cap BIGINT NOT NULL DEFAULT 615600 COMMENT '单用户每日基础返奖上限,按参考价格配置' AFTER user_hourly_payout_cap`},
{"device_daily_payout_cap", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN device_daily_payout_cap BIGINT NOT NULL DEFAULT 1026000 COMMENT '单设备每日基础返奖上限,按参考价格配置' AFTER user_daily_payout_cap`},
{"room_hourly_payout_cap", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN room_hourly_payout_cap BIGINT NOT NULL DEFAULT 684000 COMMENT '单房间每小时基础返奖上限,按参考价格配置' AFTER device_daily_payout_cap`},
{"anchor_daily_payout_cap", `ALTER TABLE lucky_gift_rule_versions ADD COLUMN anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 12312000 COMMENT '单主播每日基础返奖上限,按参考价格配置' AFTER room_hourly_payout_cap`},
}
for _, addition := range additions {
exists, err := r.columnExists(ctx, "lucky_gift_rule_versions", addition.name)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
return nil
}

View File

@ -181,9 +181,17 @@ func luckyRuleConfigFromProto(config *activityv1.LuckyGiftRuleConfig) domain.Rul
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPPM: config.GetControlBandPpm(),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
}
}
@ -219,9 +227,17 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *activityv1.LuckyGiftRuleC
SettlementWindowWager: config.SettlementWindowWager,
ControlBandPpm: config.ControlBandPPM,
GiftPriceReference: config.GiftPriceReference,
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
MaxSinglePayout: config.MaxSinglePayout,
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
UserDailyPayoutCap: config.UserDailyPayoutCap,
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
Stages: stages,
}
}

View File

@ -1,10 +1,15 @@
import http from "node:http";
import { execFile } from "node:child_process";
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, "baishun-game-test.html");
const pagePaths = new Map([
["/", join(__dirname, "baishun-game-test.html")],
["/baishun-game-test.html", join(__dirname, "baishun-game-test.html")],
["/lucky-gift-test.html", join(__dirname, "lucky-gift-test.html")],
]);
const port = Number(process.env.PORT || 8787);
const upstreamBase = (process.env.BAISHUN_API_BASE || "https://api-test.global-interaction.com").replace(/\/+$/, "");
@ -46,6 +51,72 @@ async function proxyApi(req, res) {
res.end(responseBody);
}
function sqlString(value) {
return `'${String(value ?? "").replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
}
function runMysqlJSON(sql) {
return new Promise((resolve, reject) => {
execFile(
"docker",
["exec", "hyapp-mysql", "mysql", "-uroot", "-proot", "--batch", "--raw", "--skip-column-names", "-e", sql],
{ maxBuffer: 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
reject(new Error((stderr || err.message).trim()));
return;
}
const raw = stdout.trim();
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw.split(/\r?\n/).at(-1)));
} catch {
reject(new Error(`mysql json parse failed: ${raw}`));
}
},
);
});
}
async function luckyGiftStats(req, res, url) {
const appCode = url.searchParams.get("app_code") || "lalu";
const userID = url.searchParams.get("user_id") || "";
const poolID = url.searchParams.get("pool_id") || "";
if (!/^\d+$/.test(userID)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
res.end(JSON.stringify({ code: "INVALID_ARGUMENT", message: "user_id is required" }));
return;
}
const appSQL = sqlString(appCode);
const userSQL = sqlString(userID);
const poolSQL = sqlString(poolID);
const poolFilter = poolID ? `AND pool_id = ${poolSQL}` : "";
const statePoolFilter = poolID ? `AND gift_id = ${poolSQL}` : "";
const sql = `
SELECT JSON_OBJECT(
'app_code', ${appSQL},
'user_id', CAST(${userSQL} AS UNSIGNED),
'pool_id', ${poolSQL},
'draw_count', COALESCE((SELECT COUNT(*) FROM hyapp_activity.lucky_draw_records WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${poolFilter}), 0),
'win_count', COALESCE((SELECT COUNT(*) FROM hyapp_activity.lucky_draw_records WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${poolFilter} AND effective_reward_coins > 0), 0),
'granted_count', COALESCE((SELECT COUNT(*) FROM hyapp_activity.lucky_draw_records WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${poolFilter} AND reward_status = 'granted'), 0),
'coin_spent', COALESCE((SELECT SUM(coin_spent) FROM hyapp_activity.lucky_draw_records WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${poolFilter}), 0),
'base_reward_coins', COALESCE((SELECT SUM(base_reward_coins) FROM hyapp_activity.lucky_draw_records WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${poolFilter}), 0),
'effective_reward_coins', COALESCE((SELECT SUM(effective_reward_coins) FROM hyapp_activity.lucky_draw_records WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${poolFilter}), 0),
'state_paid_draws', COALESCE((SELECT SUM(paid_draws) FROM hyapp_activity.lucky_user_states WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${statePoolFilter}), 0),
'state_cumulative_wager_coins', COALESCE((SELECT SUM(cumulative_wager_coins) FROM hyapp_activity.lucky_user_states WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${statePoolFilter}), 0),
'state_equivalent_draws', COALESCE((SELECT MAX(equivalent_draws) FROM hyapp_activity.lucky_user_states WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${statePoolFilter}), 0),
'loss_streak', COALESCE((SELECT MAX(loss_streak) FROM hyapp_activity.lucky_user_states WHERE app_code = ${appSQL} AND user_id = CAST(${userSQL} AS UNSIGNED) ${statePoolFilter}), 0)
)
`;
const data = await runMysqlJSON(sql);
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
res.end(JSON.stringify({ code: "OK", data }));
}
const server = http.createServer(async (req, res) => {
try {
if (req.method === "OPTIONS") {
@ -53,8 +124,10 @@ const server = http.createServer(async (req, res) => {
res.end();
return;
}
if (req.url === "/" || req.url === "/baishun-game-test.html") {
const html = await readFile(htmlPath);
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
const pagePath = pagePaths.get(url.pathname);
if (pagePath) {
const html = await readFile(pagePath);
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
@ -66,6 +139,10 @@ const server = http.createServer(async (req, res) => {
await proxyApi(req, res);
return;
}
if (url.pathname === "/__local/lucky-gift/stats") {
await luckyGiftStats(req, res, url);
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found");
} catch (err) {
@ -75,6 +152,7 @@ const server = http.createServer(async (req, res) => {
});
server.listen(port, "127.0.0.1", () => {
console.log(`Baishun game test page: http://127.0.0.1:${port}`);
console.log(`Baishun game test page: http://127.0.0.1:${port}/baishun-game-test.html`);
console.log(`Lucky gift test page: http://127.0.0.1:${port}/lucky-gift-test.html`);
console.log(`Proxy upstream: ${upstreamBase}`);
});

View File

@ -193,6 +193,7 @@
<h2>3. 发幸运礼物</h2>
<div class="actions">
<button id="sendBtn" type="button">发礼物</button>
<button id="refreshStatsBtn" class="secondary" type="button">刷新用户统计</button>
<button id="clearBtn" class="danger" type="button">清空日志</button>
<span id="sendStatus" class="status"></span>
</div>
@ -214,6 +215,24 @@
<div id="rewardStatusValue" class="value">-</div>
</div>
</div>
<div class="result" style="margin-top:12px">
<div class="metric">
<div class="name">该用户抽奖次数</div>
<div id="userDrawCountValue" class="value">-</div>
</div>
<div class="metric">
<div class="name">该用户消耗金币</div>
<div id="userSpentValue" class="value">-</div>
</div>
<div class="metric">
<div class="name">该用户返还金币</div>
<div id="userRewardValue" class="value">-</div>
</div>
<div class="metric">
<div class="name">等价抽数 / 连亏</div>
<div id="userStateValue" class="value">-</div>
</div>
</div>
</section>
<section>
@ -304,6 +323,16 @@
return payload.data;
}
async function localRequest(path) {
const response = await fetch(path, { headers: { "Cache-Control": "no-store" } });
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";
}
@ -361,6 +390,7 @@
saveToken(data);
addLog("账号登录成功", data);
setStatus("authStatus", "登录成功token 已填入", "ok");
await refreshUserStats();
await prepareRandomRoomAndGift();
} catch (err) {
addLog("账号登录失败", errorMessage(err), "error");
@ -381,6 +411,7 @@
saveToken(data);
addLog("快捷创建成功", data);
setStatus("authStatus", `创建成功:${data.account || data.uid || ""}`, "ok");
await refreshUserStats();
await prepareRandomRoomAndGift();
} catch (err) {
addLog("快捷创建失败", errorMessage(err), "error");
@ -675,12 +706,52 @@
renderEmptyResult("HTTP 成功,响应无 lucky_gift");
}
setStatus("sendStatus", "发礼物成功,等待 IM 同步", "ok");
await refreshUserStats();
} catch (err) {
addLog("发礼物失败", errorMessage(err), "error");
setStatus("sendStatus", errorMessage(err), "error");
}
}
async function refreshUserStats() {
const userID = state.selfUserID || state.loginUserID || jwtUserID(token()) || $("account").value.trim();
if (!userID || !/^\d+$/.test(String(userID))) {
renderUserStats(null);
return;
}
try {
const params = new URLSearchParams({
app_code: appCode(),
user_id: String(userID),
pool_id: $("poolId").value.trim(),
});
const data = await localRequest(`/__local/lucky-gift/stats?${params.toString()}`);
renderUserStats(data);
} catch (err) {
addLog("刷新用户抽奖统计失败", errorMessage(err), "error");
renderUserStats(null);
}
}
function renderUserStats(stats) {
if (!stats) {
$("userDrawCountValue").textContent = "-";
$("userSpentValue").textContent = "-";
$("userRewardValue").textContent = "-";
$("userStateValue").textContent = "-";
return;
}
const draws = Number(stats.draw_count || 0);
const spent = Number(stats.coin_spent || 0);
const reward = Number(stats.effective_reward_coins || 0);
const equivalentDraws = Number(stats.state_equivalent_draws || 0);
const lossStreak = Number(stats.loss_streak || 0);
$("userDrawCountValue").textContent = `${draws} 抽`;
$("userSpentValue").textContent = `${spent} 金币`;
$("userRewardValue").textContent = `${reward} 金币`;
$("userStateValue").textContent = `${equivalentDraws} / ${lossStreak}`;
}
function giftBaseBody() {
const roomID = $("roomId").value.trim();
const giftID = $("giftId").value.trim();
@ -807,6 +878,7 @@
$("imBtn").onclick = loginIM;
$("joinBtn").onclick = joinRoomAndGroup;
$("sendBtn").onclick = sendLuckyGift;
$("refreshStatsBtn").onclick = refreshUserStats;
$("clearBtn").onclick = () => {
$("log").innerHTML = "";
state.seenEventIDs.clear();
@ -814,7 +886,11 @@
};
$("giftSelect").onchange = applySelectedGift;
$("poolId").oninput = rememberForm;
$("poolPreset").onchange = (event) => usePoolPreset(event.target.value);
$("poolPreset").onchange = async (event) => {
usePoolPreset(event.target.value);
await refreshUserStats();
};
void refreshUserStats();
</script>
</body>
</html>

View File

@ -0,0 +1,177 @@
#!/usr/bin/env bash
set -euo pipefail
# 清理本地 Docker MySQL 的幸运礼物运行态数据,不删除后台发布的 lucky_gift_rule_versions / stage_tiers。
# 常用:
# APP_CODE=lalu ./tools/reset-lucky-gift-local-data.sh
# APP_CODE=lalu USER_ID=123456 POOL_ID=lucky_100 ./tools/reset-lucky-gift-local-data.sh
APP_CODE="${APP_CODE:-lalu}"
USER_ID="${USER_ID:-}"
POOL_ID="${POOL_ID:-}"
RESET_SHARED="${RESET_SHARED:-1}"
MYSQL_CONTAINER="${MYSQL_CONTAINER:-hyapp-mysql}"
if [[ -n "$USER_ID" && ! "$USER_ID" =~ ^[0-9]+$ ]]; then
echo "USER_ID must be numeric: $USER_ID" >&2
exit 2
fi
sql_string() {
local value="${1//\\/\\\\}"
value="${value//\'/\'\'}"
printf "'%s'" "$value"
}
APP_SQL="$(sql_string "$APP_CODE")"
USER_SQL="$(sql_string "$USER_ID")"
POOL_SQL="$(sql_string "$POOL_ID")"
RESET_SHARED_SQL="$(sql_string "$RESET_SHARED")"
docker exec -i "$MYSQL_CONTAINER" mysql -uroot -proot <<SQL
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_activity;
SET @app_code := ${APP_SQL};
SET @user_id := ${USER_SQL};
SET @pool_id := ${POOL_SQL};
SET @reset_shared := ${RESET_SHARED_SQL};
SET @run_id := REPLACE(UUID(), '-', '');
CREATE TABLE IF NOT EXISTS local_lucky_reset_draws (
run_id VARCHAR(64) NOT NULL,
draw_id VARCHAR(128) NOT NULL,
command_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
pool_id VARCHAR(96) NOT NULL,
room_id VARCHAR(96) NOT NULL,
anchor_id VARCHAR(96) NOT NULL,
device_id VARCHAR(128) NOT NULL,
PRIMARY KEY (run_id, draw_id),
KEY idx_local_lucky_reset_draw_command (run_id, command_id),
KEY idx_local_lucky_reset_draw_scope (run_id, pool_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS local_lucky_reset_wallet_txs (
run_id VARCHAR(64) NOT NULL,
app_code VARCHAR(32) NOT NULL,
transaction_id VARCHAR(96) NOT NULL,
PRIMARY KEY (run_id, app_code, transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
DELETE FROM local_lucky_reset_draws WHERE run_id = @run_id;
DELETE FROM local_lucky_reset_wallet_txs WHERE run_id = @run_id;
INSERT INTO local_lucky_reset_draws (run_id, draw_id, command_id, user_id, pool_id, room_id, anchor_id, device_id)
SELECT @run_id, draw_id, command_id, user_id, pool_id, room_id, anchor_id, device_id
FROM hyapp_activity.lucky_draw_records
WHERE app_code = @app_code
AND (@user_id = '' OR user_id = CAST(NULLIF(@user_id, '') AS UNSIGNED))
AND (@pool_id = '' OR pool_id = @pool_id);
INSERT IGNORE INTO local_lucky_reset_wallet_txs (run_id, app_code, transaction_id)
SELECT @run_id, wt.app_code, wt.transaction_id
FROM hyapp_wallet.wallet_transactions wt
JOIN local_lucky_reset_draws d
ON d.run_id = @run_id
AND wt.app_code = @app_code
AND wt.biz_type = 'lucky_gift_reward'
AND wt.command_id = CONCAT('lucky_reward:', d.draw_id);
START TRANSACTION;
UPDATE hyapp_wallet.wallet_accounts a
JOIN (
SELECT e.app_code, e.user_id, e.asset_type,
SUM(e.available_delta) AS available_delta,
SUM(e.frozen_delta) AS frozen_delta
FROM hyapp_wallet.wallet_entries e
JOIN local_lucky_reset_wallet_txs t
ON t.run_id = @run_id AND t.app_code = e.app_code AND t.transaction_id = e.transaction_id
GROUP BY e.app_code, e.user_id, e.asset_type
) x
ON x.app_code = a.app_code AND x.user_id = a.user_id AND x.asset_type = a.asset_type
SET a.available_amount = a.available_amount - x.available_delta,
a.frozen_amount = a.frozen_amount - x.frozen_delta,
a.version = a.version + 1,
a.updated_at_ms = UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000;
DELETE wo
FROM hyapp_wallet.wallet_outbox wo
JOIN local_lucky_reset_wallet_txs t
ON t.run_id = @run_id AND t.app_code = wo.app_code AND t.transaction_id = wo.transaction_id;
DELETE e
FROM hyapp_wallet.wallet_entries e
JOIN local_lucky_reset_wallet_txs t
ON t.run_id = @run_id AND t.app_code = e.app_code AND t.transaction_id = e.transaction_id;
DELETE wt
FROM hyapp_wallet.wallet_transactions wt
JOIN local_lucky_reset_wallet_txs t
ON t.run_id = @run_id AND t.app_code = wt.app_code AND t.transaction_id = wt.transaction_id;
DELETE ao
FROM hyapp_activity.activity_outbox ao
JOIN local_lucky_reset_draws d
ON d.run_id = @run_id
AND ao.app_code = @app_code
AND ao.event_type = 'LuckyGiftDrawn'
AND CONVERT(ao.payload USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONCAT('%', d.draw_id, '%') COLLATE utf8mb4_unicode_ci;
DELETE dr
FROM hyapp_activity.lucky_draw_records dr
JOIN local_lucky_reset_draws d
ON d.run_id = @run_id AND d.draw_id = dr.draw_id
WHERE dr.app_code = @app_code;
DELETE FROM hyapp_activity.lucky_user_states
WHERE app_code = @app_code
AND (@user_id = '' OR user_id = CAST(NULLIF(@user_id, '') AS UNSIGNED))
AND (@pool_id = '' OR gift_id = @pool_id);
DELETE rc
FROM hyapp_activity.lucky_risk_counters rc
LEFT JOIN (
SELECT 'user' AS scope_type, CONCAT(pool_id, ':', user_id) AS scope_id FROM local_lucky_reset_draws WHERE run_id = @run_id
UNION SELECT 'device', CONCAT(pool_id, ':', device_id) FROM local_lucky_reset_draws WHERE run_id = @run_id AND device_id <> ''
UNION SELECT 'room', CONCAT(pool_id, ':', room_id) FROM local_lucky_reset_draws WHERE run_id = @run_id AND room_id <> ''
UNION SELECT 'anchor', CONCAT(pool_id, ':', anchor_id) FROM local_lucky_reset_draws WHERE run_id = @run_id AND anchor_id <> ''
) s ON s.scope_type = rc.scope_type AND s.scope_id = rc.scope_id
WHERE rc.app_code = @app_code
AND (@user_id = '' OR s.scope_id IS NOT NULL)
AND (@pool_id = '' OR rc.scope_id = @pool_id OR rc.scope_id LIKE CONCAT(@pool_id, ':%'));
DELETE FROM hyapp_activity.lucky_rtp_windows
WHERE @reset_shared = '1'
AND app_code = @app_code
AND (@pool_id = '' OR scope_id = @pool_id OR scope_id LIKE CONCAT(@pool_id, ':%'));
DELETE FROM hyapp_activity.lucky_pools
WHERE @reset_shared = '1'
AND app_code = @app_code
AND (@pool_id = '' OR scope_id = @pool_id OR scope_id LIKE CONCAT(@pool_id, ':%'));
DELETE FROM hyapp_activity.lucky_room_atmosphere_pools
WHERE @reset_shared = '1'
AND app_code = @app_code
AND (@pool_id = '' OR room_id IN (SELECT room_id FROM local_lucky_reset_draws WHERE run_id = @run_id));
DELETE FROM hyapp_activity.lucky_activity_budgets
WHERE @reset_shared = '1'
AND app_code = @app_code
AND (@pool_id = '' OR gift_id = @pool_id);
COMMIT;
SELECT JSON_OBJECT(
'app_code', @app_code,
'user_id', @user_id,
'pool_id', @pool_id,
'reset_shared', @reset_shared,
'remaining_draw_records', (SELECT COUNT(*) FROM hyapp_activity.lucky_draw_records WHERE app_code = @app_code AND (@user_id = '' OR user_id = CAST(NULLIF(@user_id, '') AS UNSIGNED)) AND (@pool_id = '' OR pool_id = @pool_id)),
'remaining_user_states', (SELECT COUNT(*) FROM hyapp_activity.lucky_user_states WHERE app_code = @app_code AND (@user_id = '' OR user_id = CAST(NULLIF(@user_id, '') AS UNSIGNED)) AND (@pool_id = '' OR gift_id = @pool_id))
) AS result;
DELETE FROM local_lucky_reset_wallet_txs WHERE run_id = @run_id;
DELETE FROM local_lucky_reset_draws WHERE run_id = @run_id;
SQL