feat(lucky-gift): settle jackpot eligibility by round and 48h RTP

This commit is contained in:
zhx 2026-07-15 20:36:32 +08:00
parent 9c1dd0e3a4
commit afce96dc42
29 changed files with 4832 additions and 1968 deletions

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,9 @@ message LuckyGiftMeta {
int64 last_recharged_at_ms = 21;
// gift_income_coins wallet dynamic_v3
int64 gift_income_coins = 22;
// user_registered_at_ms user-service
// dynamic_v3 48 fail-close
int64 user_registered_at_ms = 23;
}
message LuckyGiftRuleTier {
@ -91,8 +94,9 @@ message LuckyGiftRuleConfig {
int64 recharge_boost_factor_ppm = 26;
repeated int64 jackpot_multiplier_ppms = 27;
int64 jackpot_global_rtp_max_ppm = 28;
int64 jackpot_user_day_rtp_max_ppm = 29;
int64 jackpot_user_72h_rtp_max_ppm = 30;
int64 jackpot_user_round_rtp_max_ppm = 29;
int64 jackpot_user_48h_rtp_max_ppm = 30;
// dynamic_v3
int64 jackpot_spend_threshold_coins = 31;
int64 max_jackpot_hits_per_user_day = 32;
int64 max_single_payout = 33;
@ -183,6 +187,8 @@ message ExternalGiftDrawRequest {
string pool_id = 12;
// dynamic_v3 luck-gateway /fixed_v2
string device_id = 13;
// user_registered_at_ms App dynamic_v3 fail-close
int64 user_registered_at_ms = 14;
}
message ExternalGiftDrawResponse {

File diff suppressed because it is too large Load Diff

View File

@ -1163,6 +1163,9 @@ message SendGiftRequest {
// combo_session_id + batch_seq Flutter command_id
string combo_session_id = 19;
int64 batch_seq = 20;
// sender_registered_at_ms gateway user-service
// room-service lucky-gift-service
int64 sender_registered_at_ms = 21;
}
// SendGiftResponse

View File

@ -6,9 +6,9 @@
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐累计消费大奖门槛、普通/高阶充值门槛及六项金额上限,才允许启用。
- 新奖池未配置时返回 `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`任一动态字段缺失或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时运行侧 fail-close。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`可信 `device_id`、owner `paid_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。
@ -38,7 +38,8 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
- 95%101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%101%,禁止为了追 RTP 透支奖池。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,该抽完成后立即关闭并结算当前大盘轮次,下一抽进入新轮次。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
- 大盘轮次按 `app_code + pool_id + rule_version` 独立结算。大盘在 9:00 重置、10:00 再次重置时10:00 形成的资格只读取刚关闭的 9:0010:00 大盘 RTP以及同一个 9:0010:00 轮次内该用户实际发生的总流水和总返奖;不能拿用户 UTC 日 RTP 或正在进行的新轮次代替。
### 2.3 原图概率表不能作为生产默认概率
@ -65,10 +66,10 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
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 状态和六维风控计数。
5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
7. 优先处理已持久化的“消费里程碑大奖 token”其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖
7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。
8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功、奖池不足或风控阻断都会消费该轮资格,本抽随后按普通规则完成,旧资格不能等待补池或重复触发
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
@ -128,31 +129,42 @@ adjusted_weight = base_weight × water_factor × recharge_factor
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。
## 6. 两种大奖机制
## 6. 大奖资格:已结算轮次的下一次尝试
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
### 6.1 机制一RTP 亏损补偿大奖
### 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%
- 刚关闭的大盘轮次 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
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 `[now_ms-72h, now_ms)` 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”
用户两条 RTP 门是逻辑 **AND**:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过
### 6.2 机制二:消费里程碑大奖
滚动 48 小时必须先汇总总额再相除,不允许对小时 RTP、单次 RTP或若干分段 RTP 做算术平均。例如 48 小时消费 10,000 金币、返奖 9,500 金币RTP 为 95%。分母为 0 表示没有有效样本,不是 0% RTP不能获得大奖资格。
- `jackpot_spend_threshold_coins` 是当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
- 下一抽优先消费已有 token若大奖集合因 `P`、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。
- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。
### 6.2 注册时间和“只玩最后 5 分钟”边界
原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token结果仍只影响下一抽但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。
- 注册 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. 六维风控与时间口径
@ -171,20 +183,20 @@ adjusted_weight = base_weight × water_factor × recharge_factor
设备日 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`、容器时区或客户端时区切分。
所有时间范围遵守 `[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、RTP、token、日大奖次数和风控计数;不能并行抽 N 次后再合并。
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager``i+1` 抽必须先滚到新窗口;不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
- 第 `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`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明设备真实性,所以调用方必须在自身认证/请求签名边界内绑定这些值owner 不得用 `external_user_id``request_id` 或请求到达时间兜底。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
外部 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. 接口和配置字段
@ -200,13 +212,14 @@ adjusted_weight = base_weight × water_factor × recharge_factor
`luckygift.v1.LuckyGiftMeta` 接收:
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
- `user_registered_at_ms`(来自 user owner 的账号创建时间,供轮次关闭时判断是否注册满 48 小时)
- `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`
`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 不可变规则字段
@ -218,7 +231,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 资金拆分 | `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` |
| 大奖 | `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[]` |
@ -231,18 +244,19 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| `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` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `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_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 条件、大奖机制、token 产生/消费/保留、最终原因和阻断原因。
每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、来源轮次及关闭时间、大盘 RTP、用户同轮次 RTP、滚动 48 小时总消费/总返奖、注册年龄、一次性资格是否已尝试、最终原因和阻断原因。
状态边界:
@ -267,12 +281,12 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 |
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100后 98 抽进入新窗;证明不按抽数提前滚动 |
| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 |
| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 |
| 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间两个用户RTP严格<96%且AND大盘允许=98%只玩5分钟按实际总额计算零样本不触发 |
| 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 |
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
| 日 5 次 | 已中 4 次、5 次UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
| 六维风控 | 每一维分别取 `W=remaining``W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP再普通安全门永远最后裁决 |
| 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 |
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
| 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%101%当资金不足时硬断言 |
@ -289,6 +303,8 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \
-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`
@ -301,6 +317,6 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \
1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
3. 累计消费大奖门槛、低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 必须 fail-close
3. 低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。累计消费大奖门槛保持停用,不能成为独立大奖通道。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 时整次开奖 fail-close缺失/无效 `user_registered_at_ms` 时只关闭补偿大奖资格,普通开奖继续
5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@
对接 App 负责:
- 调用前完成用户扣费。
- 从自己的账务和用户主数据提供真实的扣费时间、稳定设备标识和账号注册时间。
- 在自己的鉴权或请求签名边界内绑定上述业务事实,不能让 App 或 Web 客户端直接伪造。
- 成功收到结果后,在自己的账务系统中向用户发放 `reward_amount`
- 对超时或网络失败使用原 `request_id` 重试,不得重新生成业务请求 ID。
@ -43,9 +45,19 @@ GET https://lucky-api.global-interaction.com/healthz/ready
1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。
2. 确认该 App 的幸运礼物规则已启用。
3. 对接 App 必须先扣费,再调用抽奖接口。
4. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
4. 如果使用 `dynamic_v3`,对接 App 必须能够提供真实 `paid_at_ms`、稳定 `device_id` 和真实 `user_registered_at_ms`
5. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
当前版本的 HTTP 入口使用 `app_code` 白名单,尚未启用 Authorization Token 或请求签名,因此必须仅由已开通的对接 App 服务端调用。
### 3.1 真实性与信任边界
当前 HTTP 入口只校验 `app_code` 是否在白名单,尚未启用 Authorization Token 或平台侧请求签名。白名单只能决定“这个 App 是否已开通”,不能独立证明某次请求中的用户、设备、扣费时间和注册时间是真实的。
对接 App 必须在自己的服务端边界内完成以下保障:
- `external_user_id` 来自已登录用户,不直接信任客户端自报值。
- `device_id` 与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。
- `paid_at_ms` 由扣费成功的账务事实产生,`user_registered_at_ms` 从用户主数据读取,不使用幸运礼物接口的收包时间或用户首次抽奖时间代替。
- 如果对接 App 自身使用请求签名,签名内容必须覆盖这些字段;重试时必须保持原始业务事实不变。
## 4. 发送幸运礼物
@ -67,8 +79,11 @@ X-Request-Id: trace-20260715-000001
"app_code": "partner_app",
"request_id": "gift-order-20260715-000001",
"external_user_id": "user-10001",
"device_id": "device-installation-7f80d90b",
"gift_count": 3,
"unit_amount": 100,
"paid_at_ms": 1784073590000,
"user_registered_at_ms": 1783814400000,
"currency": "COIN",
"metadata": {
"external_order_id": "order-1000001",
@ -85,8 +100,11 @@ X-Request-Id: trace-20260715-000001
| `app_code` | string | 是 | 去除首尾空格后 132 字符 | 平台分配的 App 编码;按小写存储和校验。 |
| `request_id` | string | 是 | 去除首尾空格后 1128 字符 | 对接 App 生成的业务幂等 ID建议直接使用送礼订单号或 UUID。 |
| `external_user_id` | string | 是 | 去除首尾空格后 1128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
| `device_id` | string | `dynamic_v3` 是 | 去除首尾空格后 1128 字符 | 对接 App 在自己鉴权/签名边界内绑定的稳定设备标识;用于设备级风控,不得每次请求重新生成。 |
| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 |
| `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 |
| `paid_at_ms` | int64 | `dynamic_v3` 是 | `> 0`Unix epoch milliseconds | 对接 App 账务系统完成本次扣费的真实 UTC 时间;系统不会用接口收到请求的时间代替。 |
| `user_registered_at_ms` | int64 | `dynamic_v3` 业务必填 | `> 0`Unix epoch milliseconds | 用户账号的真实创建时间,必须从对接 App 的用户主数据获取。技术上缺失不会阻断普通抽奖,但系统会按不满足注册 48 小时处理,关闭该用户的 RTP 补偿大奖资格。 |
| `currency` | string | 否 | 只支持 `COIN` | 缺省或空字符串时按 `COIN` 处理。 |
| `metadata` | object | 否 | JSON object | 对接方的展示或排查元数据;不参与金额、规则或幂等判定。 |
@ -96,7 +114,9 @@ X-Request-Id: trace-20260715-000001
total_amount = gift_count * unit_amount
```
请求体不得传入 `total_amount``pool_id``paid_at_ms`。奖池由平台规则选择,扣费完成时间由网关按 UTC 服务端时间写入。接口会拒绝所有未定义的顶层字段。
请求体不得传入 `total_amount``pool_id``total_amount` 由系统根据数量和单价计算,奖池由平台已发布的规则选择。接口会拒绝所有未定义的顶层字段。
`dynamic_v3` 下,缺失 `paid_at_ms``device_id` 会使整次抽奖失败;缺失、不合理或尚未满 48 小时的 `user_registered_at_ms` 不阻断普通抽奖,但 RTP 补偿大奖资格会严格按“不符合”处理。`fixed_v2` 为历史接入保留了缺省兼容,对接方不应依赖该兼容,否则规则切换到 `dynamic_v3` 后会失败或丢失大奖资格。
一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。
@ -183,7 +203,7 @@ app_code + request_id
| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 |
| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 |
| 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 |
| 500 | 50000 | 抽奖服务超时或内部处理失败 | 保持原 `request_id` 重试;不得重新扣费。 |
| 500 | 50000 | 抽奖服务超时、内部处理失败,或 `dynamic_v3` 在规则层发现 `paid_at_ms` / `device_id` 缺失 | 先本地校验 V3 业务事实已齐全;事实齐全时保持原 `request_id` 重试,不得重新扣费。 |
业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code``0`
@ -198,8 +218,11 @@ curl --request POST \
"app_code": "partner_app",
"request_id": "gift-order-20260715-000001",
"external_user_id": "user-10001",
"device_id": "device-installation-7f80d90b",
"gift_count": 3,
"unit_amount": 100,
"paid_at_ms": 1784073590000,
"user_registered_at_ms": 1783814400000,
"currency": "COIN",
"metadata": {
"external_order_id": "order-1000001",
@ -211,17 +234,22 @@ curl --request POST \
## 8. 标准处理流程
1. 对接 App 生成全局唯一的 `request_id`
2. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`
3. 对接 App 调用幸运礼物接口。
4. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
5. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`
6. 任何未知结果都使用原 `request_id` 重试;禁止重复扣费或重新生成抽奖业务号。
1. 对接 App 在已鉴权的服务端上确定 `external_user_id`、稳定 `device_id` 和用户主数据中的 `user_registered_at_ms`
2. 对接 App 生成全局唯一的 `request_id`
3. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`,并以扣费成功事实的 UTC 毫秒时间作为 `paid_at_ms`
4. 对接 App 调用幸运礼物接口,一次传入上述业务事实。
5. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
6. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`
7. 任何未知结果都使用原 `request_id` 和原始业务事实重试;禁止重复扣费或重新生成抽奖业务号。
## 9. 联调检查清单
- [ ] `app_code` 已加入幸运礼物网关白名单。
- [ ] 规则已启用,并确认金币单位一致。
- [ ] `dynamic_v3` 请求中的 `paid_at_ms` 来自真实扣费事实,不是当前时间的临时填充值。
- [ ] `device_id` 已绑定已鉴权设备且跨请求稳定,不用请求号或会话 token 代替。
- [ ] `user_registered_at_ms` 来自用户主数据已验证缺失时普通抽奖可继续、RTP 补偿大奖资格会关闭。
- [ ] 调用能力仅保留在对接 App 服务端,上述业务事实已纳入自身鉴权或请求签名边界。
- [ ] 正常送礼可获得 HTTP 200 / `code=0`
- [ ] 重复发送相同 `request_id` 可获得相同 `draw_id` 和结果。
- [ ] 模拟超时后只重试抽奖请求,不重复扣费。

View File

@ -2,6 +2,7 @@ package luckygift
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
@ -32,41 +33,71 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
}
type configRequest struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserDayRTPMaxPercent float64 `json:"jackpot_user_day_rtp_max_percent"`
JackpotUser72hRTPMaxPercent float64 `json:"jackpot_user_72h_rtp_max_percent"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"`
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"`
// Deprecated in-process aliases keep historical handler builders source-compatible. They are not
// accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
JackpotUserDayRTPMaxPercent float64 `json:"-"`
JackpotUser72hRTPMaxPercent float64 `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
}
// configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段,
// 因此输入 DTO 明确接收两代名字configRequest 继续只暴露 canonical JSON保证 GET 和写入响应不会双写字段。
type configUpsertRequest struct {
configRequest
JackpotUserRoundRTPMaxPercent *float64 `json:"jackpot_user_round_rtp_max_percent"`
JackpotUser48hRTPMaxPercent *float64 `json:"jackpot_user_48h_rtp_max_percent"`
LegacyUserDayRTPMaxPercent *float64 `json:"jackpot_user_day_rtp_max_percent"`
LegacyUser72hRTPMaxPercent *float64 `json:"jackpot_user_72h_rtp_max_percent"`
}
func (req configUpsertRequest) canonicalConfig() configRequest {
config := req.configRequest
// canonical 字段优先;指针用于区分“未发送”和显式发送 0避免新旧字段同时存在时旧值覆盖新值。
if req.JackpotUserRoundRTPMaxPercent != nil {
config.JackpotUserRoundRTPMaxPercent = *req.JackpotUserRoundRTPMaxPercent
} else if req.LegacyUserDayRTPMaxPercent != nil {
config.JackpotUserRoundRTPMaxPercent = *req.LegacyUserDayRTPMaxPercent
}
if req.JackpotUser48hRTPMaxPercent != nil {
config.JackpotUser48hRTPMaxPercent = *req.JackpotUser48hRTPMaxPercent
} else if req.LegacyUser72hRTPMaxPercent != nil {
config.JackpotUser48hRTPMaxPercent = *req.LegacyUser72hRTPMaxPercent
}
return config
}
type configDTO struct {
@ -189,11 +220,15 @@ func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
}
func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
var input configUpsertRequest
decoder := json.NewDecoder(c.Request.Body)
// 兼容字段必须进入显式 DTO不能退回 map 或宽松二次反序列化,否则拼错的配置字段会被静默忽略。
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
response.BadRequest(c, "幸运礼物配置参数不正确")
return
}
req := input.canonicalConfig()
if req.PoolID == "" {
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
}
@ -443,6 +478,14 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
Tiers: tiers,
})
}
roundRTPMaxPercent := req.JackpotUserRoundRTPMaxPercent
if roundRTPMaxPercent == 0 {
roundRTPMaxPercent = req.JackpotUserDayRTPMaxPercent
}
rolling48hRTPMaxPercent := req.JackpotUser48hRTPMaxPercent
if rolling48hRTPMaxPercent == 0 {
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
}
return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID),
@ -468,8 +511,8 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotUserDayRtpMaxPpm: percentToPPM(req.JackpotUserDayRTPMaxPercent),
JackpotUser_72HRtpMaxPpm: percentToPPM(req.JackpotUser72hRTPMaxPercent),
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
MaxSinglePayout: req.MaxSinglePayout,
@ -541,8 +584,10 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserDayRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_72HRtpMaxPpm()),
JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),

View File

@ -15,12 +15,15 @@ type SendCommand struct {
RequestID string
ExternalUserID string
DeviceID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
MetadataJSON string
PaidAtMS int64
// UserRegisteredAtMS 是外部 App 用户 owner 提供的账号创建事实integration 层只原样透传,
// 不用网关收包时间或该用户首次请求时间填充,否则会错误改变完整历史窗口资格。
UserRegisteredAtMS int64
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
MetadataJSON string
PaidAtMS int64
}
type SendResult struct {
@ -58,16 +61,17 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe
SentAtMs: time.Now().UTC().UnixMilli(),
AppCode: cmd.AppCode,
},
AppCode: cmd.AppCode,
ExternalUserId: cmd.ExternalUserID,
DeviceId: cmd.DeviceID,
RequestId: cmd.RequestID,
GiftCount: cmd.GiftCount,
UnitAmount: cmd.UnitAmount,
TotalAmount: cmd.TotalAmount,
Currency: cmd.Currency,
PaidAtMs: cmd.PaidAtMS,
MetadataJson: cmd.MetadataJSON,
AppCode: cmd.AppCode,
ExternalUserId: cmd.ExternalUserID,
DeviceId: cmd.DeviceID,
UserRegisteredAtMs: cmd.UserRegisteredAtMS,
RequestId: cmd.RequestID,
GiftCount: cmd.GiftCount,
UnitAmount: cmd.UnitAmount,
TotalAmount: cmd.TotalAmount,
Currency: cmd.Currency,
PaidAtMs: cmd.PaidAtMS,
MetadataJson: cmd.MetadataJSON,
})
if err != nil {
return SendResult{}, err

View File

@ -35,11 +35,14 @@ type sendRequest struct {
DeviceID string `json:"device_id"`
// PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms调用方必须把它纳入自己的认证/签名边界。
// gateway 不用到达时间替代。fixed_v2 允许 0 兼容dynamic_v3 由 owner 读取规则后强制要求正值。
PaidAtMS int64 `json:"paid_at_ms"`
GiftCount int64 `json:"gift_count"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Metadata map[string]any `json:"metadata"`
PaidAtMS int64 `json:"paid_at_ms"`
// UserRegisteredAtMS 是外部 App 用户主数据中的账号创建时间,不是本次请求首次出现时间。
// fixed_v2 允许 0 兼容旧接入dynamic_v3 缺失时由 owner 让补偿大奖资格 fail-close。
UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
GiftCount int64 `json:"gift_count"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Metadata map[string]any `json:"metadata"`
}
func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
@ -64,16 +67,17 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), h.requestTimeout)
defer cancel()
result, err := h.client.SendLuckyGift(ctx, luckygiftclient.SendCommand{
TraceRequestID: requestID,
AppCode: appCode,
RequestID: strings.TrimSpace(req.RequestID),
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
DeviceID: strings.TrimSpace(req.DeviceID),
GiftCount: req.GiftCount,
UnitAmount: req.UnitAmount,
TotalAmount: req.GiftCount * req.UnitAmount,
Currency: strings.TrimSpace(req.Currency),
MetadataJSON: metadataJSON(req.Metadata),
TraceRequestID: requestID,
AppCode: appCode,
RequestID: strings.TrimSpace(req.RequestID),
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
DeviceID: strings.TrimSpace(req.DeviceID),
UserRegisteredAtMS: req.UserRegisteredAtMS,
GiftCount: req.GiftCount,
UnitAmount: req.UnitAmount,
TotalAmount: req.GiftCount * req.UnitAmount,
Currency: strings.TrimSpace(req.Currency),
MetadataJSON: metadataJSON(req.Metadata),
// pool 仍由 owner 默认规则选择paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
PaidAtMS: req.PaidAtMS,
})
@ -109,8 +113,11 @@ func (r sendRequest) validate() error {
if r.PaidAtMS < 0 {
return errors.New("paid_at_ms is invalid")
}
// 不在这里统一要求 device_id网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。
// owner 读到规则后对 dynamic_v3 权威拒绝空值fixed_v2 继续兼容旧调用方。
if r.UserRegisteredAtMS < 0 {
return errors.New("user_registered_at_ms is invalid")
}
// 不在这里统一要求 device_id/user_registered_at_ms网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。
// owner 读到规则后对 dynamic_v3 权威处理缺失事实fixed_v2 继续兼容旧调用方。
if r.GiftCount <= 0 || r.UnitAmount <= 0 {
return errors.New("gift_count and unit_amount must be positive")
}

View File

@ -2030,13 +2030,16 @@ func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Reque
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, roomID, commandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
PoolId: body.PoolID,
Meta: httpkit.RoomMeta(request, roomID, commandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
PoolId: body.PoolID,
// 注册时间属于 user-service 主数据快照;只允许 gateway 从已鉴权 sender 资料注入,
// room/lucky-gift 后续据此判断账号是否已跨过完整历史窗口,不能信任客户端自报。
SenderRegisteredAtMs: userSnapshots.senderRegisteredAtMS,
SenderCountryId: userSnapshots.senderCountryID,
SenderRegionId: userSnapshots.senderRegionID,
TargetIsHost: firstTargetScope.GetTargetIsHost(),
@ -2126,6 +2129,7 @@ func (h *Handler) resolveRoomUserDisplayProfileBestEffort(request *http.Request,
}
type giftUserSnapshots struct {
senderRegisteredAtMS int64
senderCountryID int64
senderRegionID int64
senderDisplayProfile *roomv1.SendGiftDisplayProfile
@ -2161,6 +2165,9 @@ func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs
}
}
return giftUserSnapshots{
// created_at_ms 是账号 owner 返回的稳定事实;即使同一 command_id 重试时资料发生变化,
// room-service 也会恢复首次持久化的命令快照,避免补偿大奖资格随重试漂移。
senderRegisteredAtMS: sender.GetCreatedAtMs(),
senderCountryID: sender.GetCountryId(),
senderRegionID: sender.GetRegionId(),
senderDisplayProfile: giftDisplayProfileFromUser(sender),

View File

@ -95,8 +95,8 @@ func runSimulation(options simulationOptions) simulationReport {
simulateRechargeWindowBoundaries(config),
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN),
simulateMechanismOneTruthTable(config, options.Seed+30),
simulateMechanismTwo(config, options.Seed+40),
simulateSettledRoundQualification(config, options.Seed+30),
simulatePoolBlockedQualificationConsumption(config, options.Seed+40),
simulateJackpotSet(config, options.Seed+50),
simulateDailyJackpotLimit(config, options.Seed+60),
simulateSixRiskCapacities(config),

View File

@ -354,108 +354,154 @@ func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) dom
return domain.StrategyWeightTrace{}
}
type mechanismOneRow struct {
GlobalPass bool `json:"global_pass"`
UserDayPass bool `json:"user_day_pass"`
User72HourPass bool `json:"user_72h_pass"`
Triggered bool `json:"triggered"`
SelectedTier string `json:"selected_tier"`
type settledRoundQualificationRow struct {
Case string `json:"case"`
RegisteredAgeHours int64 `json:"registered_age_hours"`
RoundWagerCoins int64 `json:"round_wager_coins"`
RoundPayoutCoins int64 `json:"round_payout_coins"`
RollingWagerCoins int64 `json:"rolling_48h_wager_coins"`
RollingPayoutCoins int64 `json:"rolling_48h_payout_coins"`
GlobalWagerCoins int64 `json:"global_wager_coins"`
GlobalPayoutCoins int64 `json:"global_payout_coins"`
Wanted bool `json:"wanted"`
Triggered bool `json:"triggered"`
SelectedTier string `json:"selected_tier"`
}
func simulateMechanismOneTruthTable(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "大奖机制1真值表"
config = baseProbabilityConfig(config)
config.JackpotMechanism1Enabled = true
config.DailyJackpotLimit = 5
rows := make([]mechanismOneRow, 0, 8)
func simulateSettledRoundQualification(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "已结算轮次+注册48小时大奖资格"
config = settledRoundJackpotConfig(config)
const (
hourMS = int64(60 * 60 * 1000)
closedAtMS = int64(100 * 60 * 60 * 1000)
)
base := qualifiedSettledRoundState(20_000, closedAtMS)
cases := []struct {
name string
state domain.StrategyState
want bool
}{
{name: "注册47小时即使两个用户RTP都是95%也失败", state: withRegisteredAge(base, 47*hourMS), want: false},
{name: "注册恰满48小时两个用户RTP都是95%通过", state: withRegisteredAge(base, 48*hourMS), want: true},
{name: "注册已满48小时且只在最后5分钟玩48小时总RTP就是这5分钟的95%", state: withLastFiveMinuteHistory(withRegisteredAge(base, 72*hourMS)), want: true},
{name: "已结算用户轮次RTP恰好96%失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false},
{name: "滚动48小时总RTP恰好96%失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false},
{name: "已结算大盘RTP恰好98%仍通过", state: withRegisteredAge(base, 48*hourMS), want: true},
{name: "已结算大盘RTP超过98%失败", state: withGlobalRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_801), want: false},
{name: "仅用户轮次门失败48小时门通过仍失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false},
{name: "仅48小时门失败用户轮次门通过仍失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false},
{name: "用户轮次没有消费样本失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false},
{name: "滚动48小时没有消费样本失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false},
}
rows := make([]settledRoundQualificationRow, 0, len(cases))
passed := true
rowIndex := int64(0)
for mask := 0; mask < 8; mask++ {
globalPass := mask&1 != 0
dayPass := mask&2 != 0
hour72Pass := mask&4 != 0
state := domain.StrategyState{
PoolBalanceCoins: 20_000,
GlobalRTP: rtpForTruth(globalPass, 98),
UserDayRTP: rtpForTruth(dayPass, 96),
User72HourRTP: rtpForTruth(hour72Pass, 96),
}
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+rowIndex))
for index, item := range cases {
decision, err := domain.DecideLuckyGiftStrategy(config, item.state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
if err != nil {
return failedScenario(name, err)
}
triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation
want := globalPass && dayPass && hour72Pass
passed = passed && triggered == want
rows = append(rows, mechanismOneRow{GlobalPass: globalPass, UserDayPass: dayPass, User72HourPass: hour72Pass, Triggered: triggered, SelectedTier: decision.SelectedTier.ID})
rowIndex++
passed = passed && triggered == item.want
rows = append(rows, settledRoundQualificationRow{
Case: item.name, RegisteredAgeHours: (item.state.QualificationClosedAtMS - item.state.UserRegisteredAtMS) / hourMS,
RoundWagerCoins: item.state.UserRoundRTP.WagerCoins, RoundPayoutCoins: item.state.UserRoundRTP.PayoutCoins,
RollingWagerCoins: item.state.User48HourRTP.WagerCoins, RollingPayoutCoins: item.state.User48HourRTP.PayoutCoins,
GlobalWagerCoins: item.state.GlobalRTP.WagerCoins, GlobalPayoutCoins: item.state.GlobalRTP.PayoutCoins,
Wanted: item.want, Triggered: triggered, SelectedTier: decision.SelectedTier.ID,
})
}
// The denominator-zero row is separate from boolean “ratio above threshold” so
// the report proves an empty window never qualifies as an apparent 0% RTP.
zeroState := domain.StrategyState{
PoolBalanceCoins: 20_000, GlobalRTP: domain.StrategyRTP{},
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
zeroDecision, err := domain.DecideLuckyGiftStrategy(config, zeroState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+99))
if err != nil {
return failedScenario(name, err)
}
zeroBlocked := zeroDecision.JackpotMechanism != domain.StrategyJackpotMechanismRTPCompensation
passed = passed && zeroBlocked
return scenarioResult{
Name: name, Passed: passed,
Summary: "global<=98%、用户日<=96%、用户72h<=96%三项全真才触发任一分母为0明确阻断",
Data: map[string]any{"truth_table": rows, "denominator_zero_blocked": zeroBlocked, "denominator_zero_conditions": zeroDecision.Trace.Conditions},
Summary: "注册<48h失败恰48h及只玩最后5分钟按实际总流水判断用户轮次/48h严格<96%,大盘允许=98%,超限或零样本失败",
Data: rows,
}
}
func rtpForTruth(pass bool, limitPercent int64) domain.StrategyRTP {
payout := limitPercent
if !pass {
payout++
}
return domain.StrategyRTP{WagerCoins: 100, PayoutCoins: payout}
}
func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "机制2里程碑/池不足"
func settledRoundJackpotConfig(config domain.StrategyConfig) domain.StrategyConfig {
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
// 模拟只用一个便于验证跨档的金币值;生产值必须来自当前不可变规则版本。
config.MilestoneSpendCoins = 50
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = false
config.MilestoneSpendCoins = 0
config.SettledRoundRTPEligibility = true
config.GlobalRTPMaxPPM = 980_000
config.UserRoundRTPMaxPPM = 960_000
config.User48HourRTPMaxPPM = 960_000
config.DailyJackpotLimit = 5
input := domain.StrategyInput{GiftPriceCoins: 10}
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, domain.NewSeededStrategyRandom(seed))
return config
}
func qualifiedSettledRoundState(pool, closedAtMS int64) domain.StrategyState {
return domain.StrategyState{
PoolBalanceCoins: pool, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS,
UserRegisteredAtMS: closedAtMS - 48*60*60*1000,
GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800},
UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
}
}
func withRegisteredAge(state domain.StrategyState, ageMS int64) domain.StrategyState {
state.UserRegisteredAtMS = state.QualificationClosedAtMS - ageMS
return state
}
func withLastFiveMinuteHistory(state domain.StrategyState) domain.StrategyState {
// 前47小时55分钟没有送礼不会制造“空白样本”滚动48小时的总分子/分母就是最后5分钟实际发生的95/100。
state.UserRoundRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95}
state.User48HourRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95}
return state
}
func withUserRoundRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState {
state.UserRoundRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}
return state
}
func withUser48HourRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState {
state.User48HourRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}
return state
}
func withGlobalRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState {
state.GlobalRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}
return state
}
func simulatePoolBlockedQualificationConsumption(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "池不足仍消费已结算轮次资格"
config = settledRoundJackpotConfig(config)
// 普通档固定为0隔离出“大奖不可支付后只消费一次资格”的状态变化。
config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true},
{ID: "200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
}
state := qualifiedSettledRoundState(49, 100*60*60*1000)
first, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed))
if err != nil {
return failedScenario(name, err)
}
payableState := blocked.NextState
payableState.PoolBalanceCoins = 20_000
paid, err := domain.DecideLuckyGiftStrategy(config, payableState, input, domain.NewSeededStrategyRandom(seed+1))
afterTopUp := first.NextState
afterTopUp.PoolBalanceCoins = 20_000
second, err := domain.DecideLuckyGiftStrategy(config, afterTopUp, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1))
if err != nil {
return failedScenario(name, err)
}
crossed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}, input, domain.NewSeededStrategyRandom(seed+2))
if err != nil {
return failedScenario(name, err)
}
passed := blocked.NextState.PendingMilestoneTokens == 1 && blocked.Trace.MilestoneTokenRetained && paid.ConsumedMilestoneToken && paid.NextState.PendingMilestoneTokens == 0 && crossed.NextState.PendingMilestoneTokens == 1 && !crossed.ConsumedMilestoneToken
passed := !first.Jackpot && !first.NextState.SettledRoundPending && !second.Jackpot && second.JackpotMechanism == ""
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("49池余额时资格保留=%v补池后命中%s并消费40→50本抽新增token=%d供下一抽", blocked.Trace.MilestoneTokenRetained, paid.SelectedTier.ID, crossed.Trace.MilestoneTokensEarned),
Data: map[string]any{"milestone_crossings_49_to_101": domain.MilestoneTokensEarned(49, 101, 50), "blocked": blocked, "paid": paid, "earned_for_next_draw": crossed},
Summary: fmt.Sprintf("首抽池=%d不足支付200x资格尝试后pending=%v补池到20000再抽也不会复用旧资格", state.PoolBalanceCoins, first.NextState.SettledRoundPending),
Data: map[string]any{"pool_blocked_attempt": first, "after_top_up_without_new_closed_round": second},
}
}
func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "大奖集合"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
config = settledRoundJackpotConfig(config)
all := map[string]int{}
for index := int64(0); index < 120; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 10_000, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index))
decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(10_000, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index))
if err != nil {
return failedScenario(name, err)
}
@ -463,7 +509,7 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult
}
limited := map[string]int{}
for index := int64(0); index < 40; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 4_999, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index))
decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(4_999, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index))
if err != nil {
return failedScenario(name, err)
}
@ -482,12 +528,12 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult
func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "日5次上限"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := domain.StrategyState{PoolBalanceCoins: 1_000_000}
config = settledRoundJackpotConfig(config)
state := qualifiedSettledRoundState(1_000_000, 100*60*60*1000)
for index := 0; index < 5; index++ {
state.PendingMilestoneTokens = 1
// 每次循环代表又结算了一个新的大盘轮次;同一轮资格在完成一次尝试后不会自动续期。
state.SettledRoundPending = true
state.QualificationClosedAtMS += 60 * 60 * 1000
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
if err != nil {
return failedScenario(name, err)
@ -497,17 +543,18 @@ func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenari
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d次未按资格出大奖", index+1)}
}
}
state.PendingMilestoneTokens = 1
state.SettledRoundPending = true
state.QualificationClosedAtMS += 60 * 60 * 1000
// Force ordinary 200x after the special path is blocked, then force 0x on the
// retry. This proves the daily ceiling also constrains accidental base jackpots.
sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}})
if err != nil {
return failedScenario(name, err)
}
passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && sixth.NextState.PendingMilestoneTokens == 1
passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && !sixth.NextState.SettledRoundPending
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("前5次大奖计数=%d第6次特殊与基础大奖均被硬阻断且资格保留", state.UserDailyJackpotWins),
Summary: fmt.Sprintf("5个独立已结算轮次命中后计数=%d第6个轮次受日上限阻断且该轮资格仍按一次尝试消费", state.UserDailyJackpotWins),
Data: map[string]any{"sixth": sixth},
}
}
@ -579,22 +626,14 @@ func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "组合优先级"
config = baseProbabilityConfig(config)
config = settledRoundJackpotConfig(config)
config.LowWaterThresholdCoins = 20_000
config.HighWaterThresholdCoins = 30_000
config.LowWaterFactorPPM = 700_000
config.HighWaterFactorPPM = 1_300_000
config.RechargeFactorPPM = 1_100_000
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
config.MilestoneSpendCoins = 50
state := domain.StrategyState{
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
GlobalRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
state := qualifiedSettledRoundState(10_000, 100*60*60*1000)
state.ConsecutiveZeroDraws = 5
input := domain.StrategyInput{
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
@ -603,9 +642,8 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
if err != nil {
return failedScenario(name, err)
}
// When both jackpot mechanisms are eligible, the persisted token is contractual
// state and therefore wins before RTP compensation or sixth-draw protection.
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && priority.ConsumedMilestoneToken && priority.Stage == domain.StageAdvanced
// 已结算轮次资格在普通概率、第6抽保底和动态因子之前尝试独立消费里程碑机制已经停用。
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.Stage == domain.StageAdvanced
blockedState := state
blockedState.PoolBalanceCoins = 50
@ -613,12 +651,11 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
if err != nil {
return failedScenario(name, err)
}
// Neither special mechanism may overdraw the pool. The token survives and the
// ordinary sixth-draw protection may pay only the affordable 5x boundary.
blockedPass := blocked.Trace.MilestoneTokenRetained && blocked.NextState.PendingMilestoneTokens == 1 && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
// 大奖资格不能透支奖池该轮资格被消费后同一抽仍可按普通第6抽保底支付恰好可负担的5x。
blockedPass := !blocked.NextState.SettledRoundPending && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
return scenarioResult{
Name: name, Passed: priorityPass && blockedPass,
Summary: fmt.Sprintf("优先级=持久token>%s>普通P/W/第6抽特殊档池不足时资格保留并仅赔可支付%s", domain.StrategyJackpotMechanismRTPCompensation, blocked.SelectedTier.ID),
Summary: fmt.Sprintf("优先级=已结算轮次资格>普通P/W/第6抽大奖池不足时资格消费本抽仅赔可支付%s", blocked.SelectedTier.ID),
Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
}
}

View File

@ -101,8 +101,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子ppm',
jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT '动态大奖倍率列表1x=1000000',
jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖全局 RTP 上限ppm',
jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户当日 RTP 上限ppm',
jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 72 小时 RTP 上限ppm',
jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户已结算轮次 RTP 严格上限ppm',
jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 48 小时 RTP 严格上限ppm',
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本动态配置的用户 UTC 日累计消费大奖门槛金币',
max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限',
max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限',
@ -145,12 +145,37 @@ CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
actual_base_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口实际基础返奖',
carry_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '整数除法余数',
status VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT 'open/closed/underpaid',
started_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口首笔扣费事实时间UTC epoch ms0 表示历史窗口未知',
last_paid_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口末笔扣费事实时间UTC epoch ms0 表示历史窗口未知',
closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '达到结算流水并冻结资格的时间UTC epoch ms未关闭或历史未知为 0',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id, window_index),
KEY idx_lucky_rtp_windows_open (app_code, scope_type, scope_id, status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 RTP 控制窗口';
-- 每个用户在每个全局结算轮次只保留一行聚合与一次性资格状态。滚动 48 小时合计在闭窗时冻结,
-- 后续同毫秒子抽或延迟使用资格都读取该快照,不会被闭窗后的新事实反向污染。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本;跨版本资格不能继承',
window_index BIGINT NOT NULL COMMENT '对应全局 RTP 结算窗口序号',
user_id BIGINT NOT NULL COMMENT '用户 ID',
user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总消耗金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总返奖金币',
rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总消耗快照',
rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总返奖快照',
qualification_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/attempted/ineligible/expired',
attempted_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消费本轮资格的下一次子抽 ID未尝试为空',
evaluated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本轮资格判定或消费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, rule_version, window_index, user_id),
KEY idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按全局结算轮次聚合的 RTP 与一次性大奖资格';
CREATE TABLE IF NOT EXISTS lucky_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'pool',
@ -214,10 +239,11 @@ CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
KEY idx_lucky_user_rtp_hour_retention (app_code, bucket_hour_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动72小时RTP完整小时聚合桶';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动48小时RTP完整小时聚合桶';
-- 小时桶负责完整小时,边界事件只用于精确补齐 [now-72h,now) 两端不足一小时的片段。
-- 这样热路径最多扫描 71 个小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
-- 小时桶负责完整小时closed_at_ms 保存实际末笔支付毫秒,查询用 [closed_at+1ms-48h,closed_at+1ms)
-- 纳入触发结算的末笔事实,边界事件只补齐两端不足一小时的片段。
-- 这样闭窗热路径最多扫描 47 个完整小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
@ -229,7 +255,7 @@ CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, draw_id),
KEY idx_lucky_user_rtp_boundary_range (app_code, pool_id, user_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动72小时RTP边界事件';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动48小时RTP边界事件';
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',

View File

@ -0,0 +1,103 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 性能边界:
-- 1. 规则版本表只保存低频发布的不可变配置,两个 CHANGE 仅改列名/注释且保持 BIGINT/NOT NULL/DEFAULT 不变;
-- 显式要求 ALGORITHM=INSTANT若目标 MySQL 不支持即时 DDL 就直接失败,禁止静默退化为整表 COPY。
-- 只有旧、新列异常并存时才扫描这张小配置表把旧非零值补到新列,再即时删除旧列;不会扫描任何抽奖事实。
-- 2. lucky_rtp_windows 是热状态表。三个常量默认列分别使用即时 DDL不回写历史行每条 ALTER 仍需短暂 MDL
-- 上线前应确认没有长事务占用该表,否则迁移会等待元数据锁,但不会扫描 lucky_draw_records 等明细大表。
-- 3. lucky_user_rtp_windows 是全新空表,主键与待资格索引随建表一次完成;迁移不做历史资格回填,避免全表聚合。
-- 旧库保留 day/72h 列,新库可能已由 initdb 创建 round/48h 列;逐列探测保证两种环境和重复执行都安全。
-- CHANGE 保持列类型、空值属性与默认值完全不变,只把唯一业务口径改成“已结算轮次”和“滚动 48 小时”。
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm');
SET @ddl := CASE
WHEN @has_old_column > 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions CHANGE COLUMN jackpot_user_day_rtp_max_ppm jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限ppm'', ALGORITHM=INSTANT'
WHEN @has_old_column = 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限ppm'', ALGORITHM=INSTANT'
ELSE 'SELECT 1'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 历史 004 可能在全量重放时再次补出旧列。若旧、新列同时存在,以已发布的新列为准,仅在新值为 0 时承接旧非零值,
-- 随后删除旧列,保证仓储永远只面对一个口径;规则版本表数据量是发布次数级,不是送礼流水级。
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm');
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_round_rtp_max_ppm = jackpot_user_day_rtp_max_ppm WHERE jackpot_user_round_rtp_max_ppm = 0 AND jackpot_user_day_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'ALTER TABLE lucky_gift_rule_versions DROP COLUMN jackpot_user_day_rtp_max_ppm, ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_48h_rtp_max_ppm');
SET @ddl := CASE
WHEN @has_old_column > 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions CHANGE COLUMN jackpot_user_72h_rtp_max_ppm jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限ppm'', ALGORITHM=INSTANT'
WHEN @has_old_column = 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限ppm'', ALGORITHM=INSTANT'
ELSE 'SELECT 1'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_48h_rtp_max_ppm');
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_48h_rtp_max_ppm = jackpot_user_72h_rtp_max_ppm WHERE jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'ALTER TABLE lucky_gift_rule_versions DROP COLUMN jackpot_user_72h_rtp_max_ppm, ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 历史窗口无法可靠推导首笔、末笔和闭窗业务时间,因此默认 0 明确表示 unknown新运行逻辑只对新窗口写真实 paid_at。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'started_at_ms') = 0,
'ALTER TABLE lucky_rtp_windows ADD COLUMN started_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''本窗口首笔扣费事实时间UTC epoch ms0 表示历史窗口未知'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'last_paid_at_ms') = 0,
'ALTER TABLE lucky_rtp_windows ADD COLUMN last_paid_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''本窗口末笔扣费事实时间UTC epoch ms0 表示历史窗口未知'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'closed_at_ms') = 0,
'ALTER TABLE lucky_rtp_windows ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''达到结算流水并冻结资格的时间UTC epoch ms未关闭或历史未知为 0'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 用户轮次行与全局窗口同版本、同 window_index 对齐。滚动 48 小时分子/分母在闭窗时冻结,
-- qualification_status 只允许业务层在 pending/attempted/ineligible/expired 间单向推进,资格不会跨规则版本复用。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本;跨版本资格不能继承',
window_index BIGINT NOT NULL COMMENT '对应全局 RTP 结算窗口序号',
user_id BIGINT NOT NULL COMMENT '用户 ID',
user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总消耗金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总返奖金币',
rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总消耗快照',
rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总返奖快照',
qualification_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/attempted/ineligible/expired',
attempted_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消费本轮资格的下一次子抽 ID未尝试为空',
evaluated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本轮资格判定或消费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, rule_version, window_index, user_id),
KEY idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按全局结算轮次聚合的 RTP 与一次性大奖资格';
-- 兼容极少数曾提前创建初版用户轮次表的环境;正式 CREATE 已包含这些列,正常路径均为空操作。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'user_registered_at_ms') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD COLUMN user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'rolling_48h_wager_coins') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD COLUMN rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''闭窗时最近 48 小时总消耗快照'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'rolling_48h_payout_coins') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD COLUMN rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''闭窗时最近 48 小时总返奖快照'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND INDEX_NAME = 'idx_lucky_user_rtp_windows_pending') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD INDEX idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -57,27 +57,34 @@ type Config struct {
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
ControlBandPPM int64 `json:"control_band_ppm"`
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
// Deprecated aliases only keep in-process callers built against the previous draft source-compatible.
// Persistence, protobuf and runtime decisions use the round/48h fields above; these aliases never create a second rule meaning.
JackpotUserDayRTPMaxPPM int64 `json:"-"`
JackpotUser72hRTPMaxPPM int64 `json:"-"`
// SettledRoundRTPEligibility marks configs received through the round/48h contract.
// It is transport metadata only: persistence stores the two canonical limits and reconstructs this marker on read.
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
@ -117,35 +124,39 @@ type RuleStage struct {
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
// 比例和倍率统一使用 ppm后台百分比/倍率业务单位只在发布入口转换。
type RuleConfig struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
// Deprecated aliases are normalized at service/repository boundaries and are never persisted as separate values.
JackpotUserDayRTPMaxPPM int64 `json:"-"`
JackpotUser72hRTPMaxPPM int64 `json:"-"`
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
@ -165,23 +176,26 @@ type CheckCommand struct {
}
type DrawCommand struct {
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
// UserRegisteredAtMS is the user owner snapshot captured by gateway at the original gift request.
// Zero is tolerated for an old recovered command but makes the 48-hour jackpot maturity gate fail closed.
UserRegisteredAtMS int64
VisibleRegionID int64
CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string
SenderAvatar string
@ -334,15 +348,18 @@ type ExternalDrawCommand struct {
AppCode string
ExternalUserID string
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识dynamic_v3 必填。
DeviceID string
RequestID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
PaidAtMS int64
MetadataJSON string
PoolID string
DeviceID string
RequestID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
PaidAtMS int64
// UserRegisteredAtMS is supplied by the external App's authenticated user-data boundary.
// Missing or inconsistent facts do not block an ordinary draw, but can never mature a compensation jackpot.
UserRegisteredAtMS int64
MetadataJSON string
PoolID string
}
// ExternalDrawResult 是返回给外部 App 的最终抽奖结果。

View File

@ -35,11 +35,16 @@ const (
StrategyRemovalMissProtection = "miss_protection_excludes_zero"
StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero"
StrategyConditionGlobalRTP = "global_rtp"
StrategyConditionUserDayRTP = "user_day_rtp"
StrategyConditionUser72HourRTP = "user_72h_rtp"
StrategyConditionDailyJackpotLimit = "daily_jackpot_limit"
StrategyConditionPayableJackpot = "payable_jackpot_candidate"
StrategyDefaultZeroTierID = "0x"
StrategyConditionSettledRound = "settled_round_qualification"
StrategyConditionUserRoundRTP = "user_round_rtp"
StrategyConditionUser48HourRTP = "user_48h_rtp"
StrategyConditionUser48HourMaturity = "user_registered_48h"
// Deprecated trace names remain available to the pure legacy compatibility path only.
StrategyConditionUserDayRTP = "user_day_rtp"
StrategyConditionUser72HourRTP = "user_72h_rtp"
StrategyConditionDailyJackpotLimit = "daily_jackpot_limit"
StrategyConditionPayableJackpot = "payable_jackpot_candidate"
StrategyDefaultZeroTierID = "0x"
)
var (
@ -122,8 +127,14 @@ type StrategyConfig struct {
JackpotMechanism1Enabled bool `json:"jackpot_mechanism_1_enabled"`
JackpotMechanism2Enabled bool `json:"jackpot_mechanism_2_enabled"`
GlobalRTPMaxPPM int64 `json:"global_rtp_max_ppm"`
UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"`
User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"`
UserRoundRTPMaxPPM int64 `json:"user_round_rtp_max_ppm"`
User48HourRTPMaxPPM int64 `json:"user_48h_rtp_max_ppm"`
// SettledRoundRTPEligibility switches production V3 to the closed-window qualification flow.
// False preserves the isolated legacy kernel API used by fixed historical simulations; runtime V3 always sets true.
SettledRoundRTPEligibility bool `json:"settled_round_rtp_eligibility"`
// Deprecated aliases are read only by the legacy kernel branch.
UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"`
User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"`
RechargeStages []StrategyRechargeStage `json:"recharge_stages"`
}
@ -140,15 +151,21 @@ type StrategyRTP struct {
// must update it atomically with the draw record; Version is provided for CAS/optimistic
// locking but the kernel itself intentionally performs no I/O.
type StrategyState struct {
PoolBalanceCoins int64 `json:"pool_balance_coins"`
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
GlobalRTP StrategyRTP `json:"global_rtp"`
UserDayRTP StrategyRTP `json:"user_day_rtp"`
User72HourRTP StrategyRTP `json:"user_72h_rtp"`
Version int64 `json:"version"`
PoolBalanceCoins int64 `json:"pool_balance_coins"`
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
GlobalRTP StrategyRTP `json:"global_rtp"`
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
SettledRoundPending bool `json:"settled_round_pending"`
QualificationClosedAtMS int64 `json:"qualification_closed_at_ms"`
UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
// Deprecated aliases are updated only by the legacy kernel branch.
UserDayRTP StrategyRTP `json:"user_day_rtp"`
User72HourRTP StrategyRTP `json:"user_72h_rtp"`
Version int64 `json:"version"`
}
// StrategyRiskCapacity contains the six hard payout ceilings from the product rule.
@ -471,7 +488,7 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput)
}
for _, rtp := range []StrategyRTP{state.GlobalRTP, state.UserDayRTP, state.User72HourRTP} {
for _, rtp := range []StrategyRTP{state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.UserDayRTP, state.User72HourRTP} {
if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput)
}
@ -488,7 +505,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
if config.RechargeBoostStartMS < 0 || config.RechargeBoostEndMS <= config.RechargeBoostStartMS || config.MissProtectionZeroDraws < 0 || config.DailyJackpotLimit < 0 || config.MilestoneSpendCoins < 0 || config.ColdStartPoolCoins < 0 {
return fmt.Errorf("%w: recharge window, miss protection or daily limit is invalid", ErrStrategyConfig)
}
if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale || config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale || config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale {
if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale ||
config.UserRoundRTPMaxPPM < 0 || config.UserRoundRTPMaxPPM > StrategyPPMScale ||
config.User48HourRTPMaxPPM < 0 || config.User48HourRTPMaxPPM > StrategyPPMScale ||
config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale ||
config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale {
return fmt.Errorf("%w: RTP jackpot limits must be within [0,100%%]", ErrStrategyConfig)
}
var splitRateTotal int64
@ -631,6 +652,12 @@ func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool {
}
func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
if config.SettledRoundRTPEligibility {
return settledRoundJackpotConditions(config, state)
}
// Historical pure-kernel callers used opportunistic current/day/72h checks. Keeping this branch
// source-compatible does not expose it to dynamic_v3: the production adapter always enables the
// settled-round flag and disables the independent milestone mechanism.
checks := []struct {
name string
rtp StrategyRTP
@ -659,6 +686,75 @@ func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]Strat
return traces, eligible && limitPassed
}
func settledRoundJackpotConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
traces := make([]StrategyConditionTrace, 0, 6)
pending := state.SettledRoundPending
traces = append(traces, StrategyConditionTrace{
Name: StrategyConditionSettledRound, Passed: pending,
Reason: boolReason(pending, "closed_round_pending", "no_closed_round_pending"),
})
eligible := pending
// The platform permits equality at 98%, while both user gates are deliberately strict: an exact
// 96% user ratio is not “losing more than two points” and must not receive compensation.
globalRatio, globalValid := state.GlobalRTP.RatioPPM()
globalPassed := globalValid && rtpAtOrBelowExact(state.GlobalRTP, config.GlobalRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionGlobalRTP, state.GlobalRTP, globalRatio, config.GlobalRTPMaxPPM, globalValid, globalPassed))
eligible = eligible && globalPassed
roundRatio, roundValid := state.UserRoundRTP.RatioPPM()
roundPassed := roundValid && rtpBelowExact(state.UserRoundRTP, config.UserRoundRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionUserRoundRTP, state.UserRoundRTP, roundRatio, config.UserRoundRTPMaxPPM, roundValid, roundPassed))
eligible = eligible && roundPassed
const maturityMS = int64(48 * 60 * 60 * 1000)
ageMS := int64(0)
if state.UserRegisteredAtMS > 0 && state.QualificationClosedAtMS >= state.UserRegisteredAtMS {
ageMS = state.QualificationClosedAtMS - state.UserRegisteredAtMS
}
mature := state.UserRegisteredAtMS > 0 && ageMS >= maturityMS
traces = append(traces, StrategyConditionTrace{
Name: StrategyConditionUser48HourMaturity, Numerator: ageMS, Denominator: maturityMS,
Passed: mature, Reason: boolReason(mature, "registered_48h", "registered_under_48h_or_unknown"),
})
eligible = eligible && mature
rollingRatio, rollingValid := state.User48HourRTP.RatioPPM()
rollingPassed := rollingValid && rtpBelowExact(state.User48HourRTP, config.User48HourRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionUser48HourRTP, state.User48HourRTP, rollingRatio, config.User48HourRTPMaxPPM, rollingValid, rollingPassed))
eligible = eligible && rollingPassed
limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)})
return traces, eligible && limitPassed
}
func rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
reason := "within_limit"
if !valid {
reason = "denominator_zero"
} else if !passed {
reason = "above_or_equal_limit"
}
return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
}
func rtpBelowExact(rtp StrategyRTP, limitPPM int64) bool {
return compareRTPExact(rtp, limitPPM) < 0
}
func rtpAtOrBelowExact(rtp StrategyRTP, limitPPM int64) bool {
return compareRTPExact(rtp, limitPPM) <= 0
}
func compareRTPExact(rtp StrategyRTP, limitPPM int64) int {
// Cross multiplication keeps the configured mathematical boundary exact even when payout*1e6
// exceeds int64. RatioPPM remains a display/audit value and is never trusted for eligibility.
left := new(big.Int).Mul(big.NewInt(rtp.PayoutCoins), big.NewInt(StrategyPPMScale))
right := new(big.Int).Mul(big.NewInt(rtp.WagerCoins), big.NewInt(limitPPM))
return left.Cmp(right)
}
func selectPayableJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource) (StrategyTier, int64, bool, string, []StrategyRemovalTrace, error) {
if state.UserDailyJackpotWins >= config.DailyJackpotLimit {
return StrategyTier{}, 0, false, StrategyReasonDailyJackpotLimit, nil, nil
@ -788,6 +884,11 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
return StrategyDecision{}, fmt.Errorf("%w: payout=%d exceeds available pool=%d", ErrStrategyInput, payout, available)
}
next := state
if config.SettledRoundRTPEligibility {
// A closed-round qualification is exactly one attempt. Pool/risk rejection falls through to the
// ordinary draw in this same call, but the qualification can never repeat on a later paid draw.
next.SettledRoundPending = false
}
next.PoolBalanceCoins = available - payout
if next.PoolBalanceCoins < 0 {
return StrategyDecision{}, fmt.Errorf("%w: strategy would make pool negative", ErrStrategyInput)
@ -813,8 +914,13 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
return StrategyDecision{}, err
}
// A threshold crossed by this draw is persisted after selection, so the newly
// earned token can affect only the next locked draw and never the current one.
earnedTokens := MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
// earned token can affect only the next locked draw and never the current one. Production V3's
// settled-round mode has no independent spend-token qualification, so it must neither earn nor
// carry a hidden third jackpot condition even if an old rule row still contains the legacy value.
earnedTokens := int64(0)
if !config.SettledRoundRTPEligibility {
earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
}
if earnedTokens > 0 {
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
if err != nil {
@ -822,14 +928,16 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
}
trace.MilestoneTokensEarned = earnedTokens
}
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
if !config.SettledRoundRTPEligibility {
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
}
if next.Version == math.MaxInt64 {
return StrategyDecision{}, fmt.Errorf("%w: state version overflow", ErrStrategyInput)

View File

@ -40,9 +40,11 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
RechargeBoostFactorPPM: 1_100_000,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRTPMaxPPM: 980_000,
JackpotUserDayRTPMaxPPM: 960_000,
JackpotUser72hRTPMaxPPM: 960_000,
MaxJackpotHitsPerUserDay: 5,
// Deprecated aliases keep old in-process draft builders source-compatible. New API requests populate
// the canonical round/48h fields and therefore opt into the settled-window algorithm.
JackpotUserDayRTPMaxPPM: 960_000,
JackpotUser72hRTPMaxPPM: 960_000,
MaxJackpotHitsPerUserDay: 5,
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
@ -94,6 +96,24 @@ func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int6
}
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
// The new contract names are the only persisted/runtime meaning. During a rolling source upgrade an
// old caller may still fill the day/72h aliases; copy them once without pretending that caller opted
// into the settled-round algorithm. A config read from the new database columns sets the marker.
if config.JackpotUserRoundRTPMaxPPM > 0 || config.JackpotUser48hRTPMaxPPM > 0 {
config.SettledRoundRTPEligibility = true
}
if config.JackpotUserRoundRTPMaxPPM == 0 {
config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
}
if config.JackpotUser48hRTPMaxPPM == 0 {
config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
}
if config.JackpotUserDayRTPMaxPPM == 0 {
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
}
if config.JackpotUser72hRTPMaxPPM == 0 {
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
}
config.PoolID = normalizePoolID(config.PoolID)
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
@ -116,6 +136,7 @@ func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
}
func validateRuleConfig(config domain.RuleConfig) error {
config = normalizeRuleConfig(config)
if normalizePoolID(config.PoolID) == "" {
return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
}
@ -200,9 +221,23 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
return err
}
// Jackpot multipliers belong exclusively to the compensation path. Allowing
// the same multiplier to carry an ordinary stage weight would let a draw reach
// a jackpot without satisfying both user RTP gates.
jackpotMultipliers := make(map[int64]struct{}, len(config.JackpotMultiplierPPMs))
for _, multiplierPPM := range config.JackpotMultiplierPPMs {
jackpotMultipliers[multiplierPPM] = struct{}{}
}
for _, stage := range stages {
for _, tier := range stage.Tiers {
if _, jackpot := jackpotMultipliers[tier.MultiplierPPM]; jackpot && tier.Enabled && tier.BaseWeightPPM > 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot multipliers cannot also have ordinary stage probability")
}
}
}
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {
config.JackpotUserRoundRTPMaxPPM <= 0 || config.JackpotUserRoundRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
config.JackpotUser48hRTPMaxPPM <= 0 || config.JackpotUser48hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit")
}
if config.MaxJackpotHitsPerUserDay <= 0 {
@ -221,7 +256,9 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
// disabled 草稿允许金额口径仍为 0启用前必须由各 App 按自身业务口径和风控预算显式补齐。
return nil
}
if config.JackpotSpendThresholdCoins <= 0 {
if !config.SettledRoundRTPEligibility && config.JackpotSpendThresholdCoins <= 0 {
// Only the old draft contract required the now-retired milestone amount. New round/48h configs
// preserve the column for read compatibility but never require or consume it.
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot cumulative spend threshold coins must be configured as a positive amount before enabling")
}
return validateDynamicPayoutCaps(config, true)

View File

@ -94,14 +94,18 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
}
poolID := luckyPoolID(cmd.PoolID)
rule, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, cmd.AppCode, poolID, false)
// External and internal draws must lock the same immutable rule row before any
// consistent-read query. Under InnoDB REPEATABLE READ, an unlocked read here
// could pin a snapshot from before the previous user's settlement commit and
// make the rolling 48-hour numerator/denominator miss that completed draw.
rule, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, cmd.AppCode, poolID, true)
if err != nil {
return domain.ExternalDrawResult{}, err
}
if exists && rule.StrategyVersion == domain.StrategyDynamicV3 {
if cmd.PaidAtMS <= 0 {
// 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间
// 不是支付时间不能用于充值短窗、UTC 日/小时或精确 72h 风控归属。
// 不是支付时间不能用于充值短窗、UTC 日/小时或精确滚动 48h 风控归属。
return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required")
}
} else if cmd.PaidAtMS <= 0 {
@ -278,17 +282,18 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
}
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
drawCommand := domain.DrawCommand{
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: config.PoolID,
UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
RoomID: externalLuckyScopeID(cmd.AppCode),
AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID,
GiftCount: int32(cmd.GiftCount),
CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS,
GiftIncomeCoins: split.AnchorReturnCoins,
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: config.PoolID,
UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
RoomID: externalLuckyScopeID(cmd.AppCode),
AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID,
GiftCount: int32(cmd.GiftCount),
CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS,
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
GiftIncomeCoins: split.AnchorReturnCoins,
}
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
@ -30,7 +31,12 @@ type luckyDynamicBatchState struct {
RiskCounters map[string]luckyDynamicRiskCounter
DayState luckyUserStrategyDay
HourState luckyUserRTPHour
Strategy domain.StrategyState
UserWindow luckyUserRTPWindow
UserWindows []luckyUserRTPWindow
// QualificationWindow is the one closed round consumed by the next personal subdraw. It is
// persisted after decision so pool/risk rejection cannot leave an endlessly reusable promise.
QualificationWindow *luckyUserRTPWindow
Strategy domain.StrategyState
}
// luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。
@ -48,10 +54,18 @@ func (p luckyDynamicPersistence) external() bool {
// 所有共享行只锁一次gift_count=N 在内存里严格推进 N 次,事务尾再批量落 draw facts 和聚合钱包 outbox。
func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, rule domain.RuleConfig, persistence luckyDynamicPersistence, nowMS int64) ([]domain.DrawResult, error) {
if cmd.PaidAtMS <= 0 {
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确 72h 都必须归属 wallet owner 的扣费事实时间。
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确滚动 48h 都必须归属 wallet owner 的扣费事实时间。
// 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required")
}
// Account creation is an eligibility-only owner fact: an unknown value or a
// timestamp later than this payment cannot describe the paying account. Keep
// ordinary drawing available, but freeze 0 into the round so compensation
// remains fail-closed even if that malformed timestamp would age past 48 hours
// before the platform window eventually settles.
if cmd.UserRegisteredAtMS <= 0 || cmd.UserRegisteredAtMS > cmd.PaidAtMS {
cmd.UserRegisteredAtMS = 0
}
// 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
@ -69,17 +83,18 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
}
strategyConfig := luckyDynamicStrategyConfig(config)
referencePrice := config.GiftPrice
paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()
userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
if err != nil {
return nil, err
}
windowScopeID := luckyRuleWindowScopeID(config)
globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS)
globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
if err != nil {
return nil, err
}
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, nowMS)
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
if err != nil {
return nil, err
}
@ -100,7 +115,13 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil {
return nil, err
}
rolling72, err := r.getLuckyUserRolling72HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, luckyPaidTime(cmd, nowMS).UnixMilli())
userWindow, err := r.getOrCreateLuckyUserRTPWindow(ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS)
if err != nil {
return nil, err
}
qualificationWindow, qualificationGlobal, hasQualification, err := r.getLatestPendingLuckyUserRTPWindow(
ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, windowScopeID, nowMS,
)
if err != nil {
return nil, err
}
@ -125,29 +146,37 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
RiskCounters: riskCounters,
DayState: dayState,
HourState: hourState,
UserWindow: userWindow,
UserWindows: make([]luckyUserRTPWindow, 0, 2),
Strategy: domain.StrategyState{
PoolBalanceCoins: basePool.Balance,
ConsecutiveZeroDraws: userState.LossStreak,
PendingMilestoneTokens: userState.PendingJackpotTokens,
PoolBalanceCoins: basePool.Balance,
ConsecutiveZeroDraws: userState.LossStreak,
// Consumption milestones were retired from dynamic_v3 jackpot eligibility. Old durable tokens
// are intentionally cleared on the next paid draw rather than becoming a hidden third gate.
PendingMilestoneTokens: 0,
UserDailyJackpotWins: dayState.JackpotHits,
UserDaySpendCoins: dayState.SpendCoins,
GlobalRTP: domain.StrategyRTP{WagerCoins: globalWindow.WagerCoins, PayoutCoins: globalWindow.ActualPayoutCoins},
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User72HourRTP: rolling72,
Version: userState.PaidDraws,
},
}
for index := int32(1); index <= drawCount; index++ {
// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满,
// 本抽先创建下一窗口并让 mechanism1 使用新窗口 RTP不能等整批结束后才滚动。
if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config.SettlementWindowWager, &state, nowMS); err != nil {
if hasQualification {
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil {
return nil, err
}
}
for index := int32(1); index <= drawCount; index++ {
unitCommand := cmd
unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
unitCommand.GiftCount = 1
unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index)
// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满,
// 本抽先创建下一窗口并把刚闭窗的用户快照冻结成一次性资格,不能等整批结束后才结算。
if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config, unitCommand, &state, nowMS); err != nil {
return nil, err
}
unitSplit := unitSplits[index-1]
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
if unitCommand.CoinSpent <= 0 {
@ -199,36 +228,50 @@ func normalizeLuckyDynamicDeviceID(value string) (string, error) {
return value, nil
}
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, controlWager int64, state *luckyDynamicBatchState, nowMS int64) error {
if controlWager <= 0 {
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, state *luckyDynamicBatchState, nowMS int64) error {
if config.SettlementWindowWager <= 0 {
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
}
// 图中“结算窗口流水”是金币阈值,不是抽数。整笔单抽归属进入时所在窗口;本抽把流水推过阈值后,下一抽才开启新窗口,避免拆分一次中奖事实。
if state.GlobalWindow.WagerCoins >= controlWager {
paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()
// 图中“结算窗口流水”是金币阈值,不是抽数。越过阈值的完整单抽归属旧窗口;窗口已在
// 上一抽结束时冻结,本抽先创建下一窗口并消费刚结算用户轮次的一次性资格。
if state.GlobalWindow.Status == "closed" || state.GlobalWindow.WagerCoins >= config.SettlementWindowWager {
closed := state.GlobalWindow
closed.Status = "closed"
if closed.Status != "closed" {
closed.Status = "closed"
closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
}
closedUser := state.UserWindow
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil {
return err
}
state.GlobalWindows = append(state.GlobalWindows, closed)
next, err := r.createLuckyRTPWindow(
state.UserWindows = append(state.UserWindows, closedUser)
next, err := r.createLuckyRTPWindowAt(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS,
)
if err != nil {
return err
}
state.GlobalWindow = next
// 大奖机制一只看当前结算窗口;窗口滚动后分母为 0必须先积累新样本不能沿用旧窗口资格。
state.Strategy.GlobalRTP = domain.StrategyRTP{
WagerCoins: next.WagerCoins,
PayoutCoins: next.ActualPayoutCoins,
state.UserWindow, err = r.getOrCreateLuckyUserRTPWindow(
ctx, tx, appCode, config.PoolID, config.RuleVersion, next.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS,
)
if err != nil {
return err
}
}
if state.GiftWindow.WagerCoins >= controlWager {
if state.GiftWindow.Status == "closed" || state.GiftWindow.WagerCoins >= config.SettlementWindowWager {
closed := state.GiftWindow
closed.Status = "closed"
if closed.Status != "closed" {
closed.Status = "closed"
closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
}
state.GiftWindows = append(state.GiftWindows, closed)
next, err := r.createLuckyRTPWindow(
next, err := r.createLuckyRTPWindowAt(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS,
)
if err != nil {
return err
@ -238,6 +281,57 @@ func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, ap
return nil
}
func (r *Repository) prepareLuckySettledRoundQualification(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, global luckyRTPWindow, user luckyUserRTPWindow, state *luckyDynamicBatchState, nowMS int64) error {
if global.WindowIndex != user.WindowIndex || user.UserID != cmd.UserID {
return xerr.New(xerr.Internal, "dynamic lucky gift user RTP window is not aligned with platform settlement")
}
rolling := domain.StrategyRTP{WagerCoins: user.Rolling48hWagerCoins, PayoutCoins: user.Rolling48hPayoutCoins}
if rolling.WagerCoins == 0 && global.ClosedAtMS > 0 {
rollingEndMS := luckyExclusiveEndMS(global.ClosedAtMS)
var err error
rolling, err = r.getLuckyUserRolling48HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, rollingEndMS)
if err != nil {
return err
}
startMS := rollingEndMS - int64(48*time.Hour/time.Millisecond)
for _, record := range state.Records {
occurredAtMS := luckyPaidTime(record.Command, nowMS).UnixMilli()
if occurredAtMS < startMS || occurredAtMS >= rollingEndMS {
continue
}
var addErr error
rolling.WagerCoins, addErr = luckyCheckedAdd(rolling.WagerCoins, record.Command.CoinSpent)
if addErr != nil {
return addErr
}
rolling.PayoutCoins, addErr = luckyCheckedAdd(rolling.PayoutCoins, record.Candidate.effectiveReward())
if addErr != nil {
return addErr
}
}
user.Rolling48hWagerCoins = rolling.WagerCoins
user.Rolling48hPayoutCoins = rolling.PayoutCoins
}
qualification := user
state.QualificationWindow = &qualification
state.Strategy.SettledRoundPending = true
state.Strategy.GlobalRTP = domain.StrategyRTP{WagerCoins: global.WagerCoins, PayoutCoins: global.ActualPayoutCoins}
state.Strategy.UserRoundRTP = domain.StrategyRTP{WagerCoins: user.WagerCoins, PayoutCoins: user.PayoutCoins}
state.Strategy.User48HourRTP = rolling
state.Strategy.QualificationClosedAtMS = global.ClosedAtMS
// Maturity is evaluated from the owner snapshot captured while the user participated in the
// closed round, not a value supplied again on the later draw that consumes the qualification.
state.Strategy.UserRegisteredAtMS = user.UserRegisteredAtMS
return nil
}
func luckyCheckedAdd(left, right int64) (int64, error) {
if right < 0 || left > int64(^uint64(0)>>1)-right {
return 0, xerr.New(xerr.Internal, "dynamic lucky gift RTP aggregate overflow")
}
return left + right, nil
}
func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Config, referencePrice int64, cmd domain.DrawCommand, split domain.StrategyFundSplit, decision domain.StrategyDecision, nowMS int64, state *luckyDynamicBatchState) error {
state.PublicIn += split.PublicPoolCoins
state.ProfitIn += split.ProfitPoolCoins
@ -250,16 +344,30 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
state.GlobalWindow.PaidDraws++
state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins
state.GlobalWindow.LastPaidAtMS = maxInt64(state.GlobalWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli())
state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent)
state.GiftWindow.PaidDraws++
state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins
state.GiftWindow.LastPaidAtMS = maxInt64(state.GiftWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli())
state.UserWindow.WagerCoins += cmd.CoinSpent
state.UserWindow.PayoutCoins += decision.PayoutCoins
// Reaching the configured wager resets the platform RTP immediately at this draw boundary. The
// complete draw remains in the closed round; the next subdraw is the first member of the new round.
if state.GlobalWindow.WagerCoins >= config.SettlementWindowWager {
state.GlobalWindow.Status = "closed"
state.GlobalWindow.ClosedAtMS = state.GlobalWindow.LastPaidAtMS
}
if state.GiftWindow.WagerCoins >= config.SettlementWindowWager {
state.GiftWindow.Status = "closed"
state.GiftWindow.ClosedAtMS = state.GiftWindow.LastPaidAtMS
}
state.UserState.PaidDraws++
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens
state.DayState.WagerCoins = decision.NextState.UserDayRTP.WagerCoins
state.DayState.PayoutCoins = decision.NextState.UserDayRTP.PayoutCoins
state.DayState.WagerCoins += cmd.CoinSpent
state.DayState.PayoutCoins += decision.PayoutCoins
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
state.HourState.WagerCoins += cmd.CoinSpent
@ -278,6 +386,18 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000,
}
drawID := idgen.New("lucky_draw")
if state.QualificationWindow != nil {
qualification := *state.QualificationWindow
qualification.AttemptedDrawID = drawID
qualification.EvaluatedAtMS = nowMS
if luckySettledRoundConditionsPassed(decision.Trace) {
qualification.QualificationStatus = "attempted"
} else {
qualification.QualificationStatus = "ineligible"
}
state.UserWindows = append(state.UserWindows, qualification)
state.QualificationWindow = nil
}
rewardStatus := luckyInitialRewardStatus(candidate, false)
limited := luckyDynamicLimitedReasons(decision.Trace)
decisionCopy := decision
@ -297,14 +417,14 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
BasePool: poolSnapshot,
StrategyDecision: &decisionCopy,
FundSplit: split,
UserDayRTP: decision.NextState.UserDayRTP,
User72HourRTP: decision.NextState.User72HourRTP,
UserRoundRTP: domain.StrategyRTP{WagerCoins: state.UserWindow.WagerCoins, PayoutCoins: state.UserWindow.PayoutCoins},
User48HourRTP: decision.NextState.User48HourRTP,
RewardStatus: rewardStatus,
NowMS: nowMS,
}
state.DrawIDs = append(state.DrawIDs, drawID)
state.Records = append(state.Records, record)
globalRTP, _ := decision.NextState.GlobalRTP.RatioPPM()
globalRTP := luckyRTPPPM(globalSnapshot.WagerCoins, globalSnapshot.ActualPayoutCoins)
giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
state.Results = append(state.Results, domain.DrawResult{
DrawID: drawID,
@ -329,6 +449,33 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
return nil
}
func luckySettledRoundConditionsPassed(trace domain.DecisionTrace) bool {
required := map[string]bool{
domain.StrategyConditionSettledRound: false,
domain.StrategyConditionGlobalRTP: false,
domain.StrategyConditionUserRoundRTP: false,
domain.StrategyConditionUser48HourMaturity: false,
domain.StrategyConditionUser48HourRTP: false,
domain.StrategyConditionDailyJackpotLimit: false,
}
seen := make(map[string]bool, len(required))
for _, condition := range trace.Conditions {
if _, ok := required[condition.Name]; !ok {
continue
}
seen[condition.Name] = true
if !condition.Passed {
return false
}
}
for name := range required {
if !seen[name] {
return false
}
}
return true
}
func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, state luckyDynamicBatchState, persistence luckyDynamicPersistence, nowMS int64) error {
if err := validateLuckyDynamicBatchState(state, cmd.CoinSpent); err != nil {
return err
@ -339,14 +486,23 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?,
actual_base_payout_coins = ?, carry_ppm = ?, status = ?, updated_at_ms = ?
actual_base_payout_coins = ?, carry_ppm = ?, status = ?,
last_paid_at_ms = ?, closed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status, nowMS,
window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status,
window.LastPaidAtMS, window.ClosedAtMS, nowMS,
appCode, window.ScopeType, window.ScopeID, window.WindowIndex,
); err != nil {
return err
}
}
// Values are ordered so a later evaluated copy of the same closed row overwrites its earlier
// pending snapshot. Multiple UPDATEs stay bounded by subdraw window crossings, never user count.
for _, userWindow := range append(state.UserWindows, state.UserWindow) {
if err := r.persistLuckyUserRTPWindow(ctx, tx, appCode, userWindow, nowMS); err != nil {
return err
}
}
if err := r.updateLuckyDynamicPoolNet(ctx, tx, appCode, luckyBasePoolScopeID(config), state.PublicIn, state.Payout, state.ProfitIn, state.AnchorIn, nowMS); err != nil {
return err
}

View File

@ -34,6 +34,113 @@ type luckyUserRTPHour struct {
PayoutCoins int64
}
// luckyUserRTPWindow is the user's money aggregate for one platform settlement window.
// qualification_status starts as pending while the platform window is open. It is evaluated lazily
// on that user's next paid draw after the platform window closes, avoiding a full-user scan at reset.
type luckyUserRTPWindow struct {
PoolID string
RuleVersion int64
WindowIndex int64
UserID int64
UserRegisteredAtMS int64
WagerCoins int64
PayoutCoins int64
Rolling48hWagerCoins int64
Rolling48hPayoutCoins int64
QualificationStatus string
AttemptedDrawID string
EvaluatedAtMS int64
}
func (r *Repository) getOrCreateLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS int64) (luckyUserRTPWindow, error) {
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_rtp_windows (
app_code, pool_id, rule_version, window_index, user_id, user_registered_at_ms,
wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins,
qualification_status, attempted_draw_id, evaluated_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 'pending', '', 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, poolID, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS, nowMS,
); err != nil {
return luckyUserRTPWindow{}, err
}
state := luckyUserRTPWindow{PoolID: poolID, RuleVersion: ruleVersion, WindowIndex: windowIndex, UserID: userID}
err := tx.QueryRowContext(ctx, `
SELECT user_registered_at_ms, wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins,
qualification_status, attempted_draw_id, evaluated_at_ms
FROM lucky_user_rtp_windows
WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ?
FOR UPDATE`, appCode, poolID, ruleVersion, windowIndex, userID,
).Scan(&state.UserRegisteredAtMS, &state.WagerCoins, &state.PayoutCoins, &state.Rolling48hWagerCoins, &state.Rolling48hPayoutCoins,
&state.QualificationStatus, &state.AttemptedDrawID, &state.EvaluatedAtMS)
return state, err
}
// getLatestPendingLuckyUserRTPWindow returns at most one closed qualification. A user cannot bank
// multiple compensation attempts: if historical pending rows exist, only the newest is retained and
// older rows are expired in the same transaction before the next paid draw is selected.
func (r *Repository) getLatestPendingLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, beforeWindowIndex, userID int64, globalScopeID string, nowMS int64) (luckyUserRTPWindow, luckyRTPWindow, bool, error) {
var userWindow luckyUserRTPWindow
userWindow.PoolID = poolID
userWindow.RuleVersion = ruleVersion
userWindow.UserID = userID
var global luckyRTPWindow
err := tx.QueryRowContext(ctx, `
SELECT u.window_index, u.user_registered_at_ms, u.wager_coins, u.payout_coins,
u.rolling_48h_wager_coins, u.rolling_48h_payout_coins,
u.qualification_status, u.attempted_draw_id, u.evaluated_at_ms,
g.window_index, g.scope_type, g.scope_id, g.target_rtp_ppm, g.control_window_draws,
g.paid_draws, g.wager_coins, g.target_payout_coins, g.actual_base_payout_coins,
g.carry_ppm, g.status, g.started_at_ms, g.last_paid_at_ms, g.closed_at_ms
FROM lucky_user_rtp_windows u
JOIN lucky_rtp_windows g
ON g.app_code = u.app_code AND g.scope_type = 'pool' AND g.scope_id = ?
AND g.window_index = u.window_index AND g.status = 'closed'
WHERE u.app_code = ? AND u.pool_id = ? AND u.rule_version = ? AND u.user_id = ?
AND u.qualification_status = 'pending' AND u.window_index < ?
ORDER BY u.window_index DESC
LIMIT 1
FOR UPDATE`, globalScopeID, appCode, poolID, ruleVersion, userID, beforeWindowIndex,
).Scan(&userWindow.WindowIndex, &userWindow.UserRegisteredAtMS, &userWindow.WagerCoins, &userWindow.PayoutCoins,
&userWindow.Rolling48hWagerCoins, &userWindow.Rolling48hPayoutCoins,
&userWindow.QualificationStatus, &userWindow.AttemptedDrawID, &userWindow.EvaluatedAtMS,
// JOIN 的全局轮次必须显式扫回若只读取用户轮次global 默认 0 会让所有正常闭窗资格
// 在 prepare 对齐校验中失败并回滚下一次送礼。
&global.WindowIndex, &global.ScopeType, &global.ScopeID, &global.TargetRTPPPM, &global.ControlDraws,
&global.PaidDraws, &global.WagerCoins, &global.TargetPayoutCoins, &global.ActualPayoutCoins,
&global.CarryPPM, &global.Status, &global.StartedAtMS, &global.LastPaidAtMS, &global.ClosedAtMS)
if err != nil {
if err == sql.ErrNoRows {
return luckyUserRTPWindow{}, luckyRTPWindow{}, false, nil
}
return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_user_rtp_windows
SET qualification_status = 'expired', evaluated_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND user_id = ?
AND qualification_status = 'pending' AND window_index < ?`,
nowMS, nowMS, appCode, poolID, ruleVersion, userID, userWindow.WindowIndex,
); err != nil {
return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err
}
return userWindow, global, true, nil
}
func (r *Repository) persistLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, state luckyUserRTPWindow, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE lucky_user_rtp_windows
SET wager_coins = ?, payout_coins = ?,
rolling_48h_wager_coins = ?, rolling_48h_payout_coins = ?,
qualification_status = ?, attempted_draw_id = ?, evaluated_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ?`,
state.WagerCoins, state.PayoutCoins, state.Rolling48hWagerCoins, state.Rolling48hPayoutCoins,
state.QualificationStatus, state.AttemptedDrawID, state.EvaluatedAtMS, nowMS,
appCode, state.PoolID, state.RuleVersion, state.WindowIndex, state.UserID,
)
return err
}
func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time {
ms := cmd.PaidAtMS
if ms <= 0 {
@ -174,13 +281,22 @@ func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx
return state, err
}
func (r *Repository) getLuckyUserRolling48HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 48)
}
// getLuckyUserRolling72HourRTP remains only for old in-package callers during rolling source upgrades.
func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
// 严格口径是 [end-72h,end),不能把“当前小时+前71整点桶”近似成 72h。
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 72)
}
func (r *Repository) getLuckyUserRollingHourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS, hours int64) (domain.StrategyRTP, error) {
// 严格口径是 [end-hours,end),不能把“当前小时+前 N 个整点桶”近似成滚动窗口。
// 完整小时从聚合桶读取;头尾两个不足一小时的片段从专用边界事件读取,避免回扫 lucky_draw_records。
const hourMS = int64(time.Hour / time.Millisecond)
startMS := endMS - 72*hourMS
if startMS < 0 || endMS <= 0 {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h range is invalid")
startMS := endMS - hours*hourMS
if hours <= 0 || startMS < 0 || endMS <= 0 {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP range is invalid")
}
fullStartMS := startMS
if remainder := startMS % hourMS; remainder != 0 {
@ -214,7 +330,7 @@ func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.T
return domain.StrategyRTP{}, err
}
if state.WagerCoins > int64(^uint64(0)>>1)-wager || state.PayoutCoins > int64(^uint64(0)>>1)-payout {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h RTP overflow")
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP overflow")
}
state.WagerCoins += wager
state.PayoutCoins += payout

View File

@ -50,6 +50,12 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
if !configured.Enabled {
continue
}
if _, jackpot := jackpotSet[configured.MultiplierPPM]; jackpot {
// A jackpot multiplier is reachable only through the settled-round and
// rolling-48h eligibility gates. Ignore historical overlapping ordinary
// weights here as a runtime backstop; new configs are rejected earlier.
continue
}
tier := tiersByMultiplier[configured.MultiplierPPM]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames))
@ -97,27 +103,28 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
tiers = append(tiers, *tiersByMultiplier[multiplier])
}
return domain.StrategyConfig{
Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM,
AnchorReturnRatePPM: config.AnchorRatePPM,
ColdStartPoolCoins: config.InitialPoolCoins,
LowWaterThresholdCoins: config.LowWatermarkCoins,
HighWaterThresholdCoins: config.HighWatermarkCoins,
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
RechargeFactorPPM: config.RechargeBoostFactorPPM,
RechargeBoostStartMS: 0,
RechargeBoostEndMS: config.RechargeBoostWindowMS,
MissProtectionZeroDraws: config.LossStreakGuarantee,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
JackpotMechanism1Enabled: true,
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
UserDayRTPMaxPPM: config.JackpotUserDayRTPMaxPPM,
User72HourRTPMaxPPM: config.JackpotUser72hRTPMaxPPM,
RechargeStages: rechargeStages,
Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM,
AnchorReturnRatePPM: config.AnchorRatePPM,
ColdStartPoolCoins: config.InitialPoolCoins,
LowWaterThresholdCoins: config.LowWatermarkCoins,
HighWaterThresholdCoins: config.HighWatermarkCoins,
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
RechargeFactorPPM: config.RechargeBoostFactorPPM,
RechargeBoostStartMS: 0,
RechargeBoostEndMS: config.RechargeBoostWindowMS,
MissProtectionZeroDraws: config.LossStreakGuarantee,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
MilestoneSpendCoins: 0,
JackpotMechanism1Enabled: true,
JackpotMechanism2Enabled: false,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
SettledRoundRTPEligibility: true,
RechargeStages: rechargeStages,
}
}

