hyapp-server/docs/通用抽奖引擎技术文档.md
2026-06-18 17:13:56 +08:00

21 KiB
Raw Permalink Blame History

通用抽奖引擎技术文档

本文档描述当前服务端通用抽奖能力的代码结构、运行口径和转盘落地方式。抽奖通用能力目前由 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/wheelinternal/service/wheelinternal/storage/mysql/wheel_repository.go
钱包 reason wallet-service 新增 wheel_reward 入账 reason、交易、分录、钱包 outbox 不判断转盘中奖资格,不计算 RTP services/wallet-service/internal/service/wallet/service.gointernal/storage/mysql/repository.go
Proto/gRPC api/proto + transport 暴露转盘抽奖和后台配置/记录/统计 RPC 当前未新增 gateway HTTP 包装 api/proto/activity/v1/activity.protoservices/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 引擎唯一输入视图:IDWeightPrizeKindRTPValueCoins
同上 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 RuleConfigTierDrawCommandDrawResultDrawSummary 转盘领域模型
services/activity-service/internal/service/wheel/config.go NormalizeRuleConfigValidateRuleConfig 后台配置发布前归一化和硬边界校验
services/activity-service/internal/service/wheel/service.go Service.DrawUpsertConfigGetConfigListDrawsGetDrawSummary 转盘用例入口
同上 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 价值归零
同上 ListWheelDrawsGetWheelDrawSummary 后台抽奖明细和汇总统计读取
同上 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 WheelServerAdminWheelServer 转盘 App 抽奖和后台配置/统计 gRPC
services/activity-service/internal/app/services.go wheelservice.New activity-service 装配转盘 service
services/activity-service/internal/app/grpc_registration.go RegisterWheelServiceServerRegisterAdminWheelServiceServer 注册转盘 gRPC 服务

2.3 钱包代码

文件 关键符号 说明
api/proto/wallet/v1/wallet.proto CreditWheelRewardRequestCreditWheelReward 钱包转盘金币奖品入账 RPC
services/wallet-service/internal/domain/ledger/ledger.go WheelRewardCommandWheelRewardReceipt 钱包 service/repository 间领域命令
services/wallet-service/internal/service/wallet/service.go CreditWheelReward 校验转盘金币奖品入账命令,固定钱包职责边界
services/wallet-service/internal/storage/mysql/repository.go bizTypeWheelRewardCreditWheelReward 交易、账户余额、分录和钱包 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 后台查看转盘统计汇总
同上 WheelRuleConfigWheelPrizeTier 转盘规则和奖档配置
同上 WheelDrawMetaWheelDrawResultWheelDrawSummary 抽奖入参、结果和统计
api/proto/wallet/v1/wallet.proto CreditWheelRewardRequestCreditWheelRewardResponse 转盘金币发放钱包契约

三、通用引擎模型

3.1 Candidate

lotteryengine.Candidate 是通用引擎唯一关心的数据:

字段 含义 规则
ID 业务奖档 ID 引擎只原样返回,业务侧负责保证唯一
Weight 权重 负数拒绝;正数参与随机;全部为 0 时每个候选权重按 1 处理
PrizeKind 奖品类型 归一化为 coin/gift/prop/dress 等标准值
RTPValueCoins 计入 RTP 的金币价值 负数按 0prop/dress 强制为 0

引擎不直接使用 RewardCoinsRewardID、库存、奖池余额、用户信息。这些都属于业务模块。

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_ppmpool_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/dressrtp_value_coins 在归一化后必须为 0。
  10. 至少一个奖档启用。

五、转盘抽奖链路

5.1 调用前提

转盘抽奖 RPC 是扣费后的服务端事实入口:

阶段 Owner 说明
扣费 调用方 + wallet-service 先扣用户金币,拿到真实 coin_spentpaid_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

事务内顺序:

  1. 归一化 wheel_iddraw_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 总金额按次数拆分,余数给前几抽
幂等聚合 collectWheelDrawsByCommandaggregateWheelDrawResults 完整存在则聚合返回
发放限制 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
转盘仓储 normalizeWheelTierswheelPayableCandidates
转盘测试 services/activity-service/internal/service/wheel/config_test.godomain/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:<draw_id>
钱包事件 WalletWheelRewardCredited

7.2 礼物/道具/装扮奖品

当前状态:

  1. 抽奖结果会落 wheel_draw_records
  2. reward_statuspending
  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_idcommand_idwheel_idrule_versionselected_tier_idreward_typereward_idreward_countreward_coinsrtp_value_coinsreward_statuswallet_transaction_idrtp_window_indexmetadata_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.go001_activity_service.sql
wheel_prize_tiers 每个规则版本的奖档快照 同上
wheel_rtp_windows RTP 观察窗口 getOpenWheelRTPWindowcreateWheelRTPWindow
wheel_pools 转盘奖池水位 getOrCreateWheelPoolpersistWheelPoolDelta
wheel_draw_records 抽奖事实明细 executeSingleWheelDrawListWheelDraws
wheel_draw_stats 后台汇总统计 updateWheelStatsGetWheelDrawSummary
wheel_draw_stat_users 统计去重用户 updateWheelStats
activity_outbox 转盘发奖补偿事实 insertWheelRewardOutboxMarkWheelDrawsGranted
wallet_transactions 钱包交易 wallet-service/internal/storage/mysql.Repository.CreditWheelReward
wallet_outbox 钱包余额和奖励事实 同上

十、和幸运礼物的关系

幸运礼物不是直接迁移成转盘,也不是把转盘塞进幸运礼物:

幸运礼物 转盘
规则维度 pool_id,房间送礼场景 wheel_idH5/活动转盘场景
奖品 金币返奖 金币、礼物、道具、装扮
RTP 口径 原幸运礼物基础返奖口径 rtp_value_coins,道具/装扮强制 0
记录表 lucky_draw_records wheel_draw_records
钱包 reason lucky_gift_reward wheel_reward
通用引擎使用 候选随机复用 lotteryengine.SelectWeighted 候选随机和 RTP 归一化都复用

幸运礼物保留自己的奖池、风控、用户阶段和 room IM 逻辑。通用引擎只替换了“已过滤候选里的权重随机”这一小段,避免改动原业务行为。

十一、扩展新抽奖活动的步骤

新增一个抽奖活动时,不要复制幸运礼物或转盘整套逻辑。按下面顺序接入:

  1. 新建独立 domain定义自己的 RuleConfigTierDrawCommandDrawResult
  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

十二、验证命令

当前相关验证命令:

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 gRPCH5 页面还需要 gateway 包装 services/gateway-service/internal/transport/http/activityapi
后台管理页面 当前只有 gRPC 后台能力,未接 admin web 模块 server/admin