# 通用抽奖引擎技术文档 本文档描述当前服务端通用抽奖能力的代码结构、运行口径和转盘落地方式。抽奖通用能力目前由 `activity-service` 持有,钱包入账由 `wallet-service` 持有。幸运礼物已经复用通用权重选择;转盘新增独立配置、抽奖记录、RTP 统计、钱包 reason 和后台查询入口。 ## 一、边界和结论 | 模块 | Owner | 当前职责 | 不做什么 | 代码位置 | | --- | --- | --- | --- | --- | | 通用抽奖引擎 | `activity-service` | 候选归一化、权重随机、奖品类型归一化、RTP 计值归一化 | 不读取数据库、不判断资格、不改奖池、不发奖 | `services/activity-service/internal/domain/lotteryengine/engine.go` | | 幸运礼物 | `activity-service` | 继续拥有幸运礼物规则、奖池、RTP 窗口、风控、记录和 outbox;候选随机改为调用通用引擎 | 不改变原有幸运礼物 RTP 观察和不可支付过滤逻辑 | `services/activity-service/internal/storage/mysql/lucky_gift_repository.go` | | 转盘 | `activity-service` | 独立规则版本、奖档、奖池、RTP 窗口、抽奖记录、后台统计、金币快路径发奖 | 不复用幸运礼物表;不把道具/装扮价值计入 RTP | `services/activity-service/internal/domain/wheel`、`internal/service/wheel`、`internal/storage/mysql/wheel_repository.go` | | 钱包 reason | `wallet-service` | 新增 `wheel_reward` 入账 reason、交易、分录、钱包 outbox | 不判断转盘中奖资格,不计算 RTP | `services/wallet-service/internal/service/wallet/service.go`、`internal/storage/mysql/repository.go` | | Proto/gRPC | `api/proto` + transport | 暴露转盘抽奖和后台配置/记录/统计 RPC | 当前未新增 gateway HTTP 包装 | `api/proto/activity/v1/activity.proto`、`services/activity-service/internal/transport/grpc/wheel_server.go` | 核心原则: 1. 通用引擎只负责“从已过滤候选里按权重选中一个奖档”。 2. 业务模块必须在调用引擎前完成资格、价格、奖池、风控和预算过滤。 3. RTP 只使用 `rtp_value_coins` 作为分子;转盘道具和装扮无论配置价值多少,运行态都强制为 0。 4. 金币发放必须走钱包独立 reason `wheel_reward`,不能混用 `lucky_gift_reward`。 5. 转盘配置、记录和统计使用 `wheel_*` 表,不能写入幸运礼物表。 ## 二、代码索引 ### 2.1 通用抽奖引擎 | 文件 | 关键符号 | 说明 | | --- | --- | --- | | `services/activity-service/internal/domain/lotteryengine/engine.go` | `Candidate` | 引擎唯一输入视图:`ID`、`Weight`、`PrizeKind`、`RTPValueCoins` | | 同上 | `NormalizeCandidates` | 复制并清洗候选;负权重拒绝;全部权重为 0 时退化为等权随机 | | 同上 | `SelectWeighted` | 使用 `crypto/rand` 生成随机下标,按权重选择候选 | | 同上 | `NormalizePrizeKind` | 把 `coin/gift/prop/dress` 及同义值归一化 | | 同上 | `NormalizeRTPValueCoins` | 金币/礼物保留非负 RTP 价值;`prop/dress` 强制归零 | | `services/activity-service/internal/domain/lotteryengine/engine_test.go` | 单测 | 覆盖权重选择、全 0 权重兜底、道具/装扮 RTP 归零 | ### 2.2 转盘代码 | 文件 | 关键符号 | 说明 | | --- | --- | --- | | `services/activity-service/internal/domain/wheel/wheel.go` | `RuleConfig`、`Tier`、`DrawCommand`、`DrawResult`、`DrawSummary` | 转盘领域模型 | | `services/activity-service/internal/service/wheel/config.go` | `NormalizeRuleConfig`、`ValidateRuleConfig` | 后台配置发布前归一化和硬边界校验 | | `services/activity-service/internal/service/wheel/service.go` | `Service.Draw`、`UpsertConfig`、`GetConfig`、`ListDraws`、`GetDrawSummary` | 转盘用例入口 | | 同上 | `creditCoinRewardFastPath` | 金币奖品同步调用钱包 `CreditWheelReward`,成功后回写 draw、统计和 outbox | | `services/activity-service/internal/storage/mysql/wheel_repository.go` | `PublishWheelRuleConfig` | 发布不可变规则版本和奖档 | | 同上 | `ExecuteWheelDraw` | 转盘抽奖主事务:幂等、规则锁定、价格校验、RTP 窗口、奖池、记录、统计、outbox | | 同上 | `executeSingleWheelDraw` | 单次命中奖档、更新 RTP 窗口、写 draw record 和 outbox | | 同上 | `wheelPayableCandidates` | 按奖池容量和单次 RTP 上限过滤候选;道具/装扮 RTP 价值归零 | | 同上 | `ListWheelDraws`、`GetWheelDrawSummary` | 后台抽奖明细和汇总统计读取 | | 同上 | `MarkWheelDrawsGranted` | 金币发放成功后回写记录、统计和 `activity_outbox` | | `services/activity-service/internal/storage/mysql/wheel_schema.go` | `ensureWheelTables` | 运行时兜底创建 `wheel_*` 表 | | `services/activity-service/deploy/mysql/initdb/001_activity_service.sql` | `wheel_*` DDL | 新环境初始化表结构 | | `services/activity-service/internal/transport/grpc/wheel_server.go` | `WheelServer`、`AdminWheelServer` | 转盘 App 抽奖和后台配置/统计 gRPC | | `services/activity-service/internal/app/services.go` | `wheelservice.New` | activity-service 装配转盘 service | | `services/activity-service/internal/app/grpc_registration.go` | `RegisterWheelServiceServer`、`RegisterAdminWheelServiceServer` | 注册转盘 gRPC 服务 | ### 2.3 钱包代码 | 文件 | 关键符号 | 说明 | | --- | --- | --- | | `api/proto/wallet/v1/wallet.proto` | `CreditWheelRewardRequest`、`CreditWheelReward` | 钱包转盘金币奖品入账 RPC | | `services/wallet-service/internal/domain/ledger/ledger.go` | `WheelRewardCommand`、`WheelRewardReceipt` | 钱包 service/repository 间领域命令 | | `services/wallet-service/internal/service/wallet/service.go` | `CreditWheelReward` | 校验转盘金币奖品入账命令,固定钱包职责边界 | | `services/wallet-service/internal/storage/mysql/repository.go` | `bizTypeWheelReward`、`CreditWheelReward` | 交易、账户余额、分录和钱包 outbox 同事务提交 | | 同上 | `wheelRewardRequestHash` | 保证同一转盘 draw 幂等入账 | | `services/wallet-service/internal/transport/grpc/server.go` | `CreditWheelReward` | gRPC 请求转换为钱包领域命令 | ### 2.4 Proto 契约 | 文件 | RPC/Message | 说明 | | --- | --- | --- | | `api/proto/activity/v1/activity.proto` | `WheelService.ExecuteWheelDraw` | 扣费后执行转盘抽奖 | | 同上 | `AdminWheelService.GetWheelConfig` | 后台读取当前转盘配置 | | 同上 | `AdminWheelService.UpsertWheelConfig` | 后台发布新规则版本 | | 同上 | `AdminWheelService.ListWheelDraws` | 后台查看抽奖明细 | | 同上 | `AdminWheelService.GetWheelDrawSummary` | 后台查看转盘统计汇总 | | 同上 | `WheelRuleConfig`、`WheelPrizeTier` | 转盘规则和奖档配置 | | 同上 | `WheelDrawMeta`、`WheelDrawResult`、`WheelDrawSummary` | 抽奖入参、结果和统计 | | `api/proto/wallet/v1/wallet.proto` | `CreditWheelRewardRequest`、`CreditWheelRewardResponse` | 转盘金币发放钱包契约 | ## 三、通用引擎模型 ### 3.1 Candidate `lotteryengine.Candidate` 是通用引擎唯一关心的数据: | 字段 | 含义 | 规则 | | --- | --- | --- | | `ID` | 业务奖档 ID | 引擎只原样返回,业务侧负责保证唯一 | | `Weight` | 权重 | 负数拒绝;正数参与随机;全部为 0 时每个候选权重按 1 处理 | | `PrizeKind` | 奖品类型 | 归一化为 `coin/gift/prop/dress` 等标准值 | | `RTPValueCoins` | 计入 RTP 的金币价值 | 负数按 0;`prop/dress` 强制为 0 | 引擎不直接使用 `RewardCoins`、`RewardID`、库存、奖池余额、用户信息。这些都属于业务模块。 ### 3.2 权重选择 流程: 1. 调用方传入已过滤候选。 2. `NormalizeCandidates` 清洗权重、奖品类型和 RTP 价值。 3. `SelectWeighted` 汇总权重。 4. 用 `crypto/rand.Int` 生成 `[0,totalWeight)` 的随机数。 5. 按权重区间返回命中的 `Candidate`。 设计原因: | 设计 | 原因 | | --- | --- | | 使用 `crypto/rand` | 高价值奖品不能被时间种子或进程启动顺序预测 | | 全 0 权重兜底为等权 | 兼容旧配置或临时配置错误,避免整个活动不可抽 | | 负权重直接失败 | 负权重没有业务语义,继续运行会让概率不可解释 | | 不在引擎内做 RTP 修正 | RTP 修正依赖奖池、预算、用户风险和活动策略,必须留在业务层 | ## 四、转盘配置模型 ### 4.1 RuleConfig `domain/wheel.RuleConfig` 是一个不可变版本快照: | 字段 | 含义 | | --- | --- | | `WheelID` | 转盘 ID,空值归一为 `default` | | `RuleVersion` | 不可变规则版本,每次发布 +1 | | `Enabled` | 是否启用 | | `DrawPriceCoins` | 单抽价格 | | `TargetRTPPPM` | RTP 目标,ppm | | `PoolRatePPM` | 每笔消耗进入转盘奖池比例,ppm | | `SettlementWindowDraws` | RTP 观察窗口抽数 | | `InitialPoolCoins` | 初始奖池水位 | | `PoolReserveCoins` | 奖池保底水位 | | `MaxSingleRTPPayout` | 单次可支付的 RTP 价值上限 | | `Tiers` | 奖档列表 | ### 4.2 Tier | 字段 | 含义 | 特殊规则 | | --- | --- | --- | | `TierID` | 奖档 ID | 同一规则版本内必须唯一 | | `DisplayName` | 展示名称快照 | 只用于后台和前端展示 | | `RewardType` | `coin/gift/prop/dress` | 会调用通用引擎归一化 | | `RewardID` | 礼物、道具、装扮资源 ID | 非金币奖品必填 | | `RewardCount` | 奖励数量 | 小于等于 0 时归一为 1 | | `RewardCoins` | 金币奖励金额 | `coin` 使用 | | `RTPValueCoins` | 计入 RTP 的价值 | `prop/dress` 强制为 0 | | `WeightPPM` | 随机权重 | 当前作为权重值使用,不要求总和等于 1000000 | | `Enabled` | 是否启用 | 至少需要一个启用奖档 | | `MetadataJSON` | 展示和发放扩展快照 | 空值或非法 JSON 归一为 `{}` | ### 4.3 配置校验 入口:`services/activity-service/internal/service/wheel/config.go` 规则: 1. `draw_price_coins` 必须大于 0。 2. `target_rtp_ppm`、`pool_rate_ppm` 不能为负。 3. `settlement_window_draws` 必须大于 0。 4. 奖档不能为空。 5. `tier_id` 必须唯一。 6. `weight_ppm` 不能为负。 7. `coin` 奖档的 `reward_coins` 不能为负。 8. `gift/prop/dress` 必须有 `reward_id`。 9. `prop/dress` 的 `rtp_value_coins` 在归一化后必须为 0。 10. 至少一个奖档启用。 ## 五、转盘抽奖链路 ### 5.1 调用前提 转盘抽奖 RPC 是扣费后的服务端事实入口: | 阶段 | Owner | 说明 | | --- | --- | --- | | 扣费 | 调用方 + wallet-service | 先扣用户金币,拿到真实 `coin_spent` 和 `paid_at_ms` | | 抽奖 | activity-service | 使用 `ExecuteWheelDraw` 固化中奖结果和 RTP | | 金币发放 | wallet-service | `coin` 奖品通过 `CreditWheelReward` 入账 | | 礼物/道具/装扮发放 | 对应资产 owner | 当前只固化 pending 记录和 outbox 事实,后续接资产发放接口 | 转盘 repository 会校验: ```text coin_spent == draw_price_coins * draw_count ``` 这样避免客户端或调用方伪造 RTP 分母。 ### 5.2 主事务 入口:`Repository.ExecuteWheelDraw` 事务内顺序: 1. 归一化 `wheel_id` 和 `draw_count`。 2. 按 `command_id` 收集已有 draw,完整存在则幂等返回。 3. 如果只存在部分子抽,返回冲突,避免重复发奖。 4. 锁定当前启用的转盘规则版本。 5. 校验 `coin_spent` 与当前单价一致。 6. 获取或创建 RTP 窗口。 7. 获取或创建转盘奖池。 8. 按 `draw_count` 拆分每次消耗。 9. 每一抽调用 `executeSingleWheelDraw`。 10. 汇总写回奖池净变化。 11. 更新后台统计。 12. 提交事务。 ### 5.3 单抽 入口:`executeSingleWheelDraw` 单抽顺序: 1. `wheelPayableCandidates` 过滤可支付候选。 2. 组装 `lotteryengine.Candidate`。 3. 调用 `lotteryengine.SelectWeighted` 命中奖档。 4. 用命中奖档的 `RTPValueCoins` 更新奖池和 RTP 窗口。 5. 写入 `wheel_draw_records`。 6. 如果奖品需要发放,写入 `activity_outbox`,事件类型为 `WheelRewardSettlement`。 7. 返回 `DrawResult`。 候选过滤口径: | 过滤项 | 说明 | | --- | --- | | `tier.Enabled` | 未启用奖档不参与 | | `RTPValueCoins` | `prop/dress` 强制为 0 | | 奖池容量 | `pool.balance - reserve_floor` | | 单次上限 | `max_single_rtp_payout` 大于 0 时限制容量 | 注意:道具/装扮 `RTPValueCoins=0`,所以不会因为展示价值占用奖池,也不会提高 RTP 分子。 ### 5.4 批量抽奖 当前转盘支持 `draw_count`: | 功能 | 代码 | 说明 | | --- | --- | --- | | 子命令 ID | `wheelSubCommandID` | 单抽使用原 command,多抽追加 `#000001` 这类后缀 | | 金额拆分 | 复用 `luckyDrawUnitSpend` | 总金额按次数拆分,余数给前几抽 | | 幂等聚合 | `collectWheelDrawsByCommand`、`aggregateWheelDrawResults` | 完整存在则聚合返回 | | 发放限制 | `creditCoinRewardFastPath` | 当前只对单抽金币做同步快路径,多抽保留 pending/outbox | ## 六、RTP 口径 ### 6.1 分子和分母 | 项 | 字段 | 来源 | | --- | --- | --- | | RTP 分母 | `coin_spent` / `wager_coins` | 用户实际扣费金币 | | RTP 分子 | `rtp_value_coins` / `actual_rtp_value_coins` | 命中奖档计入 RTP 的价值 | | 目标值 | `target_rtp_ppm` | 后台规则配置 | | 当前值 | `actual_rtp_ppm` | `actual_rtp_value_coins * 1000000 / wager_coins` | ### 6.2 奖品类型口径 | 奖品类型 | 是否可配置 | 是否发放 | 是否计入 RTP | | --- | --- | --- | --- | | `coin` | 是 | 钱包 `wheel_reward` | 是,使用 `rtp_value_coins` | | `gift` | 是 | 待接礼物/资源 owner | 是,使用 `rtp_value_coins` | | `prop` | 是 | 待接道具 owner | 否,强制 0 | | `dress` | 是 | 待接装扮 owner | 否,强制 0 | 强制归零的位置: | 层 | 代码 | | --- | --- | | 通用引擎 | `lotteryengine.NormalizeRTPValueCoins` | | 转盘配置 | `wheel.NormalizeRuleConfig` | | 转盘仓储 | `normalizeWheelTiers`、`wheelPayableCandidates` | | 转盘测试 | `services/activity-service/internal/service/wheel/config_test.go`、`domain/lotteryengine/engine_test.go` | ## 七、发奖和 outbox ### 7.1 金币奖品 金币奖品路径: 1. `ExecuteWheelDraw` 写入 `wheel_draw_records`,初始状态为 `pending`。 2. `Service.Draw` 在事务提交后进入 `creditCoinRewardFastPath`。 3. 调用 wallet-service `CreditWheelReward`。 4. wallet-service 以 `biz_type=wheel_reward` 写交易、账户、分录和 wallet outbox。 5. activity-service 调用 `MarkWheelDrawsGranted`。 6. `MarkWheelDrawsGranted` 同事务更新: - `wheel_draw_records.reward_status = granted` - `wheel_draw_records.reward_transaction_id` - `wheel_draw_stats.pending_draws/granted_draws` - `activity_outbox.status = delivered` 钱包 reason: | 项 | 值 | | --- | --- | | wallet biz type | `wheel_reward` | | RPC reason | `wheel_reward` | | command ID | `wheel_reward:` | | 钱包事件 | `WalletWheelRewardCredited` | ### 7.2 礼物/道具/装扮奖品 当前状态: 1. 抽奖结果会落 `wheel_draw_records`。 2. `reward_status` 为 `pending`。 3. `activity_outbox` 会写 `WheelRewardSettlement`。 4. 具体发放 worker 和资产 owner RPC 还没有接入。 后续接入时必须遵守: 1. 不允许在 activity-service 直接改钱包或资产表。 2. 发放成功后调用转盘仓储状态回写方法,保持 draw record、统计和 outbox 一致。 3. 道具/装扮发放成功也不能把展示价值写入 RTP。 4. 礼物如果要计入 RTP,必须使用抽奖时固化的 `rtp_value_coins`,不能按发放时礼物价格重新估值。 ## 八、后台记录和统计 ### 8.1 明细 入口:`AdminWheelService.ListWheelDraws` 仓储:`Repository.ListWheelDraws` 支持筛选: | 字段 | 说明 | | --- | --- | | `wheel_id` | 转盘 ID | | `user_id` | 用户 ID | | `status` | `pending/granted/failed` | | `page/page_size` | 分页,`page_size` 最大 100 | 返回字段包括: `draw_id`、`command_id`、`wheel_id`、`rule_version`、`selected_tier_id`、`reward_type`、`reward_id`、`reward_count`、`reward_coins`、`rtp_value_coins`、`reward_status`、`wallet_transaction_id`、`rtp_window_index`、`metadata_json`。 ### 8.2 汇总 入口:`AdminWheelService.GetWheelDrawSummary` 仓储:`Repository.GetWheelDrawSummary` 统计字段: | 字段 | 说明 | | --- | --- | | `total_draws` | 抽奖次数 | | `unique_users` | 去重用户数 | | `total_spent_coins` | 总消耗金币 | | `total_rtp_value_coins` | 计入 RTP 的返奖价值 | | `actual_rtp_ppm` | 实际 RTP | | `pending_draws` | 待发放 | | `granted_draws` | 已发放 | | `failed_draws` | 失败 | 当只按 `wheel_id` 汇总时,优先读 `wheel_draw_stats`;带 `user_id/status` 过滤时直接聚合 `wheel_draw_records`。 ## 九、数据表 | 表 | 用途 | 代码 | | --- | --- | --- | | `wheel_rule_versions` | 转盘不可变规则版本 | `wheel_schema.go`、`001_activity_service.sql` | | `wheel_prize_tiers` | 每个规则版本的奖档快照 | 同上 | | `wheel_rtp_windows` | RTP 观察窗口 | `getOpenWheelRTPWindow`、`createWheelRTPWindow` | | `wheel_pools` | 转盘奖池水位 | `getOrCreateWheelPool`、`persistWheelPoolDelta` | | `wheel_draw_records` | 抽奖事实明细 | `executeSingleWheelDraw`、`ListWheelDraws` | | `wheel_draw_stats` | 后台汇总统计 | `updateWheelStats`、`GetWheelDrawSummary` | | `wheel_draw_stat_users` | 统计去重用户 | `updateWheelStats` | | `activity_outbox` | 转盘发奖补偿事实 | `insertWheelRewardOutbox`、`MarkWheelDrawsGranted` | | `wallet_transactions` | 钱包交易 | `wallet-service/internal/storage/mysql.Repository.CreditWheelReward` | | `wallet_outbox` | 钱包余额和奖励事实 | 同上 | ## 十、和幸运礼物的关系 幸运礼物不是直接迁移成转盘,也不是把转盘塞进幸运礼物: | 项 | 幸运礼物 | 转盘 | | --- | --- | --- | | 规则维度 | `pool_id`,房间送礼场景 | `wheel_id`,H5/活动转盘场景 | | 奖品 | 金币返奖 | 金币、礼物、道具、装扮 | | RTP 口径 | 原幸运礼物基础返奖口径 | `rtp_value_coins`,道具/装扮强制 0 | | 记录表 | `lucky_draw_records` | `wheel_draw_records` | | 钱包 reason | `lucky_gift_reward` | `wheel_reward` | | 通用引擎使用 | 候选随机复用 `lotteryengine.SelectWeighted` | 候选随机和 RTP 归一化都复用 | 幸运礼物保留自己的奖池、风控、用户阶段和 room IM 逻辑。通用引擎只替换了“已过滤候选里的权重随机”这一小段,避免改动原业务行为。 ## 十一、扩展新抽奖活动的步骤 新增一个抽奖活动时,不要复制幸运礼物或转盘整套逻辑。按下面顺序接入: 1. 新建独立 domain,定义自己的 `RuleConfig`、`Tier`、`DrawCommand`、`DrawResult`。 2. 新建 service,负责入参归一化、配置校验和跨服务调用。 3. 新建 storage,负责规则版本、奖池、RTP 窗口、记录、统计和 outbox。 4. 业务层先过滤资格、预算、库存、奖池和风控。 5. 把过滤后的候选转换成 `lotteryengine.Candidate`。 6. 调用 `lotteryengine.SelectWeighted`。 7. 用返回候选固化抽奖结果。 8. 如果有金币奖品,在 wallet-service 增加独立 reason,不复用已有 reason。 9. 如果有非金币奖品,接对应 owner service 的发放接口。 10. 补 proto、gRPC、表结构、initdb 和测试。 必须明确回答的问题: | 问题 | 必须落地的位置 | | --- | --- | | 奖品有哪些类型 | domain `Tier.RewardType` 和 proto | | 哪些奖品计入 RTP | `RTPValueCoins` 归一化 | | 哪个服务发奖 | service 跨服务依赖 | | 钱包 reason 是什么 | wallet-service domain/service/storage/proto | | 如何幂等 | command ID、draw ID、钱包 request hash | | 后台查什么 | draw records 和 summary | | 失败如何补偿 | activity outbox 或 owner outbox | ## 十二、验证命令 当前相关验证命令: ```bash PATH=/Users/hy/go/bin:$PATH make proto go test ./services/activity-service/internal/domain/lotteryengine go test ./services/activity-service/internal/service/wheel go test ./services/activity-service/internal/storage/mysql go test ./services/activity-service/internal/transport/grpc go test ./services/activity-service/internal/app go test ./services/wallet-service/internal/service/wallet ./services/wallet-service/internal/storage/mysql ./services/wallet-service/internal/transport/grpc go test ./services/activity-service/... ``` 已知当前仓库全量 `make test` 仍会失败在既有 MifaPay 配置测试: ```text services/wallet-service/internal/config.TestLoadMifaPayMerchantConfig mifapay merchant identity mismatch in ../../configs/config.docker.yaml ``` 该失败与通用抽奖和转盘改动无关。 ## 十三、当前未完成事项 | 事项 | 原因 | 后续接入位置 | | --- | --- | --- | | 礼物奖品真实发放 | 需要确认礼物/资源 owner 的发放接口和背包表现 | activity-service 转盘 outbox worker + 对应 owner RPC | | 道具/装扮真实发放 | 需要确认装扮资源 owner、有效期和背包协议 | activity-service 转盘 outbox worker + resource/wallet owner | | Gateway HTTP/H5 接口 | 当前只到 activity gRPC;H5 页面还需要 gateway 包装 | `services/gateway-service/internal/transport/http/activityapi` | | 后台管理页面 | 当前只有 gRPC 后台能力,未接 admin web 模块 | `server/admin` |