merge: update lucky gift threshold strategy

This commit is contained in:
zhx 2026-07-15 18:16:57 +08:00
commit 9c1dd0e3a4
9 changed files with 112 additions and 62 deletions

View File

@ -6,7 +6,7 @@
- `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 必须先补齐 50 美元等值金币门槛及六项金额上限,才允许启用。 - 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐累计消费大奖门槛、普通/高阶充值门槛及六项金额上限,才允许启用。
- 启用前还必须确认 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`。任一动态字段缺失或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时运行侧 fail-close。
- 比例、概率和倍率统一使用 ppm`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。 - 比例、概率和倍率统一使用 ppm`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
@ -80,9 +80,10 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
V3 固定有 `novice/normal/advanced` 三层,但两个门槛由每个不可变规则版本配置: V3 固定有 `novice/normal/advanced` 三层,但两个门槛由每个不可变规则版本配置:
- 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。 - 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。
- 用户进入同时满足两个最低充值门槛的最高层;不能只满足 7 日或只满足 30 日门槛就越级。 - 用户只在同时满足某层的 7 日和 30 日最低充值门槛时才进入该层;不能只满足其中一维就越级。
- `novice` 门槛必须是 `0/0``normal` 至少一维高于 novice`advanced` 两维均不得低于 normal且至少一维严格更高。 - `novice` 是无条件兜底层,它的 `0/0` 只是协议和存储 sentinel不表示“只有充值为 0 才是新手”。若 `7d < normal_7d` **或** `30d < normal_30d`,即使两个充值值都非零,用户仍属于 `novice`
- 阶段阈值相等时算已达到,即使用 `>=`;所有金额门槛按 App 金币口径显式发布。 - `normal` 启用前至少一维必须为正数;`advanced` 两维均不得低于 `normal`且至少一维严格更高。disabled 草稿可保留 `0/0` 表示尚未配置,但不能以该状态启用。
- 阶段阈值相等时算已达到,即使用 `>=`;两维都等于普通门槛时进入 `normal`。所有金额门槛按 App 金币口径显式发布。
充值快照由 `wallet-service` 在礼物扣费事务中查询并写入交易 metadata同一 `command_id` 重放返回首次快照,避免重试时跨层或丢失五分钟加成。 充值快照由 `wallet-service` 在礼物扣费事务中查询并写入交易 metadata同一 `command_id` 重放返回首次快照,避免重试时跨层或丢失五分钟加成。
@ -145,7 +146,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
### 6.2 机制二:消费里程碑大奖 ### 6.2 机制二:消费里程碑大奖
- `jackpot_spend_threshold_coins`各 App 对“50 美元等值幸运礼物金币”的显式换算,服务不读取实时汇率推断。 - `jackpot_spend_threshold_coins`当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。 - 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。 - 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
- 下一抽优先消费已有 token若大奖集合因 `P`、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。 - 下一抽优先消费已有 token若大奖集合因 `P`、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。
@ -228,7 +229,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 表 | V3 事实 | | 表 | V3 事实 |
| --- | --- | | --- | --- |
| `lucky_gift_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 | | `lucky_gift_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 |
| `lucky_gift_stage_tiers` | 三层充值门槛、基础倍率和基础概率 | | `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` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 | | `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 |
@ -300,6 +301,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. 50 美元等值金币、低/高水位、三层充值门槛、大奖集合、日次数和六项风控都必须按 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。
5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。 5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。

View File

@ -18,6 +18,12 @@ type scriptedRandom struct {
// imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights // imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights
// have 26.5x EV and therefore must never be exposed as the domain's runtime default. // have 26.5x EV and therefore must never be exposed as the domain's runtime default.
func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig { func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
// 模拟显式给出金币分层边界,避免把生产 disabled 草稿的 0/0 sentinel 当成可运行默认值。
config.RechargeStages = []domain.StrategyRechargeStage{
{Name: domain.StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: domain.StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
{Name: domain.StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
}
config.Tiers = []domain.StrategyTier{ config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true}, {ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
@ -111,10 +117,10 @@ func simulateFundSplitAndColdStart(config domain.StrategyConfig) scenarioResult
return failedScenario(name, err) return failedScenario(name, err)
} }
conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100 conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100
passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 1_000_000 passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 0
return scenarioResult{ return scenarioResult{
Name: name, Passed: passed, Name: name, Passed: passed,
Summary: fmt.Sprintf("100金币拆为%d/%d/%d10金币按公共池floor、余数归盈利拆为%d/%d/%d冷启动=%d", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins), Summary: fmt.Sprintf("100金币拆为%d/%d/%d10金币按公共池floor、余数归盈利拆为%d/%d/%d动态池冷启动=%d,启动资金只能审计注资", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins),
Data: map[string]any{ Data: map[string]any{
"split_100": split, "split_10_rounding": small, "split_100": split, "split_10_rounding": small,
"rounding_policy": "public=floor, anchor=floor, residue=profit", "rounding_policy": "public=floor, anchor=floor, residue=profit",
@ -416,8 +422,7 @@ func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResu
const name = "机制2里程碑/池不足" const name = "机制2里程碑/池不足"
config = baseProbabilityConfig(config) config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true config.JackpotMechanism2Enabled = true
// Simulator makes the product's USD 50 threshold explicit in its own demo unit; // 模拟只用一个便于验证跨档的金币值;生产值必须来自当前不可变规则版本。
// production must inject the region/version-specific USD-to-coin conversion.
config.MilestoneSpendCoins = 50 config.MilestoneSpendCoins = 50
config.DailyJackpotLimit = 5 config.DailyJackpotLimit = 5
input := domain.StrategyInput{GiftPriceCoins: 10} input := domain.StrategyInput{GiftPriceCoins: 10}
@ -554,8 +559,14 @@ func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
Recharge30D int64 `json:"recharge_30d"` Recharge30D int64 `json:"recharge_30d"`
Stage string `json:"stage"` Stage string `json:"stage"`
} }
inputs := [][2]int64{{0, 0}, {0, 1}, {1, 1}} inputs := [][2]int64{
wants := []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {99, 499}, // 非零但两维低于 normal。
{100, 499}, // 7 日等于 normal30 日未达,仍为 novice。
{99, 500}, // 30 日等于 normal7 日未达,仍为 novice。
{100, 500}, // 两维等于 normal进入 normal。
{500, 2_000},
}
wants := []string{domain.StageNovice, domain.StageNovice, domain.StageNovice, domain.StageNormal, domain.StageAdvanced}
rows := make([]row, 0, len(inputs)) rows := make([]row, 0, len(inputs))
passed := true passed := true
for index, input := range inputs { for index, input := range inputs {
@ -563,7 +574,7 @@ func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
passed = passed && stage == wants[index] passed = passed && stage == wants[index]
rows = append(rows, row{Recharge7D: input[0], Recharge30D: input[1], Stage: stage}) rows = append(rows, row{Recharge7D: input[0], Recharge30D: input[1], Stage: stage})
} }
return scenarioResult{Name: name, Passed: passed, Summary: "三阶段同时要求7日与30日显式下限选择满足条件的最高阶段", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}} return scenarioResult{Name: name, Passed: passed, Summary: "normal/advanced同时达到7日与30日下限才提档任一维低于normal即使非零也是novice", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}}
} }
func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult { func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
@ -586,7 +597,7 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
} }
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: 1, Recharge30DCoins: 1, LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
} }
priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed)) priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed))
if err != nil { if err != nil {
@ -728,9 +739,9 @@ func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int)
recharge7D int64 recharge7D int64
recharge30D int64 recharge30D int64
}{ }{
{stage: domain.StageNovice, recharge7D: 0, recharge30D: 0}, {stage: domain.StageNovice, recharge7D: 99, recharge30D: 499},
{stage: domain.StageNormal, recharge7D: 0, recharge30D: 1}, {stage: domain.StageNormal, recharge7D: 100, recharge30D: 500},
{stage: domain.StageAdvanced, recharge7D: 1, recharge30D: 1}, {stage: domain.StageAdvanced, recharge7D: 500, recharge30D: 2_000},
} }
results := make([]groupLongRunSummary, 0, len(groups)) results := make([]groupLongRunSummary, 0, len(groups))
passed := true passed := true

View File

@ -103,7 +103,7 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
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_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户当日 RTP 上限ppm',
jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 72 小时 RTP 上限ppm', jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 72 小时 RTP 上限ppm',
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户消费门槛金币,由 App 显式换算 50 美元等值', 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 '单次返奖金币上限',
user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 小时返奖上限', user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 小时返奖上限',
@ -121,8 +121,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本', rule_version BIGINT NOT NULL COMMENT '规则版本',
stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced', stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced',
min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT '进入该阶段的最近 7 日充值金币门槛', min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'normal/advanced 最近 7 日充值下限novice 的 0 仅为 sentinel',
min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT '进入该阶段的最近 30 日充值金币门槛', min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'normal/advanced 最近 30 日充值下限novice 的 0 仅为 sentinel',
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID', tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000', multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000',
base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm', base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm',

View File

@ -65,7 +65,7 @@ SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHE
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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_gift_rule_versions' AND COLUMN_NAME = 'jackpot_spend_threshold_coins') = 0, SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_spend_threshold_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''大奖消费门槛金币需显式换算50美元等值'' AFTER jackpot_user_72h_rtp_max_ppm', 'SELECT 1'); 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''规则版本动态配置的用户 UTC 日累计消费大奖门槛金币'' AFTER jackpot_user_72h_rtp_max_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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_gift_rule_versions' AND COLUMN_NAME = 'max_jackpot_hits_per_user_day') = 0, SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'max_jackpot_hits_per_user_day') = 0,
@ -97,11 +97,11 @@ SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHE
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_7d_coins') = 0, SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_7d_coins') = 0,
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''阶段最近7日充值金币门槛'' AFTER stage', 'SELECT 1'); 'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''normal/advanced 最近7日充值下限novice 的0仅为sentinel'' AFTER stage', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_30d_coins') = 0, SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_30d_coins') = 0,
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''阶段最近30日充值金币门槛'' AFTER min_recharge_7d_coins', 'SELECT 1'); 'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''normal/advanced 最近30日充值下限novice 的0仅为sentinel'' AFTER min_recharge_7d_coins', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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_pools' AND COLUMN_NAME = 'profit_total') = 0, SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'profit_total') = 0,

View File

@ -107,7 +107,8 @@ type RuleTier struct {
// RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。 // RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。
type RuleStage struct { type RuleStage struct {
Stage string `json:"stage"` Stage string `json:"stage"`
// novice 的两个 0 仅为协议/存储 sentinelnormal/advanced 才用这两个下限提档。
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"` MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"` MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
Tiers []RuleTier `json:"tiers"` Tiers []RuleTier `json:"tiers"`

