30 KiB
幸运礼物动态策略 V3:实现边界与模拟验收
1. 版本边界
本文只描述 dynamic_v3 的规则、持久化契约和可重复模拟口径,不改变已经发布的 fixed_v2 规则语义,也不代表该策略已在线上自动启用。
strategy_version=fixed_v2:继续按历史不可变规则执行;旧请求或旧记录缺少strategy_version时必须归一为fixed_v2。strategy_version=dynamic_v3:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。- 新奖池未配置时返回
dynamic_v3的disabled草稿。各 App 必须先补齐普通/高阶充值门槛及六项金额上限,才允许启用;独立的“累计消费达到门槛就获得大奖”机制已经停用,不再是发布必填项,也不能绕过 RTP 资格。 - 启用前还必须确认 user/gateway/room 已升级到由
auth_sessions.device_id签入 access JWT,再通过room.RequestMeta和可恢复 command 原样传递设备作用域。旧 JWT 缺device_id时fixed_v2仍兼容,dynamic_v3必须 fail-close,不得用session_id/command_id伪造设备。 - 启用前还必须确认 user/gateway/room/wallet 已按顺序升级:access JWT 和 room meta 携带 auth session 绑定的可信
device_id,wallet 回执携带交易paid_at_ms、充值快照和gift_income_coins。可信device_id、ownerpaid_at_ms缺失,或真实收礼返币比例与规则anchor_rate_ppm不一致时,整次动态开奖 fail-close;user_registered_at_ms缺失或晚于扣费时间时普通开奖继续,但本轮补偿大奖资格 fail-close。 - 比例、概率和倍率统一使用 ppm,
1_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 滚窗。- 大盘轮次按
app_code + pool_id + rule_version独立结算。大盘在 9:00 重置、10:00 再次重置时,10:00 形成的资格只读取刚关闭的 9:00~10:00 大盘 RTP,以及同一个 9:00~10:00 轮次内该用户实际发生的总流水和总返奖;不能拿用户 UTC 日 RTP 或正在进行的新轮次代替。
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% 静态 EV:0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x。普通概率表可以配置 200x/500x/1000x,也可以与大奖集合使用相同倍率,但这些普通高倍档必须与其他普通档一起纳入静态 EV 和控制带校验,不能因为倍率也出现在大奖集合就从成本计算中扣除。运行时为同倍率建立独立的 multiplier_* 普通 tier 和 jackpot_* 补偿 tier:前者按阶段概率随机,后者普通权重固定为 0,只能通过大奖资格路径命中。
3. 一次抽奖的确定顺序
一次真实抽奖按以下顺序完成;同一事务内锁住的状态必须与 draw record 一起提交:
user-service把auth_sessions.device_id签入 access JWT;gateway 验签后写入 roomRequestMeta,room saga 固化该设备快照,禁止用可轮换的 session 或 command ID 代替。wallet-service完成真实扣费,并在幂等回执中固化coin_spent、交易paid_at_ms、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。lucky-gift-service只消费上述可信事实,不直连钱包库,不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。- 本抽金币按 98/1/1 拆分;公共池份额先计入
P,再比较本抽奖金W。 - 按
app_code + pool_id + rule_version读取不可变规则,锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。 - 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子;0 档接收剩余概率。
- 若用户有一个尚未尝试的已结算轮次,先按“全局
<=98%、用户同轮次<96%、注册满 48 小时、用户滚动 48 小时总 RTP<96%”完整判断资格;否则直接进入普通概率抽奖。 - 有资格时只在用户下一次子抽尝试一次大奖,并对选中的
W执行奖池、单用户日大奖次数和六维风控检查;成功时本抽直接结束,奖池不足或风控阻断时消费该轮资格并回落普通规则。普通路径即使命中同倍率也不是大奖,不增加或受制于日大奖次数。 - 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为
pending/granted/failed。
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
4. 动态概率、分层和保底
4.1 充值分层
V3 固定有 novice/normal/advanced 三层,但两个门槛由每个不可变规则版本配置:
- 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。
- 用户只在同时满足某层的 7 日和 30 日最低充值门槛时才进入该层;不能只满足其中一维就越级。
novice是无条件兜底层,它的0/0只是协议和存储 sentinel,不表示“只有充值为 0 才是新手”。若7d < normal_7d或30d < normal_30d,即使两个充值值都非零,用户仍属于novice。normal启用前至少一维必须为正数;advanced两维均不得低于normal,且至少一维严格更高。disabled 草稿可保留0/0表示尚未配置,但不能以该状态启用。- 阶段阈值相等时算已达到,即使用
>=;两维都等于普通门槛时进入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。普通抽奖执行:
- 第一次按当前动态权重抽档。
W <= P且所有风控均允许时直接支付;W == P可以支付,支付后P=0。- 若
W > P,同时删除该中奖档和 0 档,再对剩余正奖档重抽。 - 重抽仍有
W > P时继续删除该中奖档;0 档只在第一次资金不足时删除一次。 - 若最终没有可支付的正奖档,安全回退 0,不改变
P为负数。
原图 P=800、单价 10 的脚本化示例只用于分支验收:先抽到 200x(W=2000) 时删除 200x+0x;若再抽到 1000x(W=10000),再删除 1000x;随后只能在仍有权重且 W<=800 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。
奖池不足、风险额度不足和补偿大奖次数达到上限是不同的审计原因。只有普通候选的 W>P 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。普通奖档不读取补偿大奖次数,即使倍率相同也仍按普通 P/W 和六维风控执行。
6. 大奖资格:已结算轮次的下一次尝试
大奖集合由 jackpot_multiplier_ppms 配置,默认产品集合为 200x/500x/1000x。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这个等权只决定补偿路径内部如何选大奖,与普通基础概率相互独立。相同倍率可以同时出现在普通概率表和大奖集合,审计分别记录 multiplier_* 与 jackpot_* tier ID,不能用倍率反推命中来源。若产品需要补偿大奖非等权,必须先新增显式权重契约和成本验收,不能借用普通奖档概率猜算。
6.1 RTP、注册年龄和安全门必须同时通过
大盘轮次结算时固化本轮快照;用户下一次送出幸运礼物时,只有以下条件同时通过,才允许从当前可支付大奖集合随机选择:
- 刚关闭的大盘轮次 RTP
<= jackpot_global_rtp_max_ppm,默认 98%;等于 98% 可以继续判断,超过 98% 直接失败,保证平台不因补偿大奖扩大亏损; - 该用户在同一个刚关闭轮次内的 RTP 严格
< jackpot_user_round_rtp_max_ppm,默认严格小于 96%;等于 96% 不通过; - 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长;
closed_at_ms保存触发结算的末笔支付毫秒;汇总使用[closed_at_ms+1ms-48h, closed_at_ms+1ms),因此会纳入这笔结算事实。全部真实消费与全部真实返奖相除后,用户滚动 48 小时总 RTP 严格< jackpot_user_48h_rtp_max_ppm,默认严格小于 96%;- 用户本 UTC 日通过补偿路径命中的大奖总次数
< max_jackpot_hits_per_user_day,默认 5;所有jackpot_*倍率合计计数,multiplier_*普通命中不计数; - 候选
W<=P且不超过六维风控的最小剩余额度。
用户两条 RTP 门是逻辑 AND:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过。
滚动 48 小时必须先汇总总额再相除,不允许对小时 RTP、单次 RTP或若干分段 RTP 做算术平均。例如 48 小时消费 10,000 金币、返奖 9,500 金币,RTP 为 95%。分母为 0 表示没有有效样本,不是 0% RTP,不能获得大奖资格。
6.2 注册时间和“只玩最后 5 分钟”边界
- 注册 47 小时的用户,即使本轮 RTP=95%、已有流水的滚动 RTP=95%,也因账号未满 48 小时而不通过。
- 注册恰满 48 小时即满足成熟度边界;不需要“玩满 48 小时”。
- 用户已注册超过 48 小时,但前 47 小时 55 分钟都没玩、最后 5 分钟消费 100 金币并返奖 95 金币时,最近 48 小时的有效总流水就是 100/95,滚动 RTP=95%。若这 5 分钟也正好构成刚关闭的用户轮次,则两条用户 RTP 门都通过。
- 注册时间必须来自 user owner 的
created_at_ms可信事实。内部链路缺失或恢复记录无法还原时,大奖资格 fail-close;不能用首次送礼时间、请求到达时间或外部自报的活跃时长兜底。
6.3 一轮资格只尝试一次
资格属于一个已关闭的 app_code + pool_id + rule_version + window_index + user_id 轮次,只能用于该用户的下一次子抽:
- 能支付且风控允许:随机命中一个可支付大奖,资格消费;
- 奖池不足、达到日次数上限或任一风控不足:不强发大奖,资格仍消费,本抽继续走普通概率和保底;
- 用户迟迟不送礼:该轮资格可以等到下一次个人送礼再尝试,但不会因后续关闭更多轮次而叠加多个机会;只保留当前规则版本下最新待尝试轮次,旧轮次失效;
- 规则版本改变:旧版本资格不跨版本继承。
独立的累计消费门槛/里程碑 token 机制在新版规则中停用。历史字段只用于旧数据兼容读取,生产 dynamic_v3 不产生、不优先、不保留这类 token,也不能把消费金额作为第三条获奖通道。
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 分钟和滚动 48 小时都不得使用 time.Local、容器时区或客户端时区切分。滚动 48 小时以上一个已关闭轮次的 closed_at_ms 为上界,不能用用户下一次送礼的到达时间漂移窗口。
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、已结算轮次资格、日大奖次数和风控计数;不能并行抽 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 和账号真实 user_registered_at_ms,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist,不能独立证明这些调用方事实,所以接入方必须在自身认证/请求签名边界内把它们绑定到 external_user_id。owner 不得用 request_id、首次送礼时间或请求到达时间兜底注册年龄。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 novice 且不加最近充值因子,不能从 metadata 猜充值额。fixed_v2 外部请求继续保持缺少新事实的历史兼容路径。
9. 接口和配置字段
9.1 事实输入
wallet.v1.DebitGiftResponse 新增以下幂等回执字段:
recharge_seven_day_coinsrecharge_thirty_day_coinslast_recharged_at_mspaid_at_ms
luckygift.v1.LuckyGiftMeta 接收:
device_id(从已验签 access JWT 穿过 room command 恢复链路)user_registered_at_ms(来自 user owner 的账号创建时间,供轮次关闭时判断是否注册满 48 小时)paid_at_ms(逐目标 wallet transaction 的稳定创建时间)recharge_7d_coinsrecharge_30d_coinslast_recharged_at_msgift_income_coins
room.v1.RequestMeta.device_id 只由 gateway 从验签 JWT 的 device_id claim 写入;内部送礼请求的注册时间由 gateway 查询 user owner 后固化到可恢复 room command;ExternalGiftDrawRequest.device_id 和 user_registered_at_ms 由外部认证调用方承担真实性责任。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_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm, max_jackpot_hits_per_user_day;历史 jackpot_spend_threshold_coins 在新版规则中停用 |
| 六维风控 | 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 |
普通/高阶充值门槛、新手兜底 sentinel、基础倍率和基础概率 |
lucky_pools |
公共池 balance/total_in/total_out,以及 profit_total/anchor_income_total/initial_seed_total |
lucky_rtp_windows |
全局/奖池窗口的流水、目标返奖、实际返奖、started_at_ms/closed_at_ms 和 open/closed/underpaid 状态;关闭时间是48小时统计与注册年龄判断的固定锚点 |
lucky_user_rtp_windows |
每个用户在同一大盘 rule_version + window_index 内的总消费/总返奖,以及 pending/attempted/ineligible/expired 资格状态;一轮只尝试一次 |
lucky_user_rtp_hour_buckets |
用户 UTC 小时流水/返奖桶,用于汇总严格滚动 48 小时 |
lucky_user_rtp_boundary_events |
只补齐 [closed_at+1ms-48h,closed_at+1ms) 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
lucky_user_strategy_days |
用户 UTC 日消费和已命中大奖次数;不再作为用户 RTP 资格来源,也不再生成消费里程碑资格 |
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、用户同轮次 RTP、滚动 48 小时总消费/总返奖、注册年龄、一次性资格是否已尝试、最终原因和阻断原因。同倍率双来源必须保存实际 selected_tier_id 和该来源的实际选择权重:普通奖记录调整后的阶段权重,补偿大奖记录大奖集合内权重,不能把补偿 tier 的普通权重 0 写成命中权重。
状态边界:
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 删除自身+0;1000x 继续删除;最终不透支 |
| P/W 边界 | W=P-1/P/P+1、全部正奖不可付 |
等于可付,大于重抽,候选耗尽回 0,P 永不为负 |
| 连 0 保底 | streak 0~5;第 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 抽进入新窗;证明不按抽数提前滚动 |
| 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间;两个用户RTP严格<96%且AND;大盘允许=98%;只玩5分钟按实际总额计算;零样本不触发 |
| 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 |
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次补偿大奖可中,第 6 次补偿大奖被阻断,新 UTC 日重新计数;普通奖不占次数 |
| 同倍率双来源 | 100/200/500/1000x 分别配置普通与补偿路径,并在达到日上限后再抽普通 1000x | 每个倍率的普通命中都不计大奖、补偿命中都计数;补偿达到上限后同倍率普通奖仍可命中,tier ID 和选择权重可区分来源 |
| 六维风控 | 每一维分别取 W=remaining 和 W=remaining+1 |
等于允许,超过任一维拒绝,原因可审计 |
| 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 |
| 并发/幂等 | 同 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
2026-07-15 按上述默认规模生成的完整机器可读报告保存在 幸运礼物动态策略V3模拟结果-20260715.json;旧的 2026-07-12 文件只代表历史 day/72h 规则,不得作为本次 48 小时版本的验收依据。
策略模拟进程只有在全部场景满足断言时退出 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. 上线前验收门
- 存量规则空
strategy_version读取和重放仍为fixed_v2,且无需补 V3 金额字段。 - 新
dynamic_v3启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。 - 低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。累计消费大奖门槛保持停用,不能成为独立大奖通道。
- user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信
device_id或 ownerpaid_at_ms时整次开奖 fail-close,缺失/无效user_registered_at_ms时只关闭补偿大奖资格,普通开奖继续。 - 本地单测、完整模拟、
make proto、make test通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。