21 KiB
通用抽奖引擎技术文档
本文档描述当前服务端通用抽奖能力的代码结构、运行口径和转盘落地方式。抽奖通用能力目前由 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 |
核心原则:
- 通用引擎只负责“从已过滤候选里按权重选中一个奖档”。
- 业务模块必须在调用引擎前完成资格、价格、奖池、风控和预算过滤。
- RTP 只使用
rtp_value_coins作为分子;转盘道具和装扮无论配置价值多少,运行态都强制为 0。 - 金币发放必须走钱包独立 reason
wheel_reward,不能混用lucky_gift_reward。 - 转盘配置、记录和统计使用
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 权重选择
流程:
- 调用方传入已过滤候选。
NormalizeCandidates清洗权重、奖品类型和 RTP 价值。SelectWeighted汇总权重。- 用
crypto/rand.Int生成[0,totalWeight)的随机数。 - 按权重区间返回命中的
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
规则:
draw_price_coins必须大于 0。target_rtp_ppm、pool_rate_ppm不能为负。settlement_window_draws必须大于 0。- 奖档不能为空。
tier_id必须唯一。weight_ppm不能为负。coin奖档的reward_coins不能为负。gift/prop/dress必须有reward_id。prop/dress的rtp_value_coins在归一化后必须为 0。- 至少一个奖档启用。
五、转盘抽奖链路
5.1 调用前提
转盘抽奖 RPC 是扣费后的服务端事实入口:
| 阶段 | Owner | 说明 |
|---|---|---|
| 扣费 | 调用方 + wallet-service | 先扣用户金币,拿到真实 coin_spent 和 paid_at_ms |
| 抽奖 | activity-service | 使用 ExecuteWheelDraw 固化中奖结果和 RTP |
| 金币发放 | wallet-service | coin 奖品通过 CreditWheelReward 入账 |
| 礼物/道具/装扮发放 | 对应资产 owner | 当前只固化 pending 记录和 outbox 事实,后续接资产发放接口 |
转盘 repository 会校验:
coin_spent == draw_price_coins * draw_count
这样避免客户端或调用方伪造 RTP 分母。
5.2 主事务
入口:Repository.ExecuteWheelDraw
事务内顺序:
- 归一化
wheel_id和draw_count。 - 按
command_id收集已有 draw,完整存在则幂等返回。 - 如果只存在部分子抽,返回冲突,避免重复发奖。
- 锁定当前启用的转盘规则版本。
- 校验
coin_spent与当前单价一致。 - 获取或创建 RTP 窗口。
- 获取或创建转盘奖池。
- 按
draw_count拆分每次消耗。 - 每一抽调用
executeSingleWheelDraw。 - 汇总写回奖池净变化。
- 更新后台统计。
- 提交事务。
5.3 单抽
入口:executeSingleWheelDraw
单抽顺序:
wheelPayableCandidates过滤可支付候选。- 组装
lotteryengine.Candidate。 - 调用
lotteryengine.SelectWeighted命中奖档。 - 用命中奖档的
RTPValueCoins更新奖池和 RTP 窗口。 - 写入
wheel_draw_records。 - 如果奖品需要发放,写入
activity_outbox,事件类型为WheelRewardSettlement。 - 返回
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 金币奖品
金币奖品路径:
ExecuteWheelDraw写入wheel_draw_records,初始状态为pending。Service.Draw在事务提交后进入creditCoinRewardFastPath。- 调用 wallet-service
CreditWheelReward。 - wallet-service 以
biz_type=wheel_reward写交易、账户、分录和 wallet outbox。 - activity-service 调用
MarkWheelDrawsGranted。 MarkWheelDrawsGranted同事务更新:wheel_draw_records.reward_status = grantedwheel_draw_records.reward_transaction_idwheel_draw_stats.pending_draws/granted_drawsactivity_outbox.status = delivered
钱包 reason:
| 项 | 值 |
|---|---|
| wallet biz type | wheel_reward |
| RPC reason | wheel_reward |
| command ID | wheel_reward:<draw_id> |
| 钱包事件 | WalletWheelRewardCredited |
7.2 礼物/道具/装扮奖品
当前状态:
- 抽奖结果会落
wheel_draw_records。 reward_status为pending。activity_outbox会写WheelRewardSettlement。- 具体发放 worker 和资产 owner RPC 还没有接入。
后续接入时必须遵守:
- 不允许在 activity-service 直接改钱包或资产表。
- 发放成功后调用转盘仓储状态回写方法,保持 draw record、统计和 outbox 一致。
- 道具/装扮发放成功也不能把展示价值写入 RTP。
- 礼物如果要计入 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 逻辑。通用引擎只替换了“已过滤候选里的权重随机”这一小段,避免改动原业务行为。
十一、扩展新抽奖活动的步骤
新增一个抽奖活动时,不要复制幸运礼物或转盘整套逻辑。按下面顺序接入:
- 新建独立 domain,定义自己的
RuleConfig、Tier、DrawCommand、DrawResult。 - 新建 service,负责入参归一化、配置校验和跨服务调用。
- 新建 storage,负责规则版本、奖池、RTP 窗口、记录、统计和 outbox。
- 业务层先过滤资格、预算、库存、奖池和风控。
- 把过滤后的候选转换成
lotteryengine.Candidate。 - 调用
lotteryengine.SelectWeighted。 - 用返回候选固化抽奖结果。
- 如果有金币奖品,在 wallet-service 增加独立 reason,不复用已有 reason。
- 如果有非金币奖品,接对应 owner service 的发放接口。
- 补 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 |
十二、验证命令
当前相关验证命令:
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 配置测试:
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 |