hyapp-server/docs/幸运礼物动态策略V3实现与模拟.md

24 KiB
Raw Blame History

幸运礼物动态策略 V3实现边界与模拟验收

1. 版本边界

本文只描述 dynamic_v3 的规则、持久化契约和可重复模拟口径,不改变已经发布的 fixed_v2 规则语义,也不代表该策略已在线上自动启用。

  • strategy_version=fixed_v2:继续按历史不可变规则执行;旧请求或旧记录缺少 strategy_version 时必须归一为 fixed_v2
  • strategy_version=dynamic_v3:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
  • 新奖池未配置时返回 dynamic_v3disabled 草稿。各 App 必须先补齐 50 美元等值金币门槛及六项金额上限,才允许启用。
  • 启用前还必须确认 user/gateway/room 已升级到由 auth_sessions.device_id 签入 access JWT再通过 room.RequestMeta 和可恢复 command 原样传递设备作用域。旧 JWT 缺 device_idfixed_v2 仍兼容,dynamic_v3 必须 fail-close不得用 session_id/command_id 伪造设备。
  • 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 device_idwallet 回执携带交易 paid_at_ms、充值快照和 gift_income_coins。任一动态字段缺失或真实收礼返币比例与规则 anchor_rate_ppm 不一致时运行侧 fail-close。
  • 比例、概率和倍率统一使用 ppm1_000_000=100%=1x;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
  • 离线模拟使用固定 seed 的 math/rand;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。

2. 已确认的产品口径

2.1 资金拆分采用 98/1/1

每笔已成功扣费的幸运礼物按整数守恒拆分:

去向 默认比例 作用
公共奖金池 P 98% 在本抽开奖前先入池,承担用户返奖
平台盈利 1% 只记盈利拆账,不进入可返奖余额
收礼主播收益 1% 规则计算值必须与钱包已落账回执完全一致,不允许缺字段或其他返币比例静默覆盖规则

原图同一行同时出现“抽取 98%”和“92% 可灵活配置”两者冲突。V3 采用能够与 1%+1% 合计为 100% 的 98/1/1“92%”视为原图笔误,不进入默认配置、代码或模拟。比例可发布为其他值时,三项仍必须非负且合计 100%,不允许出现去向不明的余额。

公共池与主播份额分别向下取整,无法再拆的整币余数归平台盈利,任何价格都满足:

public_pool_coins + profit_coins + anchor_return_coins = coin_spent

2.2 RTP 是观察目标,不是透支承诺

  • 发布目标为 98%,正常观察带为 95%101%98%±3 个百分点)。
  • RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
  • 95%101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
  • 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 underpaid 或短期偏离 95%101%,禁止为了追 RTP 透支奖池。
  • settlement_window_wager 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。gift_price_reference 只用于体验阶段的等价抽数,不参与 V3 滚窗。

2.3 原图概率表不能作为生产默认概率

原图示例为:

倍率 概率 EV 贡献
0x 60% 0x
5x 20% 1x
50x 15% 7.5x
200x 4% 8x
1000x 1% 10x
合计 100% 26.5x

因此该表的名义 EV=26.5x,即 RTP=2650%,不可能同时是 98% RTP。它只用于验证原图的 P/W 删档、删 0 和重抽分支,不能写入生产默认基础奖档。

dynamic_v3 的安全发布草稿采用独立的 98% 静态 EV0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x200x/500x/1000x 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表。

3. 一次抽奖的确定顺序

一次真实抽奖按以下顺序完成;同一事务内锁住的状态必须与 draw record 一起提交:

  1. user-serviceauth_sessions.device_id 签入 access JWTgateway 验签后写入 room RequestMetaroom saga 固化该设备快照,禁止用可轮换的 session 或 command ID 代替。
  2. wallet-service 完成真实扣费,并在幂等回执中固化 coin_spent、交易 paid_at_ms、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。
  3. lucky-gift-service 只消费上述可信事实不直连钱包库不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。
  4. 本抽金币按 98/1/1 拆分;公共池份额先计入 P,再比较本抽奖金 W
  5. app_code + pool_id + rule_version 读取不可变规则锁定公共池、用户日状态、72 小时桶/边界事件、连 0 状态和六维风控计数。
  6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
  7. 优先处理已持久化的“消费里程碑大奖 token”其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
  8. 对选中的 W 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖。
  9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 pending/granted/failed

这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。

4. 动态概率、分层和保底

4.1 充值分层

V3 固定有 novice/normal/advanced 三层,但两个门槛由每个不可变规则版本配置:

  • 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。
  • 用户进入同时满足两个最低充值门槛的最高层;不能只满足 7 日或只满足 30 日门槛就越级。
  • novice 门槛必须是 0/0normal 至少一维高于 noviceadvanced 两维均不得低于 normal且至少一维严格更高。
  • 阶段阈值相等时算已达到,即使用 >=;所有金额门槛按 App 金币口径显式发布。

