diff --git a/docs/flutter对接/幸运礼物Flutter对接.md b/docs/flutter对接/幸运礼物Flutter对接.md new file mode 100644 index 00000000..d405ca71 --- /dev/null +++ b/docs/flutter对接/幸运礼物Flutter对接.md @@ -0,0 +1,331 @@ +# 幸运礼物 Flutter 对接 + +本文只保留 App 对接需要的信息:地址、参数、返回值、IM 字段。服务端实现细节看 [幸运礼物实现文档](../幸运礼物实现文档.md),礼物面板通用字段看 [礼物 Tab Flutter 对接](礼物TabFlutter对接.md)。 + +## 1. 送礼前检查 + +地址: + +```http +POST /api/v1/activities/lucky-gifts/check +Authorization: Bearer +X-App-Code: lalu +Content-Type: application/json +``` + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前房间 ID。 | +| `gift_id` | string | 是 | 礼物 ID。 | +| `pool_id` | string | 是 | 幸运礼物奖池 ID,例如 `lucky`、`super_lucky` 或后台配置值。 | + +请求示例: + +```json +{ + "room_id": "lalu_room_001", + "gift_id": "rose", + "pool_id": "super_lucky" +} +``` + +返回值: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | bool | 是否可参与幸运礼物抽奖。 | +| `reason` | string | 不可参与原因;可为空。 | +| `room_id` | string | 房间 ID。 | +| `gift_id` | string | 礼物 ID。 | +| `pool_id` | string | 奖池 ID。 | +| `gift_price` | int64 | 当前规则参考礼物价格,只展示用,扣费以后端送礼结果为准。 | +| `rule_version` | int64 | 命中的幸运礼物规则版本。 | +| `target_rtp_ppm` | int64 | 目标 RTP,ppm 口径;`950000` 表示 95%。 | +| `experience_pool` | string | 当前用户体验池,例如 `novice`、`normal`、`advanced`。 | + +返回示例: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_abc", + "data": { + "enabled": true, + "reason": "", + "room_id": "lalu_room_001", + "gift_id": "rose", + "pool_id": "super_lucky", + "gift_price": 100, + "rule_version": 12, + "target_rtp_ppm": 950000, + "experience_pool": "novice" + } +} +``` + +## 2. 发送幸运礼物 + +地址: + +```http +POST /api/v1/rooms/gift/send +Authorization: Bearer +X-App-Code: lalu +Content-Type: application/json +``` + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前房间 ID。 | +| `command_id` | string | 是 | 本次送礼幂等键;同一次重试必须复用。 | +| `target_type` | string | 否 | 当前固定传 `user`;为空后端按 `user`。 | +| `target_user_ids` | int64[] | 是 | 收礼用户列表;当前只传 1 个用户。 | +| `target_user_id` | int64 | 否 | 旧单目标兼容字段,新代码优先用 `target_user_ids`。 | +| `gift_id` | string | 是 | 礼物 ID。 | +| `gift_count` | int32 | 是 | 礼物数量,必须大于 0;批量送礼会聚合返回一次中奖结果。 | +| `pool_id` | string | 是 | 幸运礼物奖池 ID;普通礼物不传。 | + +请求示例: + +```json +{ + "room_id": "lalu_room_001", + "command_id": "gift_1779259000000_10001_rose_01", + "target_type": "user", + "target_user_ids": [10002], + "gift_id": "rose", + "gift_count": 9, + "pool_id": "super_lucky" +} +``` + +返回值: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `result.applied` | bool | 本次命令是否实际落地;重复提交同一 `command_id` 可能为 `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 | 本次送礼命令 ID。 | +| `pool_id` | string | 奖池 ID。 | +| `gift_id` | string | 礼物 ID。 | +| `rule_version` | int64 | 规则版本。 | +| `experience_pool` | string | 用户体验池。 | +| `selected_tier_id` | string | 命中奖档 ID。 | +| `multiplier_ppm` | int64 | 倍率 ppm;`2000000` 表示 2x。 | +| `base_reward_coins` | int64 | 基础奖池返奖金币。 | +| `room_atmosphere_reward_coins` | int64 | 房间气氛池返奖金币。 | +| `activity_subsidy_coins` | int64 | 活动补贴返奖金币。 | +| `effective_reward_coins` | int64 | 实际中奖金币;0 表示未中奖。 | +| `reward_status` | string | `granted` 已入账;`pending`/`granting` 到账处理中;`failed` 需要人工处理。 | +| `stage_feedback` | bool | 是否有阶段反馈表现。 | +| `high_multiplier` | bool | 是否高倍率中奖。 | +| `created_at_ms` | int64 | 抽奖创建时间。 | +| `wallet_transaction_id` | string | 返奖钱包交易 ID;已入账时有值。 | +| `coin_balance_after` | int64 | 返奖入账后的 COIN 可用余额。 | + +返回示例: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_abc", + "data": { + "result": { + "applied": true, + "room_version": 14, + "server_time_ms": 1779259000000 + }, + "billing_receipt_id": "receipt-lucky", + "room_heat": 123456, + "gift_rank": [], + "room": {}, + "treasure": {}, + "lucky_gift": { + "enabled": true, + "draw_id": "lucky_draw_test", + "command_id": "gift_1779259000000_10001_rose_01", + "pool_id": "super_lucky", + "gift_id": "rose", + "rule_version": 12, + "experience_pool": "novice", + "selected_tier_id": "novice_2x", + "multiplier_ppm": 2000000, + "base_reward_coins": 1800, + "room_atmosphere_reward_coins": 0, + "activity_subsidy_coins": 0, + "effective_reward_coins": 1800, + "reward_status": "granted", + "stage_feedback": false, + "high_multiplier": false, + "created_at_ms": 1779259000000, + "wallet_transaction_id": "wallet_tx_lucky", + "coin_balance_after": 8800 + } + } +} +``` + +客户端处理: + +- `reward_status=granted` 且 `effective_reward_coins > 0`:立即展示中奖,并用 `coin_balance_after` 覆盖本地金币余额。 +- `reward_status=pending` 或 `granting`:展示“到账处理中”,不要本地加金币,不要生成新 `command_id` 重发。 +- `effective_reward_coins=0`:未中奖,只展示普通送礼表现。 +- 同一用户同一房间连续发送幸运礼物时,客户端需要本地排队;重试必须复用原 `command_id`。 + +## 3. IM 字段 + +### 3.1 送礼房间 IM + +用途:房间内播放普通送礼动效、同步热度和礼物榜。幸运礼物也会先产生这条送礼 IM。 + +| 字段 | 值 | +| --- | --- | +| `MsgContent.Desc` | `room_gift_sent` | +| `Data.event_type` | `room_gift_sent` | + +`Data` 核心字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | string | 房间事件 ID,用于去重。 | +| `room_id` | string | 房间 ID。 | +| `room_version` | int64 | 房间版本。 | +| `occurred_at_ms` | int64 | 事件发生时间。 | +| `actor_user_id` | string/int64 | 送礼用户 ID。 | +| `target_user_id` | string/int64 | 收礼用户 ID。 | +| `gift_value` | int64 | 本次送礼房间价值/热度口径。 | +| `room_heat` | int64 | 送礼后的房间热度。 | +| `attributes.gift_id` | string | 礼物 ID。 | +| `attributes.pool_id` | string | 幸运礼物奖池 ID;普通礼物可为空。 | +| `attributes.gift_count` | string | 礼物数量,字符串格式。 | +| `attributes.billing_receipt_id` | string | 钱包扣费回执。 | +| `attributes.coin_spent` | string | 本次实际消耗金币,字符串格式。 | +| `attributes.gift_point_added` | string | 主播礼物积分增量,字符串格式。 | +| `attributes.price_version` | string | 价格版本。 | + +示例: + +```json +{ + "event_type": "room_gift_sent", + "event_id": "room_event_xxx", + "room_id": "lalu_room_001", + "room_version": 14, + "occurred_at_ms": 1779259000000, + "actor_user_id": "10001", + "target_user_id": "10002", + "gift_value": 900, + "room_heat": 123456, + "attributes": { + "gift_id": "rose", + "pool_id": "super_lucky", + "gift_count": "9", + "billing_receipt_id": "receipt-lucky", + "coin_spent": "900", + "gift_point_added": "900", + "price_version": "v1" + } +} +``` + +### 3.2 幸运礼物开奖 IM + +用途:房间公屏展示幸运礼物中奖结果、其他用户同步中奖表现。当前用户自己的余额以 HTTP 返回的 `coin_balance_after` 为准,不等这条 IM。 + +| 字段 | 值 | +| --- | --- | +| `MsgContent.Desc` | `lucky_gift_drawn` | +| `Data.event_type` | `lucky_gift_drawn` | + +`Data` 核心字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | string | 开奖事件 ID,用于去重。 | +| `app_code` | string | App Code。 | +| `draw_id` | string | 聚合抽奖 ID。 | +| `command_id` | string | 送礼命令 ID。 | +| `pool_id` | string | 奖池 ID。 | +| `room_id` | string | 房间 ID。 | +| `gift_id` | string | 礼物 ID。 | +| `gift_count` | int32 | 礼物数量。 | +| `user_id` | int64 | 中奖用户 ID。 | +| `sender_user_id` | int64 | 送礼用户 ID。 | +| `target_user_id` | int64 | 收礼用户 ID。 | +| `visible_region_id` | int64 | 展示区域 ID。 | +| `coin_spent` | int64 | 本次送礼消耗金币。 | +| `rule_version` | int64 | 规则版本。 | +| `experience_pool` | string | 用户体验池。 | +| `selected_tier_id` | string | 命中奖档 ID。 | +| `multiplier_ppm` | int64 | 命中倍率 ppm。 | +| `base_reward_coins` | int64 | 基础奖池返奖金币。 | +| `room_atmosphere_reward_coins` | int64 | 房间气氛池返奖金币。 | +| `activity_subsidy_coins` | int64 | 活动补贴返奖金币。 | +| `effective_reward_coins` | int64 | 实际中奖金币。 | +| `stage_feedback` | bool | 是否有阶段反馈表现。 | +| `high_multiplier` | bool | 是否高倍率中奖。 | +| `created_at_ms` | int64 | 抽奖创建时间。 | + +示例: + +```json +{ + "event_type": "lucky_gift_drawn", + "event_id": "lucky_gift_drawn:lucky_draw_test", + "app_code": "lalu", + "draw_id": "lucky_draw_test", + "command_id": "gift_1779259000000_10001_rose_01", + "pool_id": "super_lucky", + "room_id": "lalu_room_001", + "gift_id": "rose", + "gift_count": 9, + "user_id": 10001, + "sender_user_id": 10001, + "target_user_id": 10002, + "visible_region_id": 688, + "coin_spent": 900, + "rule_version": 12, + "experience_pool": "novice", + "selected_tier_id": "novice_2x", + "multiplier_ppm": 2000000, + "base_reward_coins": 1800, + "room_atmosphere_reward_coins": 0, + "activity_subsidy_coins": 0, + "effective_reward_coins": 1800, + "stage_feedback": false, + "high_multiplier": false, + "created_at_ms": 1779259000000 +} +``` + +## 4. 状态处理 + +| 场景 | App 处理 | +| --- | --- | +| `lucky_gift` 为空 | 当普通礼物处理。 | +| `effective_reward_coins=0` | 未中奖,不更新中奖余额。 | +| `reward_status=granted` | 已入账;用 `coin_balance_after` 更新余额。 | +| `reward_status=pending` / `granting` | 到账处理中;等待钱包通知、刷新余额或后端补偿。 | +| `reward_status=failed` | 展示异常状态,记录 `request_id/draw_id/command_id` 方便排查。 | +| HTTP 超时 | 保留原 `command_id` 重试,不生成新命令。 | + diff --git a/docs/flutter对接/礼物TabFlutter对接.md b/docs/flutter对接/礼物TabFlutter对接.md index 09ad09ae..0b874510 100644 --- a/docs/flutter对接/礼物TabFlutter对接.md +++ b/docs/flutter对接/礼物TabFlutter对接.md @@ -10,10 +10,11 @@ - 房间送礼面板入口是 `GET /api/v1/rooms/{room_id}/gift-panel`,返回当前房间可送礼物、可收礼人、金币余额和房间内可展示 Tab。 - `GET /api/v1/gift-tabs` 不能替代 `GET /api/v1/rooms/{room_id}/gift-panel`;App 真正送礼前必须以房间面板返回的数据为准。 - 送礼只调用 `POST /api/v1/rooms/gift/send`。 -- 金币余额以礼物面板、送礼响应后的钱包通知或 `GET /api/v1/wallet/me/balances` 为准。 +- 金币余额以礼物面板、送礼响应里的 `lucky_gift.coin_balance_after`、钱包通知或 `GET /api/v1/wallet/me/balances` 为准;普通礼物和未中奖场景仍以后续钱包事实校准。 - 房间内其他用户看到的礼物表现以腾讯云 IM 自定义消息 `room_gift_sent` 为准。 - `command_id` 是送礼幂等键;同一次用户点击重试必须复用同一个值。 - 当前 App 送礼主流程只按单个用户收礼处理:`target_type=user`,`target_user_ids` 只传 1 个用户。 +- 幸运礼物的专门接入规则见 [幸运礼物 Flutter 对接](幸运礼物Flutter对接.md)。 ## 交互流程 @@ -23,9 +24,9 @@ 4. 用 `tabs` 渲染当前房间分类,用 `gifts` 渲染礼物网格,用 `recipients` 渲染收礼人。 5. 用户选择收礼人、礼物和数量。 6. 点击发送时生成稳定 `command_id`,调用 `POST /api/v1/rooms/gift/send`。 -7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`treasure` 更新房间 UI。 +7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`treasure` 更新房间 UI;如果有 `lucky_gift` 且已入账,用 `coin_balance_after` 立即刷新金币余额。 8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。 -9. 监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。 +9. 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。 ## 获取礼物 Tab 配置 @@ -294,7 +295,25 @@ Content-Type: application/json } ], "room": {}, - "treasure": {} + "treasure": {}, + "lucky_gift": { + "enabled": true, + "draw_id": "lucky_draw_test", + "command_id": "gift_1779259000000_10001_rose_01", + "pool_id": "super_lucky", + "gift_id": "rose", + "rule_version": 12, + "experience_pool": "novice", + "selected_tier_id": "novice_2x", + "multiplier_ppm": 2000000, + "effective_reward_coins": 1800, + "reward_status": "granted", + "stage_feedback": false, + "high_multiplier": false, + "created_at_ms": 1779259000000, + "wallet_transaction_id": "wallet_tx_lucky", + "coin_balance_after": 8800 + } } } ``` @@ -311,13 +330,16 @@ Content-Type: application/json | `gift_rank` | 最新房间礼物榜。 | | `room` | 房间快照,字段较大;App 可按已有房间快照解析逻辑局部更新。 | | `treasure` | 房间宝箱状态;宝箱 UI 以该字段或宝箱 IM 事件更新。 | +| `lucky_gift` | 幸运礼物抽奖结果;普通礼物可为空。`reward_status=granted` 且中奖时,`coin_balance_after` 是当前用户返奖入账后的余额。 | 发送后处理: - 成功后立即关闭 loading,保留礼物面板或按产品交互关闭。 - 用 `room_heat` 和 `gift_rank` 更新房间顶部和贡献榜。 - 播放当前用户送礼动效时按 `command_id` 去重,避免 HTTP 响应和 IM 各播放一次。 -- 不要本地直接写最终余额;等待 `WalletBalanceChanged` 私有 IM 或主动刷新余额。 +- 如果 `lucky_gift.reward_status=granted` 且 `lucky_gift.effective_reward_coins > 0`,立即用 `lucky_gift.coin_balance_after` 覆盖本地金币余额。 +- 如果 `lucky_gift.reward_status=pending`,展示中奖到账处理中,不要生成新 `command_id` 重送;补偿 worker 会继续使用同一个 draw 发放。 +- 普通礼物和未中奖场景不要本地直接写最终余额;等待 `WalletBalanceChanged` 私有 IM 或主动刷新余额。 ## 房间 IM 同步 diff --git a/docs/幸运礼物实现文档.md b/docs/幸运礼物实现文档.md index 070553f0..5edf4292 100644 --- a/docs/幸运礼物实现文档.md +++ b/docs/幸运礼物实现文档.md @@ -1,6 +1,6 @@ # 幸运礼物实现文档 -本文档描述当前代码里的幸运礼物实现。旧的产品方案、重构分步文档、Flutter 单独对接文档和早期 activity-service 开发文档已经下线;后续以本文档为唯一实现说明。 +本文档描述当前代码里的幸运礼物服务端实现。旧的产品方案、重构分步文档和早期 activity-service 开发文档已经下线;服务端实现以后续本文档为准,Flutter App 对接以 [幸运礼物 Flutter 对接](flutter对接/幸运礼物Flutter对接.md) 为准。 当前实现不兼容旧玩法假设。幸运礼物以 `pool_id` 为规则维度,以 `activity-service` 为规则、抽奖、奖池、RTP 窗口、风控、抽奖事实和 outbox owner;扣费和返奖入账属于 `wallet-service`;房间状态、热度、礼物榜和房间 outbox 属于 `room-service`。 @@ -8,12 +8,12 @@ | 功能点 | 当前规则 | 代码函数实现 | 验收 | | --- | --- | --- | --- | -| 服务边界 | `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 | +| 服务边界 | `activity-service` 只做幸运礼物规则和开奖结果,不改 Room Cell,不直接改钱包表;返奖必须通过 wallet-service gRPC | `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 快路径,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 | +| 聚合发放 | 批量抽奖可以有 N 条抽奖明细,但正常路径只同步给钱包加币 1 次;outbox 保留 1 条聚合事件用于补偿和表现 | `insertLuckyAggregateDrawOutbox`、`creditLuckyGiftRewardFastPath`、`decodeDrawOutboxPayload`、`MarkLuckyGiftDrawsGranted` | 已验收:批量只按聚合 draw 发放,worker 遇到已 granted 会跳过钱包 | ## 二、配置模型 @@ -22,7 +22,7 @@ | 功能点 | 字段或 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` 字段给送礼用户 | +| 抽奖结果 | `LuckyGiftDrawResult`,包含 `multiplier_ppm`、`effective_reward_coins`、`reward_status`、`wallet_transaction_id`、`coin_balance_after`、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` 查询 | @@ -69,7 +69,7 @@ | 先扣费 | 房间链路先调用 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` | +| 同步响应 | 当前送礼用户立即拿到抽奖结果;中奖快路径成功时同时拿到返奖交易 ID 和账后余额 | `creditLuckyGiftRewardFastPath`、`luckyGiftResultFromProto`、`roomv1.SendGiftResponse.LuckyGift` | 已验收:room response 带 `lucky_gift.wallet_transaction_id` 和 `lucky_gift.coin_balance_after` | | 房间事实 | 送礼本身仍写 Room Cell、热度、榜单和 room outbox | `Service.SendGift`、`outbox.Build` | 已验收:幸运礼物不替代普通送礼事实 | ## 四、抽奖事务 @@ -111,16 +111,17 @@ | 聚合响应 | 批量响应把金额、倍数、阶段反馈和高倍标记聚合 | `luckyAggregateDrawResults`、`luckyAggregateBudgetSourcesJSON` | 已验收:总倍数为各命中倍数相加,不是平均倍率 | | 聚合发放 | 整批只写一条 outbox,payload 里有聚合 `draw_id` 和全部 `draw_ids` | `insertLuckyAggregateDrawOutbox` | 已验收:待发放队列按送礼命令增长,不按中奖子抽增长 | -## 五、返奖和房间表现 +## 五、返奖快路径和房间表现 | 功能点 | 行为 | 代码函数实现 | 验收 | | --- | --- | --- | --- | -| worker 循环 | 持续 claim `LuckyGiftDrawn` outbox | `Service.RunWorker`、`Service.ProcessPendingDrawOutbox`、`Repository.ClaimPendingLuckyGiftOutbox` | 已验收:claim 使用 `delivering` 锁和 lock TTL | +| 同步返奖快路径 | 抽奖事务提交后,如果 `effective_reward_coins > 0`,立即调用 wallet-service `CreditLuckyGiftReward`;成功后把 draw 标记为 `granted` 并把交易 ID、账后余额写回响应 | `Service.Draw`、`creditLuckyGiftRewardFastPath`、`creditLuckyGiftReward`、`MarkLuckyGiftDrawsGranted` | 已验收:不在 activity MySQL 事务里调用 wallet,成功响应可直接给 App 刷余额 | +| 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 | +| 钱包补偿 | worker 先检查 draw 是否已 `granted`;已发奖则跳过 wallet,未发奖才用聚合 `draw_id` 生成幂等 command 补发一次 | `Service.processDrawOutbox`、`GetLuckyGiftDrawRewardState`、`walletv1.CreditLuckyGiftReward` 调用 | 已验收:wallet 成功但 activity 标记失败时,worker 可用同一 command 补标;已 granted 不重复加币 | +| 房间开奖结果 IM | worker 投递 `lucky_gift_drawn` 房间群消息 | `publishLuckyGiftDrawn` | 已验收:发布失败时 outbox 可重试;已返奖的 draw 不会因为 IM 失败被标 failed | | 区域播报 | 符合条件的中奖写区域播报 outbox | `shouldPublishLuckyGiftRegionBroadcast`、`publishLuckyGiftRegionBroadcast`、`luckyGiftRegionBroadcastPayload` | 已验收:当前阈值为有区域、有效奖励、聚合倍数不低于 1x | -| 成功收敛 | 钱包、区域播报、房间 IM 都成功后批量标记 draw records 为 granted | `MarkLuckyGiftDrawsGranted`、`markLuckyGiftDraws`、`MarkLuckyGiftOutboxDelivered` | 已验收:批量更新按 500 个 draw_id 分片 | +| 成功收敛 | 钱包成功后 draw records 先收敛为 `granted`;区域播报和房间 IM 成功后 outbox 标记 delivered | `MarkLuckyGiftDrawsGranted`、`markLuckyGiftDraws`、`MarkLuckyGiftOutboxDelivered` | 已验收:批量更新按 500 个 draw_id 分片 | | 失败收敛 | 未返奖且达到最大重试时,批量标记 pending draw failed | `markOutboxFailed`、`MarkLuckyGiftDrawsFailed` | 已验收:已返奖但 IM 失败不会覆盖账务事实 | | 重试退避 | outbox 失败按指数退避重试 | `luckyGiftBackoff`、`MarkLuckyGiftOutboxRetryable` | 已验收:错误保存在 `activity_outbox.last_error` | @@ -148,7 +149,7 @@ | `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 | +| `activity_outbox` | 幸运礼物钱包补偿和表现副作用,不再承载正常发币主路径 | `insertLuckyAggregateDrawOutbox`、`ClaimPendingLuckyGiftOutbox`、`MarkLuckyGiftOutboxDelivered` | 已验收:批量送礼只写一条 `LuckyGiftDrawn` outbox,已 granted 时 worker 跳过 wallet | ## 八、运行边界 @@ -159,7 +160,7 @@ | 小额礼物 | 阶段按累计流水折算,单抽奖励按单份金额缩放,风控上限不被缩小 | `luckyRuntimeConfig`、`luckyEquivalentDraws`、`luckyRiskCapacity` | 已验收:单测覆盖大额后小额仍可中小奖 | | 高倍奖 | 发布校验要求高倍档 `HighWaterOnly`,运行时仍受奖池和风控过滤 | `validateRuleStage`、`selectLuckyCandidate` | 已验收:高倍率未标高水位会拒绝发布 | | 批量 777 份 | 抽 777 次,保留 777 条明细,状态写入和 outbox 聚合 | `executeOptimizedLuckyGiftDrawBatch`、`stageLuckyBatchDraw`、`persistLuckyBatchDraw` | 已验收:代码结构已移出循环内 SQL 状态写入 | -| worker 重试 | 钱包用聚合 `draw_id` 幂等,outbox 失败可重试 | `processDrawOutbox`、`MarkLuckyGiftOutboxRetryable` | 已验收:成功后 delivered,不重复加币 | +| worker 重试 | 钱包补偿用聚合 `draw_id` 幂等,outbox 失败可重试;已 `granted` 的 draw 只补 IM/播报,不重复加币 | `processDrawOutbox`、`GetLuckyGiftDrawRewardState`、`MarkLuckyGiftOutboxRetryable` | 已验收:成功后 delivered,不重复加币 | | 已返奖但 IM 失败 | 不把 draw 标 failed,只让 outbox failed 并保留告警空间 | `markOutboxFailed` | 已验收:`alreadyCredited` 分支不覆盖 draw 状态 | ## 九、代码验收清单 diff --git a/docs/语音房功能清单.md b/docs/语音房功能清单.md index d0f73133..ae123365 100644 --- a/docs/语音房功能清单.md +++ b/docs/语音房功能清单.md @@ -148,7 +148,7 @@ | 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 | | LuckyGift Check | `DONE` | `CheckLuckyGift` RPC、gateway 检查接口和 room-service 扣费前检查已实现 | App 礼物面板按 `pool_id` 联调 | | LuckyGift Draw | `DONE` | `ExecuteLuckyGiftDraw`、三层奖池、RTP 窗口、风控计数、批量抽奖和幂等已实现 | 运营配置和长压测继续补 | -| 奖励入账 | `DONE` | activity outbox worker 调 wallet `CreditLuckyGiftReward`,批量抽奖按一次送礼聚合发放 | 线上监控和积压告警需补 | +| 奖励入账 | `DONE` | 幸运礼物中奖后优先同步调 wallet `CreditLuckyGiftReward` 入账并把余额返回给 App;activity outbox worker 只做钱包补偿、IM 和区域播报 | 同步发奖成功率、补偿 pending age 和播报积压告警需补 | | 幸运礼物审计 | `DONE` | `lucky_draw_records`、后台 draws 和 draw-summary 查询已实现 | 后台可视化维度继续补 | ## Messaging Product Surface