feat(lucky-gift): settle jackpot eligibility by round and 48h RTP
This commit is contained in:
parent
9c1dd0e3a4
commit
afce96dc42
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,9 @@ message LuckyGiftMeta {
|
|||||||
int64 last_recharged_at_ms = 21;
|
int64 last_recharged_at_ms = 21;
|
||||||
// gift_income_coins 是 wallet 已实际入账给收礼人的收益金币;dynamic_v3 用它校验规则主播拆账,缺失或比例不一致均拒绝开奖。
|
// gift_income_coins 是 wallet 已实际入账给收礼人的收益金币;dynamic_v3 用它校验规则主播拆账,缺失或比例不一致均拒绝开奖。
|
||||||
int64 gift_income_coins = 22;
|
int64 gift_income_coins = 22;
|
||||||
|
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
|
||||||
|
// dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。
|
||||||
|
int64 user_registered_at_ms = 23;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LuckyGiftRuleTier {
|
message LuckyGiftRuleTier {
|
||||||
@ -91,8 +94,9 @@ message LuckyGiftRuleConfig {
|
|||||||
int64 recharge_boost_factor_ppm = 26;
|
int64 recharge_boost_factor_ppm = 26;
|
||||||
repeated int64 jackpot_multiplier_ppms = 27;
|
repeated int64 jackpot_multiplier_ppms = 27;
|
||||||
int64 jackpot_global_rtp_max_ppm = 28;
|
int64 jackpot_global_rtp_max_ppm = 28;
|
||||||
int64 jackpot_user_day_rtp_max_ppm = 29;
|
int64 jackpot_user_round_rtp_max_ppm = 29;
|
||||||
int64 jackpot_user_72h_rtp_max_ppm = 30;
|
int64 jackpot_user_48h_rtp_max_ppm = 30;
|
||||||
|
// 历史消费门槛字段只用于旧配置回读;dynamic_v3 当前大奖资格不再消费该值。
|
||||||
int64 jackpot_spend_threshold_coins = 31;
|
int64 jackpot_spend_threshold_coins = 31;
|
||||||
int64 max_jackpot_hits_per_user_day = 32;
|
int64 max_jackpot_hits_per_user_day = 32;
|
||||||
int64 max_single_payout = 33;
|
int64 max_single_payout = 33;
|
||||||
@ -183,6 +187,8 @@ message ExternalGiftDrawRequest {
|
|||||||
string pool_id = 12;
|
string pool_id = 12;
|
||||||
// dynamic_v3 必填的稳定设备作用域。luck-gateway 不独立证明其真实性,调用方必须在自己的认证/签名边界内绑定该值;fixed_v2 允许缺失。
|
// dynamic_v3 必填的稳定设备作用域。luck-gateway 不独立证明其真实性,调用方必须在自己的认证/签名边界内绑定该值;fixed_v2 允许缺失。
|
||||||
string device_id = 13;
|
string device_id = 13;
|
||||||
|
// user_registered_at_ms 由外部 App 按自己的用户主数据提供;dynamic_v3 缺失或晚于支付时间时补偿大奖资格 fail-close。
|
||||||
|
int64 user_registered_at_ms = 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExternalGiftDrawResponse {
|
message ExternalGiftDrawResponse {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1163,6 +1163,9 @@ message SendGiftRequest {
|
|||||||
// combo_session_id + batch_seq 只描述 Flutter 微批会话,command_id 仍是跨服务唯一幂等键。
|
// combo_session_id + batch_seq 只描述 Flutter 微批会话,command_id 仍是跨服务唯一幂等键。
|
||||||
string combo_session_id = 19;
|
string combo_session_id = 19;
|
||||||
int64 batch_seq = 20;
|
int64 batch_seq = 20;
|
||||||
|
// sender_registered_at_ms 是 gateway 从 user-service 读取的账号创建事实;客户端不能提交或覆盖。
|
||||||
|
// room-service 只随首次送礼命令持久化并透传给 lucky-gift-service,不据此拥有用户主数据。
|
||||||
|
int64 sender_registered_at_ms = 21;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`。
|
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`。
|
||||||
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
|
- `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 已升级到由 `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。
|
- 比例、概率和倍率统一使用 ppm,`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
|
||||||
- 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。
|
- 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。
|
||||||
|
|
||||||
@ -38,7 +38,8 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
|
|||||||
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
|
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
|
||||||
- 95%~101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
|
- 95%~101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
|
||||||
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%~101%,禁止为了追 RTP 透支奖池。
|
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `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:00~10:00 大盘 RTP,以及同一个 9:00~10:00 轮次内该用户实际发生的总流水和总返奖;不能拿用户 UTC 日 RTP 或正在进行的新轮次代替。
|
||||||
|
|
||||||
### 2.3 原图概率表不能作为生产默认概率
|
### 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 自然日充值额、最后充值时间及主播收益金额。
|
2. `wallet-service` 完成真实扣费,并在幂等回执中固化 `coin_spent`、交易 `paid_at_ms`、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。
|
||||||
3. `lucky-gift-service` 只消费上述可信事实,不直连钱包库,不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。
|
3. `lucky-gift-service` 只消费上述可信事实,不直连钱包库,不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。
|
||||||
4. 本抽金币按 98/1/1 拆分;公共池份额先计入 `P`,再比较本抽奖金 `W`。
|
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 档接收剩余概率。
|
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子;0 档接收剩余概率。
|
||||||
7. 优先处理已持久化的“消费里程碑大奖 token”,其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
|
7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。
|
||||||
8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖。
|
8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功、奖池不足或风控阻断都会消费该轮资格,本抽随后按普通规则完成,旧资格不能等待补池或重复触发。
|
||||||
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`。
|
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`。
|
||||||
|
|
||||||
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
|
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
|
||||||
@ -128,31 +129,42 @@ adjusted_weight = base_weight × water_factor × recharge_factor
|
|||||||
|
|
||||||
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。
|
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。
|
||||||
|
|
||||||
## 6. 两种大奖机制
|
## 6. 大奖资格:已结算轮次的下一次尝试
|
||||||
|
|
||||||
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
|
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
|
||||||
|
|
||||||
### 6.1 机制一:RTP 亏损补偿大奖
|
### 6.1 RTP、注册年龄和安全门必须同时通过
|
||||||
|
|
||||||
本抽开始时同时满足以下条件,才允许从当前可支付大奖集合随机选择:
|
大盘轮次结算时固化本轮快照;用户下一次送出幸运礼物时,只有以下条件同时通过,才允许从当前可支付大奖集合随机选择:
|
||||||
|
|
||||||
- 全局结算 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%;
|
- 刚关闭的大盘轮次 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%;等于 98% 可以继续判断,超过 98% 直接失败,保证平台不因补偿大奖扩大亏损;
|
||||||
- 用户当前 UTC 日 RTP `<= jackpot_user_day_rtp_max_ppm`,默认 96%;
|
- 该用户在同一个刚关闭轮次内的 RTP **严格 `< jackpot_user_round_rtp_max_ppm`**,默认严格小于 96%;等于 96% 不通过;
|
||||||
- 用户滚动 72 小时 RTP `<= jackpot_user_72h_rtp_max_ppm`,默认 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;
|
- 用户本 UTC 日大奖次数 `< max_jackpot_hits_per_user_day`,默认 5;
|
||||||
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
|
- 候选 `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 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。
|
### 6.2 注册时间和“只玩最后 5 分钟”边界
|
||||||
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token;同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
|
|
||||||
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
|
|
||||||
- 下一抽优先消费已有 token;若大奖集合因 `P`、日 5 次上限或风控不可支付,token 保留,用户仍获得本次普通抽奖。
|
|
||||||
- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。
|
|
||||||
|
|
||||||
原图写的是“定时任务”。实现改为在 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. 六维风控与时间口径
|
## 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,避免暴露原始标识。
|
设备日 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 次的语义
|
## 8. 批量 N 次的语义
|
||||||
|
|
||||||
`gift_count=N` 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 `gift_count=99` 必须产生 99 次状态推进。
|
`gift_count=N` 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 `gift_count=99` 必须产生 99 次状态推进。
|
||||||
|
|
||||||
- 总 `coin_spent` 拆成 N 个整数单份,余数从前往后每份加 1;三资金桶使用联合累计配额,保证每一抽 `public_i+profit_i+anchor_i=unit_spent_i`,同时整批三桶精确等于钱包回执总拆账。
|
- 总 `coin_spent` 拆成 N 个整数单份,余数从前往后每份加 1;三资金桶使用联合累计配额,保证每一抽 `public_i+profit_i+anchor_i=unit_spent_i`,同时整批三桶精确等于钱包回执总拆账。
|
||||||
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、RTP、token、日大奖次数和风控计数;不能并行抽 N 次后再合并。
|
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、已结算轮次资格、日大奖次数和风控计数;不能并行抽 N 次后再合并。
|
||||||
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`,第 `i+1` 抽必须先滚到新窗口;不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
|
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`,该抽完成后立即关闭大盘和参与用户的同轮次快照;第 `i+1` 抽先使用这个已关闭轮次判断该用户的一次性资格,再进入新窗口。不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
|
||||||
- 每个子抽用稳定子幂等键(现有约定为 `command_id#序号`)保留独立 draw record;重放整批不能跳过、补写或重复支付其中一抽。
|
- 每个子抽用稳定子幂等键(现有约定为 `command_id#序号`)保留独立 draw record;重放整批不能跳过、补写或重复支付其中一抽。
|
||||||
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
|
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
|
||||||
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。
|
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 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. 接口和配置字段
|
## 9. 接口和配置字段
|
||||||
|
|
||||||
@ -200,13 +212,14 @@ adjusted_weight = base_weight × water_factor × recharge_factor
|
|||||||
`luckygift.v1.LuckyGiftMeta` 接收:
|
`luckygift.v1.LuckyGiftMeta` 接收:
|
||||||
|
|
||||||
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
|
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
|
||||||
|
- `user_registered_at_ms`(来自 user owner 的账号创建时间,供轮次关闭时判断是否注册满 48 小时)
|
||||||
- `paid_at_ms`(逐目标 wallet transaction 的稳定创建时间)
|
- `paid_at_ms`(逐目标 wallet transaction 的稳定创建时间)
|
||||||
- `recharge_7d_coins`
|
- `recharge_7d_coins`
|
||||||
- `recharge_30d_coins`
|
- `recharge_30d_coins`
|
||||||
- `last_recharged_at_ms`
|
- `last_recharged_at_ms`
|
||||||
- `gift_income_coins`
|
- `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 不可变规则字段
|
### 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` |
|
| 资金拆分 | `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` |
|
| 保底和水位 | `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` |
|
| 最近充值 | `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` |
|
| 六维风控 | `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[]` |
|
| 充值层级 | `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_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 |
|
||||||
| `lucky_gift_stage_tiers` | 普通/高阶充值门槛、新手兜底 sentinel、基础倍率和基础概率 |
|
| `lucky_gift_stage_tiers` | 普通/高阶充值门槛、新手兜底 sentinel、基础倍率和基础概率 |
|
||||||
| `lucky_pools` | 公共池 `balance/total_in/total_out`,以及 `profit_total/anchor_income_total/initial_seed_total` |
|
| `lucky_pools` | 公共池 `balance/total_in/total_out`,以及 `profit_total/anchor_income_total/initial_seed_total` |
|
||||||
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 |
|
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖、`started_at_ms/closed_at_ms` 和 `open/closed/underpaid` 状态;关闭时间是48小时统计与注册年龄判断的固定锚点 |
|
||||||
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 |
|
| `lucky_user_rtp_windows` | 每个用户在同一大盘 `rule_version + window_index` 内的总消费/总返奖,以及 `pending/attempted/ineligible/expired` 资格状态;一轮只尝试一次 |
|
||||||
| `lucky_user_rtp_boundary_events` | 只补齐 `[now-72h,now)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
|
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于汇总严格滚动 48 小时 |
|
||||||
| `lucky_user_strategy_days` | 用户 UTC 日流水、返奖、消费和已命中大奖次数 |
|
| `lucky_user_rtp_boundary_events` | 只补齐 `[closed_at+1ms-48h,closed_at+1ms)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
|
||||||
| `lucky_user_states` | 累计抽数、等价流水、`loss_streak` 和可跨日保留的待消费 token |
|
| `lucky_user_strategy_days` | 用户 UTC 日消费和已命中大奖次数;不再作为用户 RTP 资格来源,也不再生成消费里程碑资格 |
|
||||||
|
| `lucky_user_states` | 累计抽数、等价流水和 `loss_streak`;历史 token 字段仅兼容旧记录,新版规则不产生或消费 |
|
||||||
| `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 |
|
| `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 |
|
||||||
| `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 |
|
| `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 |
|
||||||
| `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 |
|
| `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 |
|
||||||
| `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 |
|
| `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 |
|
||||||
| `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 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 | 必须同时达到两维,选择最高满足层 |
|
| 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 |
|
||||||
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
|
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
|
||||||
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100,后 98 抽进入新窗;证明不按抽数提前滚动 |
|
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100,后 98 抽进入新窗;证明不按抽数提前滚动 |
|
||||||
| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 |
|
| 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间;两个用户RTP严格<96%且AND;大盘允许=98%;只玩5分钟按实际总额计算;零样本不触发 |
|
||||||
| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 |
|
| 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 |
|
||||||
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
|
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
|
||||||
| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
|
| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
|
||||||
| 六维风控 | 每一维分别取 `W=remaining` 和 `W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
|
| 六维风控 | 每一维分别取 `W=remaining` 和 `W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
|
||||||
| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP,再普通;安全门永远最后裁决 |
|
| 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 |
|
||||||
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
|
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
|
||||||
| 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%~101%当资金不足时硬断言 |
|
| 有资金长跑 | 固定 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
|
-json-out /tmp/lucky-gift-dynamic-v3-simulation.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-07-15 按上述默认规模生成的完整机器可读报告保存在 `幸运礼物动态策略V3模拟结果-20260715.json`;旧的 2026-07-12 文件只代表历史 day/72h 规则,不得作为本次 48 小时版本的验收依据。
|
||||||
|
|
||||||
策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足:
|
策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足:
|
||||||
|
|
||||||
- `nominal_probability_table.expected_multiplier=26.5`;
|
- `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 金额字段。
|
1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。
|
||||||
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
|
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
|
||||||
3. 累计消费大奖门槛、低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
|
3. 低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。累计消费大奖门槛保持停用,不能成为独立大奖通道。
|
||||||
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 必须 fail-close。
|
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%。
|
5. 本地单测、完整模拟、`make proto`、`make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。
|
||||||
|
|||||||
2029
docs/幸运礼物动态策略V3模拟结果-20260715.json
Normal file
2029
docs/幸运礼物动态策略V3模拟结果-20260715.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,8 @@
|
|||||||
对接 App 负责:
|
对接 App 负责:
|
||||||
|
|
||||||
- 调用前完成用户扣费。
|
- 调用前完成用户扣费。
|
||||||
|
- 从自己的账务和用户主数据提供真实的扣费时间、稳定设备标识和账号注册时间。
|
||||||
|
- 在自己的鉴权或请求签名边界内绑定上述业务事实,不能让 App 或 Web 客户端直接伪造。
|
||||||
- 成功收到结果后,在自己的账务系统中向用户发放 `reward_amount`。
|
- 成功收到结果后,在自己的账务系统中向用户发放 `reward_amount`。
|
||||||
- 对超时或网络失败使用原 `request_id` 重试,不得重新生成业务请求 ID。
|
- 对超时或网络失败使用原 `request_id` 重试,不得重新生成业务请求 ID。
|
||||||
|
|
||||||
@ -43,9 +45,19 @@ GET https://lucky-api.global-interaction.com/healthz/ready
|
|||||||
1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。
|
1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。
|
||||||
2. 确认该 App 的幸运礼物规则已启用。
|
2. 确认该 App 的幸运礼物规则已启用。
|
||||||
3. 对接 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. 发送幸运礼物
|
## 4. 发送幸运礼物
|
||||||
|
|
||||||
@ -67,8 +79,11 @@ X-Request-Id: trace-20260715-000001
|
|||||||
"app_code": "partner_app",
|
"app_code": "partner_app",
|
||||||
"request_id": "gift-order-20260715-000001",
|
"request_id": "gift-order-20260715-000001",
|
||||||
"external_user_id": "user-10001",
|
"external_user_id": "user-10001",
|
||||||
|
"device_id": "device-installation-7f80d90b",
|
||||||
"gift_count": 3,
|
"gift_count": 3,
|
||||||
"unit_amount": 100,
|
"unit_amount": 100,
|
||||||
|
"paid_at_ms": 1784073590000,
|
||||||
|
"user_registered_at_ms": 1783814400000,
|
||||||
"currency": "COIN",
|
"currency": "COIN",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"external_order_id": "order-1000001",
|
"external_order_id": "order-1000001",
|
||||||
@ -85,8 +100,11 @@ X-Request-Id: trace-20260715-000001
|
|||||||
| `app_code` | string | 是 | 去除首尾空格后 1–32 字符 | 平台分配的 App 编码;按小写存储和校验。 |
|
| `app_code` | string | 是 | 去除首尾空格后 1–32 字符 | 平台分配的 App 编码;按小写存储和校验。 |
|
||||||
| `request_id` | string | 是 | 去除首尾空格后 1–128 字符 | 对接 App 生成的业务幂等 ID;建议直接使用送礼订单号或 UUID。 |
|
| `request_id` | string | 是 | 去除首尾空格后 1–128 字符 | 对接 App 生成的业务幂等 ID;建议直接使用送礼订单号或 UUID。 |
|
||||||
| `external_user_id` | string | 是 | 去除首尾空格后 1–128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
|
| `external_user_id` | string | 是 | 去除首尾空格后 1–128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
|
||||||
|
| `device_id` | string | `dynamic_v3` 是 | 去除首尾空格后 1–128 字符 | 对接 App 在自己鉴权/签名边界内绑定的稳定设备标识;用于设备级风控,不得每次请求重新生成。 |
|
||||||
| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 |
|
| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 |
|
||||||
| `unit_amount` | 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` 处理。 |
|
| `currency` | string | 否 | 只支持 `COIN` | 缺省或空字符串时按 `COIN` 处理。 |
|
||||||
| `metadata` | object | 否 | JSON object | 对接方的展示或排查元数据;不参与金额、规则或幂等判定。 |
|
| `metadata` | object | 否 | JSON object | 对接方的展示或排查元数据;不参与金额、规则或幂等判定。 |
|
||||||
|
|
||||||
@ -96,7 +114,9 @@ X-Request-Id: trace-20260715-000001
|
|||||||
total_amount = gift_count * unit_amount
|
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` 不会产生多个独立响应。
|
一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。
|
||||||
|
|
||||||
@ -183,7 +203,7 @@ app_code + request_id
|
|||||||
| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 |
|
| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 |
|
||||||
| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 |
|
| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 |
|
||||||
| 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 |
|
| 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`。
|
业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code` 为 `0`。
|
||||||
|
|
||||||
@ -198,8 +218,11 @@ curl --request POST \
|
|||||||
"app_code": "partner_app",
|
"app_code": "partner_app",
|
||||||
"request_id": "gift-order-20260715-000001",
|
"request_id": "gift-order-20260715-000001",
|
||||||
"external_user_id": "user-10001",
|
"external_user_id": "user-10001",
|
||||||
|
"device_id": "device-installation-7f80d90b",
|
||||||
"gift_count": 3,
|
"gift_count": 3,
|
||||||
"unit_amount": 100,
|
"unit_amount": 100,
|
||||||
|
"paid_at_ms": 1784073590000,
|
||||||
|
"user_registered_at_ms": 1783814400000,
|
||||||
"currency": "COIN",
|
"currency": "COIN",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"external_order_id": "order-1000001",
|
"external_order_id": "order-1000001",
|
||||||
@ -211,17 +234,22 @@ curl --request POST \
|
|||||||
|
|
||||||
## 8. 标准处理流程
|
## 8. 标准处理流程
|
||||||
|
|
||||||
1. 对接 App 生成全局唯一的 `request_id`。
|
1. 对接 App 在已鉴权的服务端上确定 `external_user_id`、稳定 `device_id` 和用户主数据中的 `user_registered_at_ms`。
|
||||||
2. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`。
|
2. 对接 App 生成全局唯一的 `request_id`。
|
||||||
3. 对接 App 调用幸运礼物接口。
|
3. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`,并以扣费成功事实的 UTC 毫秒时间作为 `paid_at_ms`。
|
||||||
4. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
|
4. 对接 App 调用幸运礼物接口,一次传入上述业务事实。
|
||||||
5. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`。
|
5. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
|
||||||
6. 任何未知结果都使用原 `request_id` 重试;禁止重复扣费或重新生成抽奖业务号。
|
6. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`。
|
||||||
|
7. 任何未知结果都使用原 `request_id` 和原始业务事实重试;禁止重复扣费或重新生成抽奖业务号。
|
||||||
|
|
||||||
## 9. 联调检查清单
|
## 9. 联调检查清单
|
||||||
|
|
||||||
- [ ] `app_code` 已加入幸运礼物网关白名单。
|
- [ ] `app_code` 已加入幸运礼物网关白名单。
|
||||||
- [ ] 规则已启用,并确认金币单位一致。
|
- [ ] 规则已启用,并确认金币单位一致。
|
||||||
|
- [ ] `dynamic_v3` 请求中的 `paid_at_ms` 来自真实扣费事实,不是当前时间的临时填充值。
|
||||||
|
- [ ] `device_id` 已绑定已鉴权设备且跨请求稳定,不用请求号或会话 token 代替。
|
||||||
|
- [ ] `user_registered_at_ms` 来自用户主数据;已验证缺失时普通抽奖可继续、RTP 补偿大奖资格会关闭。
|
||||||
|
- [ ] 调用能力仅保留在对接 App 服务端,上述业务事实已纳入自身鉴权或请求签名边界。
|
||||||
- [ ] 正常送礼可获得 HTTP 200 / `code=0`。
|
- [ ] 正常送礼可获得 HTTP 200 / `code=0`。
|
||||||
- [ ] 重复发送相同 `request_id` 可获得相同 `draw_id` 和结果。
|
- [ ] 重复发送相同 `request_id` 可获得相同 `draw_id` 和结果。
|
||||||
- [ ] 模拟超时后只重试抽奖请求,不重复扣费。
|
- [ ] 模拟超时后只重试抽奖请求,不重复扣费。
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package luckygift
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
@ -32,41 +33,71 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configRequest struct {
|
type configRequest struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
StrategyVersion string `json:"strategy_version"`
|
StrategyVersion string `json:"strategy_version"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||||||
PoolRatePercent float64 `json:"pool_rate_percent"`
|
PoolRatePercent float64 `json:"pool_rate_percent"`
|
||||||
ProfitRatePercent float64 `json:"profit_rate_percent"`
|
ProfitRatePercent float64 `json:"profit_rate_percent"`
|
||||||
AnchorRatePercent float64 `json:"anchor_rate_percent"`
|
AnchorRatePercent float64 `json:"anchor_rate_percent"`
|
||||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||||
ControlBandPercent float64 `json:"control_band_percent"`
|
ControlBandPercent float64 `json:"control_band_percent"`
|
||||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||||
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
|
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
|
||||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||||
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
|
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
|
||||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||||
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
|
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
|
||||||
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
|
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
|
||||||
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
|
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
|
||||||
JackpotUserDayRTPMaxPercent float64 `json:"jackpot_user_day_rtp_max_percent"`
|
JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"`
|
||||||
JackpotUser72hRTPMaxPercent float64 `json:"jackpot_user_72h_rtp_max_percent"`
|
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"`
|
||||||
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
|
// Deprecated in-process aliases keep historical handler builders source-compatible. They are not
|
||||||
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
|
// accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
|
||||||
MaxSinglePayout int64 `json:"max_single_payout"`
|
JackpotUserDayRTPMaxPercent float64 `json:"-"`
|
||||||
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
|
JackpotUser72hRTPMaxPercent float64 `json:"-"`
|
||||||
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
|
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
|
||||||
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
|
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
|
||||||
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
|
MaxSinglePayout int64 `json:"max_single_payout"`
|
||||||
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
|
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
|
||||||
Stages []stageDTO `json:"stages"`
|
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 {
|
type configDTO struct {
|
||||||
@ -189,11 +220,15 @@ func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
||||||
var req configRequest
|
var input configUpsertRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
decoder := json.NewDecoder(c.Request.Body)
|
||||||
|
// 兼容字段必须进入显式 DTO,不能退回 map 或宽松二次反序列化,否则拼错的配置字段会被静默忽略。
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&input); err != nil {
|
||||||
response.BadRequest(c, "幸运礼物配置参数不正确")
|
response.BadRequest(c, "幸运礼物配置参数不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req := input.canonicalConfig()
|
||||||
if req.PoolID == "" {
|
if req.PoolID == "" {
|
||||||
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
|
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
|
||||||
}
|
}
|
||||||
@ -443,6 +478,14 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
|||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
roundRTPMaxPercent := req.JackpotUserRoundRTPMaxPercent
|
||||||
|
if roundRTPMaxPercent == 0 {
|
||||||
|
roundRTPMaxPercent = req.JackpotUserDayRTPMaxPercent
|
||||||
|
}
|
||||||
|
rolling48hRTPMaxPercent := req.JackpotUser48hRTPMaxPercent
|
||||||
|
if rolling48hRTPMaxPercent == 0 {
|
||||||
|
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
|
||||||
|
}
|
||||||
return &luckygiftv1.LuckyGiftRuleConfig{
|
return &luckygiftv1.LuckyGiftRuleConfig{
|
||||||
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
|
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
|
||||||
PoolId: strings.TrimSpace(req.PoolID),
|
PoolId: strings.TrimSpace(req.PoolID),
|
||||||
@ -468,8 +511,8 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
|||||||
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
|
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
|
||||||
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
|
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
|
||||||
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
|
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
|
||||||
JackpotUserDayRtpMaxPpm: percentToPPM(req.JackpotUserDayRTPMaxPercent),
|
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
|
||||||
JackpotUser_72HRtpMaxPpm: percentToPPM(req.JackpotUser72hRTPMaxPercent),
|
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
|
||||||
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
|
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
|
||||||
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
|
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
|
||||||
MaxSinglePayout: req.MaxSinglePayout,
|
MaxSinglePayout: req.MaxSinglePayout,
|
||||||
@ -541,8 +584,10 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
|
|||||||
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
|
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
|
||||||
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
|
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
|
||||||
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
|
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
|
||||||
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserDayRtpMaxPpm()),
|
JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
|
||||||
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_72HRtpMaxPpm()),
|
JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
|
||||||
|
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
|
||||||
|
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
|
||||||
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
||||||
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
||||||
MaxSinglePayout: config.GetMaxSinglePayout(),
|
MaxSinglePayout: config.GetMaxSinglePayout(),
|
||||||
|
|||||||
@ -15,12 +15,15 @@ type SendCommand struct {
|
|||||||
RequestID string
|
RequestID string
|
||||||
ExternalUserID string
|
ExternalUserID string
|
||||||
DeviceID string
|
DeviceID string
|
||||||
GiftCount int64
|
// UserRegisteredAtMS 是外部 App 用户 owner 提供的账号创建事实;integration 层只原样透传,
|
||||||
UnitAmount int64
|
// 不用网关收包时间或该用户首次请求时间填充,否则会错误改变完整历史窗口资格。
|
||||||
TotalAmount int64
|
UserRegisteredAtMS int64
|
||||||
Currency string
|
GiftCount int64
|
||||||
MetadataJSON string
|
UnitAmount int64
|
||||||
PaidAtMS int64
|
TotalAmount int64
|
||||||
|
Currency string
|
||||||
|
MetadataJSON string
|
||||||
|
PaidAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendResult struct {
|
type SendResult struct {
|
||||||
@ -58,16 +61,17 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe
|
|||||||
SentAtMs: time.Now().UTC().UnixMilli(),
|
SentAtMs: time.Now().UTC().UnixMilli(),
|
||||||
AppCode: cmd.AppCode,
|
AppCode: cmd.AppCode,
|
||||||
},
|
},
|
||||||
AppCode: cmd.AppCode,
|
AppCode: cmd.AppCode,
|
||||||
ExternalUserId: cmd.ExternalUserID,
|
ExternalUserId: cmd.ExternalUserID,
|
||||||
DeviceId: cmd.DeviceID,
|
DeviceId: cmd.DeviceID,
|
||||||
RequestId: cmd.RequestID,
|
UserRegisteredAtMs: cmd.UserRegisteredAtMS,
|
||||||
GiftCount: cmd.GiftCount,
|
RequestId: cmd.RequestID,
|
||||||
UnitAmount: cmd.UnitAmount,
|
GiftCount: cmd.GiftCount,
|
||||||
TotalAmount: cmd.TotalAmount,
|
UnitAmount: cmd.UnitAmount,
|
||||||
Currency: cmd.Currency,
|
TotalAmount: cmd.TotalAmount,
|
||||||
PaidAtMs: cmd.PaidAtMS,
|
Currency: cmd.Currency,
|
||||||
MetadataJson: cmd.MetadataJSON,
|
PaidAtMs: cmd.PaidAtMS,
|
||||||
|
MetadataJson: cmd.MetadataJSON,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SendResult{}, err
|
return SendResult{}, err
|
||||||
|
|||||||
@ -35,11 +35,14 @@ type sendRequest struct {
|
|||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
// PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms;调用方必须把它纳入自己的认证/签名边界。
|
// PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms;调用方必须把它纳入自己的认证/签名边界。
|
||||||
// gateway 不用到达时间替代。fixed_v2 允许 0 兼容,dynamic_v3 由 owner 读取规则后强制要求正值。
|
// gateway 不用到达时间替代。fixed_v2 允许 0 兼容,dynamic_v3 由 owner 读取规则后强制要求正值。
|
||||||
PaidAtMS int64 `json:"paid_at_ms"`
|
PaidAtMS int64 `json:"paid_at_ms"`
|
||||||
GiftCount int64 `json:"gift_count"`
|
// UserRegisteredAtMS 是外部 App 用户主数据中的账号创建时间,不是本次请求首次出现时间。
|
||||||
UnitAmount int64 `json:"unit_amount"`
|
// fixed_v2 允许 0 兼容旧接入;dynamic_v3 缺失时由 owner 让补偿大奖资格 fail-close。
|
||||||
Currency string `json:"currency"`
|
UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
|
||||||
Metadata map[string]any `json:"metadata"`
|
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) {
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), h.requestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := h.client.SendLuckyGift(ctx, luckygiftclient.SendCommand{
|
result, err := h.client.SendLuckyGift(ctx, luckygiftclient.SendCommand{
|
||||||
TraceRequestID: requestID,
|
TraceRequestID: requestID,
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
RequestID: strings.TrimSpace(req.RequestID),
|
RequestID: strings.TrimSpace(req.RequestID),
|
||||||
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
|
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
|
||||||
DeviceID: strings.TrimSpace(req.DeviceID),
|
DeviceID: strings.TrimSpace(req.DeviceID),
|
||||||
GiftCount: req.GiftCount,
|
UserRegisteredAtMS: req.UserRegisteredAtMS,
|
||||||
UnitAmount: req.UnitAmount,
|
GiftCount: req.GiftCount,
|
||||||
TotalAmount: req.GiftCount * req.UnitAmount,
|
UnitAmount: req.UnitAmount,
|
||||||
Currency: strings.TrimSpace(req.Currency),
|
TotalAmount: req.GiftCount * req.UnitAmount,
|
||||||
MetadataJSON: metadataJSON(req.Metadata),
|
Currency: strings.TrimSpace(req.Currency),
|
||||||
|
MetadataJSON: metadataJSON(req.Metadata),
|
||||||
// pool 仍由 owner 默认规则选择;paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
|
// pool 仍由 owner 默认规则选择;paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
|
||||||
PaidAtMS: req.PaidAtMS,
|
PaidAtMS: req.PaidAtMS,
|
||||||
})
|
})
|
||||||
@ -109,8 +113,11 @@ func (r sendRequest) validate() error {
|
|||||||
if r.PaidAtMS < 0 {
|
if r.PaidAtMS < 0 {
|
||||||
return errors.New("paid_at_ms is invalid")
|
return errors.New("paid_at_ms is invalid")
|
||||||
}
|
}
|
||||||
// 不在这里统一要求 device_id:网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。
|
if r.UserRegisteredAtMS < 0 {
|
||||||
// owner 读到规则后对 dynamic_v3 权威拒绝空值,fixed_v2 继续兼容旧调用方。
|
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 {
|
if r.GiftCount <= 0 || r.UnitAmount <= 0 {
|
||||||
return errors.New("gift_count and unit_amount must be positive")
|
return errors.New("gift_count and unit_amount must be positive")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2030,13 +2030,16 @@ func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Reque
|
|||||||
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
|
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
|
||||||
|
|
||||||
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
|
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
|
||||||
Meta: httpkit.RoomMeta(request, roomID, commandID),
|
Meta: httpkit.RoomMeta(request, roomID, commandID),
|
||||||
TargetType: targetType,
|
TargetType: targetType,
|
||||||
TargetUserIds: targetUserIDs,
|
TargetUserIds: targetUserIDs,
|
||||||
TargetUserId: firstUserID(targetUserIDs),
|
TargetUserId: firstUserID(targetUserIDs),
|
||||||
GiftId: body.GiftID,
|
GiftId: body.GiftID,
|
||||||
GiftCount: body.GiftCount,
|
GiftCount: body.GiftCount,
|
||||||
PoolId: body.PoolID,
|
PoolId: body.PoolID,
|
||||||
|
// 注册时间属于 user-service 主数据快照;只允许 gateway 从已鉴权 sender 资料注入,
|
||||||
|
// room/lucky-gift 后续据此判断账号是否已跨过完整历史窗口,不能信任客户端自报。
|
||||||
|
SenderRegisteredAtMs: userSnapshots.senderRegisteredAtMS,
|
||||||
SenderCountryId: userSnapshots.senderCountryID,
|
SenderCountryId: userSnapshots.senderCountryID,
|
||||||
SenderRegionId: userSnapshots.senderRegionID,
|
SenderRegionId: userSnapshots.senderRegionID,
|
||||||
TargetIsHost: firstTargetScope.GetTargetIsHost(),
|
TargetIsHost: firstTargetScope.GetTargetIsHost(),
|
||||||
@ -2126,6 +2129,7 @@ func (h *Handler) resolveRoomUserDisplayProfileBestEffort(request *http.Request,
|
|||||||
}
|
}
|
||||||
|
|
||||||
type giftUserSnapshots struct {
|
type giftUserSnapshots struct {
|
||||||
|
senderRegisteredAtMS int64
|
||||||
senderCountryID int64
|
senderCountryID int64
|
||||||
senderRegionID int64
|
senderRegionID int64
|
||||||
senderDisplayProfile *roomv1.SendGiftDisplayProfile
|
senderDisplayProfile *roomv1.SendGiftDisplayProfile
|
||||||
@ -2161,6 +2165,9 @@ func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return giftUserSnapshots{
|
return giftUserSnapshots{
|
||||||
|
// created_at_ms 是账号 owner 返回的稳定事实;即使同一 command_id 重试时资料发生变化,
|
||||||
|
// room-service 也会恢复首次持久化的命令快照,避免补偿大奖资格随重试漂移。
|
||||||
|
senderRegisteredAtMS: sender.GetCreatedAtMs(),
|
||||||
senderCountryID: sender.GetCountryId(),
|
senderCountryID: sender.GetCountryId(),
|
||||||
senderRegionID: sender.GetRegionId(),
|
senderRegionID: sender.GetRegionId(),
|
||||||
senderDisplayProfile: giftDisplayProfileFromUser(sender),
|
senderDisplayProfile: giftDisplayProfileFromUser(sender),
|
||||||
|
|||||||
@ -95,8 +95,8 @@ func runSimulation(options simulationOptions) simulationReport {
|
|||||||
simulateRechargeWindowBoundaries(config),
|
simulateRechargeWindowBoundaries(config),
|
||||||
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
|
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
|
||||||
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN),
|
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN),
|
||||||
simulateMechanismOneTruthTable(config, options.Seed+30),
|
simulateSettledRoundQualification(config, options.Seed+30),
|
||||||
simulateMechanismTwo(config, options.Seed+40),
|
simulatePoolBlockedQualificationConsumption(config, options.Seed+40),
|
||||||
simulateJackpotSet(config, options.Seed+50),
|
simulateJackpotSet(config, options.Seed+50),
|
||||||
simulateDailyJackpotLimit(config, options.Seed+60),
|
simulateDailyJackpotLimit(config, options.Seed+60),
|
||||||
simulateSixRiskCapacities(config),
|
simulateSixRiskCapacities(config),
|
||||||
|
|||||||
@ -354,108 +354,154 @@ func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) dom
|
|||||||
return domain.StrategyWeightTrace{}
|
return domain.StrategyWeightTrace{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type mechanismOneRow struct {
|
type settledRoundQualificationRow struct {
|
||||||
GlobalPass bool `json:"global_pass"`
|
Case string `json:"case"`
|
||||||
UserDayPass bool `json:"user_day_pass"`
|
RegisteredAgeHours int64 `json:"registered_age_hours"`
|
||||||
User72HourPass bool `json:"user_72h_pass"`
|
RoundWagerCoins int64 `json:"round_wager_coins"`
|
||||||
Triggered bool `json:"triggered"`
|
RoundPayoutCoins int64 `json:"round_payout_coins"`
|
||||||
SelectedTier string `json:"selected_tier"`
|
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 {
|
func simulateSettledRoundQualification(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
const name = "大奖机制1真值表"
|
const name = "已结算轮次+注册48小时大奖资格"
|
||||||
config = baseProbabilityConfig(config)
|
config = settledRoundJackpotConfig(config)
|
||||||
config.JackpotMechanism1Enabled = true
|
const (
|
||||||
config.DailyJackpotLimit = 5
|
hourMS = int64(60 * 60 * 1000)
|
||||||
rows := make([]mechanismOneRow, 0, 8)
|
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
|
passed := true
|
||||||
rowIndex := int64(0)
|
for index, item := range cases {
|
||||||
for mask := 0; mask < 8; mask++ {
|
decision, err := domain.DecideLuckyGiftStrategy(config, item.state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
|
||||||
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))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation
|
triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation
|
||||||
want := globalPass && dayPass && hour72Pass
|
passed = passed && triggered == item.want
|
||||||
passed = passed && triggered == want
|
rows = append(rows, settledRoundQualificationRow{
|
||||||
rows = append(rows, mechanismOneRow{GlobalPass: globalPass, UserDayPass: dayPass, User72HourPass: hour72Pass, Triggered: triggered, SelectedTier: decision.SelectedTier.ID})
|
Case: item.name, RegisteredAgeHours: (item.state.QualificationClosedAtMS - item.state.UserRegisteredAtMS) / hourMS,
|
||||||
rowIndex++
|
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{
|
return scenarioResult{
|
||||||
Name: name, Passed: passed,
|
Name: name, Passed: passed,
|
||||||
Summary: "global<=98%、用户日<=96%、用户72h<=96%三项全真才触发;任一分母为0明确阻断",
|
Summary: "注册<48h失败;恰48h及只玩最后5分钟按实际总流水判断;用户轮次/48h严格<96%,大盘允许=98%,超限或零样本失败",
|
||||||
Data: map[string]any{"truth_table": rows, "denominator_zero_blocked": zeroBlocked, "denominator_zero_conditions": zeroDecision.Trace.Conditions},
|
Data: rows,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func rtpForTruth(pass bool, limitPercent int64) domain.StrategyRTP {
|
func settledRoundJackpotConfig(config domain.StrategyConfig) domain.StrategyConfig {
|
||||||
payout := limitPercent
|
|
||||||
if !pass {
|
|
||||||
payout++
|
|
||||||
}
|
|
||||||
return domain.StrategyRTP{WagerCoins: 100, PayoutCoins: payout}
|
|
||||||
}
|
|
||||||
|
|
||||||
func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResult {
|
|
||||||
const name = "机制2里程碑/池不足"
|
|
||||||
config = baseProbabilityConfig(config)
|
config = baseProbabilityConfig(config)
|
||||||
config.JackpotMechanism2Enabled = true
|
config.JackpotMechanism1Enabled = true
|
||||||
// 模拟只用一个便于验证跨档的金币值;生产值必须来自当前不可变规则版本。
|
config.JackpotMechanism2Enabled = false
|
||||||
config.MilestoneSpendCoins = 50
|
config.MilestoneSpendCoins = 0
|
||||||
|
config.SettledRoundRTPEligibility = true
|
||||||
|
config.GlobalRTPMaxPPM = 980_000
|
||||||
|
config.UserRoundRTPMaxPPM = 960_000
|
||||||
|
config.User48HourRTPMaxPPM = 960_000
|
||||||
config.DailyJackpotLimit = 5
|
config.DailyJackpotLimit = 5
|
||||||
input := domain.StrategyInput{GiftPriceCoins: 10}
|
return config
|
||||||
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, domain.NewSeededStrategyRandom(seed))
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
payableState := blocked.NextState
|
afterTopUp := first.NextState
|
||||||
payableState.PoolBalanceCoins = 20_000
|
afterTopUp.PoolBalanceCoins = 20_000
|
||||||
paid, err := domain.DecideLuckyGiftStrategy(config, payableState, input, domain.NewSeededStrategyRandom(seed+1))
|
second, err := domain.DecideLuckyGiftStrategy(config, afterTopUp, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
crossed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}, input, domain.NewSeededStrategyRandom(seed+2))
|
passed := !first.Jackpot && !first.NextState.SettledRoundPending && !second.Jackpot && second.JackpotMechanism == ""
|
||||||
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
|
|
||||||
return scenarioResult{
|
return scenarioResult{
|
||||||
Name: name, Passed: passed,
|
Name: name, Passed: passed,
|
||||||
Summary: fmt.Sprintf("49池余额时资格保留=%v;补池后命中%s并消费;40→50本抽新增token=%d供下一抽", blocked.Trace.MilestoneTokenRetained, paid.SelectedTier.ID, crossed.Trace.MilestoneTokensEarned),
|
Summary: fmt.Sprintf("首抽池=%d不足支付200x,资格尝试后pending=%v;补池到20000再抽也不会复用旧资格", state.PoolBalanceCoins, first.NextState.SettledRoundPending),
|
||||||
Data: map[string]any{"milestone_crossings_49_to_101": domain.MilestoneTokensEarned(49, 101, 50), "blocked": blocked, "paid": paid, "earned_for_next_draw": crossed},
|
Data: map[string]any{"pool_blocked_attempt": first, "after_top_up_without_new_closed_round": second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
|
func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
const name = "大奖集合"
|
const name = "大奖集合"
|
||||||
config = baseProbabilityConfig(config)
|
config = settledRoundJackpotConfig(config)
|
||||||
config.JackpotMechanism2Enabled = true
|
|
||||||
config.DailyJackpotLimit = 5
|
|
||||||
all := map[string]int{}
|
all := map[string]int{}
|
||||||
for index := int64(0); index < 120; index++ {
|
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 {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
@ -463,7 +509,7 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult
|
|||||||
}
|
}
|
||||||
limited := map[string]int{}
|
limited := map[string]int{}
|
||||||
for index := int64(0); index < 40; index++ {
|
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 {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
@ -482,12 +528,12 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult
|
|||||||
|
|
||||||
func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult {
|
func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
const name = "日5次上限"
|
const name = "日5次上限"
|
||||||
config = baseProbabilityConfig(config)
|
config = settledRoundJackpotConfig(config)
|
||||||
config.JackpotMechanism2Enabled = true
|
state := qualifiedSettledRoundState(1_000_000, 100*60*60*1000)
|
||||||
config.DailyJackpotLimit = 5
|
|
||||||
state := domain.StrategyState{PoolBalanceCoins: 1_000_000}
|
|
||||||
for index := 0; index < 5; index++ {
|
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)))
|
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
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)}
|
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
|
// 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.
|
// 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}})
|
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 {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
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{
|
return scenarioResult{
|
||||||
Name: name, Passed: passed,
|
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},
|
Data: map[string]any{"sixth": sixth},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -579,22 +626,14 @@ func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
|
|||||||
|
|
||||||
func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
|
func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
const name = "组合优先级"
|
const name = "组合优先级"
|
||||||
config = baseProbabilityConfig(config)
|
config = settledRoundJackpotConfig(config)
|
||||||
config.LowWaterThresholdCoins = 20_000
|
config.LowWaterThresholdCoins = 20_000
|
||||||
config.HighWaterThresholdCoins = 30_000
|
config.HighWaterThresholdCoins = 30_000
|
||||||
config.LowWaterFactorPPM = 700_000
|
config.LowWaterFactorPPM = 700_000
|
||||||
config.HighWaterFactorPPM = 1_300_000
|
config.HighWaterFactorPPM = 1_300_000
|
||||||
config.RechargeFactorPPM = 1_100_000
|
config.RechargeFactorPPM = 1_100_000
|
||||||
config.JackpotMechanism1Enabled = true
|
state := qualifiedSettledRoundState(10_000, 100*60*60*1000)
|
||||||
config.JackpotMechanism2Enabled = true
|
state.ConsecutiveZeroDraws = 5
|
||||||
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},
|
|
||||||
}
|
|
||||||
input := domain.StrategyInput{
|
input := domain.StrategyInput{
|
||||||
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
|
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
|
||||||
LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
|
LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
|
||||||
@ -603,9 +642,8 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
// When both jackpot mechanisms are eligible, the persisted token is contractual
|
// 已结算轮次资格在普通概率、第6抽保底和动态因子之前尝试;独立消费里程碑机制已经停用。
|
||||||
// state and therefore wins before RTP compensation or sixth-draw protection.
|
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.Stage == domain.StageAdvanced
|
||||||
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && priority.ConsumedMilestoneToken && priority.Stage == domain.StageAdvanced
|
|
||||||
|
|
||||||
blockedState := state
|
blockedState := state
|
||||||
blockedState.PoolBalanceCoins = 50
|
blockedState.PoolBalanceCoins = 50
|
||||||
@ -613,12 +651,11 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
// Neither special mechanism may overdraw the pool. The token survives and the
|
// 大奖资格不能透支奖池;该轮资格被消费后,同一抽仍可按普通第6抽保底支付恰好可负担的5x。
|
||||||
// ordinary sixth-draw protection may pay only the affordable 5x boundary.
|
blockedPass := !blocked.NextState.SettledRoundPending && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
|
||||||
blockedPass := blocked.Trace.MilestoneTokenRetained && blocked.NextState.PendingMilestoneTokens == 1 && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
|
|
||||||
return scenarioResult{
|
return scenarioResult{
|
||||||
Name: name, Passed: priorityPass && blockedPass,
|
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},
|
Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,8 +101,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
|
|||||||
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子,ppm',
|
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子,ppm',
|
||||||
jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT '动态大奖倍率列表,1x=1000000',
|
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_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_round_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_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 48 小时 RTP 严格上限,ppm',
|
||||||
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本动态配置的用户 UTC 日累计消费大奖门槛金币',
|
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_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限',
|
||||||
max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限',
|
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 '窗口实际基础返奖',
|
actual_base_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口实际基础返奖',
|
||||||
carry_ppm 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',
|
status VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT 'open/closed/underpaid',
|
||||||
|
started_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口首笔扣费事实时间,UTC epoch ms;0 表示历史窗口未知',
|
||||||
|
last_paid_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口末笔扣费事实时间,UTC epoch ms;0 表示历史窗口未知',
|
||||||
|
closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '达到结算流水并冻结资格的时间,UTC epoch ms;未关闭或历史未知为 0',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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)
|
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 控制窗口';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS lucky_pools (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
scope_type VARCHAR(32) NOT NULL COMMENT 'pool',
|
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',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
|
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
|
||||||
KEY idx_lucky_user_rtp_hour_retention (app_code, 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) 两端不足一小时的片段。
|
-- 小时桶负责完整小时;closed_at_ms 保存实际末笔支付毫秒,查询用 [closed_at+1ms-48h,closed_at+1ms)
|
||||||
-- 这样热路径最多扫描 71 个小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
|
-- 纳入触发结算的末笔事实,边界事件只补齐两端不足一小时的片段。
|
||||||
|
-- 这样闭窗热路径最多扫描 47 个完整小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
|
||||||
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
|
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
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',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, pool_id, user_id, draw_id),
|
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)
|
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 (
|
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
|||||||
@ -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 ms;0 表示历史窗口未知'', 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 ms;0 表示历史窗口未知'', 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;
|
||||||
@ -57,27 +57,34 @@ type Config struct {
|
|||||||
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
||||||
ControlBandPPM int64 `json:"control_band_ppm"`
|
ControlBandPPM int64 `json:"control_band_ppm"`
|
||||||
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
|
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
|
||||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||||
GlobalWindowDraws int64 `json:"global_window_draws"`
|
GlobalWindowDraws int64 `json:"global_window_draws"`
|
||||||
GiftWindowDraws int64 `json:"gift_window_draws"`
|
GiftWindowDraws int64 `json:"gift_window_draws"`
|
||||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||||
HighMultiplier int64 `json:"high_multiplier"`
|
HighMultiplier int64 `json:"high_multiplier"`
|
||||||
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
||||||
InitialBasePool int64 `json:"initial_base_pool"`
|
InitialBasePool int64 `json:"initial_base_pool"`
|
||||||
BasePoolReserve int64 `json:"base_pool_reserve"`
|
BasePoolReserve int64 `json:"base_pool_reserve"`
|
||||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||||
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
||||||
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
||||||
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
|
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
|
||||||
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_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"`
|
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
|
||||||
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
|
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
|
||||||
MaxSinglePayout int64 `json:"max_single_payout"`
|
MaxSinglePayout int64 `json:"max_single_payout"`
|
||||||
@ -117,35 +124,39 @@ type RuleStage struct {
|
|||||||
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
|
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
|
||||||
// 比例和倍率统一使用 ppm,后台百分比/倍率业务单位只在发布入口转换。
|
// 比例和倍率统一使用 ppm,后台百分比/倍率业务单位只在发布入口转换。
|
||||||
type RuleConfig struct {
|
type RuleConfig struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
RuleVersion int64 `json:"rule_version"`
|
RuleVersion int64 `json:"rule_version"`
|
||||||
StrategyVersion string `json:"strategy_version"`
|
StrategyVersion string `json:"strategy_version"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||||
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
||||||
ProfitRatePPM int64 `json:"profit_rate_ppm"`
|
ProfitRatePPM int64 `json:"profit_rate_ppm"`
|
||||||
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
||||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||||
ControlBandPPM int64 `json:"control_band_ppm"`
|
ControlBandPPM int64 `json:"control_band_ppm"`
|
||||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||||
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
||||||
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
||||||
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
|
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
|
||||||
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_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"`
|
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
|
||||||
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
|
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
|
||||||
MaxSinglePayout int64 `json:"max_single_payout"`
|
MaxSinglePayout int64 `json:"max_single_payout"`
|
||||||
@ -165,23 +176,26 @@ type CheckCommand struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DrawCommand struct {
|
type DrawCommand struct {
|
||||||
CommandID string
|
CommandID string
|
||||||
PoolID string
|
PoolID string
|
||||||
UserID int64
|
UserID int64
|
||||||
TargetUserID int64
|
TargetUserID int64
|
||||||
DeviceID string
|
DeviceID string
|
||||||
RoomID string
|
RoomID string
|
||||||
AnchorID string
|
AnchorID string
|
||||||
GiftID string
|
GiftID string
|
||||||
GiftCount int32
|
GiftCount int32
|
||||||
CoinSpent int64
|
CoinSpent int64
|
||||||
PaidAtMS int64
|
PaidAtMS int64
|
||||||
VisibleRegionID int64
|
// UserRegisteredAtMS is the user owner snapshot captured by gateway at the original gift request.
|
||||||
CountryID int64
|
// Zero is tolerated for an old recovered command but makes the 48-hour jackpot maturity gate fail closed.
|
||||||
Recharge7DCoins int64
|
UserRegisteredAtMS int64
|
||||||
Recharge30DCoins int64
|
VisibleRegionID int64
|
||||||
LastRechargedAtMS int64
|
CountryID int64
|
||||||
GiftIncomeCoins int64
|
Recharge7DCoins int64
|
||||||
|
Recharge30DCoins int64
|
||||||
|
LastRechargedAtMS int64
|
||||||
|
GiftIncomeCoins int64
|
||||||
// Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
|
// Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
|
||||||
SenderName string
|
SenderName string
|
||||||
SenderAvatar string
|
SenderAvatar string
|
||||||
@ -334,15 +348,18 @@ type ExternalDrawCommand struct {
|
|||||||
AppCode string
|
AppCode string
|
||||||
ExternalUserID string
|
ExternalUserID string
|
||||||
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;dynamic_v3 必填。
|
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;dynamic_v3 必填。
|
||||||
DeviceID string
|
DeviceID string
|
||||||
RequestID string
|
RequestID string
|
||||||
GiftCount int64
|
GiftCount int64
|
||||||
UnitAmount int64
|
UnitAmount int64
|
||||||
TotalAmount int64
|
TotalAmount int64
|
||||||
Currency string
|
Currency string
|
||||||
PaidAtMS int64
|
PaidAtMS int64
|
||||||
MetadataJSON string
|
// UserRegisteredAtMS is supplied by the external App's authenticated user-data boundary.
|
||||||
PoolID string
|
// 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 的最终抽奖结果。
|
// ExternalDrawResult 是返回给外部 App 的最终抽奖结果。
|
||||||
|
|||||||
@ -35,11 +35,16 @@ const (
|
|||||||
StrategyRemovalMissProtection = "miss_protection_excludes_zero"
|
StrategyRemovalMissProtection = "miss_protection_excludes_zero"
|
||||||
StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero"
|
StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero"
|
||||||
StrategyConditionGlobalRTP = "global_rtp"
|
StrategyConditionGlobalRTP = "global_rtp"
|
||||||
StrategyConditionUserDayRTP = "user_day_rtp"
|
StrategyConditionSettledRound = "settled_round_qualification"
|
||||||
StrategyConditionUser72HourRTP = "user_72h_rtp"
|
StrategyConditionUserRoundRTP = "user_round_rtp"
|
||||||
StrategyConditionDailyJackpotLimit = "daily_jackpot_limit"
|
StrategyConditionUser48HourRTP = "user_48h_rtp"
|
||||||
StrategyConditionPayableJackpot = "payable_jackpot_candidate"
|
StrategyConditionUser48HourMaturity = "user_registered_48h"
|
||||||
StrategyDefaultZeroTierID = "0x"
|
// 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 (
|
var (
|
||||||
@ -122,8 +127,14 @@ type StrategyConfig struct {
|
|||||||
JackpotMechanism1Enabled bool `json:"jackpot_mechanism_1_enabled"`
|
JackpotMechanism1Enabled bool `json:"jackpot_mechanism_1_enabled"`
|
||||||
JackpotMechanism2Enabled bool `json:"jackpot_mechanism_2_enabled"`
|
JackpotMechanism2Enabled bool `json:"jackpot_mechanism_2_enabled"`
|
||||||
GlobalRTPMaxPPM int64 `json:"global_rtp_max_ppm"`
|
GlobalRTPMaxPPM int64 `json:"global_rtp_max_ppm"`
|
||||||
UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"`
|
UserRoundRTPMaxPPM int64 `json:"user_round_rtp_max_ppm"`
|
||||||
User72HourRTPMaxPPM int64 `json:"user_72h_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"`
|
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
|
// must update it atomically with the draw record; Version is provided for CAS/optimistic
|
||||||
// locking but the kernel itself intentionally performs no I/O.
|
// locking but the kernel itself intentionally performs no I/O.
|
||||||
type StrategyState struct {
|
type StrategyState struct {
|
||||||
PoolBalanceCoins int64 `json:"pool_balance_coins"`
|
PoolBalanceCoins int64 `json:"pool_balance_coins"`
|
||||||
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
|
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
|
||||||
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
|
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
|
||||||
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
|
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
|
||||||
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
|
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
|
||||||
GlobalRTP StrategyRTP `json:"global_rtp"`
|
GlobalRTP StrategyRTP `json:"global_rtp"`
|
||||||
UserDayRTP StrategyRTP `json:"user_day_rtp"`
|
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
|
||||||
User72HourRTP StrategyRTP `json:"user_72h_rtp"`
|
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
|
||||||
Version int64 `json:"version"`
|
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.
|
// 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 {
|
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)
|
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 {
|
if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
|
||||||
return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput)
|
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 {
|
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)
|
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)
|
return fmt.Errorf("%w: RTP jackpot limits must be within [0,100%%]", ErrStrategyConfig)
|
||||||
}
|
}
|
||||||
var splitRateTotal int64
|
var splitRateTotal int64
|
||||||
@ -631,6 +652,12 @@ func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, 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 {
|
checks := []struct {
|
||||||
name string
|
name string
|
||||||
rtp StrategyRTP
|
rtp StrategyRTP
|
||||||
@ -659,6 +686,75 @@ func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]Strat
|
|||||||
return traces, eligible && limitPassed
|
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) {
|
func selectPayableJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource) (StrategyTier, int64, bool, string, []StrategyRemovalTrace, error) {
|
||||||
if state.UserDailyJackpotWins >= config.DailyJackpotLimit {
|
if state.UserDailyJackpotWins >= config.DailyJackpotLimit {
|
||||||
return StrategyTier{}, 0, false, StrategyReasonDailyJackpotLimit, nil, nil
|
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)
|
return StrategyDecision{}, fmt.Errorf("%w: payout=%d exceeds available pool=%d", ErrStrategyInput, payout, available)
|
||||||
}
|
}
|
||||||
next := state
|
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
|
next.PoolBalanceCoins = available - payout
|
||||||
if next.PoolBalanceCoins < 0 {
|
if next.PoolBalanceCoins < 0 {
|
||||||
return StrategyDecision{}, fmt.Errorf("%w: strategy would make pool negative", ErrStrategyInput)
|
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
|
return StrategyDecision{}, err
|
||||||
}
|
}
|
||||||
// A threshold crossed by this draw is persisted after selection, so the newly
|
// 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.
|
// earned token can affect only the next locked draw and never the current one. Production V3's
|
||||||
earnedTokens := MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
|
// 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 {
|
if earnedTokens > 0 {
|
||||||
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
|
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -822,14 +928,16 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
|
|||||||
}
|
}
|
||||||
trace.MilestoneTokensEarned = earnedTokens
|
trace.MilestoneTokensEarned = earnedTokens
|
||||||
}
|
}
|
||||||
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
|
if !config.SettledRoundRTPEligibility {
|
||||||
return StrategyDecision{}, err
|
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.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.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil {
|
||||||
|
return StrategyDecision{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if next.Version == math.MaxInt64 {
|
if next.Version == math.MaxInt64 {
|
||||||
return StrategyDecision{}, fmt.Errorf("%w: state version overflow", ErrStrategyInput)
|
return StrategyDecision{}, fmt.Errorf("%w: state version overflow", ErrStrategyInput)
|
||||||
|
|||||||
@ -40,9 +40,11 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
|
|||||||
RechargeBoostFactorPPM: 1_100_000,
|
RechargeBoostFactorPPM: 1_100_000,
|
||||||
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
|
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
|
||||||
JackpotGlobalRTPMaxPPM: 980_000,
|
JackpotGlobalRTPMaxPPM: 980_000,
|
||||||
JackpotUserDayRTPMaxPPM: 960_000,
|
// Deprecated aliases keep old in-process draft builders source-compatible. New API requests populate
|
||||||
JackpotUser72hRTPMaxPPM: 960_000,
|
// the canonical round/48h fields and therefore opt into the settled-window algorithm.
|
||||||
MaxJackpotHitsPerUserDay: 5,
|
JackpotUserDayRTPMaxPPM: 960_000,
|
||||||
|
JackpotUser72hRTPMaxPPM: 960_000,
|
||||||
|
MaxJackpotHitsPerUserDay: 5,
|
||||||
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
|
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
|
||||||
NoviceMaxEquivalentDraws: 2_000,
|
NoviceMaxEquivalentDraws: 2_000,
|
||||||
NormalMaxEquivalentDraws: 20_000,
|
NormalMaxEquivalentDraws: 20_000,
|
||||||
@ -94,6 +96,24 @@ func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int6
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
|
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.PoolID = normalizePoolID(config.PoolID)
|
||||||
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||||||
if config.StrategyVersion == "" {
|
if config.StrategyVersion == "" {
|
||||||
@ -116,6 +136,7 @@ func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateRuleConfig(config domain.RuleConfig) error {
|
func validateRuleConfig(config domain.RuleConfig) error {
|
||||||
|
config = normalizeRuleConfig(config)
|
||||||
if normalizePoolID(config.PoolID) == "" {
|
if normalizePoolID(config.PoolID) == "" {
|
||||||
return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
|
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 {
|
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
|
||||||
return err
|
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 ||
|
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
|
||||||
config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
|
config.JackpotUserRoundRTPMaxPPM <= 0 || config.JackpotUserRoundRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
|
||||||
config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > 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")
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit")
|
||||||
}
|
}
|
||||||
if config.MaxJackpotHitsPerUserDay <= 0 {
|
if config.MaxJackpotHitsPerUserDay <= 0 {
|
||||||
@ -221,7 +256,9 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
|
|||||||
// disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。
|
// disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。
|
||||||
return nil
|
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 xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot cumulative spend threshold coins must be configured as a positive amount before enabling")
|
||||||
}
|
}
|
||||||
return validateDynamicPayoutCaps(config, true)
|
return validateDynamicPayoutCaps(config, true)
|
||||||
|
|||||||
@ -94,14 +94,18 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
|
|||||||
}
|
}
|
||||||
|
|
||||||
poolID := luckyPoolID(cmd.PoolID)
|
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 {
|
if err != nil {
|
||||||
return domain.ExternalDrawResult{}, err
|
return domain.ExternalDrawResult{}, err
|
||||||
}
|
}
|
||||||
if exists && rule.StrategyVersion == domain.StrategyDynamicV3 {
|
if exists && rule.StrategyVersion == domain.StrategyDynamicV3 {
|
||||||
if cmd.PaidAtMS <= 0 {
|
if cmd.PaidAtMS <= 0 {
|
||||||
// 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间
|
// 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间
|
||||||
// 不是支付时间,不能用于充值短窗、UTC 日/小时或精确 72h 风控归属。
|
// 不是支付时间,不能用于充值短窗、UTC 日/小时或精确滚动 48h 风控归属。
|
||||||
return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required")
|
return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required")
|
||||||
}
|
}
|
||||||
} else if cmd.PaidAtMS <= 0 {
|
} else if cmd.PaidAtMS <= 0 {
|
||||||
@ -278,17 +282,18 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
|
|||||||
}
|
}
|
||||||
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
|
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
|
||||||
drawCommand := domain.DrawCommand{
|
drawCommand := domain.DrawCommand{
|
||||||
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
|
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
|
||||||
PoolID: config.PoolID,
|
PoolID: config.PoolID,
|
||||||
UserID: externalUserID,
|
UserID: externalUserID,
|
||||||
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
|
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
|
||||||
RoomID: externalLuckyScopeID(cmd.AppCode),
|
RoomID: externalLuckyScopeID(cmd.AppCode),
|
||||||
AnchorID: externalLuckyAnchorID(cmd.AppCode),
|
AnchorID: externalLuckyAnchorID(cmd.AppCode),
|
||||||
GiftID: config.GiftID,
|
GiftID: config.GiftID,
|
||||||
GiftCount: int32(cmd.GiftCount),
|
GiftCount: int32(cmd.GiftCount),
|
||||||
CoinSpent: cmd.TotalAmount,
|
CoinSpent: cmd.TotalAmount,
|
||||||
PaidAtMS: cmd.PaidAtMS,
|
PaidAtMS: cmd.PaidAtMS,
|
||||||
GiftIncomeCoins: split.AnchorReturnCoins,
|
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
|
||||||
|
GiftIncomeCoins: split.AnchorReturnCoins,
|
||||||
}
|
}
|
||||||
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
|
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
|
||||||
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。
|
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/idgen"
|
"hyapp/pkg/idgen"
|
||||||
@ -30,7 +31,12 @@ type luckyDynamicBatchState struct {
|
|||||||
RiskCounters map[string]luckyDynamicRiskCounter
|
RiskCounters map[string]luckyDynamicRiskCounter
|
||||||
DayState luckyUserStrategyDay
|
DayState luckyUserStrategyDay
|
||||||
HourState luckyUserRTPHour
|
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 自行结算区分开。
|
// luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。
|
||||||
@ -48,10 +54,18 @@ func (p luckyDynamicPersistence) external() bool {
|
|||||||
// 所有共享行只锁一次,gift_count=N 在内存里严格推进 N 次,事务尾再批量落 draw facts 和聚合钱包 outbox。
|
// 所有共享行只锁一次,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) {
|
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 {
|
if cmd.PaidAtMS <= 0 {
|
||||||
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确 72h 都必须归属 wallet owner 的扣费事实时间。
|
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确滚动 48h 都必须归属 wallet owner 的扣费事实时间。
|
||||||
// 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
|
// 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
|
||||||
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required")
|
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 才能进入事务状态。
|
// 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。
|
||||||
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
|
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
|
||||||
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
|
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
|
||||||
@ -69,17 +83,18 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
|||||||
}
|
}
|
||||||
strategyConfig := luckyDynamicStrategyConfig(config)
|
strategyConfig := luckyDynamicStrategyConfig(config)
|
||||||
referencePrice := config.GiftPrice
|
referencePrice := config.GiftPrice
|
||||||
|
paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()
|
||||||
|
|
||||||
userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
|
userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
windowScopeID := luckyRuleWindowScopeID(config)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -100,7 +115,13 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -125,29 +146,37 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
|||||||
RiskCounters: riskCounters,
|
RiskCounters: riskCounters,
|
||||||
DayState: dayState,
|
DayState: dayState,
|
||||||
HourState: hourState,
|
HourState: hourState,
|
||||||
|
UserWindow: userWindow,
|
||||||
|
UserWindows: make([]luckyUserRTPWindow, 0, 2),
|
||||||
Strategy: domain.StrategyState{
|
Strategy: domain.StrategyState{
|
||||||
PoolBalanceCoins: basePool.Balance,
|
PoolBalanceCoins: basePool.Balance,
|
||||||
ConsecutiveZeroDraws: userState.LossStreak,
|
ConsecutiveZeroDraws: userState.LossStreak,
|
||||||
PendingMilestoneTokens: userState.PendingJackpotTokens,
|
// 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,
|
UserDailyJackpotWins: dayState.JackpotHits,
|
||||||
UserDaySpendCoins: dayState.SpendCoins,
|
UserDaySpendCoins: dayState.SpendCoins,
|
||||||
GlobalRTP: domain.StrategyRTP{WagerCoins: globalWindow.WagerCoins, PayoutCoins: globalWindow.ActualPayoutCoins},
|
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
|
||||||
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
|
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
|
||||||
User72HourRTP: rolling72,
|
|
||||||
Version: userState.PaidDraws,
|
Version: userState.PaidDraws,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if hasQualification {
|
||||||
for index := int32(1); index <= drawCount; index++ {
|
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil {
|
||||||
// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满,
|
|
||||||
// 本抽先创建下一窗口并让 mechanism1 使用新窗口 RTP,不能等整批结束后才滚动。
|
|
||||||
if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config.SettlementWindowWager, &state, nowMS); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := int32(1); index <= drawCount; index++ {
|
||||||
unitCommand := cmd
|
unitCommand := cmd
|
||||||
unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
|
unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
|
||||||
unitCommand.GiftCount = 1
|
unitCommand.GiftCount = 1
|
||||||
unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index)
|
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]
|
unitSplit := unitSplits[index-1]
|
||||||
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
|
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
|
||||||
if unitCommand.CoinSpent <= 0 {
|
if unitCommand.CoinSpent <= 0 {
|
||||||
@ -199,36 +228,50 @@ func normalizeLuckyDynamicDeviceID(value string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, controlWager int64, state *luckyDynamicBatchState, nowMS int64) error {
|
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, state *luckyDynamicBatchState, nowMS int64) error {
|
||||||
if controlWager <= 0 {
|
if config.SettlementWindowWager <= 0 {
|
||||||
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
|
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
|
||||||
}
|
}
|
||||||
// 图中“结算窗口流水”是金币阈值,不是抽数。整笔单抽归属进入时所在窗口;本抽把流水推过阈值后,下一抽才开启新窗口,避免拆分一次中奖事实。
|
paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()
|
||||||
if state.GlobalWindow.WagerCoins >= controlWager {
|
// 图中“结算窗口流水”是金币阈值,不是抽数。越过阈值的完整单抽归属旧窗口;窗口已在
|
||||||
|
// 上一抽结束时冻结,本抽先创建下一窗口并消费刚结算用户轮次的一次性资格。
|
||||||
|
if state.GlobalWindow.Status == "closed" || state.GlobalWindow.WagerCoins >= config.SettlementWindowWager {
|
||||||
closed := state.GlobalWindow
|
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)
|
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,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state.GlobalWindow = next
|
state.GlobalWindow = next
|
||||||
// 大奖机制一只看当前结算窗口;窗口滚动后分母为 0,必须先积累新样本,不能沿用旧窗口资格。
|
state.UserWindow, err = r.getOrCreateLuckyUserRTPWindow(
|
||||||
state.Strategy.GlobalRTP = domain.StrategyRTP{
|
ctx, tx, appCode, config.PoolID, config.RuleVersion, next.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS,
|
||||||
WagerCoins: next.WagerCoins,
|
)
|
||||||
PayoutCoins: next.ActualPayoutCoins,
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if state.GiftWindow.WagerCoins >= controlWager {
|
if state.GiftWindow.Status == "closed" || state.GiftWindow.WagerCoins >= config.SettlementWindowWager {
|
||||||
closed := state.GiftWindow
|
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)
|
state.GiftWindows = append(state.GiftWindows, closed)
|
||||||
next, err := r.createLuckyRTPWindow(
|
next, err := r.createLuckyRTPWindowAt(
|
||||||
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -238,6 +281,57 @@ func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, ap
|
|||||||
return nil
|
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 {
|
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.PublicIn += split.PublicPoolCoins
|
||||||
state.ProfitIn += split.ProfitPoolCoins
|
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 = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
|
||||||
state.GlobalWindow.PaidDraws++
|
state.GlobalWindow.PaidDraws++
|
||||||
state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins
|
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 = state.GiftWindow.withCurrentWager(cmd.CoinSpent)
|
||||||
state.GiftWindow.PaidDraws++
|
state.GiftWindow.PaidDraws++
|
||||||
state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins
|
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.PaidDraws++
|
||||||
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
|
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
|
||||||
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
|
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
|
||||||
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
|
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
|
||||||
state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens
|
state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens
|
||||||
state.DayState.WagerCoins = decision.NextState.UserDayRTP.WagerCoins
|
state.DayState.WagerCoins += cmd.CoinSpent
|
||||||
state.DayState.PayoutCoins = decision.NextState.UserDayRTP.PayoutCoins
|
state.DayState.PayoutCoins += decision.PayoutCoins
|
||||||
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
|
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
|
||||||
state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
|
state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
|
||||||
state.HourState.WagerCoins += cmd.CoinSpent
|
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,
|
HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000,
|
||||||
}
|
}
|
||||||
drawID := idgen.New("lucky_draw")
|
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)
|
rewardStatus := luckyInitialRewardStatus(candidate, false)
|
||||||
limited := luckyDynamicLimitedReasons(decision.Trace)
|
limited := luckyDynamicLimitedReasons(decision.Trace)
|
||||||
decisionCopy := decision
|
decisionCopy := decision
|
||||||
@ -297,14 +417,14 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
|
|||||||
BasePool: poolSnapshot,
|
BasePool: poolSnapshot,
|
||||||
StrategyDecision: &decisionCopy,
|
StrategyDecision: &decisionCopy,
|
||||||
FundSplit: split,
|
FundSplit: split,
|
||||||
UserDayRTP: decision.NextState.UserDayRTP,
|
UserRoundRTP: domain.StrategyRTP{WagerCoins: state.UserWindow.WagerCoins, PayoutCoins: state.UserWindow.PayoutCoins},
|
||||||
User72HourRTP: decision.NextState.User72HourRTP,
|
User48HourRTP: decision.NextState.User48HourRTP,
|
||||||
RewardStatus: rewardStatus,
|
RewardStatus: rewardStatus,
|
||||||
NowMS: nowMS,
|
NowMS: nowMS,
|
||||||
}
|
}
|
||||||
state.DrawIDs = append(state.DrawIDs, drawID)
|
state.DrawIDs = append(state.DrawIDs, drawID)
|
||||||
state.Records = append(state.Records, record)
|
state.Records = append(state.Records, record)
|
||||||
globalRTP, _ := decision.NextState.GlobalRTP.RatioPPM()
|
globalRTP := luckyRTPPPM(globalSnapshot.WagerCoins, globalSnapshot.ActualPayoutCoins)
|
||||||
giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
|
giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
|
||||||
state.Results = append(state.Results, domain.DrawResult{
|
state.Results = append(state.Results, domain.DrawResult{
|
||||||
DrawID: drawID,
|
DrawID: drawID,
|
||||||
@ -329,6 +449,33 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
|
|||||||
return nil
|
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 {
|
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 {
|
if err := validateLuckyDynamicBatchState(state, cmd.CoinSpent); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -339,14 +486,23 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a
|
|||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE lucky_rtp_windows
|
UPDATE lucky_rtp_windows
|
||||||
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?,
|
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 = ?`,
|
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,
|
appCode, window.ScopeType, window.ScopeID, window.WindowIndex,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
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 {
|
if err := r.updateLuckyDynamicPoolNet(ctx, tx, appCode, luckyBasePoolScopeID(config), state.PublicIn, state.Payout, state.ProfitIn, state.AnchorIn, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,113 @@ type luckyUserRTPHour struct {
|
|||||||
PayoutCoins int64
|
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 {
|
func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time {
|
||||||
ms := cmd.PaidAtMS
|
ms := cmd.PaidAtMS
|
||||||
if ms <= 0 {
|
if ms <= 0 {
|
||||||
@ -174,13 +281,22 @@ func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx
|
|||||||
return state, err
|
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) {
|
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。
|
// 完整小时从聚合桶读取;头尾两个不足一小时的片段从专用边界事件读取,避免回扫 lucky_draw_records。
|
||||||
const hourMS = int64(time.Hour / time.Millisecond)
|
const hourMS = int64(time.Hour / time.Millisecond)
|
||||||
startMS := endMS - 72*hourMS
|
startMS := endMS - hours*hourMS
|
||||||
if startMS < 0 || endMS <= 0 {
|
if hours <= 0 || startMS < 0 || endMS <= 0 {
|
||||||
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h range is invalid")
|
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP range is invalid")
|
||||||
}
|
}
|
||||||
fullStartMS := startMS
|
fullStartMS := startMS
|
||||||
if remainder := startMS % hourMS; remainder != 0 {
|
if remainder := startMS % hourMS; remainder != 0 {
|
||||||
@ -214,7 +330,7 @@ func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.T
|
|||||||
return domain.StrategyRTP{}, err
|
return domain.StrategyRTP{}, err
|
||||||
}
|
}
|
||||||
if state.WagerCoins > int64(^uint64(0)>>1)-wager || state.PayoutCoins > int64(^uint64(0)>>1)-payout {
|
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.WagerCoins += wager
|
||||||
state.PayoutCoins += payout
|
state.PayoutCoins += payout
|
||||||
|
|||||||
@ -50,6 +50,12 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
|
|||||||
if !configured.Enabled {
|
if !configured.Enabled {
|
||||||
continue
|
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]
|
tier := tiersByMultiplier[configured.MultiplierPPM]
|
||||||
if tier == nil {
|
if tier == nil {
|
||||||
stageWeights := make(map[string]int64, len(stageNames))
|
stageWeights := make(map[string]int64, len(stageNames))
|
||||||
@ -97,27 +103,28 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
|
|||||||
tiers = append(tiers, *tiersByMultiplier[multiplier])
|
tiers = append(tiers, *tiersByMultiplier[multiplier])
|
||||||
}
|
}
|
||||||
return domain.StrategyConfig{
|
return domain.StrategyConfig{
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
PublicPoolRatePPM: config.PoolRatePPM,
|
PublicPoolRatePPM: config.PoolRatePPM,
|
||||||
ProfitPoolRatePPM: config.ProfitRatePPM,
|
ProfitPoolRatePPM: config.ProfitRatePPM,
|
||||||
AnchorReturnRatePPM: config.AnchorRatePPM,
|
AnchorReturnRatePPM: config.AnchorRatePPM,
|
||||||
ColdStartPoolCoins: config.InitialPoolCoins,
|
ColdStartPoolCoins: config.InitialPoolCoins,
|
||||||
LowWaterThresholdCoins: config.LowWatermarkCoins,
|
LowWaterThresholdCoins: config.LowWatermarkCoins,
|
||||||
HighWaterThresholdCoins: config.HighWatermarkCoins,
|
HighWaterThresholdCoins: config.HighWatermarkCoins,
|
||||||
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
|
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
|
||||||
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
|
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
|
||||||
RechargeFactorPPM: config.RechargeBoostFactorPPM,
|
RechargeFactorPPM: config.RechargeBoostFactorPPM,
|
||||||
RechargeBoostStartMS: 0,
|
RechargeBoostStartMS: 0,
|
||||||
RechargeBoostEndMS: config.RechargeBoostWindowMS,
|
RechargeBoostEndMS: config.RechargeBoostWindowMS,
|
||||||
MissProtectionZeroDraws: config.LossStreakGuarantee,
|
MissProtectionZeroDraws: config.LossStreakGuarantee,
|
||||||
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
|
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
|
||||||
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
|
MilestoneSpendCoins: 0,
|
||||||
JackpotMechanism1Enabled: true,
|
JackpotMechanism1Enabled: true,
|
||||||
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
|
JackpotMechanism2Enabled: false,
|
||||||
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
|
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
|
||||||
UserDayRTPMaxPPM: config.JackpotUserDayRTPMaxPPM,
|
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
|
||||||
User72HourRTPMaxPPM: config.JackpotUser72hRTPMaxPPM,
|
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
|
||||||
RechargeStages: rechargeStages,
|
SettledRoundRTPEligibility: true,
|
||||||
|
RechargeStages: rechargeStages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,11 @@ type luckyRTPWindow struct {
|
|||||||
ActualPayoutCoins int64
|
ActualPayoutCoins int64
|
||||||
CarryPPM int64
|
CarryPPM int64
|
||||||
Status string
|
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 {
|
func (w luckyRTPWindow) withCurrentWager(spent int64) luckyRTPWindow {
|
||||||
@ -111,8 +116,8 @@ type luckyDrawRecordInput struct {
|
|||||||
// 后台以后修改规则版本也不会让历史结果失去可解释性。
|
// 后台以后修改规则版本也不会让历史结果失去可解释性。
|
||||||
StrategyDecision *domain.StrategyDecision
|
StrategyDecision *domain.StrategyDecision
|
||||||
FundSplit domain.StrategyFundSplit
|
FundSplit domain.StrategyFundSplit
|
||||||
UserDayRTP domain.StrategyRTP
|
UserRoundRTP domain.StrategyRTP
|
||||||
User72HourRTP domain.StrategyRTP
|
User48HourRTP domain.StrategyRTP
|
||||||
RewardStatus string
|
RewardStatus string
|
||||||
NowMS int64
|
NowMS int64
|
||||||
}
|
}
|
||||||
@ -1037,9 +1042,11 @@ func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, strin
|
|||||||
if input.StrategyDecision != nil {
|
if input.StrategyDecision != nil {
|
||||||
candidateData["dynamic_v3"] = input.StrategyDecision.Trace
|
candidateData["dynamic_v3"] = input.StrategyDecision.Trace
|
||||||
poolData["fund_split"] = input.FundSplit
|
poolData["fund_split"] = input.FundSplit
|
||||||
rtpData["user_day"] = input.UserDayRTP
|
rtpData["user_round"] = input.UserRoundRTP
|
||||||
rtpData["user_72h"] = input.User72HourRTP
|
rtpData["user_48h"] = input.User48HourRTP
|
||||||
rtpData["global"] = input.StrategyDecision.NextState.GlobalRTP
|
// 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)
|
candidateSnapshot, _ := json.Marshal(candidateData)
|
||||||
poolSnapshot, _ := json.Marshal(poolData)
|
poolSnapshot, _ := json.Marshal(poolData)
|
||||||
@ -2332,8 +2339,9 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
|
|||||||
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
|
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
|
||||||
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
|
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
|
||||||
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
|
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
|
||||||
JackpotUserDayRTPMaxPPM: ruleConfig.JackpotUserDayRTPMaxPPM,
|
JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM,
|
||||||
JackpotUser72hRTPMaxPPM: ruleConfig.JackpotUser72hRTPMaxPPM,
|
JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM,
|
||||||
|
SettledRoundRTPEligibility: true,
|
||||||
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
|
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
|
||||||
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
|
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
|
||||||
MaxSinglePayout: ruleConfig.MaxSinglePayout,
|
MaxSinglePayout: ruleConfig.MaxSinglePayout,
|
||||||
@ -2474,31 +2482,38 @@ func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appC
|
|||||||
|
|
||||||
// getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3:不可变 rule_version scope 已经固定真实流水阈值,
|
// getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3:不可变 rule_version scope 已经固定真实流水阈值,
|
||||||
// 因此窗口是否结束只看 wager_coins。control_window_draws 继续保存 fixed_v2 的兼容观测值,不能参与 V3 滚动判断。
|
// 因此窗口是否结束只看 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 {
|
if windowWager <= 0 {
|
||||||
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive")
|
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)
|
window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return luckyRTPWindow{}, err
|
return luckyRTPWindow{}, err
|
||||||
}
|
}
|
||||||
if !exists {
|
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
|
return window, nil
|
||||||
}
|
}
|
||||||
if window.Status == "open" {
|
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, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE lucky_rtp_windows
|
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 = ?`,
|
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 {
|
); err != nil {
|
||||||
return luckyRTPWindow{}, err
|
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) {
|
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, `
|
row := tx.QueryRowContext(ctx, `
|
||||||
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
|
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
|
FROM lucky_rtp_windows
|
||||||
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
|
||||||
ORDER BY window_index DESC
|
ORDER BY window_index DESC
|
||||||
@ -2517,7 +2533,8 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
|
|||||||
)
|
)
|
||||||
var window luckyRTPWindow
|
var window luckyRTPWindow
|
||||||
if err := row.Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return luckyRTPWindow{}, false, nil
|
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) {
|
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 先以唯一键串行化,
|
// 同一 scope 的首次请求可能并发创建同一 window;幂等 INSERT 先以唯一键串行化,
|
||||||
// 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
|
// 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_rtp_windows (
|
INSERT INTO lucky_rtp_windows (
|
||||||
app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
|
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
|
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)
|
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`,
|
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 {
|
); err != nil {
|
||||||
return luckyRTPWindow{}, err
|
return luckyRTPWindow{}, err
|
||||||
}
|
}
|
||||||
var window luckyRTPWindow
|
var window luckyRTPWindow
|
||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
|
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
|
FROM lucky_rtp_windows
|
||||||
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?
|
||||||
FOR UPDATE`, appCode, scopeType, scopeID, index,
|
FOR UPDATE`, appCode, scopeType, scopeID, index,
|
||||||
).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
|
).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
|
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) {
|
func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
|
||||||
// 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。
|
// 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
|||||||
@ -52,6 +52,14 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
|||||||
if config.JackpotMultiplierPPMs == nil {
|
if config.JackpotMultiplierPPMs == nil {
|
||||||
config.JackpotMultiplierPPMs = []int64{}
|
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)
|
jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
|
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,
|
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||||
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
|
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,
|
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
||||||
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||||
device_daily_payout_cap, room_hourly_payout_cap, anchor_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.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
|
||||||
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
|
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
|
||||||
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
|
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
|
||||||
config.JackpotGlobalRTPMaxPPM, config.JackpotUserDayRTPMaxPPM, config.JackpotUser72hRTPMaxPPM,
|
config.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
|
||||||
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
|
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
|
||||||
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
|
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
|
||||||
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
|
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.low_watermark_coins, v.low_water_nonzero_factor_ppm,
|
||||||
v.high_watermark_coins, v.high_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.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.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.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
|
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,
|
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||||
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
|
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,
|
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
||||||
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||||
device_daily_payout_cap, room_hourly_payout_cap, anchor_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,
|
&config.RechargeBoostFactorPPM,
|
||||||
&jackpotMultiplierPPMsJSON,
|
&jackpotMultiplierPPMsJSON,
|
||||||
&config.JackpotGlobalRTPMaxPPM,
|
&config.JackpotGlobalRTPMaxPPM,
|
||||||
&config.JackpotUserDayRTPMaxPPM,
|
&config.JackpotUserRoundRTPMaxPPM,
|
||||||
&config.JackpotUser72hRTPMaxPPM,
|
&config.JackpotUser48hRTPMaxPPM,
|
||||||
&config.JackpotSpendThresholdCoins,
|
&config.JackpotSpendThresholdCoins,
|
||||||
&config.MaxJackpotHitsPerUserDay,
|
&config.MaxJackpotHitsPerUserDay,
|
||||||
&config.MaxSinglePayout,
|
&config.MaxSinglePayout,
|
||||||
@ -348,5 +356,9 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
|
|||||||
if config.JackpotMultiplierPPMs == nil {
|
if config.JackpotMultiplierPPMs == nil {
|
||||||
config.JackpotMultiplierPPMs = []int64{}
|
config.JackpotMultiplierPPMs = []int64{}
|
||||||
}
|
}
|
||||||
|
// 数据库只有轮次/48h 两个真实字段;回填旧别名仅用于同进程源码兼容,运行策略仍由 marker 选择新语义。
|
||||||
|
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
|
||||||
|
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
|
||||||
|
config.SettledRoundRTPEligibility = true
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luckygiftv1.ExternalGiftDrawRequest) (*luckygiftv1.ExternalGiftDrawResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||||
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
|
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
|
||||||
AppCode: req.GetAppCode(),
|
AppCode: req.GetAppCode(),
|
||||||
ExternalUserID: req.GetExternalUserId(),
|
ExternalUserID: req.GetExternalUserId(),
|
||||||
DeviceID: req.GetDeviceId(),
|
DeviceID: req.GetDeviceId(),
|
||||||
RequestID: req.GetRequestId(),
|
RequestID: req.GetRequestId(),
|
||||||
GiftCount: req.GetGiftCount(),
|
GiftCount: req.GetGiftCount(),
|
||||||
UnitAmount: req.GetUnitAmount(),
|
UnitAmount: req.GetUnitAmount(),
|
||||||
TotalAmount: req.GetTotalAmount(),
|
TotalAmount: req.GetTotalAmount(),
|
||||||
Currency: req.GetCurrency(),
|
Currency: req.GetCurrency(),
|
||||||
PaidAtMS: req.GetPaidAtMs(),
|
PaidAtMS: req.GetPaidAtMs(),
|
||||||
MetadataJSON: req.GetMetadataJson(),
|
MetadataJSON: req.GetMetadataJson(),
|
||||||
PoolID: req.GetPoolId(),
|
PoolID: req.GetPoolId(),
|
||||||
|
UserRegisteredAtMS: req.GetUserRegisteredAtMs(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
@ -118,6 +119,7 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
|
|||||||
GiftCount: meta.GetGiftCount(),
|
GiftCount: meta.GetGiftCount(),
|
||||||
CoinSpent: meta.GetCoinSpent(),
|
CoinSpent: meta.GetCoinSpent(),
|
||||||
PaidAtMS: meta.GetPaidAtMs(),
|
PaidAtMS: meta.GetPaidAtMs(),
|
||||||
|
UserRegisteredAtMS: meta.GetUserRegisteredAtMs(),
|
||||||
VisibleRegionID: meta.GetVisibleRegionId(),
|
VisibleRegionID: meta.GetVisibleRegionId(),
|
||||||
CountryID: meta.GetCountryId(),
|
CountryID: meta.GetCountryId(),
|
||||||
SenderName: meta.GetSenderName(),
|
SenderName: meta.GetSenderName(),
|
||||||
@ -320,8 +322,8 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
|
|||||||
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
|
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
|
||||||
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
|
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
|
||||||
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
|
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
|
||||||
JackpotUserDayRTPMaxPPM: config.GetJackpotUserDayRtpMaxPpm(),
|
JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(),
|
||||||
JackpotUser72hRTPMaxPPM: config.GetJackpotUser_72HRtpMaxPpm(),
|
JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(),
|
||||||
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
||||||
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
||||||
MaxSinglePayout: config.GetMaxSinglePayout(),
|
MaxSinglePayout: config.GetMaxSinglePayout(),
|
||||||
@ -335,6 +337,14 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
|
|||||||
}
|
}
|
||||||
|
|
||||||
func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRuleConfig {
|
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))
|
stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(config.Stages))
|
||||||
for _, stage := range config.Stages {
|
for _, stage := range config.Stages {
|
||||||
tiers := make([]*luckygiftv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
|
tiers := make([]*luckygiftv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
|
||||||
@ -383,8 +393,8 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
|
|||||||
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
|
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
|
||||||
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
|
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
|
||||||
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
|
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
|
||||||
JackpotUserDayRtpMaxPpm: config.JackpotUserDayRTPMaxPPM,
|
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
|
||||||
JackpotUser_72HRtpMaxPpm: config.JackpotUser72hRTPMaxPPM,
|
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
|
||||||
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
|
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
|
||||||
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
|
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
|
||||||
MaxSinglePayout: config.MaxSinglePayout,
|
MaxSinglePayout: config.MaxSinglePayout,
|
||||||
|
|||||||
@ -461,6 +461,9 @@ type SendGift struct {
|
|||||||
SenderRegionID int64 `json:"sender_region_id,omitempty"`
|
SenderRegionID int64 `json:"sender_region_id,omitempty"`
|
||||||
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
|
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
|
||||||
SenderCountryID int64 `json:"sender_country_id,omitempty"`
|
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 是 gateway 从 user-service active host profile 注入的工资入账开关。
|
||||||
TargetIsHost bool `json:"target_is_host,omitempty"`
|
TargetIsHost bool `json:"target_is_host,omitempty"`
|
||||||
// TargetHostRegionID 是工资政策匹配区域;room-service 不自行推导主播身份或区域。
|
// 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.
|
// not conflict merely because the user changed profile between timeout and replay.
|
||||||
delete(values, "sender_region_id")
|
delete(values, "sender_region_id")
|
||||||
delete(values, "sender_country_id")
|
delete(values, "sender_country_id")
|
||||||
|
// 注册时间与国家/区域同属 gateway 从 user owner 解析的可信快照。首次请求会完整持久化,
|
||||||
|
// 但它不是客户端送礼载荷;同一 command_id 重试不能因为用户快照修复或迁移而产生冲突。
|
||||||
|
delete(values, "sender_registered_at_ms")
|
||||||
// SendGift 的以下字段由 wallet-service 结算后写入 command log,不属于客户端请求幂等语义。
|
// SendGift 的以下字段由 wallet-service 结算后写入 command log,不属于客户端请求幂等语义。
|
||||||
delete(values, "billing_receipt_id")
|
delete(values, "billing_receipt_id")
|
||||||
delete(values, "coin_spent")
|
delete(values, "coin_spent")
|
||||||
|
|||||||
@ -82,7 +82,10 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
|
|||||||
BatchSeq: req.GetBatchSeq(),
|
BatchSeq: req.GetBatchSeq(),
|
||||||
SenderCountryID: req.GetSenderCountryId(),
|
SenderCountryID: req.GetSenderCountryId(),
|
||||||
SenderRegionID: req.GetSenderRegionId(),
|
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 只用于房间/礼物可见性。
|
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
|
||||||
TargetHostRegionID: req.GetTargetHostRegionId(),
|
TargetHostRegionID: req.GetTargetHostRegionId(),
|
||||||
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
||||||
|
|||||||
@ -113,6 +113,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
|
|||||||
PoolId: cmd.PoolID,
|
PoolId: cmd.PoolID,
|
||||||
VisibleRegionId: roomMeta.VisibleRegionID,
|
VisibleRegionId: roomMeta.VisibleRegionID,
|
||||||
CountryId: cmd.SenderCountryID,
|
CountryId: cmd.SenderCountryID,
|
||||||
|
// 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化;owner 只用它判断
|
||||||
|
// 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。
|
||||||
|
UserRegisteredAtMs: cmd.SenderRegisteredAtMS,
|
||||||
// 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。
|
// 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。
|
||||||
SenderName: giftDisplayName(cmd.SenderDisplayProfile),
|
SenderName: giftDisplayName(cmd.SenderDisplayProfile),
|
||||||
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
|
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
|
||||||
|
|||||||
@ -542,7 +542,7 @@ func (s *demoServer) ensureDynamicRule(ctx context.Context, operatorID int64) (*
|
|||||||
HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000,
|
HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000,
|
||||||
RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000,
|
RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000,
|
||||||
JackpotMultiplierPpms: []int64{200_000_000, 500_000_000, 1_000_000_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,
|
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
|
||||||
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000,
|
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000,
|
||||||
DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,
|
DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user