From 226362c9d96c94ed62156ad74bf1fc8238546cac Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 29 May 2026 12:52:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=B8=E8=BF=90=E7=A4=BC=E7=89=A9V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/activity/v1/activity.pb.go | 118 +- api/proto/activity/v1/activity.proto | 16 +- docs/flutter对接/幸运礼物Flutter对接.md | 584 ------- .../幸运礼物接口闭环Flutter对接.md | 187 --- docs/幸运礼物Activity服务开发.md | 748 --------- docs/幸运礼物产品方案.md | 467 ------ docs/幸运礼物实现文档.md | 184 +++ docs/幸运礼物线上落地文档.md | 271 ---- docs/幸运礼物重构方案.md | 456 ------ docs/幸运礼物重构第一步-配置契约与单位.md | 156 -- docs/幸运礼物重构第三步-链路接入与上线验收.md | 210 --- docs/幸运礼物重构第二步-抽奖引擎与模拟器.md | 205 --- docs/语音房功能清单.md | 10 +- .../internal/modules/luckygift/handler.go | 78 +- .../modules/luckygift/handler_test.go | 55 +- .../024_admin_app_config_schema_backfill.sql | 16 + .../mysql/initdb/001_activity_service.sql | 11 + .../internal/domain/luckygift/lucky_gift.go | 115 +- .../internal/service/luckygift/config.go | 145 +- .../internal/service/luckygift/config_test.go | 18 + .../internal/service/luckygift/service.go | 87 +- .../service/luckygift/service_test.go | 62 + .../storage/mysql/lucky_gift_repository.go | 1388 +++++++++++++---- .../mysql/lucky_gift_repository_test.go | 349 ++++- .../lucky_gift_rule_config_repository.go | 23 +- .../internal/storage/mysql/repository.go | 91 ++ .../transport/grpc/lucky_gift_server.go | 68 +- tools/baishun-game-test-server.mjs | 86 +- tools/lucky-gift-test.html | 78 +- tools/reset-lucky-gift-local-data.sh | 177 +++ 30 files changed, 2550 insertions(+), 3909 deletions(-) delete mode 100644 docs/flutter对接/幸运礼物Flutter对接.md delete mode 100644 docs/flutter对接/幸运礼物接口闭环Flutter对接.md delete mode 100644 docs/幸运礼物Activity服务开发.md delete mode 100644 docs/幸运礼物产品方案.md create mode 100644 docs/幸运礼物实现文档.md delete mode 100644 docs/幸运礼物线上落地文档.md delete mode 100644 docs/幸运礼物重构方案.md delete mode 100644 docs/幸运礼物重构第一步-配置契约与单位.md delete mode 100644 docs/幸运礼物重构第三步-链路接入与上线验收.md delete mode 100644 docs/幸运礼物重构第二步-抽奖引擎与模拟器.md create mode 100755 tools/reset-lucky-gift-local-data.sh diff --git a/api/proto/activity/v1/activity.pb.go b/api/proto/activity/v1/activity.pb.go index 2e9cf6c2..01eaa0bd 100644 --- a/api/proto/activity/v1/activity.pb.go +++ b/api/proto/activity/v1/activity.pb.go @@ -10685,22 +10685,30 @@ func (x *LuckyGiftRuleStage) GetTiers() []*LuckyGiftRuleTier { } type LuckyGiftRuleConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` - RuleVersion int64 `protobuf:"varint,3,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"` - Enabled bool `protobuf:"varint,4,opt,name=enabled,proto3" json:"enabled,omitempty"` - TargetRtpPpm int64 `protobuf:"varint,5,opt,name=target_rtp_ppm,json=targetRtpPpm,proto3" json:"target_rtp_ppm,omitempty"` - PoolRatePpm int64 `protobuf:"varint,6,opt,name=pool_rate_ppm,json=poolRatePpm,proto3" json:"pool_rate_ppm,omitempty"` - 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + RuleVersion int64 `protobuf:"varint,3,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"` + Enabled bool `protobuf:"varint,4,opt,name=enabled,proto3" json:"enabled,omitempty"` + TargetRtpPpm int64 `protobuf:"varint,5,opt,name=target_rtp_ppm,json=targetRtpPpm,proto3" json:"target_rtp_ppm,omitempty"` + PoolRatePpm int64 `protobuf:"varint,6,opt,name=pool_rate_ppm,json=poolRatePpm,proto3" json:"pool_rate_ppm,omitempty"` + 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"` + 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 } func (x *LuckyGiftRuleConfig) Reset() { @@ -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" + diff --git a/api/proto/activity/v1/activity.proto b/api/proto/activity/v1/activity.proto index a2f83613..3d64d44f 100644 --- a/api/proto/activity/v1/activity.proto +++ b/api/proto/activity/v1/activity.proto @@ -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 { diff --git a/docs/flutter对接/幸运礼物Flutter对接.md b/docs/flutter对接/幸运礼物Flutter对接.md deleted file mode 100644 index 45d24261..00000000 --- a/docs/flutter对接/幸运礼物Flutter对接.md +++ /dev/null @@ -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 `。 | -| `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 -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 -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 -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 丢失、页面重进或余额不一致时,重新拉取礼物面板和钱包余额纠正状态。 diff --git a/docs/flutter对接/幸运礼物接口闭环Flutter对接.md b/docs/flutter对接/幸运礼物接口闭环Flutter对接.md deleted file mode 100644 index f48fd573..00000000 --- a/docs/flutter对接/幸运礼物接口闭环Flutter对接.md +++ /dev/null @@ -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 ` | -| `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`,更新金币余额。 diff --git a/docs/幸运礼物Activity服务开发.md b/docs/幸运礼物Activity服务开发.md deleted file mode 100644 index 5bd18b1e..00000000 --- a/docs/幸运礼物Activity服务开发.md +++ /dev/null @@ -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 | diff --git a/docs/幸运礼物产品方案.md b/docs/幸运礼物产品方案.md deleted file mode 100644 index 4173e901..00000000 --- a/docs/幸运礼物产品方案.md +++ /dev/null @@ -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 大奖池 | -| 抽奖日志和补偿 | 后台人工指定某用户中奖 | diff --git a/docs/幸运礼物实现文档.md b/docs/幸运礼物实现文档.md new file mode 100644 index 00000000..e3ae9742 --- /dev/null +++ b/docs/幸运礼物实现文档.md @@ -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` | 已验收:总倍数为各命中倍数相加,不是平均倍率 | +| 聚合发放 | 整批只写一条 outbox,payload 里有聚合 `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 只在发布校验和窗口观察中使用;运行时只能过滤不可支付奖档。 diff --git a/docs/幸运礼物线上落地文档.md b/docs/幸运礼物线上落地文档.md deleted file mode 100644 index 41fdbbb2..00000000 --- a/docs/幸运礼物线上落地文档.md +++ /dev/null @@ -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` 或余额查询为准 | diff --git a/docs/幸运礼物重构方案.md b/docs/幸运礼物重构方案.md deleted file mode 100644 index 1ffc1cf2..00000000 --- a/docs/幸运礼物重构方案.md +++ /dev/null @@ -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`。 diff --git a/docs/幸运礼物重构第一步-配置契约与单位.md b/docs/幸运礼物重构第一步-配置契约与单位.md deleted file mode 100644 index 9fc0e33b..00000000 --- a/docs/幸运礼物重构第一步-配置契约与单位.md +++ /dev/null @@ -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` 通过 | - -第一步完成后,系统只具备“正确配置和发布规则”的能力,不要求新抽奖引擎上线。 diff --git a/docs/幸运礼物重构第三步-链路接入与上线验收.md b/docs/幸运礼物重构第三步-链路接入与上线验收.md deleted file mode 100644 index a7a16f9f..00000000 --- a/docs/幸运礼物重构第三步-链路接入与上线验收.md +++ /dev/null @@ -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` | 钱包事故时暂停返奖 dispatch,draw 保持 pending | - -## 监控指标 - -| 指标 | 告警 | -| --- | --- | -| `base_rtp` | 超出目标波动带 | -| `effective_rtp` | 高于预算预期 | -| `zero_rate` | 高于配置预期 | -| `zero_streak_p95` | 超过体验阈值 | -| `control_mode_boost_ratio` | 长期过高说明概率或奖池不健康 | -| `control_mode_cooldown_ratio` | 长期过高说明派奖过多 | -| `pool_balance_min` | 接近 reserve | -| `risk_filter_count` | 过高说明风控在替代概率控制 | -| `outbox_pending_count` | 堆积 | -| `reward_credit_latency` | 到账延迟 | -| `draw_error_count` | 抽奖事务失败 | - -## 旧逻辑删除 - -全量稳定后删除: - -| 旧内容 | 删除条件 | -| --- | --- | -| 旧 `lucky_gift_rules` 写入口 | v2 后台发布稳定 | -| 旧 `multiplier_ppms -> tiers(weight=0)` 归一化 | v2 引擎全量 | -| 旧 `selectLuckyCandidate` 自动权重模型 | v2 引擎全量 | -| 旧 `tools/lucky-gift-v2-demo` 写死倍率模拟 | 新模拟器覆盖 | -| 空 `pool_id` 默认奖池路径 | App 全部传明确 `pool_id` | - -## 实施任务 - -1. 增加 `ExecuteLuckyGiftDrawV2` 内部接口。 -2. `room-service` 送礼链路改调 v2。 -3. gateway 响应增加 `stage/control_mode`。 -4. activity outbox 拆分返奖、房间 IM、区域播报事件。 -5. 后台增加抽奖详情和候选表审计。 -6. 增加灰度开关和监控指标。 -7. shadow draw 跑满一个结算窗口。 -8. 灰度切流。 -9. 全量后删除旧逻辑。 - -## 验收标准 - -| 验收项 | 标准 | -| --- | --- | -| App 参数 | 幸运礼物不传 `pool_id` 必须拒绝,不走 default | -| 扣费顺序 | 钱包扣费成功后才调用 v2 抽奖 | -| 事务边界 | 抽奖事务内不调用钱包返奖或 IM | -| 同步响应 | `lucky_gift` 返回 `stage/control_mode/multiplier_ppm/reward_status` | -| 余额一致 | 客户端余额只由 `WalletBalanceChanged` 或余额查询更新 | -| outbox 幂等 | 重试不会重复返奖、重复 IM 可由 `event_id/draw_id` 去重 | -| 后台审计 | 任一 draw 可查看完整候选表和过滤原因 | -| shadow 验证 | shadow 结果的 RTP、命中率、连续 0 分布与模拟报告一致 | -| 灰度指标 | 灰度期间 base RTP 在控制带内,outbox 无持续堆积 | -| 体验指标 | 100 金币礼物 `zero_streak_p95` 不超过配置阈值 | -| 删除旧逻辑 | 全量稳定后旧配置入口、旧抽奖引擎、旧默认奖池路径已删除 | -| 全量测试 | `make test` 通过 | - -第三步完成后,幸运礼物 v2 才算真正上线。前两步只完成规则和引擎,不能直接对外开放。 diff --git a/docs/幸运礼物重构第二步-抽奖引擎与模拟器.md b/docs/幸运礼物重构第二步-抽奖引擎与模拟器.md deleted file mode 100644 index 838d90a8..00000000 --- a/docs/幸运礼物重构第二步-抽奖引擎与模拟器.md +++ /dev/null @@ -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,但还不直接替换线上送礼主链路。 diff --git a/docs/语音房功能清单.md b/docs/语音房功能清单.md index 14fffacb..d0f73133 100644 --- a/docs/语音房功能清单.md +++ b/docs/语音房功能清单.md @@ -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 diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index b45bb4fe..2b7e43cb 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -25,15 +25,23 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler } type configRequest struct { - PoolID string `json:"pool_id"` - Enabled bool `json:"enabled"` - TargetRTPPercent float64 `json:"target_rtp_percent"` - PoolRatePercent float64 `json:"pool_rate_percent"` - SettlementWindowWager int64 `json:"settlement_window_wager"` - ControlBandPercent float64 `json:"control_band_percent"` - GiftPriceReference int64 `json:"gift_price_reference"` - EffectiveFromMS int64 `json:"effective_from_ms"` - Stages []stageDTO `json:"stages"` + PoolID string `json:"pool_id"` + Enabled bool `json:"enabled"` + TargetRTPPercent float64 `json:"target_rtp_percent"` + PoolRatePercent float64 `json:"pool_rate_percent"` + 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"` } type configDTO struct { @@ -219,15 +227,23 @@ func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig { }) } return &activityv1.LuckyGiftRuleConfig{ - PoolId: strings.TrimSpace(req.PoolID), - Enabled: req.Enabled, - TargetRtpPpm: percentToPPM(req.TargetRTPPercent), - PoolRatePpm: percentToPPM(req.PoolRatePercent), - SettlementWindowWager: req.SettlementWindowWager, - ControlBandPpm: percentToPPM(req.ControlBandPercent), - GiftPriceReference: req.GiftPriceReference, - EffectiveFromMs: req.EffectiveFromMS, - Stages: stages, + PoolId: strings.TrimSpace(req.PoolID), + Enabled: req.Enabled, + TargetRtpPpm: percentToPPM(req.TargetRTPPercent), + PoolRatePpm: percentToPPM(req.PoolRatePercent), + 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, } } @@ -261,15 +277,23 @@ func configFromProto(config *activityv1.LuckyGiftRuleConfig) configDTO { CreatedByAdminID: config.GetCreatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), configRequest: configRequest{ - PoolID: poolID, - Enabled: config.GetEnabled(), - TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), - PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), - SettlementWindowWager: config.GetSettlementWindowWager(), - ControlBandPercent: ppmToPercent(config.GetControlBandPpm()), - GiftPriceReference: config.GetGiftPriceReference(), - EffectiveFromMS: config.GetEffectiveFromMs(), - Stages: stages, + PoolID: poolID, + Enabled: config.GetEnabled(), + TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), + PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), + 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, }, } } diff --git a/server/admin/internal/modules/luckygift/handler_test.go b/server/admin/internal/modules/luckygift/handler_test.go index b6f26e96..d7fdedc5 100644 --- a/server/admin/internal/modules/luckygift/handler_test.go +++ b/server/admin/internal/modules/luckygift/handler_test.go @@ -8,13 +8,15 @@ import ( func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) { config := configToProto(configRequest{ - PoolID: " lucky_100 ", - Enabled: true, - TargetRTPPercent: 95, - PoolRatePercent: 96, - SettlementWindowWager: 1_000_000, - ControlBandPercent: 1, - GiftPriceReference: 100, + PoolID: " lucky_100 ", + Enabled: true, + TargetRTPPercent: 95, + PoolRatePercent: 96, + 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()) @@ -61,17 +66,25 @@ func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) { func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) { config := configFromProto(&activityv1.LuckyGiftRuleConfig{ - AppCode: "hyapp", - PoolId: "lucky_100", - RuleVersion: 7, - Enabled: true, - TargetRtpPpm: 950_000, - PoolRatePpm: 960_000, - SettlementWindowWager: 1_000_000, - ControlBandPpm: 10_000, - GiftPriceReference: 100, - CreatedByAdminId: 12, - CreatedAtMs: 1_700_000_000_000, + AppCode: "hyapp", + PoolId: "lucky_100", + RuleVersion: 7, + Enabled: true, + TargetRtpPpm: 950_000, + PoolRatePpm: 960_000, + 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{ { Stage: "novice", @@ -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) } diff --git a/server/admin/migrations/024_admin_app_config_schema_backfill.sql b/server/admin/migrations/024_admin_app_config_schema_backfill.sql index 1fb00a51..fa48dd63 100644 --- a/server/admin/migrations/024_admin_app_config_schema_backfill.sql +++ b/server/admin/migrations/024_admin_app_config_schema_backfill.sql @@ -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, diff --git a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql index 7f234a92..11692dc6 100644 --- a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql +++ b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql @@ -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) diff --git a/services/activity-service/internal/domain/luckygift/lucky_gift.go b/services/activity-service/internal/domain/luckygift/lucky_gift.go index 2d96c23c..1c0a4878 100644 --- a/services/activity-service/internal/domain/luckygift/lucky_gift.go +++ b/services/activity-service/internal/domain/luckygift/lucky_gift.go @@ -43,46 +43,47 @@ type Tier struct { // Config 是幸运礼物线上规则快照。金额均为整数金币,比例均为 ppm。 type Config struct { - AppCode string `json:"app_code"` - GiftID string `json:"gift_id"` - PoolID string `json:"pool_id"` - Enabled bool `json:"enabled"` - RuleVersion int64 `json:"rule_version"` - GiftPrice int64 `json:"gift_price"` - TargetRTPPPM int64 `json:"target_rtp_ppm"` - PoolRatePPM int64 `json:"pool_rate_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"` - HighMultiplier int64 `json:"high_multiplier"` - HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` - PlatformPoolWeightPPM int64 `json:"platform_pool_weight_ppm"` - RoomPoolWeightPPM int64 `json:"room_pool_weight_ppm"` - GiftPoolWeightPPM int64 `json:"gift_pool_weight_ppm"` - InitialPlatformPool int64 `json:"initial_platform_pool"` - InitialGiftPool int64 `json:"initial_gift_pool"` - InitialRoomPool int64 `json:"initial_room_pool"` - PlatformReserve int64 `json:"platform_reserve"` - GiftReserve int64 `json:"gift_reserve"` - RoomReserve int64 `json:"room_reserve"` - 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"` - RoomAtmosphereRatePPM int64 `json:"room_atmosphere_rate_ppm"` - RoomAtmosphereInitial int64 `json:"room_atmosphere_initial"` - RoomAtmosphereReserve int64 `json:"room_atmosphere_reserve"` - ActivityBudget int64 `json:"activity_budget"` - ActivityDailyLimit int64 `json:"activity_daily_limit"` - LargeTierEnabled bool `json:"large_tier_enabled"` - MultiplierPPMs []int64 `json:"multiplier_ppms"` - Tiers []Tier `json:"tiers"` - UpdatedByAdminID int64 `json:"updated_by_admin_id"` - CreatedAtMS int64 `json:"created_at_ms"` - UpdatedAtMS int64 `json:"updated_at_ms"` + AppCode string `json:"app_code"` + GiftID string `json:"gift_id"` + PoolID string `json:"pool_id"` + Enabled bool `json:"enabled"` + RuleVersion int64 `json:"rule_version"` + 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"` + 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"` + RoomPoolWeightPPM int64 `json:"room_pool_weight_ppm"` + GiftPoolWeightPPM int64 `json:"gift_pool_weight_ppm"` + InitialPlatformPool int64 `json:"initial_platform_pool"` + InitialGiftPool int64 `json:"initial_gift_pool"` + InitialRoomPool int64 `json:"initial_room_pool"` + PlatformReserve int64 `json:"platform_reserve"` + GiftReserve int64 `json:"gift_reserve"` + RoomReserve int64 `json:"room_reserve"` + 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"` + RoomAtmosphereRatePPM int64 `json:"room_atmosphere_rate_ppm"` + RoomAtmosphereInitial int64 `json:"room_atmosphere_initial"` + RoomAtmosphereReserve int64 `json:"room_atmosphere_reserve"` + ActivityBudget int64 `json:"activity_budget"` + ActivityDailyLimit int64 `json:"activity_daily_limit"` + LargeTierEnabled bool `json:"large_tier_enabled"` + MultiplierPPMs []int64 `json:"multiplier_ppms"` + Tiers []Tier `json:"tiers"` + UpdatedByAdminID int64 `json:"updated_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` } // RuleTier 是 v2 配置契约里的阶段奖档。倍率和概率在服务端内部统一用 ppm 保存。 @@ -105,19 +106,27 @@ type RuleStage struct { // RuleConfig 是幸运礼物 v2 配置版本快照。比例和倍率均为 ppm,业务单位转换发生在发布入口。 type RuleConfig struct { - AppCode string `json:"app_code"` - PoolID string `json:"pool_id"` - RuleVersion int64 `json:"rule_version"` - Enabled bool `json:"enabled"` - TargetRTPPPM int64 `json:"target_rtp_ppm"` - PoolRatePPM int64 `json:"pool_rate_ppm"` - SettlementWindowWager int64 `json:"settlement_window_wager"` - ControlBandPPM int64 `json:"control_band_ppm"` - GiftPriceReference int64 `json:"gift_price_reference"` - EffectiveFromMS int64 `json:"effective_from_ms"` - CreatedByAdminID int64 `json:"created_by_admin_id"` - CreatedAtMS int64 `json:"created_at_ms"` - Stages []RuleStage `json:"stages"` + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + RuleVersion int64 `json:"rule_version"` + Enabled bool `json:"enabled"` + TargetRTPPPM int64 `json:"target_rtp_ppm"` + PoolRatePPM int64 `json:"pool_rate_ppm"` + 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"` } type CheckCommand struct { diff --git a/services/activity-service/internal/service/luckygift/config.go b/services/activity-service/internal/service/luckygift/config.go index 82afff96..e321c5ed 100644 --- a/services/activity-service/internal/service/luckygift/config.go +++ b/services/activity-service/internal/service/luckygift/config.go @@ -19,42 +19,43 @@ func DefaultConfig(appCode, poolID string) domain.Config { const giftPrice int64 = 10_000 poolID = normalizePoolID(poolID) return domain.Config{ - AppCode: appCode, - GiftID: poolID, - PoolID: poolID, - Enabled: false, - GiftPrice: giftPrice, - TargetRTPPPM: 950_000, - PoolRatePPM: 950_000, - GlobalWindowDraws: 100_000, - GiftWindowDraws: 100_000, - NoviceDrawLimit: 2_000, - IntermediateDrawLimit: 20_000, - HighMultiplier: 100, - HighWaterPoolMultiple: 2, - PlatformPoolWeightPPM: 200_000, - RoomPoolWeightPPM: 300_000, - GiftPoolWeightPPM: 500_000, - InitialPlatformPool: 9_500_000, - InitialGiftPool: 23_750_000, - InitialRoomPool: 5_000_000, - PlatformReserve: 1_000_000, - GiftReserve: 1_000_000, - RoomReserve: 100_000, - MaxSinglePayout: giftPrice * 500, - UserHourlyPayoutCap: 3_420_000, - UserDailyPayoutCap: 61_560_000, - DeviceDailyPayoutCap: 102_600_000, - RoomHourlyPayoutCap: 68_400_000, - AnchorDailyPayoutCap: 1_231_200_000, - RoomAtmosphereRatePPM: 10_000, - RoomAtmosphereInitial: 100_000, - RoomAtmosphereReserve: 10_000, - ActivityBudget: 0, - ActivityDailyLimit: 0, - LargeTierEnabled: true, - MultiplierPPMs: defaultMultiplierPPMs(), - Tiers: defaultTiers(giftPrice), + AppCode: appCode, + GiftID: poolID, + PoolID: poolID, + Enabled: false, + GiftPrice: giftPrice, + TargetRTPPPM: 950_000, + PoolRatePPM: 950_000, + ControlBandPPM: 10_000, + GlobalWindowDraws: 100_000, + GiftWindowDraws: 100_000, + NoviceMaxEquivalentDraws: 2_000, + NormalMaxEquivalentDraws: 20_000, + HighMultiplier: 100, + HighWaterPoolMultiple: 2, + PlatformPoolWeightPPM: 200_000, + RoomPoolWeightPPM: 300_000, + GiftPoolWeightPPM: 500_000, + InitialPlatformPool: 9_500_000, + InitialGiftPool: 23_750_000, + InitialRoomPool: 5_000_000, + PlatformReserve: 1_000_000, + GiftReserve: 1_000_000, + RoomReserve: 100_000, + MaxSinglePayout: giftPrice * 500, + UserHourlyPayoutCap: 3_420_000, + UserDailyPayoutCap: 61_560_000, + DeviceDailyPayoutCap: 102_600_000, + RoomHourlyPayoutCap: 68_400_000, + AnchorDailyPayoutCap: 1_231_200_000, + RoomAtmosphereRatePPM: 10_000, + RoomAtmosphereInitial: 100_000, + RoomAtmosphereReserve: 10_000, + ActivityBudget: 0, + ActivityDailyLimit: 0, + LargeTierEnabled: true, + MultiplierPPMs: defaultMultiplierPPMs(), + Tiers: defaultTiers(giftPrice), } } @@ -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, diff --git a/services/activity-service/internal/service/luckygift/config_test.go b/services/activity-service/internal/service/luckygift/config_test.go index 02ebf86c..e5397065 100644 --- a/services/activity-service/internal/service/luckygift/config_test.go +++ b/services/activity-service/internal/service/luckygift/config_test.go @@ -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:] diff --git a/services/activity-service/internal/service/luckygift/service.go b/services/activity-service/internal/service/luckygift/service.go index 7c6ab3bd..101e0a72 100644 --- a/services/activity-service/internal/service/luckygift/service.go +++ b/services/activity-service/internal/service/luckygift/service.go @@ -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 + // 批量抽奖只产生一条聚合 outbox;worker 处理后需要用 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) } @@ -361,31 +366,33 @@ func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery { } type luckyGiftDrawnPayload struct { - EventType string `json:"event_type"` - EventID string `json:"event_id"` - AppCode string `json:"app_code"` - DrawID string `json:"draw_id"` - CommandID string `json:"command_id"` - PoolID string `json:"pool_id"` - RoomID string `json:"room_id"` - GiftID string `json:"gift_id"` - GiftCount int32 `json:"gift_count"` - UserID int64 `json:"user_id"` - SenderUserID int64 `json:"sender_user_id"` - TargetUserID int64 `json:"target_user_id"` - VisibleRegionID int64 `json:"visible_region_id"` - CoinSpent int64 `json:"coin_spent"` - RuleVersion int64 `json:"rule_version"` - ExperiencePool string `json:"experience_pool"` - SelectedTierID string `json:"selected_tier_id"` - MultiplierPPM int64 `json:"multiplier_ppm"` - BaseRewardCoins int64 `json:"base_reward_coins"` - RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"` - ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"` - EffectiveRewardCoins int64 `json:"effective_reward_coins"` - StageFeedback bool `json:"stage_feedback"` - HighMultiplier bool `json:"high_multiplier"` - CreatedAtMS int64 `json:"created_at_ms"` + EventType string `json:"event_type"` + 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"` + GiftID string `json:"gift_id"` + GiftCount int32 `json:"gift_count"` + UserID int64 `json:"user_id"` + SenderUserID int64 `json:"sender_user_id"` + TargetUserID int64 `json:"target_user_id"` + VisibleRegionID int64 `json:"visible_region_id"` + CoinSpent int64 `json:"coin_spent"` + RuleVersion int64 `json:"rule_version"` + ExperiencePool string `json:"experience_pool"` + SelectedTierID string `json:"selected_tier_id"` + MultiplierPPM int64 `json:"multiplier_ppm"` + BaseRewardCoins int64 `json:"base_reward_coins"` + RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"` + ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"` + EffectiveRewardCoins int64 `json:"effective_reward_coins"` + StageFeedback bool `json:"stage_feedback"` + HighMultiplier bool `json:"high_multiplier"` + CreatedAtMS int64 `json:"created_at_ms"` } func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool { @@ -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 { diff --git a/services/activity-service/internal/service/luckygift/service_test.go b/services/activity-service/internal/service/luckygift/service_test.go index 3f472baa..89fbe7cd 100644 --- a/services/activity-service/internal/service/luckygift/service_test.go +++ b/services/activity-service/internal/service/luckygift/service_test.go @@ -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 } diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go index 97a7c53d..1a731e2a 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go @@ -3,7 +3,9 @@ package mysql import ( "context" crand "crypto/rand" + "crypto/sha1" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -37,40 +39,6 @@ type luckyRTPWindow struct { Status string } -func (w luckyRTPWindow) remainingDraws() int64 { - return w.ControlDraws - w.PaidDraws -} - -func (w luckyRTPWindow) remainingPayout() int64 { - return w.TargetPayoutCoins - w.ActualPayoutCoins -} - -func (w luckyRTPWindow) minRequired(minFutureMax int64) int64 { - if w.remainingDraws() <= 1 { - return w.remainingPayout() - } - required := w.remainingPayout() - minFutureMax*(w.remainingDraws()-1) - if required < 0 { - return 0 - } - return required -} - -func (w luckyRTPWindow) highWaterHeadroom(cost int64) int64 { - if w.remainingDraws() <= 1 { - if w.remainingPayout() < 0 { - return 0 - } - return w.remainingPayout() - } - expectedFuture := (w.remainingDraws() - 1) * cost * w.TargetRTPPPM / luckyPPMScale - headroom := w.remainingPayout() - expectedFuture - if headroom < 0 { - return 0 - } - return headroom -} - func (w luckyRTPWindow) withCurrentWager(spent int64) luckyRTPWindow { targetMicros := spent*w.TargetRTPPPM + w.CarryPPM w.WagerCoins += spent @@ -104,6 +72,13 @@ type luckyCounter struct { Payout int64 } +type luckyUserState struct { + PaidDraws int64 + CumulativeWagerCoins int64 + EquivalentDraws int64 + LossStreak int64 +} + type luckyCandidate struct { TierID string Source string @@ -114,7 +89,6 @@ type luckyCandidate struct { RoomReward int64 ActivityReward int64 Presentation bool - Correction bool HighMultiplier bool } @@ -122,19 +96,88 @@ func (c luckyCandidate) effectiveReward() int64 { return c.BaseReward + c.RoomReward + c.ActivityReward } +// luckyDrawRecordInput 是“待插入的抽奖事实快照”,不是运行态规则。 +// 批量抽奖会先在内存中逐抽推进 RTP、奖池和风控,再把每一抽的审计快照集中写入 lucky_draw_records。 +// 这里保留单抽时刻的窗口、奖池和候选信息,方便后续排查某一抽为什么命中或为什么被过滤到 0 倍。 +type luckyDrawRecordInput struct { + AppCode string + DrawID string + Command domain.DrawCommand + Config domain.Config + ExperiencePool string + Candidate luckyCandidate + Limited map[string]bool + StageFeedback bool + GlobalWindow luckyRTPWindow + GiftWindow luckyRTPWindow + PlatformPool luckyPool + RoomPool luckyPool + GiftPool luckyPool + Atmosphere luckyPool + BudgetSourcesJSON string + RewardStatus string + NowMS int64 +} + +// luckyBatchDrawState 是批量送礼事务里的聚合账本。 +// 设计目标是把 N 次抽奖必须逐次变化的随机过程留在内存循环里, +// 把规则读取、行锁、奖池写入、RTP 窗口写入、风控计数写入和 outbox 写入收敛到事务首尾。 +type luckyBatchDrawState struct { + // In/Out 只记录本批净变化;最后用一次 UPDATE 写回,避免 gift_count=777 时重复打数据库。 + PlatformIn int64 + PlatformOut int64 + RoomIn int64 + RoomOut int64 + GiftIn int64 + GiftOut int64 + AtmosphereIn int64 + AtmosphereOut int64 + ActivityReward int64 + EffectiveReward int64 + // NeedsOutbox 表示本批存在任意真实异步副作用:钱包加币、房间展示或阶段反馈。 + // 只要为 true,本批所有子抽奖先统一 pending,worker 成功后一起 granted。 + NeedsOutbox bool + StageFeedback bool + HighMultiplier bool + // Final* 是用户体验状态的最终值;批量抽奖不能每抽 UPDATE lucky_user_states。 + FinalLossStreak int64 + FinalCumulative int64 + FinalEquivalent int64 + // DrawIDs 进入聚合 outbox,worker 用它一次性收敛整批 draw 记录。 + DrawIDs []string + Records []luckyDrawRecordInput + // Window/Pool/Counter 都是 FOR UPDATE 读出的当前行副本,循环内只改内存副本。 + GlobalWindow luckyRTPWindow + GiftWindow luckyRTPWindow + PlatformPool luckyPool + RoomPool luckyPool + GiftPool luckyPool + Atmosphere luckyPool + Counters map[string]luckyCounter + ActivityDay string + AggregateResult domain.DrawResult + AggregateSources string + AggregateStatus string + AggregateCommandID string +} + func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) { if r == nil || r.db == nil { return domain.CheckResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } poolID := luckyPoolID(cmd.PoolID) - config, exists, err := r.getLuckyGiftConfig(ctx, r.db, appcode.FromContext(ctx), poolID, false) + ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), poolID, false) if err != nil { return domain.CheckResult{}, err } if !exists { return domain.CheckResult{Enabled: false, Reason: "not_configured", PoolID: poolID, GiftID: cmd.GiftID}, nil } - paidDraws, err := r.getLuckyUserPaidDraws(ctx, appcode.FromContext(ctx), cmd.UserID, config.GiftID) + config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig) + if err != nil { + return domain.CheckResult{}, err + } + state, err := r.getLuckyUserState(ctx, appcode.FromContext(ctx), cmd.UserID, config.GiftID) if err != nil { return domain.CheckResult{}, err } @@ -146,7 +189,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand GiftPrice: config.GiftPrice, RuleVersion: config.RuleVersion, TargetRTPPPM: config.TargetRTPPPM, - ExperiencePool: luckyExperiencePool(paidDraws+1, config), + ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config), }, nil } @@ -161,45 +204,577 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo } defer func() { _ = tx.Rollback() }() + results, err := r.executeLuckyGiftDrawBatch(ctx, tx, cmd, nowMS) + if err != nil { + return domain.DrawResult{}, err + } + if err := tx.Commit(); err != nil { + return domain.DrawResult{}, err + } + return luckyAggregateDrawResults(cmd, results), nil +} + +func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) { + // gift_count 是抽奖次数,coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。 + // 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。 + drawCount := cmd.GiftCount + if drawCount <= 0 { + drawCount = 1 + } + if drawCount == 1 { + result, err := r.executeSingleLuckyGiftDraw(ctx, tx, cmd, nowMS) + if err != nil { + return nil, err + } + return []domain.DrawResult{result}, nil + } + appCode := appcode.FromContext(ctx) + if drawCount > 1 { + // 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。 + if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists { + if err != nil { + return nil, err + } + return []domain.DrawResult{existing}, nil + } + } + // 新批量路径用 command_id#序号 做每一抽的幂等键;先探测第一条即可判断整批是否已经成功提交。 + // 如果第一条存在但后续缺失,说明数据被外部破坏,只能报冲突,不能补写造成账务二次发放。 + firstSubCommandID := luckyDrawSubCommandID(cmd.CommandID, 1, drawCount) + if _, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, firstSubCommandID, true); err != nil || exists { + if err != nil { + return nil, err + } + return r.collectLuckyBatchDrawsByCommand(ctx, tx, appCode, cmd, drawCount) + } + return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS) +} + +// collectLuckyBatchDrawsByCommand 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。 +// 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。 +func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) { + results := make([]domain.DrawResult, 0, drawCount) + for index := int32(1); index <= drawCount; index++ { + commandID := luckyDrawSubCommandID(cmd.CommandID, index, drawCount) + result, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, commandID, true) + if err != nil { + return nil, err + } + if !exists { + // 正常事务会一次提交全部子抽奖;如果只读到部分子命令,说明上一次写入被手工破坏,继续补写会造成重复返奖。 + return nil, xerr.New(xerr.Conflict, "lucky gift batch draw is partially persisted") + } + results = append(results, result) + } + return results, nil +} + +// executeOptimizedLuckyGiftDrawBatch 是 gift_count > 1 的主路径。 +// 性能关键点:所有共享行先 FOR UPDATE 一次,循环中只推进内存副本,事务尾部再批量写回。 +// 业务关键点:抽奖仍然按 N 次独立随机,发奖和 IM 表现按“一次送礼命令”聚合成一条 outbox。 +func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, nowMS int64) ([]domain.DrawResult, error) { + appCode := appcode.FromContext(ctx) + poolID := luckyPoolID(cmd.PoolID) + + // 规则配置是本批抽奖的不可变快照;同一批内不允许因为后台改配置导致前后两抽规则不同。 + ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true) + if err != nil { + return nil, err + } + if !exists || !ruleConfig.Enabled { + return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") + } + baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig) + if err != nil { + return nil, err + } + referencePrice := baseConfig.GiftPrice + // 用户状态锁必须覆盖整批抽奖,否则同一用户并发送礼会交错推进 equivalent_draws 和 loss_streak。 + userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, baseConfig.GiftID, nowMS) + if err != nil { + return nil, err + } + + windowScopeID := luckyRuleWindowScopeID(baseConfig) + // RTP 窗口只做观察和结算口径,不用 RTP 缺口改概率;批量路径仍要累计每一抽的 wager 和 base payout。 + globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool", windowScopeID, baseConfig.GlobalWindowDraws, baseConfig.TargetRTPPPM, nowMS) + if err != nil { + return nil, err + } + giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, baseConfig.GiftWindowDraws, baseConfig.TargetRTPPPM, nowMS) + if err != nil { + return nil, err + } + // 三层基础奖池和房间气氛池是本批所有子抽共享的支付能力;锁一次后在内存里扣减,保证不会透支 reserve。 + platformPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "platform", baseConfig.GiftID, baseConfig.InitialPlatformPool, baseConfig.PlatformReserve, nowMS) + if err != nil { + return nil, err + } + roomPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "room", luckyPoolScopedID(baseConfig.GiftID, cmd.RoomID), baseConfig.InitialRoomPool, baseConfig.RoomReserve, nowMS) + if err != nil { + return nil, err + } + giftPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "gift", baseConfig.GiftID, baseConfig.InitialGiftPool, baseConfig.GiftReserve, nowMS) + if err != nil { + return nil, err + } + atmosphere, err := r.getOrCreateLuckyAtmosphere(ctx, tx, appCode, luckyPoolScopedID(baseConfig.GiftID, cmd.RoomID), baseConfig.RoomAtmosphereInitial, baseConfig.RoomAtmosphereReserve, nowMS) + if err != nil { + return nil, err + } + // 活动预算和风控计数也属于共享容量;批量内存扣减后,后面的子抽会看到前面子抽已经消耗的额度。 + _, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, baseConfig, cmd.PaidAtMS, nowMS) + if err != nil { + return nil, err + } + counters, err := r.getLuckyRiskCounters(ctx, tx, appCode, baseConfig.GiftID, cmd, nowMS) + if err != nil { + return nil, err + } + + state := luckyBatchDrawState{ + GlobalWindow: globalWindow, + GiftWindow: giftWindow, + PlatformPool: platformPool, + RoomPool: roomPool, + GiftPool: giftPool, + Atmosphere: atmosphere, + Counters: counters, + ActivityDay: activityDay, + FinalLossStreak: userState.LossStreak, + FinalCumulative: userState.CumulativeWagerCoins, + FinalEquivalent: userState.EquivalentDraws, + DrawIDs: make([]string, 0, drawCount), + Records: make([]luckyDrawRecordInput, 0, drawCount), + } + results := make([]domain.DrawResult, 0, drawCount) + for index := int32(1); index <= drawCount; index++ { + // 每个礼物数量是一抽;金额按总扣费拆分,余数落在前几抽,保证所有子抽 coin_spent 之和等于扣费事实。 + unitCommand := cmd + unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount) + unitCommand.GiftCount = 1 + unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index) + if unitCommand.CoinSpent <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "lucky gift unit spend must be positive") + } + result, err := r.stageLuckyBatchDraw(appCode, baseConfig, referencePrice, unitCommand, nowMS, &state) + if err != nil { + return nil, err + } + results = append(results, result) + } + + aggregate := luckyAggregateDrawResults(cmd, results) + // 响应里的倍数/金额用聚合值,审计表仍保留每一抽的明细;客户端看到的是“一次送礼的总中奖结果”。 + state.AggregateResult = aggregate + state.AggregateSources = aggregate.BudgetSourcesJSON + state.AggregateStatus = aggregate.RewardStatus + state.AggregateCommandID = cmd.CommandID + if state.NeedsOutbox { + // 批量命令只需要一次钱包发放和一次房间表现;所有子抽奖共享这条 outbox 的最终收敛状态。 + state.AggregateResult.RewardStatus = domain.StatusPending + for index := range state.Records { + state.Records[index].RewardStatus = domain.StatusPending + results[index].RewardStatus = domain.StatusPending + } + } else { + state.AggregateResult.RewardStatus = domain.StatusGranted + } + if err := r.persistLuckyBatchDraw(ctx, tx, appCode, cmd, baseConfig, drawCount, state, nowMS); err != nil { + return nil, err + } + return results, nil +} + +// stageLuckyBatchDraw 执行“单个子抽”的纯业务推进。 +// 这个函数不写数据库:它只读取并修改 luckyBatchDrawState,确保 777 抽不会变成 777 轮 SQL 往返。 +func (r *Repository) stageLuckyBatchDraw(appCode string, baseConfig domain.Config, referencePrice int64, cmd domain.DrawCommand, nowMS int64, state *luckyBatchDrawState) (domain.DrawResult, error) { + // 奖励金额按本子抽实际金币缩放,但阶段阈值仍按累计流水 / 参考价格折算等价抽数。 + config := luckyRuntimeConfig(baseConfig, cmd.CoinSpent) + state.FinalCumulative += cmd.CoinSpent + state.FinalEquivalent = luckyEquivalentDraws(state.FinalCumulative, referencePrice) + experiencePool := luckyExperiencePool(state.FinalEquivalent, config) + stageFeedback := luckyStageFeedback(state.FinalEquivalent, config) + state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent) + state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent) + + // 先把本抽入池计入内存水位,再做候选过滤;这样同一批内前后抽的支付能力与真实资金流一致。 + platformIn, roomIn, giftIn := luckyBasePoolIn(config, cmd.CoinSpent) + creditLuckyPoolInMemory(&state.PlatformPool, platformIn) + creditLuckyPoolInMemory(&state.RoomPool, roomIn) + creditLuckyPoolInMemory(&state.GiftPool, giftIn) + atmosphereIn := cmd.CoinSpent * config.RoomAtmosphereRatePPM / luckyPPMScale + creditLuckyPoolInMemory(&state.Atmosphere, atmosphereIn) + state.PlatformIn += platformIn + state.RoomIn += roomIn + state.GiftIn += giftIn + state.AtmosphereIn += atmosphereIn + + // 候选过滤使用已经被本批前序子抽消耗过的内存池和风控计数,避免批量同时命中超过容量的高倍奖。 + candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, state.PlatformPool, state.RoomPool, state.GiftPool, state.Counters) + if err != nil { + return domain.DrawResult{}, err + } + // 随机后只落事实,不再改候选;这里扣减内存容量,让后续子抽立即感知本抽成本。 + platformOut, roomOut, giftOut := luckySplitWeighted(candidate.BaseReward, config) + debitLuckyPoolInMemory(&state.PlatformPool, platformOut) + debitLuckyPoolInMemory(&state.RoomPool, roomOut) + debitLuckyPoolInMemory(&state.GiftPool, giftOut) + debitLuckyPoolInMemory(&state.Atmosphere, candidate.RoomReward) + state.PlatformOut += platformOut + state.RoomOut += roomOut + state.GiftOut += giftOut + state.AtmosphereOut += candidate.RoomReward + state.ActivityReward += candidate.ActivityReward + state.EffectiveReward += candidate.effectiveReward() + // 风控按 effective reward 累计,基础 RTP、气氛池和活动补贴都属于用户真实收到的金币成本。 + if candidate.effectiveReward() > 0 { + incrementLuckyRiskCountersInMemory(state.Counters, candidate.effectiveReward()) + state.FinalLossStreak = 0 + } else { + state.FinalLossStreak++ + } + state.StageFeedback = state.StageFeedback || stageFeedback + state.HighMultiplier = state.HighMultiplier || candidate.HighMultiplier + if luckyDrawNeedsOutbox(candidate, stageFeedback) { + state.NeedsOutbox = true + } + + // 每个子抽必须有独立 draw_id,保证审计、列表和重试读回都能定位到具体一次随机结果。 + drawID := idgen.New("lucky_draw") + budgetSourcesJSON := luckyBudgetSourcesJSON(candidate) + rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback) + record := luckyDrawRecordInput{ + AppCode: appCode, + DrawID: drawID, + Command: cmd, + Config: config, + ExperiencePool: experiencePool, + Candidate: candidate, + Limited: limited, + StageFeedback: stageFeedback, + GlobalWindow: state.GlobalWindow, + GiftWindow: state.GiftWindow, + PlatformPool: state.PlatformPool, + RoomPool: state.RoomPool, + GiftPool: state.GiftPool, + Atmosphere: state.Atmosphere, + BudgetSourcesJSON: budgetSourcesJSON, + RewardStatus: rewardStatus, + NowMS: nowMS, + } + state.DrawIDs = append(state.DrawIDs, drawID) + state.Records = append(state.Records, record) + // 返回给上层的单抽结果只用于聚合响应和聚合 outbox;真正的 DB 写入来自 state.Records。 + return domain.DrawResult{ + DrawID: drawID, + CommandID: cmd.CommandID, + PoolID: config.GiftID, + GiftID: cmd.GiftID, + RuleVersion: config.RuleVersion, + ExperiencePool: experiencePool, + SelectedTierID: candidate.TierID, + MultiplierPPM: candidate.MultiplierPPM, + BaseRewardCoins: candidate.BaseReward, + RoomAtmosphereRewardCoins: candidate.RoomReward, + ActivitySubsidyCoins: candidate.ActivityReward, + EffectiveRewardCoins: candidate.effectiveReward(), + BudgetSourcesJSON: budgetSourcesJSON, + RewardStatus: rewardStatus, + RTPWindowIndex: state.GlobalWindow.WindowIndex, + GiftRTPWindowIndex: state.GiftWindow.WindowIndex, + GlobalBaseRTPPPM: luckyRTPPPM(state.GlobalWindow.WagerCoins, state.GlobalWindow.ActualPayoutCoins+state.PlatformOut+state.RoomOut+state.GiftOut), + GiftBaseRTPPPM: luckyRTPPPM(state.GiftWindow.WagerCoins, state.GiftWindow.ActualPayoutCoins+state.PlatformOut+state.RoomOut+state.GiftOut), + StageFeedback: stageFeedback, + HighMultiplier: candidate.HighMultiplier, + CreatedAtMS: nowMS, + }, nil +} + +// persistLuckyBatchDraw 把批量内存账本一次性落库。 +// 写入顺序保持“窗口/奖池/风控/用户状态 -> 抽奖事实 -> outbox”,让 outbox 永远指向已经存在的 draw records。 +func (r *Repository) persistLuckyBatchDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, drawCount int32, state luckyBatchDrawState, nowMS int64) error { + // RTP 窗口按本批 drawCount 增加抽数,按本批总 wager/base payout 增加结算口径。 + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_rtp_windows + SET paid_draws = paid_draws + ?, + wager_coins = ?, + target_payout_coins = ?, + actual_base_payout_coins = actual_base_payout_coins + ?, + carry_ppm = ?, + updated_at_ms = ? + WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`, + drawCount, state.GlobalWindow.WagerCoins, state.GlobalWindow.TargetPayoutCoins, state.PlatformOut+state.RoomOut+state.GiftOut, state.GlobalWindow.CarryPPM, nowMS, + appCode, state.GlobalWindow.ScopeType, state.GlobalWindow.ScopeID, state.GlobalWindow.WindowIndex, + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_rtp_windows + SET paid_draws = paid_draws + ?, + wager_coins = ?, + target_payout_coins = ?, + actual_base_payout_coins = actual_base_payout_coins + ?, + carry_ppm = ?, + updated_at_ms = ? + WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`, + drawCount, state.GiftWindow.WagerCoins, state.GiftWindow.TargetPayoutCoins, state.PlatformOut+state.RoomOut+state.GiftOut, state.GiftWindow.CarryPPM, nowMS, + appCode, state.GiftWindow.ScopeType, state.GiftWindow.ScopeID, state.GiftWindow.WindowIndex, + ); err != nil { + return err + } + if err := r.updateLuckyPoolNet(ctx, tx, appCode, "platform", config.GiftID, state.PlatformIn, state.PlatformOut, nowMS); err != nil { + return err + } + if err := r.updateLuckyPoolNet(ctx, tx, appCode, "room", state.RoomPool.ScopeID, state.RoomIn, state.RoomOut, nowMS); err != nil { + return err + } + if err := r.updateLuckyPoolNet(ctx, tx, appCode, "gift", state.GiftPool.ScopeID, state.GiftIn, state.GiftOut, nowMS); err != nil { + return err + } + if err := r.updateLuckyAtmosphereNet(ctx, tx, appCode, state.Atmosphere.ScopeID, state.AtmosphereIn, state.AtmosphereOut, nowMS); err != nil { + return err + } + // 活动预算只有命中活动补贴时才写;当前主链路活动为 0 时避免无意义 UPDATE。 + if state.ActivityReward > 0 && state.ActivityDay != "" { + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_activity_budgets + SET spent_coins = spent_coins + ?, + updated_at_ms = ? + WHERE app_code = ? AND gift_id = ? AND budget_day = ?`, + state.ActivityReward, nowMS, appCode, config.GiftID, luckyActivityTotalBudgetDay, + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_activity_budgets + SET spent_coins = spent_coins + ?, + daily_spent_coins = daily_spent_coins + ?, + updated_at_ms = ? + WHERE app_code = ? AND gift_id = ? AND budget_day = ?`, + state.ActivityReward, state.ActivityReward, nowMS, appCode, config.GiftID, state.ActivityDay, + ); err != nil { + return err + } + } + if err := r.updateLuckyRiskCounters(ctx, tx, appCode, config.GiftID, cmd, state.EffectiveReward, nowMS); err != nil { + return err + } + // 用户体验状态只取本批最终值;中间值已经体现在每条 draw record 的 experience_pool 快照里。 + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_user_states + SET paid_draws = paid_draws + ?, + cumulative_wager_coins = ?, + equivalent_draws = ?, + loss_streak = ?, + updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND gift_id = ?`, + drawCount, state.FinalCumulative, state.FinalEquivalent, state.FinalLossStreak, nowMS, appCode, cmd.UserID, config.GiftID, + ); err != nil { + return err + } + if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil { + return err + } + if state.NeedsOutbox { + // 只有本批存在钱包/IM/反馈副作用时才产生 outbox;纯 0 倍且无反馈的批次直接终态 granted。 + return r.insertLuckyAggregateDrawOutbox(ctx, tx, appCode, cmd, state, nowMS) + } + return nil +} + +// updateLuckyPoolNet 用一条 SQL 同时写入本批入池和出池,避免循环里 credit/debit 分别 UPDATE。 +func (r *Repository) updateLuckyPoolNet(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, amountIn, amountOut, nowMS int64) error { + if amountIn <= 0 && amountOut <= 0 { + return nil + } + _, err := tx.ExecContext(ctx, ` + UPDATE lucky_pools + SET balance = balance + ? - ?, + total_in = total_in + ?, + total_out = total_out + ?, + updated_at_ms = ? + WHERE app_code = ? AND scope_type = ? AND scope_id = ?`, + amountIn, amountOut, amountIn, amountOut, nowMS, appCode, scopeType, scopeID, + ) + return err +} + +// updateLuckyAtmosphereNet 与基础奖池写法一致,但落在房间气氛池表;气氛池不进入基础 RTP 返奖口径。 +func (r *Repository) updateLuckyAtmosphereNet(ctx context.Context, tx *sql.Tx, appCode, roomID string, amountIn, amountOut, nowMS int64) error { + if amountIn <= 0 && amountOut <= 0 { + return nil + } + _, err := tx.ExecContext(ctx, ` + UPDATE lucky_room_atmosphere_pools + SET balance = balance + ? - ?, + total_in = total_in + ?, + total_out = total_out + ?, + updated_at_ms = ? + WHERE app_code = ? AND room_id = ?`, + amountIn, amountOut, amountIn, amountOut, nowMS, appCode, roomID, + ) + return err +} + +// insertLuckyDrawRecords 批量写入审计明细。 +// 仍然保留每一抽一条记录,因为后台统计“抽奖次数”和问题复盘都需要单抽粒度;优化点是批量 INSERT 而不是 N 次 INSERT。 +func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput) error { + const chunkSize = 200 + for start := 0; start < len(records); start += chunkSize { + end := start + chunkSize + if end > len(records) { + end = len(records) + } + batch := records[start:end] + var sqlBuilder strings.Builder + sqlBuilder.WriteString(` + INSERT INTO lucky_draw_records ( + app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, pool_id, gift_id, + coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index, + selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, + effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier, + candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason, + paid_at_ms, created_at_ms, updated_at_ms + ) VALUES `) + args := make([]any, 0, len(batch)*31) + rowPlaceholders := "(" + luckySQLPlaceholders(31) + ")" + for index, record := range batch { + if index > 0 { + sqlBuilder.WriteString(",") + } + sqlBuilder.WriteString(rowPlaceholders) + args = append(args, luckyDrawRecordArgs(record)...) + } + if _, err := tx.ExecContext(ctx, sqlBuilder.String(), args...); err != nil { + return err + } + } + return nil +} + +// luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。 +func luckyDrawRecordArgs(input luckyDrawRecordInput) []any { + candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input) + return []any{ + input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID, + input.Command.RoomID, input.Command.AnchorID, input.Config.GiftID, input.Command.GiftID, input.Command.CoinSpent, + input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex, + input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward, + input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier, + candidateSnapshot, poolSnapshot, rtpSnapshot, input.RewardStatus, "", "", + input.Command.PaidAtMS, input.NowMS, input.NowMS, + } +} + +// luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。 +// 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。 +func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) { + candidateSnapshot, _ := json.Marshal(map[string]any{ + "base_reward": input.Candidate.BaseReward, + "limited": input.Limited, + "multiplier_ppm": input.Candidate.MultiplierPPM, + "source": input.Candidate.Source, + "weight": input.Candidate.Weight, + }) + poolSnapshot, _ := json.Marshal(map[string]any{ + "platform_balance": input.PlatformPool.Balance, + "room_balance": input.RoomPool.Balance, + "gift_balance": input.GiftPool.Balance, + "atmosphere": input.Atmosphere.Balance, + }) + rtpSnapshot, _ := json.Marshal(map[string]any{ + "global_window": input.GlobalWindow.WindowIndex, + "gift_window": input.GiftWindow.WindowIndex, + }) + return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot) +} + +// insertLuckyAggregateDrawOutbox 为整批抽奖只写一条副作用事件。 +// draw_id 使用聚合响应的首个 draw,draw_ids 保存全部子抽;worker 用总 effective_reward_coins 发一次奖,再批量 granted。 +func (r *Repository) insertLuckyAggregateDrawOutbox(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, state luckyBatchDrawState, nowMS int64) error { + aggregate := state.AggregateResult + payload, _ := json.Marshal(map[string]any{ + "event_type": "lucky_gift_drawn", + "event_id": "lucky_gift_drawn:" + aggregate.DrawID, + "app_code": appCode, + "draw_id": aggregate.DrawID, + "draw_ids": state.DrawIDs, + "command_id": cmd.CommandID, + "pool_id": aggregate.PoolID, + "user_id": cmd.UserID, + "sender_user_id": cmd.UserID, + "target_user_id": cmd.TargetUserID, + "visible_region_id": cmd.VisibleRegionID, + "room_id": cmd.RoomID, + "gift_id": cmd.GiftID, + "gift_count": cmd.GiftCount, + "coin_spent": cmd.CoinSpent, + "rule_version": aggregate.RuleVersion, + "experience_pool": aggregate.ExperiencePool, + "selected_tier_id": aggregate.SelectedTierID, + "multiplier_ppm": aggregate.MultiplierPPM, + "base_reward_coins": aggregate.BaseRewardCoins, + "room_atmosphere_reward_coins": aggregate.RoomAtmosphereRewardCoins, + "activity_subsidy_coins": aggregate.ActivitySubsidyCoins, + "effective_reward_coins": aggregate.EffectiveRewardCoins, + "stage_feedback": aggregate.StageFeedback, + "high_multiplier": aggregate.HighMultiplier, + "created_at_ms": nowMS, + }) + _, err := tx.ExecContext(ctx, ` + INSERT INTO activity_outbox ( + app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms, + locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'LuckyGiftDrawn', ?, 'pending', 0, ?, '', 0, '', ?, ?)`, + appCode, "lucky_"+aggregate.DrawID, payload, nowMS, nowMS, nowMS, + ) + return err +} + +func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) { appCode := appcode.FromContext(ctx) poolID := luckyPoolID(cmd.PoolID) if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists { if err != nil { return domain.DrawResult{}, err } - if err := tx.Commit(); err != nil { - return domain.DrawResult{}, err - } return existing, nil } - config, exists, err := r.getLuckyGiftConfig(ctx, tx, appCode, poolID, true) + ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true) if err != nil { return domain.DrawResult{}, err } - if !exists || !config.Enabled { + if !exists || !ruleConfig.Enabled { return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") } - // 后台配置的 gift_price 只是参考价格;实际抽奖必须按钱包扣费后的 coin_spent 等比缩放奖档和风控上限。 - config = luckyRuntimeConfig(config, cmd.CoinSpent) - - userPaidDraws, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS) + config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig) if err != nil { return domain.DrawResult{}, err } - // 体验池只按该 pool_id 下的用户累计付费抽数切换,不承诺单用户 RTP 或保底中奖。 - nextPaidDraw := userPaidDraws + 1 - experiencePool := luckyExperiencePool(nextPaidDraw, config) - stageFeedback := luckyStageFeedback(nextPaidDraw, config) - minFutureMax := luckyMinFutureMax(config) + referencePrice := config.GiftPrice + // 后台配置的 gift_price 只是参考价格;实际单抽按本次子抽奖的 coin_spent 等比缩放倍率奖金额。 + config = luckyRuntimeConfig(config, cmd.CoinSpent) - // 全站窗口和礼物子窗口当前都按 pool_id 隔离;withCurrentWager 会把本次扣费先计入目标支出。 - globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool", config.GiftID, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS) + userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS) + if err != nil { + return domain.DrawResult{}, err + } + // 体验池仍按“累计金币流水 / 参考价格”折算等价抽数;批量送礼已在外层拆成单抽,这里只推进当前这一抽。 + nextCumulativeWager := userState.CumulativeWagerCoins + cmd.CoinSpent + nextEquivalentDraws := luckyEquivalentDraws(nextCumulativeWager, referencePrice) + experiencePool := luckyExperiencePool(nextEquivalentDraws, config) + stageFeedback := luckyStageFeedback(nextEquivalentDraws, config) + + windowScopeID := luckyRuleWindowScopeID(config) + // RTP 窗口按 pool + rule_version 隔离,只记录观察口径;候选随机不再读取缺口,否则会出现一段时间一直亏或一直赚。 + globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS) if err != nil { return domain.DrawResult{}, err } globalWindow = globalWindow.withCurrentWager(cmd.CoinSpent) - giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool_gift", config.GiftID, config.GiftWindowDraws, config.TargetRTPPPM, nowMS) + giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.GiftWindowDraws, config.TargetRTPPPM, nowMS) if err != nil { return domain.DrawResult{}, err } @@ -231,7 +806,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo return domain.DrawResult{}, err } // 活动预算和日预算按 UTC 天切分,只作为 effective reward 的额外补贴,不用来补基础 RTP 缺口。 - activityRemaining, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, config, cmd.PaidAtMS, nowMS) + _, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, config, cmd.PaidAtMS, nowMS) if err != nil { return domain.DrawResult{}, err } @@ -242,7 +817,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo } // 所有过滤都发生在随机前;随机后只能落事实和副作用 outbox,不能再改成另一个结果。 - candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, stageFeedback, globalWindow, giftWindow, platformPool, roomPool, giftPool, atmosphere, activityRemaining, counters, minFutureMax) + candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, platformPool, roomPool, giftPool, counters) if err != nil { return domain.DrawResult{}, err } @@ -251,30 +826,29 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo budgetSourcesJSON := luckyBudgetSourcesJSON(candidate) rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback) if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{ - AppCode: appCode, - DrawID: drawID, - Command: cmd, - Config: config, - ExperiencePool: experiencePool, - Candidate: candidate, - Limited: limited, - StageFeedback: stageFeedback, - GlobalWindow: globalWindow, - GiftWindow: giftWindow, - PlatformPool: platformPool, - RoomPool: roomPool, - GiftPool: giftPool, - Atmosphere: atmosphere, - ActivityDay: activityDay, - BudgetSourcesJSON: budgetSourcesJSON, - RewardStatus: rewardStatus, - NowMS: nowMS, + AppCode: appCode, + DrawID: drawID, + Command: cmd, + Config: config, + ExperiencePool: experiencePool, + Candidate: candidate, + Limited: limited, + StageFeedback: stageFeedback, + NextCumulativeWagerCoins: nextCumulativeWager, + NextEquivalentDraws: nextEquivalentDraws, + GlobalWindow: globalWindow, + GiftWindow: giftWindow, + PlatformPool: platformPool, + RoomPool: roomPool, + GiftPool: giftPool, + Atmosphere: atmosphere, + ActivityDay: activityDay, + BudgetSourcesJSON: budgetSourcesJSON, + RewardStatus: rewardStatus, + NowMS: nowMS, }); err != nil { return domain.DrawResult{}, err } - if err := tx.Commit(); err != nil { - return domain.DrawResult{}, err - } return domain.DrawResult{ DrawID: drawID, @@ -301,25 +875,130 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo }, nil } +func luckyDrawUnitSpend(totalSpent int64, drawCount int32, drawIndex int32) int64 { + if drawCount <= 1 { + return totalSpent + } + base := totalSpent / int64(drawCount) + remainder := totalSpent % int64(drawCount) + if int64(drawIndex) <= remainder { + return base + 1 + } + return base +} + +func luckyDrawSubCommandID(commandID string, drawIndex int32, drawCount int32) string { + if drawCount <= 1 { + return commandID + } + suffix := fmt.Sprintf("#%06d", drawIndex) + const maxCommandIDLength = 128 + if len(commandID)+len(suffix) <= maxCommandIDLength { + return commandID + suffix + } + sum := sha1.Sum([]byte(commandID)) + hash := hex.EncodeToString(sum[:])[:10] + hashSuffix := "#" + hash + suffix + headLen := maxCommandIDLength - len(hashSuffix) + if headLen < 0 { + headLen = 0 + } + if len(commandID) > headLen { + commandID = commandID[:headLen] + } + return commandID + hashSuffix +} + +func luckyAggregateDrawResults(cmd domain.DrawCommand, results []domain.DrawResult) domain.DrawResult { + if len(results) == 0 { + return domain.DrawResult{} + } + aggregate := results[0] + aggregate.CommandID = cmd.CommandID + if len(results) == 1 { + return aggregate + } + aggregate.SelectedTierID = "batch" + aggregate.BaseRewardCoins = 0 + aggregate.RoomAtmosphereRewardCoins = 0 + aggregate.ActivitySubsidyCoins = 0 + aggregate.EffectiveRewardCoins = 0 + aggregate.MultiplierPPM = 0 + aggregate.StageFeedback = false + aggregate.HighMultiplier = false + aggregate.RewardStatus = domain.StatusGranted + for _, result := range results { + aggregate.RuleVersion = result.RuleVersion + aggregate.ExperiencePool = result.ExperiencePool + aggregate.RTPWindowIndex = result.RTPWindowIndex + aggregate.GiftRTPWindowIndex = result.GiftRTPWindowIndex + aggregate.GlobalBaseRTPPPM = result.GlobalBaseRTPPPM + aggregate.GiftBaseRTPPPM = result.GiftBaseRTPPPM + aggregate.CreatedAtMS = result.CreatedAtMS + aggregate.BaseRewardCoins += result.BaseRewardCoins + aggregate.RoomAtmosphereRewardCoins += result.RoomAtmosphereRewardCoins + aggregate.ActivitySubsidyCoins += result.ActivitySubsidyCoins + aggregate.EffectiveRewardCoins += result.EffectiveRewardCoins + // 批量送礼的“中奖倍数”是用户看到的总命中倍数,不是总返奖/总消耗的平均倍率。 + aggregate.MultiplierPPM += result.MultiplierPPM + aggregate.StageFeedback = aggregate.StageFeedback || result.StageFeedback + aggregate.HighMultiplier = aggregate.HighMultiplier || result.HighMultiplier + if result.RewardStatus == domain.StatusPending { + aggregate.RewardStatus = domain.StatusPending + } else if aggregate.RewardStatus != domain.StatusPending && result.RewardStatus == domain.StatusFailed { + aggregate.RewardStatus = domain.StatusFailed + } + } + aggregate.BudgetSourcesJSON = luckyAggregateBudgetSourcesJSON(results) + return aggregate +} + +func luckyAggregateBudgetSourcesJSON(results []domain.DrawResult) string { + seen := map[string]bool{} + for _, result := range results { + var payload struct { + Sources []string `json:"sources"` + } + if err := json.Unmarshal([]byte(result.BudgetSourcesJSON), &payload); err != nil { + continue + } + for _, source := range payload.Sources { + switch source { + case domain.SourceBaseRTP, domain.SourceRoomAtmosphere, domain.SourceActivity, domain.SourcePresentation: + seen[source] = true + } + } + } + sources := make([]string, 0, len(seen)) + for source := range seen { + sources = append(sources, source) + } + sort.Strings(sources) + raw, _ := json.Marshal(map[string]any{"sources": sources, "draw_count": len(results)}) + return string(raw) +} + type luckyApplyInput struct { - AppCode string - DrawID string - Command domain.DrawCommand - Config domain.Config - ExperiencePool string - Candidate luckyCandidate - Limited map[string]bool - StageFeedback bool - GlobalWindow luckyRTPWindow - GiftWindow luckyRTPWindow - PlatformPool luckyPool - RoomPool luckyPool - GiftPool luckyPool - Atmosphere luckyPool - ActivityDay string - BudgetSourcesJSON string - RewardStatus string - NowMS int64 + AppCode string + DrawID string + Command domain.DrawCommand + Config domain.Config + ExperiencePool string + Candidate luckyCandidate + Limited map[string]bool + StageFeedback bool + NextCumulativeWagerCoins int64 + NextEquivalentDraws int64 + GlobalWindow luckyRTPWindow + GiftWindow luckyRTPWindow + PlatformPool luckyPool + RoomPool luckyPool + GiftPool luckyPool + Atmosphere luckyPool + ActivityDay string + BudgetSourcesJSON string + RewardStatus string + NowMS int64 } func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input luckyApplyInput) error { @@ -399,12 +1078,20 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky if err := r.updateLuckyRiskCounters(ctx, tx, input.AppCode, input.Config.GiftID, input.Command, input.Candidate.effectiveReward(), input.NowMS); err != nil { return err } - // 用户体验池推进以付费抽数为准,即使未中奖也要计数,避免用户重复停留在新手阶段。 + lossStreakSQL := "loss_streak + 1" + if input.Candidate.effectiveReward() > 0 { + lossStreakSQL = "0" + } + // 用户体验池推进以累计金币流水为准;paid_draws 只保留审计口径,loss_streak 给后续显式保底或体验提示使用。 if _, err := tx.ExecContext(ctx, ` UPDATE lucky_user_states - SET paid_draws = paid_draws + 1, updated_at_ms = ? + SET paid_draws = paid_draws + 1, + cumulative_wager_coins = ?, + equivalent_draws = ?, + loss_streak = `+lossStreakSQL+`, + updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND gift_id = ?`, - input.NowMS, input.AppCode, input.Command.UserID, input.Config.GiftID, + input.NextCumulativeWagerCoins, input.NextEquivalentDraws, input.NowMS, input.AppCode, input.Command.UserID, input.Config.GiftID, ); err != nil { return err } @@ -595,29 +1282,82 @@ func (r *Repository) MarkLuckyGiftOutboxFailed(ctx context.Context, event domain } func (r *Repository) MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error { + return r.MarkLuckyGiftDrawsGranted(ctx, appCode, []string{drawID}, walletTransactionID, nowMS) +} + +// MarkLuckyGiftDrawsGranted 是聚合 outbox 的终态收敛入口。 +// 钱包只加币一次,但 draw_records 仍有多条;这里必须批量 granted,避免后台看到大量已发奖但仍 pending 的明细。 +func (r *Repository) MarkLuckyGiftDrawsGranted(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } - _, err := r.db.ExecContext(ctx, ` - UPDATE lucky_draw_records - SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = '', updated_at_ms = ? - WHERE app_code = ? AND draw_id = ?`, - domain.StatusGranted, walletTransactionID, nowMS, appcode.Normalize(appCode), drawID, - ) - return err + drawIDs = normalizeLuckyDrawIDs(drawIDs) + if len(drawIDs) == 0 { + return nil + } + return r.markLuckyGiftDraws(ctx, appCode, drawIDs, domain.StatusGranted, walletTransactionID, "", nowMS, false) } func (r *Repository) MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error { - if r == nil || r.db == nil || strings.TrimSpace(drawID) == "" { + return r.MarkLuckyGiftDrawsFailed(ctx, appCode, []string{drawID}, failureReason, nowMS) +} + +// MarkLuckyGiftDrawsFailed 只在未返奖且 outbox 达到最大重试时使用。 +// 已经加币成功但 IM 失败的场景不能把 draw 标记 failed,否则会把账务事实和表现事实混在一起。 +func (r *Repository) MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error { + if r == nil || r.db == nil { + return xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + drawIDs = normalizeLuckyDrawIDs(drawIDs) + if len(drawIDs) == 0 { return nil } - _, err := r.db.ExecContext(ctx, ` - UPDATE lucky_draw_records - SET reward_status = ?, reward_failure_reason = ?, updated_at_ms = ? - WHERE app_code = ? AND draw_id = ? AND reward_status = ?`, - domain.StatusFailed, trimLuckyFailureReason(failureReason), nowMS, appcode.Normalize(appCode), drawID, domain.StatusPending, - ) - return err + return r.markLuckyGiftDraws(ctx, appCode, drawIDs, domain.StatusFailed, "", trimLuckyFailureReason(failureReason), nowMS, true) +} + +// markLuckyGiftDraws 按 chunk 批量更新,避免一次 IN 列表过长压垮 MySQL SQL 包或查询优化器。 +// onlyPending 用于失败收敛,防止晚到的失败重试覆盖已经 granted 的返奖事实。 +func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, drawIDs []string, status string, walletTransactionID string, failureReason string, nowMS int64, onlyPending bool) error { + const chunkSize = 500 + appCode = appcode.Normalize(appCode) + for start := 0; start < len(drawIDs); start += chunkSize { + end := start + chunkSize + if end > len(drawIDs) { + end = len(drawIDs) + } + chunk := drawIDs[start:end] + args := []any{status, walletTransactionID, failureReason, nowMS, appCode} + for _, drawID := range chunk { + args = append(args, drawID) + } + query := ` + UPDATE lucky_draw_records + SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = ?, updated_at_ms = ? + WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `)` + if onlyPending { + query += ` AND reward_status = ?` + args = append(args, domain.StatusPending) + } + if _, err := r.db.ExecContext(ctx, query, args...); err != nil { + return err + } + } + return nil +} + +// normalizeLuckyDrawIDs 去掉空值和重复值;outbox payload 是补偿输入,必须先清洗再拼 SQL IN。 +func normalizeLuckyDrawIDs(drawIDs []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(drawIDs)) + for _, drawID := range drawIDs { + drawID = strings.TrimSpace(drawID) + if drawID == "" || seen[drawID] { + continue + } + seen[drawID] = true + out = append(out, drawID) + } + return out } func trimLuckyLastError(value string) string { @@ -788,8 +1528,10 @@ func (r *Repository) getLuckyGiftConfig(ctx context.Context, q luckyQueryer, app func scanLuckyConfig(scanner luckyScanner) (domain.Config, error) { var config domain.Config var tiersJSON string + var noviceDrawLimit int64 + var intermediateDrawLimit int64 if err := scanner.Scan(&config.AppCode, &config.GiftID, &config.Enabled, &config.RuleVersion, &config.GiftPrice, &config.TargetRTPPPM, &config.PoolRatePPM, - &config.GlobalWindowDraws, &config.GiftWindowDraws, &config.NoviceDrawLimit, &config.IntermediateDrawLimit, + &config.GlobalWindowDraws, &config.GiftWindowDraws, &noviceDrawLimit, &intermediateDrawLimit, &config.HighMultiplier, &config.HighWaterPoolMultiple, &config.PlatformPoolWeightPPM, &config.RoomPoolWeightPPM, &config.GiftPoolWeightPPM, &config.InitialPlatformPool, &config.InitialGiftPool, &config.InitialRoomPool, &config.PlatformReserve, &config.GiftReserve, &config.RoomReserve, &config.MaxSinglePayout, &config.UserHourlyPayoutCap, @@ -803,47 +1545,170 @@ func scanLuckyConfig(scanner luckyScanner) (domain.Config, error) { return domain.Config{}, err } config.PoolID = config.GiftID + config.NoviceMaxEquivalentDraws = noviceDrawLimit + config.NormalMaxEquivalentDraws = intermediateDrawLimit config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice) return config, nil } -func (r *Repository) getLuckyUserPaidDraws(ctx context.Context, appCode string, userID int64, giftID string) (int64, error) { - var paidDraws int64 +func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Config, error) { + poolID := luckyPoolID(ruleConfig.PoolID) + referencePrice := ruleConfig.GiftPriceReference + if referencePrice <= 0 { + return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive") + } + // v2 用金币流水定义结算窗口,旧运行窗口仍以抽数推进;这里按参考价格折算成“等价抽数”,实际每抽会在 withCurrentWager 中继续按真实 coin_spent 累计。 + windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice) + if windowDraws <= 0 { + windowDraws = 1 + } + tiers, maxReward, hasEnabledBaseTier := luckyRuntimeTiersFromRuleStages(ruleConfig.Stages, referencePrice) + if !hasEnabledBaseTier { + return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift enabled base tiers are required") + } + // 运行侧仍保留三层资金责任池;v2 第一阶段没有把这些比例暴露给后台,因此用产品方案里的保守默认权重承接基础 RTP 成本。 + config := domain.Config{ + AppCode: ruleConfig.AppCode, + GiftID: poolID, + PoolID: poolID, + Enabled: ruleConfig.Enabled, + RuleVersion: ruleConfig.RuleVersion, + GiftPrice: referencePrice, + TargetRTPPPM: ruleConfig.TargetRTPPPM, + PoolRatePPM: ruleConfig.PoolRatePPM, + ControlBandPPM: ruleConfig.ControlBandPPM, + GlobalWindowDraws: windowDraws, + GiftWindowDraws: windowDraws, + NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws, + NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws, + HighMultiplier: 10, + HighWaterPoolMultiple: 2, + PlatformPoolWeightPPM: 200_000, + RoomPoolWeightPPM: 300_000, + GiftPoolWeightPPM: 500_000, + InitialPlatformPool: referencePrice * 950, + InitialGiftPool: referencePrice * 2_375, + InitialRoomPool: referencePrice * 500, + PlatformReserve: referencePrice * 100, + GiftReserve: referencePrice * 100, + RoomReserve: referencePrice * 10, + // 风控上限随 v2 规则版本固化,按绝对金币额累计;这里不再使用代码硬编码。 + MaxSinglePayout: maxInt64(ruleConfig.MaxSinglePayout, maxReward), + UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap, + UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap, + DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap, + RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap, + AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap, + RoomAtmosphereRatePPM: 10_000, + RoomAtmosphereInitial: referencePrice * 10, + RoomAtmosphereReserve: referencePrice, + ActivityBudget: 0, + ActivityDailyLimit: 0, + LargeTierEnabled: true, + Tiers: tiers, + UpdatedByAdminID: ruleConfig.CreatedByAdminID, + CreatedAtMS: ruleConfig.CreatedAtMS, + UpdatedAtMS: ruleConfig.CreatedAtMS, + } + config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice) + return config, nil +} + +func luckyRuntimeTiersFromRuleStages(stages []domain.RuleStage, referencePrice int64) ([]domain.Tier, int64, bool) { + tiers := make([]domain.Tier, 0) + var maxReward int64 + hasEnabledBaseTier := false + for _, stage := range stages { + runtimePool, ok := luckyRuntimePoolFromRuleStage(stage.Stage) + if !ok { + // 发布入口已经校验 stage,这里忽略未知阶段可以让脏数据不扩散成线上候选。 + continue + } + for _, ruleTier := range stage.Tiers { + if ruleTier.RewardSource != domain.SourceBaseRTP { + // 当前运行主链路只从阶段表读取基础 RTP 候选;房间气氛和活动补贴仍由各自预算函数动态注入。 + continue + } + rewardCoins := referencePrice * ruleTier.MultiplierPPM / luckyPPMScale + tiers = append(tiers, domain.Tier{ + Pool: runtimePool, + TierID: ruleTier.TierID, + RewardCoins: rewardCoins, + MultiplierPPM: ruleTier.MultiplierPPM, + // v2 概率进入运行候选快照后默认原样参与随机;奖池、水位和风控只过滤不可支付候选,不负责日常改概率。 + Weight: ruleTier.BaseWeightPPM, + HighWaterOnly: ruleTier.HighWaterOnly, + Enabled: ruleTier.Enabled, + }) + if ruleTier.Enabled { + hasEnabledBaseTier = true + } + if ruleTier.Enabled && rewardCoins > maxReward { + maxReward = rewardCoins + } + } + } + return tiers, maxReward, hasEnabledBaseTier +} + +func luckyRuntimePoolFromRuleStage(stage string) (string, bool) { + switch stage { + case domain.StageNovice: + return domain.PoolNovice, true + case domain.StageNormal: + return domain.PoolIntermediate, true + case domain.StageAdvanced: + return domain.PoolAdvanced, true + default: + return "", false + } +} + +func luckyCeilDiv(value, divisor int64) int64 { + if value <= 0 || divisor <= 0 { + return 0 + } + return (value + divisor - 1) / divisor +} + +func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) { + var state luckyUserState err := r.db.QueryRowContext(ctx, ` - SELECT paid_draws + SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak FROM lucky_user_states WHERE app_code = ? AND user_id = ? AND gift_id = ?`, appCode, userID, giftID, - ).Scan(&paidDraws) + ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak) if errors.Is(err, sql.ErrNoRows) { - return 0, nil + return luckyUserState{}, nil } - return paidDraws, err + return state, err } -func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (int64, error) { - var paidDraws int64 +func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) { + var state luckyUserState err := tx.QueryRowContext(ctx, ` - SELECT paid_draws + SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak FROM lucky_user_states WHERE app_code = ? AND user_id = ? AND gift_id = ? FOR UPDATE`, appCode, userID, giftID, - ).Scan(&paidDraws) + ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak) if err == nil { - return paidDraws, nil + return state, nil } if !errors.Is(err, sql.ErrNoRows) { - return 0, err + return luckyUserState{}, err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO lucky_user_states (app_code, user_id, gift_id, paid_draws, created_at_ms, updated_at_ms) - VALUES (?, ?, ?, 0, ?, ?)`, + INSERT INTO lucky_user_states ( + app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?)`, appCode, userID, giftID, nowMS, nowMS, ); err != nil { - return 0, err + return luckyUserState{}, err } - return 0, nil + return luckyUserState{}, nil } func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) { @@ -984,6 +1849,41 @@ func (r *Repository) creditLuckyBasePools(ctx context.Context, tx *sql.Tx, appCo return r.creditLuckyPool(ctx, tx, appCode, "gift", gift.ScopeID, giftIn, gift, nowMS) } +// luckyBasePoolIn 复用单抽入池公式;批量路径只算金额,不在循环里写表。 +func luckyBasePoolIn(config domain.Config, spent int64) (int64, int64, int64) { + poolIn := spent * config.PoolRatePPM / luckyPPMScale + return luckySplitWeighted(poolIn, config) +} + +// creditLuckyPoolInMemory 让后续子抽立即看到本抽入池水位,保持“先入池再开奖”的单抽语义。 +func creditLuckyPoolInMemory(pool *luckyPool, amount int64) { + if amount <= 0 { + return + } + pool.Balance += amount + pool.TotalIn += amount +} + +// debitLuckyPoolInMemory 让后续子抽立即感知本抽已经占用的支付能力,避免批量命中同时透支。 +func debitLuckyPoolInMemory(pool *luckyPool, amount int64) { + if amount <= 0 { + return + } + pool.Balance -= amount + pool.TotalOut += amount +} + +// incrementLuckyRiskCountersInMemory 用本批已命中奖励预扣风控额度;最后只把总 payout 写回风险计数表。 +func incrementLuckyRiskCountersInMemory(counters map[string]luckyCounter, payout int64) { + if payout <= 0 { + return + } + for key, counter := range counters { + counter.Payout += payout + counters[key] = counter + } +} + func (r *Repository) creditLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, amount int64, pool *luckyPool, nowMS int64) error { if amount <= 0 { return nil @@ -1198,9 +2098,7 @@ func (r *Repository) updateLuckyRiskCounters(ctx context.Context, tx *sql.Tx, ap return nil } -func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, stageFeedback bool, globalWindow, giftWindow luckyRTPWindow, platformPool, roomPool, giftPool, atmosphere luckyPool, activityRemaining int64, counters map[string]luckyCounter, minFutureMax int64) (luckyCandidate, map[string]bool, error) { - // minRequired 是为了保证窗口后续仍可追平 RTP 的本次最低基础返奖;全站和子窗口取更严格的一侧。 - minRequired := maxInt64(globalWindow.minRequired(minFutureMax), giftWindow.minRequired(minFutureMax)) +func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, platformPool, roomPool, giftPool luckyPool, counters map[string]luckyCounter) (luckyCandidate, map[string]bool, error) { // 三层池按责任权重反推“本次最多可支付的基础返奖总额”,不能只看某一个池的余额。 poolCapacity := luckyWeightedPoolCapacity(platformPool, roomPool, giftPool, config) riskCapacity := luckyRiskCapacity(config, counters) @@ -1210,7 +2108,7 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta baseCandidates := make([]luckyCandidate, 0, len(config.Tiers)) var totalWeight int64 for _, tier := range config.Tiers { - // 体验池只决定当前用户能看到哪些倍率点;真实权重稍后按 RTP 缺口自动生成。 + // 体验池决定当前用户可见奖档;权重必须保持后台配置,RTP 缺口只进入监控口径,不能反向改本次概率。 if tier.Pool != pool || !tier.Enabled { continue } @@ -1219,10 +2117,6 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta limited["large_tier_disabled"] = true continue } - // 低于 minRequired 的档位会让窗口后续无解,因此即使它是小奖也必须在随机前过滤。 - if tier.RewardCoins < minRequired { - continue - } if tier.RewardCoins > baseMaxAllowed { if tier.RewardCoins > riskCapacity { limited["risk_cap"] = true @@ -1232,58 +2126,24 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta } continue } - // 高倍必须同时满足 RTP 高水位和三层池容量;不能靠随机后降档来兜底。 - if highMultiplier && !luckyHasHighWater(tier.RewardCoins, globalWindow, giftWindow, poolCapacity, config) { - limited["high_water"] = true - continue - } baseCandidates = append(baseCandidates, luckyCandidate{ TierID: tier.TierID, Source: domain.SourceBaseRTP, Pool: pool, BaseReward: tier.RewardCoins, MultiplierPPM: tier.MultiplierPPM, + Weight: tier.Weight, HighMultiplier: highMultiplier, }) } - // 把可支付倍率点压缩成最多两个基础候选,使期望返奖贴近当前 RTP 缺口。 - candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow) + // 正常运行只使用后台配置概率;奖池和风控只删除“本事务不能支付”的候选,不插值、不补缺口。 + candidates := luckyConfiguredWeightCandidates(baseCandidates) for _, candidate := range candidates { totalWeight += candidate.Weight } - if minRequired == 0 { - // 只有基础 RTP 当前不强制追平时,娱乐补贴才可进入随机;补贴不能承担基础 RTP 结清责任。 - if atmosphere.capacity() >= config.GiftPrice && riskCapacity >= config.GiftPrice { - candidates = append(candidates, luckyCandidate{TierID: "room_shared_1x", Source: domain.SourceRoomAtmosphere, Pool: pool, Weight: 1200, RoomReward: config.GiftPrice}) - totalWeight += 1200 - } - if atmosphere.capacity() >= config.GiftPrice*5 && riskCapacity >= config.GiftPrice*5 { - candidates = append(candidates, luckyCandidate{TierID: "room_burst_5x", Source: domain.SourceRoomAtmosphere, Pool: pool, Weight: 200, RoomReward: config.GiftPrice * 5}) - totalWeight += 200 - } - if activityRemaining >= config.GiftPrice/2 && riskCapacity >= config.GiftPrice/2 { - candidates = append(candidates, luckyCandidate{TierID: "activity_rebate_0_5x", Source: domain.SourceActivity, Pool: pool, Weight: 400, ActivityReward: config.GiftPrice / 2}) - totalWeight += 400 - } - if stageFeedback { - candidates = append(candidates, luckyCandidate{TierID: "stage_feedback", Source: domain.SourcePresentation, Pool: pool, Weight: 5000, Presentation: true}) - totalWeight += 5000 - } - } if len(candidates) == 0 { - if minRequired > baseMaxAllowed { - // 窗口需要支付但奖池或风控容量不足,此时必须失败并暴露配置问题,不能透支或改结果。 - return luckyCandidate{}, limited, xerr.New(xerr.Conflict, fmt.Sprintf("lucky gift RTP window cannot be settled: min_required=%d max_allowed=%d", minRequired, baseMaxAllowed)) - } - // 没有离散倍率可用但容量足够时,用修正档精确补齐窗口缺口,保证长期 RTP 收敛。 - return luckyCandidate{ - TierID: luckyCorrectionTier(minRequired), - Source: domain.SourceBaseRTP, - Pool: pool, - BaseReward: minRequired, - Correction: minRequired > 0, - HighMultiplier: minRequired >= config.GiftPrice*config.HighMultiplier, - }, limited, nil + // 全部正奖档都被奖池或风控剪掉时,本次只能落 0 倍;不能强行透支,也不能生成修正档改变概率。 + return luckyCandidate{TierID: "no_payable_tier", Source: domain.SourceBaseRTP, Pool: pool}, limited, nil } // 使用 crypto/rand 生成随机落点;候选集合已经全部可支付,随机后不再做业务降级。 index, err := luckySecureIndex(totalWeight) @@ -1328,11 +2188,11 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC return item, true, nil } -func luckyExperiencePool(paidDrawNumber int64, config domain.Config) string { - if config.NoviceDrawLimit > 0 && paidDrawNumber <= config.NoviceDrawLimit { +func luckyExperiencePool(equivalentDraws int64, config domain.Config) string { + if config.NoviceMaxEquivalentDraws > 0 && equivalentDraws <= config.NoviceMaxEquivalentDraws { return domain.PoolNovice } - if config.IntermediateDrawLimit > 0 && paidDrawNumber <= config.IntermediateDrawLimit { + if config.NormalMaxEquivalentDraws > 0 && equivalentDraws <= config.NormalMaxEquivalentDraws { return domain.PoolIntermediate } return domain.PoolAdvanced @@ -1344,13 +2204,7 @@ func luckyRuntimeConfig(config domain.Config, spent int64) domain.Config { if referenceCost <= 0 || spent <= 0 || referenceCost == spent { return config } - // 同一个 pool 可被不同价格礼物复用;按真实扣费缩放奖档和 cap,避免低价礼物借高价配置爆奖。 - config.MaxSinglePayout = luckyScaleReward(config.MaxSinglePayout, referenceCost, spent) - config.UserHourlyPayoutCap = luckyScaleReward(config.UserHourlyPayoutCap, referenceCost, spent) - config.UserDailyPayoutCap = luckyScaleReward(config.UserDailyPayoutCap, referenceCost, spent) - config.DeviceDailyPayoutCap = luckyScaleReward(config.DeviceDailyPayoutCap, referenceCost, spent) - config.RoomHourlyPayoutCap = luckyScaleReward(config.RoomHourlyPayoutCap, referenceCost, spent) - config.AnchorDailyPayoutCap = luckyScaleReward(config.AnchorDailyPayoutCap, referenceCost, spent) + // 同一个 pool 可被不同价格礼物复用;倍率奖金额按真实扣费缩放,后台风控上限是绝对金币额,不能随小额礼物缩小。 scaledTiers := make([]domain.Tier, 0, len(config.Tiers)) for _, tier := range config.Tiers { tier.RewardCoins = luckyScaleReward(tier.RewardCoins, referenceCost, spent) @@ -1367,130 +2221,44 @@ func luckyScaleReward(reward, referenceCost, spent int64) int64 { return reward * spent / referenceCost } -func luckyStageFeedback(paidDrawNumber int64, config domain.Config) bool { - limit := config.NoviceDrawLimit - if limit <= 0 || paidDrawNumber <= 0 || paidDrawNumber > limit { +func luckyStageFeedback(equivalentDraws int64, config domain.Config) bool { + limit := config.NoviceMaxEquivalentDraws + if limit <= 0 || equivalentDraws <= 0 || equivalentDraws > limit { return false } // 阶段反馈是表现事件,不是金币保底;是否发币仍由候选预算和 outbox 决定。 - return paidDrawNumber == 1 || paidDrawNumber == limit/4 || paidDrawNumber == limit/2 || paidDrawNumber == limit*3/4 || paidDrawNumber == limit + return equivalentDraws == 1 || equivalentDraws == limit/4 || equivalentDraws == limit/2 || equivalentDraws == limit*3/4 || equivalentDraws == limit } -func luckyMinFutureMax(config domain.Config) int64 { - // 取三个体验池中“可用最大返奖”的最小值,作为未来每抽最保守的追平能力。 - maxByPool := map[string]int64{ - domain.PoolNovice: 0, - domain.PoolIntermediate: 0, - domain.PoolAdvanced: 0, +func luckyEquivalentDraws(cumulativeWagerCoins int64, referencePrice int64) int64 { + if cumulativeWagerCoins <= 0 || referencePrice <= 0 { + return 0 } - for _, tier := range config.Tiers { - if !tier.Enabled || tier.RewardCoins > config.MaxSinglePayout { - continue - } - if tier.RewardCoins > maxByPool[tier.Pool] { - maxByPool[tier.Pool] = tier.RewardCoins - } - } - minMax := int64(0) - for _, value := range maxByPool { - if value <= 0 { - continue - } - if minMax == 0 || value < minMax { - minMax = value - } - } - if minMax <= 0 { - return config.MaxSinglePayout - } - return minMax + return cumulativeWagerCoins / referencePrice } -func luckyAutoWeightedBaseCandidates(candidates []luckyCandidate, minRequired int64, globalWindow, giftWindow luckyRTPWindow) []luckyCandidate { - if len(candidates) == 0 { - return nil - } - // 同一返奖金额只保留一个候选,避免相同倍率点因三档配置重复而意外放大概率。 - candidates = luckyDedupeRewardCandidates(candidates) - desired := maxInt64(minRequired, luckyDesiredBaseReward(globalWindow, giftWindow)) - if desired <= 0 { - zero := candidates[0] - for _, candidate := range candidates { - if candidate.BaseReward == 0 { - zero = candidate - break - } - } - // 无基础缺口时优先选 0x;如果运营没配置 0x,就只能选最低可用倍率,这也是配置侧需要关注的风险。 - zero.Weight = luckyPPMScale - return []luckyCandidate{zero} - } - if desired <= candidates[0].BaseReward || len(candidates) == 1 { - // 缺口低于最低可用档时只能全权选择最低档,否则会留下无法支付的负缺口。 - first := candidates[0] - first.Weight = luckyPPMScale - return []luckyCandidate{first} - } - for index := 1; index < len(candidates); index++ { - lower := candidates[index-1] - upper := candidates[index] - if desired > upper.BaseReward { - continue - } - delta := upper.BaseReward - lower.BaseReward - if delta <= 0 { - upper.Weight = luckyPPMScale - return []luckyCandidate{upper} - } - upperWeight := (desired - lower.BaseReward) * luckyPPMScale / delta - if upperWeight <= 0 { - lower.Weight = luckyPPMScale - return []luckyCandidate{lower} - } - if upperWeight >= luckyPPMScale { - upper.Weight = luckyPPMScale - return []luckyCandidate{upper} - } - // 在相邻两个倍率之间线性插值,使本次基础返奖期望值尽量贴近 RTP 目标缺口。 - lower.Weight = luckyPPMScale - upperWeight - upper.Weight = upperWeight - return []luckyCandidate{lower, upper} - } - // 缺口超过所有离散倍率时选择最高可用档;真正无解会在上层 minRequired/baseMaxAllowed 检查处暴露。 - last := candidates[len(candidates)-1] - last.Weight = luckyPPMScale - return []luckyCandidate{last} -} - -func luckyDedupeRewardCandidates(candidates []luckyCandidate) []luckyCandidate { - sort.Slice(candidates, func(i, j int) bool { - if candidates[i].BaseReward == candidates[j].BaseReward { - return candidates[i].MultiplierPPM < candidates[j].MultiplierPPM - } - return candidates[i].BaseReward < candidates[j].BaseReward - }) - out := candidates[:0] +func luckyConfiguredWeightCandidates(candidates []luckyCandidate) []luckyCandidate { + out := make([]luckyCandidate, 0, len(candidates)) + var totalWeight int64 for _, candidate := range candidates { - if len(out) > 0 && out[len(out)-1].BaseReward == candidate.BaseReward { + if candidate.Weight <= 0 { continue } + totalWeight += candidate.Weight + out = append(out, candidate) + } + if totalWeight > 0 { + return out + } + // 老数据或脏配置可能没有权重;为了让抽奖继续暴露结果而不是空候选,退化成等权随机。 + out = candidates[:0] + for _, candidate := range candidates { + candidate.Weight = 1 out = append(out, candidate) } return out } -func luckyDesiredBaseReward(globalWindow, giftWindow luckyRTPWindow) int64 { - return minInt64(luckyWindowBaseDeficit(globalWindow), luckyWindowBaseDeficit(giftWindow)) -} - -func luckyWindowBaseDeficit(window luckyRTPWindow) int64 { - remaining := window.remainingPayout() - if remaining <= 0 { - return 0 - } - return remaining -} - func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) { platform := amount * config.PlatformPoolWeightPPM / luckyPPMScale room := amount * config.RoomPoolWeightPPM / luckyPPMScale @@ -1534,6 +2302,11 @@ func luckyCounterKey(scopeType, windowType string) string { return scopeType + ":" + windowType } +func luckyRuleWindowScopeID(config domain.Config) string { + // 规则版本是概率、RTP、风控上限的不可变快照;窗口也必须绑定版本,否则新配置会继续承接旧配置的 RTP 债务。 + return fmt.Sprintf("%s:v%d", luckyPoolID(config.PoolID), config.RuleVersion) +} + func luckyPoolID(poolID string) string { poolID = strings.TrimSpace(poolID) if poolID == "" { @@ -1564,14 +2337,6 @@ func luckyMultiplierPPMsFromTiers(tiers []domain.Tier, giftPrice int64) []int64 return out } -func luckyHasHighWater(reward int64, globalWindow, giftWindow luckyRTPWindow, poolCapacity int64, config domain.Config) bool { - // 高倍必须来自此前少付形成的 RTP headroom,并且三层池总容量要达到额外水位倍数。 - if globalWindow.highWaterHeadroom(config.GiftPrice) < reward || giftWindow.highWaterHeadroom(config.GiftPrice) < reward { - return false - } - return poolCapacity >= reward*config.HighWaterPoolMultiple -} - func luckyBudgetSourcesJSON(candidate luckyCandidate) string { sources := []string{} if candidate.BaseReward > 0 || candidate.Source == domain.SourceBaseRTP { @@ -1603,13 +2368,6 @@ func luckyDrawNeedsOutbox(candidate luckyCandidate, stageFeedback bool) bool { return candidate.effectiveReward() > 0 || stageFeedback || candidate.Presentation } -func luckyCorrectionTier(reward int64) string { - if reward == 0 { - return "none" - } - return "rtp_balance" -} - func luckyEnabledReason(enabled bool) string { if enabled { return "enabled" @@ -1635,6 +2393,14 @@ func luckySecureIndex(totalWeight int64) (int64, error) { return n.Int64(), nil } +func luckySQLPlaceholders(count int) string { + // database/sql 不支持把 slice 直接绑定到 IN (?),这里生成受控占位符而不是拼接业务值。 + if count <= 0 { + return "" + } + return strings.TrimSuffix(strings.Repeat("?,", count), ",") +} + func minInt64(a, b int64) int64 { if a < b { return a diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go b/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go index c4f4228d..28e0e160 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go @@ -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}, + {TierID: "none", BaseReward: 0, Weight: 300_000}, + {TierID: "one", BaseReward: 100, Weight: 600_000}, + {TierID: "two", BaseReward: 200, Weight: 100_000}, } - window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 950} - weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window) + weighted := luckyConfiguredWeightCandidates(candidates) - if len(weighted) != 2 { - t.Fatalf("expected two generated probability points, got %#v", weighted) + if len(weighted) != 3 { + t.Fatalf("expected configured candidates inside control band, 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]) - } -} - -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 := luckyAutoWeightedBaseCandidates(candidates, 0, window, window) - - if len(weighted) != 2 { - t.Fatalf("expected deficit to interpolate between reward tiers, 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) diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_rule_config_repository.go b/services/activity-service/internal/storage/mysql/lucky_gift_rule_config_repository.go index e3f8ee2d..267857ec 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_rule_config_repository.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_rule_config_repository.go @@ -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, diff --git a/services/activity-service/internal/storage/mysql/repository.go b/services/activity-service/internal/storage/mysql/repository.go index f684b8b4..8b140ead 100644 --- a/services/activity-service/internal/storage/mysql/repository.go +++ b/services/activity-service/internal/storage/mysql/repository.go @@ -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 } diff --git a/services/activity-service/internal/transport/grpc/lucky_gift_server.go b/services/activity-service/internal/transport/grpc/lucky_gift_server.go index 2872c9e5..86247214 100644 --- a/services/activity-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/activity-service/internal/transport/grpc/lucky_gift_server.go @@ -172,19 +172,27 @@ func luckyRuleConfigFromProto(config *activityv1.LuckyGiftRuleConfig) domain.Rul }) } return domain.RuleConfig{ - AppCode: config.GetAppCode(), - PoolID: config.GetPoolId(), - RuleVersion: config.GetRuleVersion(), - Enabled: config.GetEnabled(), - TargetRTPPPM: config.GetTargetRtpPpm(), - PoolRatePPM: config.GetPoolRatePpm(), - SettlementWindowWager: config.GetSettlementWindowWager(), - ControlBandPPM: config.GetControlBandPpm(), - GiftPriceReference: config.GetGiftPriceReference(), - EffectiveFromMS: config.GetEffectiveFromMs(), - CreatedByAdminID: config.GetCreatedByAdminId(), - CreatedAtMS: config.GetCreatedAtMs(), - Stages: stages, + AppCode: config.GetAppCode(), + PoolID: config.GetPoolId(), + RuleVersion: config.GetRuleVersion(), + Enabled: config.GetEnabled(), + TargetRTPPPM: config.GetTargetRtpPpm(), + PoolRatePPM: config.GetPoolRatePpm(), + 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, } } @@ -210,19 +218,27 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *activityv1.LuckyGiftRuleC }) } return &activityv1.LuckyGiftRuleConfig{ - AppCode: config.AppCode, - PoolId: config.PoolID, - RuleVersion: config.RuleVersion, - Enabled: config.Enabled, - TargetRtpPpm: config.TargetRTPPPM, - PoolRatePpm: config.PoolRatePPM, - SettlementWindowWager: config.SettlementWindowWager, - ControlBandPpm: config.ControlBandPPM, - GiftPriceReference: config.GiftPriceReference, - EffectiveFromMs: config.EffectiveFromMS, - CreatedByAdminId: config.CreatedByAdminID, - CreatedAtMs: config.CreatedAtMS, - Stages: stages, + AppCode: config.AppCode, + PoolId: config.PoolID, + RuleVersion: config.RuleVersion, + Enabled: config.Enabled, + TargetRtpPpm: config.TargetRTPPPM, + PoolRatePpm: config.PoolRatePPM, + 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, } } diff --git a/tools/baishun-game-test-server.mjs b/tools/baishun-game-test-server.mjs index 17111427..1d3ff992 100644 --- a/tools/baishun-game-test-server.mjs +++ b/tools/baishun-game-test-server.mjs @@ -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}`); }); diff --git a/tools/lucky-gift-test.html b/tools/lucky-gift-test.html index 480dfc4e..671bef47 100644 --- a/tools/lucky-gift-test.html +++ b/tools/lucky-gift-test.html @@ -193,6 +193,7 @@

3. 发幸运礼物

+
@@ -214,6 +215,24 @@
-
+
+
+
该用户抽奖次数
+
-
+
+
+
该用户消耗金币
+
-
+
+
+
该用户返还金币
+
-
+
+
+
等价抽数 / 连亏
+
-
+
+
@@ -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(); diff --git a/tools/reset-lucky-gift-local-data.sh b/tools/reset-lucky-gift-local-data.sh new file mode 100755 index 00000000..593378c1 --- /dev/null +++ b/tools/reset-lucky-gift-local-data.sh @@ -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 < '' + 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