# 幸运礼物实现文档

本文档描述当前代码里的幸运礼物服务端实现。旧的产品方案、重构分步文档和早期 activity-service 开发文档已经下线；服务端实现以后续本文档为准，Flutter App 对接以 [幸运礼物 Flutter 对接](flutter对接/幸运礼物Flutter对接.md) 为准。

当前实现不兼容旧玩法假设。幸运礼物以 `pool_id` 为规则维度，以 `activity-service` 为规则、抽奖、奖池、RTP 窗口、风控、抽奖事实和 outbox owner；扣费和返奖入账属于 `wallet-service`；房间状态、热度、礼物榜和房间 outbox 属于 `room-service`。

## 一、核心原则

| 功能点 | 当前规则 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 服务边界 | `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 次；outbox 保留 1 条聚合事件用于补偿和表现 | `insertLuckyAggregateDrawOutbox`、`creditLuckyGiftRewardFastPath`、`decodeDrawOutboxPayload`、`MarkLuckyGiftDrawsGranted` | 已验收：批量只按聚合 draw 发放，worker 遇到已 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`、`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` 查询 |

### 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` 只做资格检查，不扣费、不抽奖；App 路径不随内部 v2 规则实现变化 | `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 不为空，风控计数可落表 |
| 同步响应 | 当前送礼用户立即拿到抽奖结果；中奖快路径成功时同时拿到返奖交易 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` | 已验收：幸运礼物不替代普通送礼事实 |

## 四、抽奖事务

### 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` | 已验收：待发放队列按送礼命令增长，不按中奖子抽增长 |

## 五、返奖快路径和房间表现

| 功能点 | 行为 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 同步返奖快路径 | 抽奖事务提交后，如果 `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 仍能发放 |
| 钱包补偿 | 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 |
| 成功收敛 | 钱包成功后 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` |

## 六、后台配置和审计

| 功能点 | HTTP 入口 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 获取单个奖池配置 | `GET /admin/activity/lucky-gifts/v2/config?pool_id=` | `Handler.GetLuckyGiftConfig`、`AdminLuckyGiftServer.GetLuckyGiftConfig`、`Service.GetConfig` | 已验收：未配置返回 disabled 草稿 |
| 发布配置 | `PUT /admin/activity/lucky-gifts/v2/config` | `Handler.UpsertLuckyGiftConfig`、`configToProto`、`Service.UpsertConfig`、`Repository.PublishLuckyGiftRuleConfig` | 已验收：百分比/倍率转换为 ppm 后发布 |
| 配置列表 | `GET /admin/activity/lucky-gifts/v2/configs` | `Handler.ListLuckyGiftConfigs`、`Service.ListConfigs`、`Repository.ListLuckyGiftRuleConfigs` | 已验收：每个奖池只返回最新版本 |
| 抽奖明细 | `GET /admin/activity/lucky-gifts/v2/draws` | `Handler.ListLuckyGiftDraws`、`Service.ListDraws`、`Repository.ListLuckyGiftDraws`、`luckyDrawWhereClause` | 已验收：分页上限由 service 控制，支持状态筛选 |
| 抽奖汇总 | `GET /admin/activity/lucky-gifts/v2/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，已 granted 时 worker 跳过 wallet |

## 八、运行边界

| 场景 | 当前处理 | 代码函数实现 | 验收 |
| --- | --- | --- | --- |
| 奖池未启用 | 送礼前检查或扣费后抽奖都会失败，不开奖 | `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 失败可重试；已 `granted` 的 draw 只补 IM/播报，不重复加币 | `processDrawOutbox`、`GetLuckyGiftDrawRewardState`、`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 只在发布校验和窗口观察中使用；运行时只能过滤不可支付奖档。
