hyapp-server/docs/幸运礼物实现文档.md

186 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 幸运礼物实现文档
本文档描述当前代码里的幸运礼物服务端实现。旧的产品方案、重构分步文档和早期 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` | 已验收:总倍数为各命中倍数相加,不是平均倍率 |
| 聚合发放 | 整批只写一条 outboxpayload 里有聚合 `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 只在发布校验和窗口观察中使用;运行时只能过滤不可支付奖档。