充值快照由 wallet-service 在礼物扣费事务中查询并写入交易 metadata同一 command_id 重放返回首次快照,避免重试时跨层或丢失五分钟加成。

4.2 水位和最近充值因子

设某层的非 0 档基础权重为 base_weight,则:

adjusted_weight = base_weight × water_factor × recharge_factor

因子是相对乘法,不是把百分点直接相加:

条件 默认因子 边界
P < low_watermark 0.70 恰等于低水位时不下调
low_watermark <= P <= high_watermark 1.00 两个端点都属于中水位
P > high_watermark 1.30 恰等于高水位时不上调
有可信充值事实且处于充值后窗口 1.10 [last_recharged_at_ms, last_recharged_at_ms + 300000)

例如低水位并处于充值后五分钟时,非 0 权重为 base × 0.70 × 1.10 = base × 0.77,不是 base × 0.80。五分钟窗口包含起点和第 299999ms,不包含第 300000ms;未来时间、缺失充值事实或 last_recharged_at_ms=0 都不加权。

调整后:若正奖权重和不超过 100%0 档权重取剩余值;若正奖权重和超过 100%,按相同比例归一化正奖权重并把 0 档置为 0。该归一化只解决概率守恒不绕过资金和风控。

4.3 连续 5 次 0 后的第 6 抽

  • loss_streak 只在最终可见返奖为 0 时加一,任意正返奖后清零。
  • 前 5 次连续为 0 后,第 6 抽从普通候选中排除 0 档,选择可支付的非 0 档。
  • “第 6 抽不出 0”是有资金、有剩余额度时的体验保证如果所有正奖都超过 P 或六维风控剩余额度,仍返回 0并记录 miss_protection_blocked_no_payable_tier。不得透支来兑现保底。

5. P/W 安全重抽

P 是本抽入池后的公共奖金池可用余额,W=gift_price × multiplier。普通抽奖执行:

  1. 第一次按当前动态权重抽档。
  2. W <= P 且所有风控均允许时直接支付;W == P 可以支付,支付后 P=0
  3. W > P,同时删除该中奖档和 0 档,再对剩余正奖档重抽。
  4. 重抽仍有 W > P 时继续删除该中奖档0 档只在第一次资金不足时删除一次。
  5. 若最终没有可支付的正奖档,安全回退 0不改变 P 为负数。

原图 P=800、单价 10 的脚本化示例只用于分支验收:先抽到 200x(W=2000) 时删除 200x+0x;若再抽到 1000x(W=10000),再删除 1000x;随后只能在仍有权重且 W<=800 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。

奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 W>P 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。

6. 两种大奖机制

大奖集合由 jackpot_multiplier_ppms 配置,默认产品集合为 200x/500x/1000x。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。

6.1 机制一RTP 亏损补偿大奖

本抽开始时同时满足以下条件,才允许从当前可支付大奖集合随机选择:

  • 全局结算 RTP <= jackpot_global_rtp_max_ppm,默认 98%
  • 用户当前 UTC 日 RTP <= jackpot_user_day_rtp_max_ppm,默认 96%
  • 用户滚动 72 小时 RTP <= jackpot_user_72h_rtp_max_ppm,默认 96%
  • 用户本 UTC 日大奖次数 < max_jackpot_hits_per_user_day,默认 5
  • 候选 W<=P 且不超过六维风控的最小剩余额度。

任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 [now_ms-72h, now_ms) 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”。

6.2 机制二:消费里程碑大奖

  • jackpot_spend_threshold_coins 是各 App 对“50 美元等值幸运礼物金币”的显式换算,服务不读取实时汇率推断。
  • 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
  • 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
  • 下一抽优先消费已有 token若大奖集合因 P、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。
  • 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。

原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token结果仍只影响下一抽但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。

7. 六维风控与时间口径

所有启用的 dynamic_v3 规则必须配置正数上限:

维度 配置字段 UTC 窗口
单次返奖 max_single_payout 当前 draw
用户小时 user_hourly_payout_cap 当前 UTC 小时
用户日 user_daily_payout_cap 当前 UTC 自然日
设备日 device_daily_payout_cap 当前 UTC 自然日
房间小时 room_hourly_payout_cap 当前 UTC 小时
主播日 anchor_daily_payout_cap 当前 UTC 自然日

一次候选的有效容量是 P 和六项剩余额度的最小值。候选金额等于剩余额度时允许,超过任一维度即拒绝;计数更新与 draw record 必须在同一锁定事务内,避免并发抽奖分别看到同一份余额。