View File

@ -39,6 +39,11 @@ type luckyRTPWindow struct {
ActualPayoutCoins int64
CarryPPM int64
Status string
// StartedAtMS/LastPaidAtMS/ClosedAtMS are business-time boundaries from the wallet payment fact.
// created_at_ms remains an infrastructure audit timestamp and must not be used for RTP eligibility.
StartedAtMS int64
LastPaidAtMS int64
ClosedAtMS int64
}
func (w luckyRTPWindow) withCurrentWager(spent int64) luckyRTPWindow {
@ -111,8 +116,8 @@ type luckyDrawRecordInput struct {
// 后台以后修改规则版本也不会让历史结果失去可解释性。
StrategyDecision *domain.StrategyDecision
FundSplit domain.StrategyFundSplit
UserDayRTP domain.StrategyRTP
User72HourRTP domain.StrategyRTP
UserRoundRTP domain.StrategyRTP
User48HourRTP domain.StrategyRTP
RewardStatus string
NowMS int64
}
@ -1037,9 +1042,11 @@ func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, strin
if input.StrategyDecision != nil {
candidateData["dynamic_v3"] = input.StrategyDecision.Trace
poolData["fund_split"] = input.FundSplit
rtpData["user_day"] = input.UserDayRTP
rtpData["user_72h"] = input.User72HourRTP
rtpData["global"] = input.StrategyDecision.NextState.GlobalRTP
rtpData["user_round"] = input.UserRoundRTP
rtpData["user_48h"] = input.User48HourRTP
// Eligibility uses the just-closed platform snapshot; it is intentionally frozen while the
// current open window advances independently on this draw.
rtpData["qualification_global"] = input.StrategyDecision.NextState.GlobalRTP
}
candidateSnapshot, _ := json.Marshal(candidateData)
poolSnapshot, _ := json.Marshal(poolData)
@ -2332,8 +2339,9 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
JackpotUserDayRTPMaxPPM: ruleConfig.JackpotUserDayRTPMaxPPM,
JackpotUser72hRTPMaxPPM: ruleConfig.JackpotUser72hRTPMaxPPM,
JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM,
JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM,
SettledRoundRTPEligibility: true,
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
MaxSinglePayout: ruleConfig.MaxSinglePayout,
@ -2474,31 +2482,38 @@ func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appC
// getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3不可变 rule_version scope 已经固定真实流水阈值,
// 因此窗口是否结束只看 wager_coins。control_window_draws 继续保存 fixed_v2 的兼容观测值,不能参与 V3 滚动判断。
func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, paidAtMS, nowMS int64) (luckyRTPWindow, error) {
if windowWager <= 0 {
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive")
}
if paidAtMS <= 0 {
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required for settlement window")
}
window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true)
if err != nil {
return luckyRTPWindow{}, err
}
if !exists {
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, nowMS)
return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, paidAtMS, nowMS)
}
if window.Status == "open" && window.WagerCoins < windowWager {
if window.Status == "open" && window.StartedAtMS > 0 && window.WagerCoins < windowWager {
return window, nil
}
if window.Status == "open" {
// 006 gives pre-upgrade windows started_at_ms=0 because their exact first payment cannot be
// reconstructed safely. Close that legacy partial window without manufacturing user-round
// aggregates; the first post-upgrade draw starts a fully aligned platform/user round.
closedAtMS := maxInt64(window.LastPaidAtMS, paidAtMS)
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET status = 'closed', updated_at_ms = ?
SET status = 'closed', closed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
nowMS, appCode, scopeType, scopeID, window.WindowIndex,
closedAtMS, nowMS, appCode, scopeType, scopeID, window.WindowIndex,
); err != nil {
return luckyRTPWindow{}, err
}
}
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, nowMS)
return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, paidAtMS, nowMS)
}
func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) {
@ -2508,7 +2523,8 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
}
row := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
started_at_ms, last_paid_at_ms, closed_at_ms
FROM lucky_rtp_windows
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
ORDER BY window_index DESC
@ -2517,7 +2533,8 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
)
var window luckyRTPWindow
if err := row.Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status); err != nil {
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status,
&window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return luckyRTPWindow{}, false, nil
}
@ -2527,30 +2544,46 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
}
func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, index, carry, windowDraws, targetPPM, nowMS, nowMS)
}
func (r *Repository) createLuckyRTPWindowAt(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, startedAtMS, nowMS int64) (luckyRTPWindow, error) {
// 同一 scope 的首次请求可能并发创建同一 window幂等 INSERT 先以唯一键串行化,
// 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_rtp_windows (
app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
started_at_ms, last_paid_at_ms, closed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, nowMS, nowMS,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, startedAtMS, nowMS, nowMS,
); err != nil {
return luckyRTPWindow{}, err
}
var window luckyRTPWindow
err := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
started_at_ms, last_paid_at_ms, closed_at_ms
FROM lucky_rtp_windows
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?
FOR UPDATE`, appCode, scopeType, scopeID, index,
).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status)
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status,
&window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS)
return window, err
}
func luckyExclusiveEndMS(value int64) int64 {
// RTP storage keeps the owner's actual settlement millisecond for maturity/audit. Range queries are
// [start,end), so only the query boundary is advanced by one millisecond to include that paid fact.
if value >= int64(^uint64(0)>>1) {
return value
}
return value + 1
}
func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
// 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。
if _, err := tx.ExecContext(ctx, `