View File

@ -285,15 +285,15 @@ func DefaultLuckyGiftStrategyConfig() StrategyConfig {
LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000, LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000,
LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000, LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000,
RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000, RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000,
// The product threshold is USD 50 while the kernel operates in integer // 累计消费门槛是不可变规则版本的动态金币配置0 表示该内核默认值尚未配置。
// coins. Zero forces adapters to supply an explicit versioned conversion.
MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0, MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0,
JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true, JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true,
GlobalRTPMaxPPM: 980_000, UserDayRTPMaxPPM: 960_000, User72HourRTPMaxPPM: 960_000, GlobalRTPMaxPPM: 980_000, UserDayRTPMaxPPM: 960_000, User72HourRTPMaxPPM: 960_000,
RechargeStages: []StrategyRechargeStage{ RechargeStages: []StrategyRechargeStage{
// 三个 0/0 仅用于未配置内核默认值;可运行规则必须由发布层注入 normal/advanced 门槛。
{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0}, {Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: StageNormal, MinRecharge7DCoins: 0, MinRecharge30DCoins: 1}, {Name: StageNormal, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: StageAdvanced, MinRecharge7DCoins: 1, MinRecharge30DCoins: 1}, {Name: StageAdvanced, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
}, },
} }
} }
@ -335,7 +335,7 @@ func SplitLuckyGiftStrategyFunds(amount int64, config StrategyConfig) (StrategyF
// MilestoneTokensEarned computes only newly crossed whole milestones. This pure // MilestoneTokensEarned computes only newly crossed whole milestones. This pure
// delta is safe to use in a periodic persisted-token job and does not grant the same // delta is safe to use in a periodic persisted-token job and does not grant the same
// 50-unit threshold again after a retry. // configured cumulative-spend threshold again after a retry.
func MilestoneTokensEarned(previousSpend, currentSpend, milestone int64) int64 { func MilestoneTokensEarned(previousSpend, currentSpend, milestone int64) int64 {
if milestone <= 0 || previousSpend < 0 || currentSpend <= previousSpend { if milestone <= 0 || previousSpend < 0 || currentSpend <= previousSpend {
return 0 return 0
@ -343,23 +343,28 @@ func MilestoneTokensEarned(previousSpend, currentSpend, milestone int64) int64 {
return currentSpend/milestone - previousSpend/milestone return currentSpend/milestone - previousSpend/milestone
} }
// SelectLuckyGiftRechargeStage returns the highest explicitly configured stage whose // SelectLuckyGiftRechargeStage 只用 normal/advanced 的版本化门槛向上提档novice 是无条件兜底,
// two recharge floors pass. It never infers a stage from local time or current balance. // 其 0/0 仅为协议和存储 sentinel。两维必须同时达到才能提档所以任一维低于 normal 都是 novice等于门槛则通过该维。
func SelectLuckyGiftRechargeStage(config StrategyConfig, recharge7D, recharge30D int64) string { func SelectLuckyGiftRechargeStage(config StrategyConfig, recharge7D, recharge30D int64) string {
stages := append([]StrategyRechargeStage(nil), config.RechargeStages...) stages := make(map[string]StrategyRechargeStage, len(config.RechargeStages))
sort.SliceStable(stages, func(i, j int) bool { for _, stage := range config.RechargeStages {
if stages[i].MinRecharge30DCoins == stages[j].MinRecharge30DCoins { stages[stage.Name] = stage
return stages[i].MinRecharge7DCoins < stages[j].MinRecharge7DCoins
}
return stages[i].MinRecharge30DCoins < stages[j].MinRecharge30DCoins
})
selected := ""
for _, stage := range stages {
if recharge7D >= stage.MinRecharge7DCoins && recharge30D >= stage.MinRecharge30DCoins {
selected = stage.Name
}
} }
return selected if rechargeStageConfiguredAndReached(stages[StageAdvanced], recharge7D, recharge30D) {
return StageAdvanced
}
if rechargeStageConfiguredAndReached(stages[StageNormal], recharge7D, recharge30D) {
return StageNormal
}
return StageNovice
}
func rechargeStageConfiguredAndReached(stage StrategyRechargeStage, recharge7D, recharge30D int64) bool {
// normal/advanced 的 0/0 表示 disabled 草稿尚未配置,不能因为任意非负值都 >=0 而意外提档。
if stage.MinRecharge7DCoins == 0 && stage.MinRecharge30DCoins == 0 {
return false
}
return recharge7D >= stage.MinRecharge7DCoins && recharge30D >= stage.MinRecharge30DCoins
} }
// PreviewLuckyGiftStrategyWeights exposes exactly the same weight builder used by // PreviewLuckyGiftStrategyWeights exposes exactly the same weight builder used by

View File

@ -359,13 +359,25 @@ func TestLuckyGiftStrategySixRiskCapacitiesAreHardInclusiveBounds(t *testing.T)
func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) { func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) {
config := DefaultLuckyGiftStrategyConfig() config := DefaultLuckyGiftStrategyConfig()
config.RechargeStages = []StrategyRechargeStage{
{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
{Name: StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
}
stageCases := []struct { stageCases := []struct {
seven, thirty int64 seven, thirty int64
want string want string
}{ }{
{seven: 0, thirty: 0, want: StageNovice}, {seven: 0, thirty: 0, want: StageNovice},
{seven: 0, thirty: 1, want: StageNormal}, // 两个非零充值仍低于 normal必须留在 novicenovice 不是“只有零充值”。
{seven: 1, thirty: 1, want: StageAdvanced}, {seven: 99, thirty: 499, want: StageNovice},
{seven: 100, thirty: 499, want: StageNovice},
{seven: 99, thirty: 500, want: StageNovice},
// 两维等于门槛都算达到;任一维未达 advanced 时仍保持 normal。
{seven: 100, thirty: 500, want: StageNormal},
{seven: 500, thirty: 1_999, want: StageNormal},
{seven: 499, thirty: 2_000, want: StageNormal},
{seven: 500, thirty: 2_000, want: StageAdvanced},
} }
for _, tt := range stageCases { for _, tt := range stageCases {
if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want { if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want {

View File

@ -13,8 +13,8 @@ const (
highTierMultiplierPPM int64 = 10_000_000 highTierMultiplierPPM int64 = 10_000_000
) )
// DefaultRuleConfig 返回 dynamic_v3 配置草稿。金额型风控上限和 50 美元等值金币门槛没有跨 App 通用换算 // DefaultRuleConfig 返回 dynamic_v3 配置草稿。大奖累计消费门槛、充值分层和金额型风控上限都没有跨 App 通用值
// 因而保留为 0 且草稿默认 disabled运营显式补齐这些值并启用后才允许发布可运行版本。 // 因而保留未配置 sentinel 且草稿默认 disabled运营显式补齐这些值并启用后才允许发布可运行版本。
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
poolID = normalizePoolID(poolID) poolID = normalizePoolID(poolID)
return domain.RuleConfig{ return domain.RuleConfig{
@ -47,10 +47,11 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
NoviceMaxEquivalentDraws: 2_000, NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000, NormalMaxEquivalentDraws: 20_000,
Stages: []domain.RuleStage{ Stages: []domain.RuleStage{
// novice 的 0/0 只是协议和存储 sentinel运行时未同时达到 normal 两个下限的用户都回落 novice。
defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)), defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)),
// 产品尚未给出具体充值金额时,用 (0,1) 与 (1,1) 保证三个阶段可执行且无重叠;运营可按 App 金币口径上调 // disabled 草稿不猜测金币门槛normal/advanced 必须由运营按 App 口径配置后才能启用
defaultRuleStage(domain.StageNormal, 0, 1, defaultSafeTierSeeds(domain.StageNormal)), defaultRuleStage(domain.StageNormal, 0, 0, defaultSafeTierSeeds(domain.StageNormal)),
defaultRuleStage(domain.StageAdvanced, 1, 1, defaultSafeTierSeeds(domain.StageAdvanced)), defaultRuleStage(domain.StageAdvanced, 0, 0, defaultSafeTierSeeds(domain.StageAdvanced)),
}, },
} }
} }
@ -213,15 +214,15 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
if err := validateDynamicPayoutCaps(config, false); err != nil { if err := validateDynamicPayoutCaps(config, false); err != nil {
return err return err
} }
if err := validateDynamicStageThresholds(stages); err != nil { if err := validateDynamicStageThresholds(stages, config.Enabled); err != nil {
return err return err
} }
if !config.Enabled { if !config.Enabled {
// disabled 草稿允许金额口径仍为 0启用前必须由各 App 按自身金币汇率和风控预算显式补齐。 // disabled 草稿允许金额口径仍为 0启用前必须由各 App 按自身业务口径和风控预算显式补齐。
return nil return nil
} }
if config.JackpotSpendThresholdCoins <= 0 { if config.JackpotSpendThresholdCoins <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins must be configured to the App's USD 50 equivalent coin 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)
} }
@ -240,18 +241,25 @@ func validateJackpotMultipliers(multiplierPPMs []int64) error {
return nil return nil
} }
func validateDynamicStageThresholds(stages map[string]domain.RuleStage) error { func validateDynamicStageThresholds(stages map[string]domain.RuleStage, requireConfigured bool) error {
novice := stages[domain.StageNovice] novice := stages[domain.StageNovice]
normal := stages[domain.StageNormal] normal := stages[domain.StageNormal]
advanced := stages[domain.StageAdvanced] advanced := stages[domain.StageAdvanced]
if novice.MinRecharge7DCoins != 0 || novice.MinRecharge30DCoins != 0 { if novice.MinRecharge7DCoins != 0 || novice.MinRecharge30DCoins != 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 novice recharge thresholds must be 0/0") return xerr.New(xerr.InvalidArgument, "dynamic_v3 novice recharge thresholds must stay 0/0 as protocol and storage sentinels")
} }
if normal.MinRecharge7DCoins < 0 || normal.MinRecharge30DCoins < 0 || advanced.MinRecharge7DCoins < 0 || advanced.MinRecharge30DCoins < 0 { if normal.MinRecharge7DCoins < 0 || normal.MinRecharge30DCoins < 0 || advanced.MinRecharge7DCoins < 0 || advanced.MinRecharge30DCoins < 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge thresholds cannot be negative") return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge thresholds cannot be negative")
} }
thresholdsUnset := normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 &&
advanced.MinRecharge7DCoins == 0 && advanced.MinRecharge30DCoins == 0
if !requireConfigured && thresholdsUnset {
// 未启用版本只在 normal/advanced 四个值全部为 0 时视为“纯未配置”草稿。
// 一旦运营开始填写任一门槛,即使仍 disabled 也必须保持完整单调边界,避免保存一份无法继续发布的半成品。
return nil
}
if normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 { if normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 normal stage must exceed novice in at least one recharge threshold") return xerr.New(xerr.InvalidArgument, "dynamic_v3 normal stage must configure a positive 7-day or 30-day recharge threshold before enabling")
} }
if advanced.MinRecharge7DCoins < normal.MinRecharge7DCoins || advanced.MinRecharge30DCoins < normal.MinRecharge30DCoins || if advanced.MinRecharge7DCoins < normal.MinRecharge7DCoins || advanced.MinRecharge30DCoins < normal.MinRecharge30DCoins ||
(advanced.MinRecharge7DCoins == normal.MinRecharge7DCoins && advanced.MinRecharge30DCoins == normal.MinRecharge30DCoins) { (advanced.MinRecharge7DCoins == normal.MinRecharge7DCoins && advanced.MinRecharge30DCoins == normal.MinRecharge30DCoins) {

View File

@ -33,7 +33,7 @@ func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) {
t.Fatalf("App-specific monetary limits must stay unset in disabled draft: %+v", config) t.Fatalf("App-specific monetary limits must stay unset in disabled draft: %+v", config)
} }
wantThresholds := [][2]int64{{0, 0}, {0, 1}, {1, 1}} wantThresholds := [][2]int64{{0, 0}, {0, 0}, {0, 0}}
for index, stage := range config.Stages { for index, stage := range config.Stages {
if got := [2]int64{stage.MinRecharge7DCoins, stage.MinRecharge30DCoins}; got != wantThresholds[index] { if got := [2]int64{stage.MinRecharge7DCoins, stage.MinRecharge30DCoins}; got != wantThresholds[index] {
t.Fatalf("stage %s thresholds=%v, want %v", stage.Stage, got, wantThresholds[index]) t.Fatalf("stage %s thresholds=%v, want %v", stage.Stage, got, wantThresholds[index])
@ -51,6 +51,10 @@ func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) {
if err := validateRuleConfig(config); err != nil { if err := validateRuleConfig(config); err != nil {
t.Fatalf("disabled dynamic_v3 draft should validate before App-specific monetary limits are known: %v", err) t.Fatalf("disabled dynamic_v3 draft should validate before App-specific monetary limits are known: %v", err)
} }
config.Enabled = true
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "normal stage") {
t.Fatalf("enabled draft error=%v, want explicit normal recharge threshold guidance", err)
}
} }
func TestValidateDynamicV3RejectsImplicitInitialFunding(t *testing.T) { func TestValidateDynamicV3RejectsImplicitInitialFunding(t *testing.T) {
@ -63,9 +67,13 @@ func TestValidateDynamicV3RejectsImplicitInitialFunding(t *testing.T) {
func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testing.T) { func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky") config := DefaultRuleConfig("lalu", "lucky")
config.Stages[1].MinRecharge7DCoins = 100
config.Stages[1].MinRecharge30DCoins = 500
config.Stages[2].MinRecharge7DCoins = 500
config.Stages[2].MinRecharge30DCoins = 2_000
config.Enabled = true config.Enabled = true
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "USD 50 equivalent") { if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "cumulative spend threshold") {
t.Fatalf("enabled draft error=%v, want explicit USD 50 equivalent coin threshold guidance", err) t.Fatalf("enabled draft error=%v, want explicit cumulative spend coin threshold guidance", err)
} }
config.JackpotSpendThresholdCoins = 5_000 config.JackpotSpendThresholdCoins = 5_000
@ -87,6 +95,10 @@ func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testi
func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) { func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky") config := DefaultRuleConfig("lalu", "lucky")
config.Stages[1].MinRecharge7DCoins = 100
config.Stages[1].MinRecharge30DCoins = 500
config.Stages[2].MinRecharge7DCoins = 500
config.Stages[2].MinRecharge30DCoins = 2_000
config.Stages[2].MinRecharge7DCoins = config.Stages[1].MinRecharge7DCoins config.Stages[2].MinRecharge7DCoins = config.Stages[1].MinRecharge7DCoins
config.Stages[2].MinRecharge30DCoins = config.Stages[1].MinRecharge30DCoins config.Stages[2].MinRecharge30DCoins = config.Stages[1].MinRecharge30DCoins
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "exceed normal") { if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "exceed normal") {
@ -98,7 +110,7 @@ func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky") config := DefaultRuleConfig("lalu", "lucky")
config.StrategyVersion = "" config.StrategyVersion = ""
config.Enabled = true config.Enabled = true
// 升级前请求没有任何 dynamic_v3 字段;空 strategy_version 必须按 fixed_v2 解释,不能要求 50 美元门槛或六维 cap。 // 升级前请求没有任何 dynamic_v3 字段;空 strategy_version 必须按 fixed_v2 解释,不能要求动态消费门槛或六维 cap。
config.ProfitRatePPM = 0 config.ProfitRatePPM = 0
config.AnchorRatePPM = 0 config.AnchorRatePPM = 0
config.InitialPoolCoins = 0 config.InitialPoolCoins = 0