设备日 scope 只接受 user-service 已落库 session 的 device_id,由签名 JWT 贯通到 room 和 lucky旧 token/旧 command 缺字段时 fixed_v2 可继续重放,但 dynamic_v3 必须拒绝开奖,不能回退到 session_id 或每单变化的 command_id。外部 App 的 device ID 由已认证调用方负责稳定提供V3 缺失同样拒绝;服务只保存 App 内稳定 hash避免暴露原始标识。

所有时间范围遵守 [start_ms,end_ms)包含开始毫秒不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 72 小时都不得使用 time.Local、容器时区或客户端时区切分。

8. 批量 N 次的语义

gift_count=N 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 gift_count=99 必须产生 99 次状态推进。

  • coin_spent 拆成 N 个整数单份,余数从前往后每份加 1三资金桶使用联合累计配额保证每一抽 public_i+profit_i+anchor_i=unit_spent_i,同时整批三桶精确等于钱包回执总拆账。
  • i 抽看到第 i-1 抽后的 P、连 0、RTP、token、日大奖次数和风控计数不能并行抽 N 次后再合并。
  • 若第 i 抽使当前 RTP 窗口真实累计流水达到或超过 settlement_window_wager,第 i+1 抽必须先滚到新窗口;不能按 ceil(window_wager/gift_price_reference) 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
  • 每个子抽用稳定子幂等键(现有约定为 command_id#序号)保留独立 draw record重放整批不能跳过、补写或重复支付其中一抽。
  • 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
  • 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。

外部 App 的 /lucky-gifts/send 也遵守同一语义:dynamic_v3 逐次执行最多 999 抽、写 external_lucky_gift_draw_items,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 dynamic_v3 必须提交稳定 device_id 和真实扣费 paid_at_ms,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明设备真实性所以调用方必须在自身认证/请求签名边界内绑定这些值owner 不得用 external_user_idrequest_id 或请求到达时间兜底。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 novice 且不加最近充值因子,不能从 metadata 猜充值额。fixed_v2 外部请求继续保持缺少新事实的历史兼容路径。

9. 接口和配置字段

9.1 事实输入

wallet.v1.DebitGiftResponse 新增以下幂等回执字段:

  • recharge_seven_day_coins
  • recharge_thirty_day_coins
  • last_recharged_at_ms
  • paid_at_ms

luckygift.v1.LuckyGiftMeta 接收:

  • device_id(从已验签 access JWT 穿过 room command 恢复链路)
  • paid_at_ms(逐目标 wallet transaction 的稳定创建时间)
  • recharge_7d_coins
  • recharge_30d_coins
  • last_recharged_at_ms
  • gift_income_coins

room.v1.RequestMeta.device_id 只由 gateway 从验签 JWT 的 device_id claim 写入;ExternalGiftDrawRequest.device_id 由外部认证调用方承担稳定性责任。V3 对两个入口都不提供 session/command fallback。其余结算字段只来自 wallet owner 的成功回执。request_id 只做链路追踪;抽奖幂等仍使用 command_id

9.2 不可变规则字段

LuckyGiftRuleConfig / lucky_gift_rule_versions 的 V3 字段分组如下:

决策 字段
版本和目标 strategy_version, target_rtp_ppm, settlement_window_wager, control_band_ppm, gift_price_reference
资金拆分 pool_rate_ppm, profit_rate_ppm, anchor_rate_ppm, initial_pool_coins
保底和水位 loss_streak_guarantee, low_watermark_coins, low_water_nonzero_factor_ppm, high_watermark_coins, high_water_nonzero_factor_ppm
最近充值 recharge_boost_window_ms, recharge_boost_factor_ppm
大奖 jackpot_multiplier_ppms, jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm, jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day
六维风控 max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
充值层级 stages[].min_recharge_7d_coins, stages[].min_recharge_30d_coins, stages[].tiers[]

后台沿用现有 /admin/activity/lucky-gifts/v2/config 配置入口URL 中的 v2 是接口兼容标识,不等于 strategy_version。发布时必须把上述字段完整映射到内部 protobuf、domain 和 MySQL不允许只在 HTTP JSON 中临时扩字段。

10. 持久化与审计

V3 事实
lucky_gift_rule_versions fixed_v2/dynamic_v3 不可变版本、资金拆分、水位、大奖门槛和六项上限
lucky_gift_stage_tiers 三层充值门槛、基础倍率和基础概率
lucky_pools 公共池 balance/total_in/total_out,以及 profit_total/anchor_income_total/initial_seed_total
lucky_rtp_windows 全局/奖池窗口的流水、目标返奖、实际返奖和 open/closed/underpaid 状态
lucky_user_rtp_hour_buckets 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时
lucky_user_rtp_boundary_events 只补齐 [now-72h,now) 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw
lucky_user_strategy_days 用户 UTC 日流水、返奖、消费和已命中大奖次数
lucky_user_states 累计抽数、等价流水、loss_streak 和可跨日保留的待消费 token
lucky_risk_counters 用户/设备/房间/主播的 UTC 小时或日累计返奖
lucky_draw_records 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态
external_lucky_gift_draw_items 外部 App gift_count=N 的 N 条顺序子抽事实;外部聚合行只负责幂等响应
lucky_gift_command_locks 内部 app_code+command_id 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实
lucky_gift_outbox 钱包补偿与投递审计,不替代 draw 业务事实

每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、三项 RTP 条件、大奖机制、token 产生/消费/保留、最终原因和阻断原因。

状态边界:

  • reward_status=pending/granted/failed 只表示自动钱包入账状态;抽奖一旦向用户展示就形成不可撤销负债,failed 表示仍欠用户、必须重放或人工补偿,不能反向释放公共池/RTP/风控额度,也不能取消奖励。已 granted 后 IM 失败不能把账务事实改成 failed
  • outbox 使用 pending/retryable/delivering/delivered/failed;失败原因、重试次数和锁到期时间必须可查。
  • 规则不可变发布;事故回放必须读取 draw 当时的 rule_version 和快照,不能套用最新配置重算。

11. 本地模拟矩阵

模拟必须调用与生产相同的纯策略内核,并使用固定 seed 输出人类摘要和 JSON。以下每项都是独立验收不用一个长跑 RTP 数字替代边界测试:

输入矩阵 必须证明
名义 EV 原图 0/5/50/200/1000 概率 26.5x/2650%passes_target=false 是预期成功
资金拆分 0、1、极小金额、普通金额 三份非负且逐笔整数守恒;冷启动只注资一次
原图 P/W P=800, price=10,脚本依次命中 5x、200x、1000x 5x 直付200x 删除自身+01000x 继续删除;最终不透支
P/W 边界 W=P-1/P/P+1、全部正奖不可付 等于可付,大于重抽,候选耗尽回 0P 永不为负
连 0 保底 streak 05第 6 抽资金足/不足 第 6 抽排除 0无可支付正奖时安全回 0并留阻断原因
水位 low-1/low/low+1/high-1/high/high+1 只在 <low 下调、>high 上调,端点为中水位
充值窗口 起点、+299999ms+300000ms、未来时间、无回执 严格 [start,end),只有可信快照生效
因子组合 低/中/高水位 × 充值开/关 因子相乘;概率合计始终 100%
充值分层 各 7/30 日门槛的下 1、等于、上 1 必须同时达到两维,选择最高满足层
批量 N=1、N=99、金额有余数 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致
结算窗口 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 第 1 抽进入旧窗并使流水到 100后 98 抽进入新窗;证明不按抽数提前滚动
大奖机制一 全局/当日/72h 各自通过和失败、分母 0 三门同时通过才有资格,零样本不触发
大奖机制二 门槛前、恰好跨过、一次跨多个门槛、资金不足 token 只影响下一抽,幂等产生,资金不足保留
大奖集合 200x/500x/1000x 分别可付/不可付 只在可付集合随机,不能选中后透支
日 5 次 已中 4 次、5 次UTC 日边界 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数
六维风控 每一维分别取 W=remainingW=remaining+1 等于允许,超过任一维拒绝,原因可审计
组合优先级 token + RTP 补偿 + 连 0 + 低水位 + 充值 token 优先,其后 RTP再普通安全门永远最后裁决
并发/幂等 同 command 重放、同用户并发状态版本 只结算一次;状态无丢失更新,奖池不为负
有资金长跑 固定 seed、足量冷启动资金、各充值层 报告实际 RTP/命中率/池底,不把 95%101%当资金不足时硬断言

11.1 可重复命令

go test ./services/lucky-gift-service/internal/domain/luckygift
go test ./services/wallet-service/internal/storage/mysql -run 'LuckyGiftRechargeSnapshot'
go run ./services/lucky-gift-service/cmd/strategy-sim \
  -seed 20260712 \
  -monte-carlo-draws 100000 \
  -long-run-draws 100000 \
  -group-long-run-draws 30000 \
  -json-out /tmp/lucky-gift-dynamic-v3-simulation.json

策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足

  • nominal_probability_table.expected_multiplier=26.5
  • nominal_probability_table.nominal_rtp_percent=2650
  • nominal_probability_table.passes_target=false
  • all_passed=true
  • 所有场景输出固定 seed、输入、最终奖档/状态、池底和阻断原因,不能只打印“通过”。

12. 上线前验收门

  1. 存量规则空 strategy_version 读取和重放仍为 fixed_v2,且无需补 V3 金额字段。
  2. dynamic_v3 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
  3. 50 美元等值金币、低/高水位、三层充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
  4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 device_id 或 owner paid_at_ms 必须 fail-close。
  5. 本地单测、完整模拟、make protomake test 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。