View File

@ -52,6 +52,14 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
// 旧的进程内调用方可能仍填写 day/72h 别名;仓储只持久化轮次/48h 两个唯一口径,
// 兼容赋值必须在生成不可变版本前完成,避免同一规则出现两套可漂移的数据库字段。
if config.JackpotUserRoundRTPMaxPPM == 0 {
config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
}
if config.JackpotUser48hRTPMaxPPM == 0 {
config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
}
jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
if err != nil {
return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
@ -88,7 +96,7 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
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_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_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
@ -102,7 +110,7 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
config.JackpotGlobalRTPMaxPPM, config.JackpotUserDayRTPMaxPPM, config.JackpotUser72hRTPMaxPPM,
config.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
@ -167,7 +175,7 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm, v.jackpot_multiplier_ppms,
v.jackpot_global_rtp_max_ppm, v.jackpot_user_day_rtp_max_ppm, v.jackpot_user_72h_rtp_max_ppm,
v.jackpot_global_rtp_max_ppm, v.jackpot_user_round_rtp_max_ppm, v.jackpot_user_48h_rtp_max_ppm,
v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day,
v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap,
v.device_daily_payout_cap, v.room_hourly_payout_cap, v.anchor_daily_payout_cap
@ -219,7 +227,7 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
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_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_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
@ -323,8 +331,8 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
&config.RechargeBoostFactorPPM,
&jackpotMultiplierPPMsJSON,
&config.JackpotGlobalRTPMaxPPM,
&config.JackpotUserDayRTPMaxPPM,
&config.JackpotUser72hRTPMaxPPM,
&config.JackpotUserRoundRTPMaxPPM,
&config.JackpotUser48hRTPMaxPPM,
&config.JackpotSpendThresholdCoins,
&config.MaxJackpotHitsPerUserDay,
&config.MaxSinglePayout,
@ -348,5 +356,9 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
// 数据库只有轮次/48h 两个真实字段;回填旧别名仅用于同进程源码兼容,运行策略仍由 marker 选择新语义。
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
config.SettledRoundRTPEligibility = true
return config, nil
}

View File

@ -87,17 +87,18 @@ func (s *LuckyGiftServer) BatchExecuteLuckyGiftDraw(ctx context.Context, req *lu
func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luckygiftv1.ExternalGiftDrawRequest) (*luckygiftv1.ExternalGiftDrawResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
AppCode: req.GetAppCode(),
ExternalUserID: req.GetExternalUserId(),
DeviceID: req.GetDeviceId(),
RequestID: req.GetRequestId(),
GiftCount: req.GetGiftCount(),
UnitAmount: req.GetUnitAmount(),
TotalAmount: req.GetTotalAmount(),
Currency: req.GetCurrency(),
PaidAtMS: req.GetPaidAtMs(),
MetadataJSON: req.GetMetadataJson(),
PoolID: req.GetPoolId(),
AppCode: req.GetAppCode(),
ExternalUserID: req.GetExternalUserId(),
DeviceID: req.GetDeviceId(),
RequestID: req.GetRequestId(),
GiftCount: req.GetGiftCount(),
UnitAmount: req.GetUnitAmount(),
TotalAmount: req.GetTotalAmount(),
Currency: req.GetCurrency(),
PaidAtMS: req.GetPaidAtMs(),
MetadataJSON: req.GetMetadataJson(),
PoolID: req.GetPoolId(),
UserRegisteredAtMS: req.GetUserRegisteredAtMs(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -118,6 +119,7 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
GiftCount: meta.GetGiftCount(),
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
UserRegisteredAtMS: meta.GetUserRegisteredAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(),
SenderName: meta.GetSenderName(),
@ -320,8 +322,8 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
JackpotUserDayRTPMaxPPM: config.GetJackpotUserDayRtpMaxPpm(),
JackpotUser72hRTPMaxPPM: config.GetJackpotUser_72HRtpMaxPpm(),
JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(),
JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
@ -335,6 +337,14 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
}
func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRuleConfig {
roundRTPMaxPPM := config.JackpotUserRoundRTPMaxPPM
if roundRTPMaxPPM == 0 {
roundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
}
rolling48hRTPMaxPPM := config.JackpotUser48hRTPMaxPPM
if rolling48hRTPMaxPPM == 0 {
rolling48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
}
stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(config.Stages))
for _, stage := range config.Stages {
tiers := make([]*luckygiftv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
@ -383,8 +393,8 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
JackpotUserDayRtpMaxPpm: config.JackpotUserDayRTPMaxPPM,
JackpotUser_72HRtpMaxPpm: config.JackpotUser72hRTPMaxPPM,
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
MaxSinglePayout: config.MaxSinglePayout,

View File

@ -461,6 +461,9 @@ type SendGift struct {
SenderRegionID int64 `json:"sender_region_id,omitempty"`
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
SenderCountryID int64 `json:"sender_country_id,omitempty"`
// SenderRegisteredAtMS 是 gateway 从 user-service 固化的账号创建时间。
// room 恢复首次送礼 saga 时必须继续使用该值;旧命令缺失时保留 0让 lucky-gift 对补偿大奖资格 fail-close。
SenderRegisteredAtMS int64 `json:"sender_registered_at_ms,omitempty"`
// TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。
TargetIsHost bool `json:"target_is_host,omitempty"`
// TargetHostRegionID 是工资政策匹配区域room-service 不自行推导主播身份或区域。
@ -684,6 +687,9 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
// not conflict merely because the user changed profile between timeout and replay.
delete(values, "sender_region_id")
delete(values, "sender_country_id")
// 注册时间与国家/区域同属 gateway 从 user owner 解析的可信快照。首次请求会完整持久化,
// 但它不是客户端送礼载荷;同一 command_id 重试不能因为用户快照修复或迁移而产生冲突。
delete(values, "sender_registered_at_ms")
// SendGift 的以下字段由 wallet-service 结算后写入 command log不属于客户端请求幂等语义。
delete(values, "billing_receipt_id")
delete(values, "coin_spent")

View File

@ -82,7 +82,10 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
BatchSeq: req.GetBatchSeq(),
SenderCountryID: req.GetSenderCountryId(),
SenderRegionID: req.GetSenderRegionId(),
TargetIsHost: req.GetTargetIsHost(),
// 注册时间随 command 一起进入 gift_operation 和最终 command log恢复时使用首次快照
// 不在 room-service 回查 user-service也不让重试请求覆盖已经用于开奖判定的事实。
SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(),
TargetIsHost: req.GetTargetIsHost(),
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),

View File

@ -113,6 +113,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
// 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化owner 只用它判断
// 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。
UserRegisteredAtMs: cmd.SenderRegisteredAtMS,
// 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。
SenderName: giftDisplayName(cmd.SenderDisplayProfile),
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),

View File

@ -542,7 +542,7 @@ func (s *demoServer) ensureDynamicRule(ctx context.Context, operatorID int64) (*
HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000,
RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000,
JackpotMultiplierPpms: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRtpMaxPpm: 980_000, JackpotUserDayRtpMaxPpm: 960_000, JackpotUser_72HRtpMaxPpm: 960_000,
JackpotGlobalRtpMaxPpm: 980_000, JackpotUserRoundRtpMaxPpm: 960_000, JackpotUser_48HRtpMaxPpm: 960_000,
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000,
DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,