From 787d8d36daab94a80d847273118a68f937f5251d Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 18:16:19 +0800 Subject: [PATCH] fix(lucky-gift): derive novice stage from configured thresholds --- docs/幸运礼物动态策略V3实现与模拟.md | 15 ++++--- .../cmd/strategy-sim/scenarios.go | 33 +++++++++----- .../mysql/initdb/001_lucky_gift_service.sql | 6 +-- .../mysql/migrations/004_dynamic_strategy.sql | 6 +-- .../internal/domain/luckygift/lucky_gift.go | 3 +- .../internal/domain/luckygift/strategy.go | 45 ++++++++++--------- .../domain/luckygift/strategy_test.go | 16 ++++++- .../internal/service/luckygift/config.go | 30 ++++++++----- .../internal/service/luckygift/config_test.go | 20 +++++++-- 9 files changed, 112 insertions(+), 62 deletions(-) diff --git a/docs/幸运礼物动态策略V3实现与模拟.md b/docs/幸运礼物动态策略V3实现与模拟.md index 324bf162..ea6ca083 100644 --- a/docs/幸运礼物动态策略V3实现与模拟.md +++ b/docs/幸运礼物动态策略V3实现与模拟.md @@ -6,7 +6,7 @@ - `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`。 - `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/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。 @@ -80,9 +80,10 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent V3 固定有 `novice/normal/advanced` 三层,但两个门槛由每个不可变规则版本配置: - 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。 -- 用户进入同时满足两个最低充值门槛的最高层;不能只满足 7 日或只满足 30 日门槛就越级。 -- `novice` 门槛必须是 `0/0`;`normal` 至少一维高于 novice;`advanced` 两维均不得低于 normal,且至少一维严格更高。 -- 阶段阈值相等时算已达到,即使用 `>=`;所有金额门槛按 App 金币口径显式发布。 +- 用户只在同时满足某层的 7 日和 30 日最低充值门槛时才进入该层;不能只满足其中一维就越级。 +- `novice` 是无条件兜底层,它的 `0/0` 只是协议和存储 sentinel,不表示“只有充值为 0 才是新手”。若 `7d < normal_7d` **或** `30d < normal_30d`,即使两个充值值都非零,用户仍属于 `novice`。 +- `normal` 启用前至少一维必须为正数;`advanced` 两维均不得低于 `normal`,且至少一维严格更高。disabled 草稿可保留 `0/0` 表示尚未配置,但不能以该状态启用。 +- 阶段阈值相等时算已达到,即使用 `>=`;两维都等于普通门槛时进入 `normal`。所有金额门槛按 App 金币口径显式发布。 充值快照由 `wallet-service` 在礼物扣费事务中查询并写入交易 metadata;同一 `command_id` 重放返回首次快照,避免重试时跨层或丢失五分钟加成。 @@ -145,7 +146,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor ### 6.2 机制二:消费里程碑大奖 -- `jackpot_spend_threshold_coins` 是各 App 对“50 美元等值幸运礼物金币”的显式换算,服务不读取实时汇率推断。 +- `jackpot_spend_threshold_coins` 是当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。 - 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token;同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。 - 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。 - 下一抽优先消费已有 token;若大奖集合因 `P`、日 5 次上限或风控不可支付,token 保留,用户仍获得本次普通抽奖。 @@ -228,7 +229,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor | 表 | 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_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 | | `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 金额字段。 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。 5. 本地单测、完整模拟、`make proto`、`make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。 diff --git a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go index 573aac76..fa765979 100644 --- a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go +++ b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go @@ -18,6 +18,12 @@ type scriptedRandom struct { // 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. 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{ {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_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) } 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{ Name: name, Passed: passed, - Summary: fmt.Sprintf("100金币拆为%d/%d/%d;10金币按公共池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/%d;10金币按公共池floor、余数归盈利拆为%d/%d/%d;动态池冷启动=%d,启动资金只能审计注资", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins), Data: map[string]any{ "split_100": split, "split_10_rounding": small, "rounding_policy": "public=floor, anchor=floor, residue=profit", @@ -416,8 +422,7 @@ func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResu const name = "机制2里程碑/池不足" config = baseProbabilityConfig(config) 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.DailyJackpotLimit = 5 input := domain.StrategyInput{GiftPriceCoins: 10} @@ -554,8 +559,14 @@ func simulateRechargeStages(config domain.StrategyConfig) scenarioResult { Recharge30D int64 `json:"recharge_30d"` Stage string `json:"stage"` } - inputs := [][2]int64{{0, 0}, {0, 1}, {1, 1}} - wants := []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} + inputs := [][2]int64{ + {99, 499}, // 非零但两维低于 normal。 + {100, 499}, // 7 日等于 normal,30 日未达,仍为 novice。 + {99, 500}, // 30 日等于 normal,7 日未达,仍为 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)) passed := true for index, input := range inputs { @@ -563,7 +574,7 @@ func simulateRechargeStages(config domain.StrategyConfig) scenarioResult { passed = passed && stage == wants[index] 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 { @@ -586,7 +597,7 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena } input := domain.StrategyInput{ 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)) if err != nil { @@ -728,9 +739,9 @@ func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int) recharge7D int64 recharge30D int64 }{ - {stage: domain.StageNovice, recharge7D: 0, recharge30D: 0}, - {stage: domain.StageNormal, recharge7D: 0, recharge30D: 1}, - {stage: domain.StageAdvanced, recharge7D: 1, recharge30D: 1}, + {stage: domain.StageNovice, recharge7D: 99, recharge30D: 499}, + {stage: domain.StageNormal, recharge7D: 100, recharge30D: 500}, + {stage: domain.StageAdvanced, recharge7D: 500, recharge30D: 2_000}, } results := make([]groupLongRunSummary, 0, len(groups)) passed := true diff --git a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql index 981ee29d..fe00e01d 100644 --- a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql +++ b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql @@ -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_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_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_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限', 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', rule_version BIGINT NOT NULL COMMENT '规则版本', stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced', - min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT '进入该阶段的最近 7 日充值金币门槛', - min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT '进入该阶段的最近 30 日充值金币门槛', + 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 'normal/advanced 最近 30 日充值下限;novice 的 0 仅为 sentinel', tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID', multiplier_ppm BIGINT NOT NULL COMMENT '倍率,1x = 1000000', base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率,ppm', diff --git a/services/lucky-gift-service/deploy/mysql/migrations/004_dynamic_strategy.sql b/services/lucky-gift-service/deploy/mysql/migrations/004_dynamic_strategy.sql index 3985f60b..836d5bde 100644 --- a/services/lucky-gift-service/deploy/mysql/migrations/004_dynamic_strategy.sql +++ b/services/lucky-gift-service/deploy/mysql/migrations/004_dynamic_strategy.sql @@ -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; 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; 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; 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; 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; SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'profit_total') = 0, diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index 3ed7825c..c27b4f6c 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -107,7 +107,8 @@ type RuleTier struct { // RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。 type RuleStage struct { - Stage string `json:"stage"` + Stage string `json:"stage"` + // novice 的两个 0 仅为协议/存储 sentinel;normal/advanced 才用这两个下限提档。 MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"` MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"` Tiers []RuleTier `json:"tiers"` diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy.go b/services/lucky-gift-service/internal/domain/luckygift/strategy.go index 00faaeae..d96ec23f 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy.go @@ -285,15 +285,15 @@ func DefaultLuckyGiftStrategyConfig() StrategyConfig { LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000, LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000, RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000, - // The product threshold is USD 50 while the kernel operates in integer - // coins. Zero forces adapters to supply an explicit versioned conversion. + // 累计消费门槛是不可变规则版本的动态金币配置;0 表示该内核默认值尚未配置。 MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0, JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true, GlobalRTPMaxPPM: 980_000, UserDayRTPMaxPPM: 960_000, User72HourRTPMaxPPM: 960_000, RechargeStages: []StrategyRechargeStage{ + // 三个 0/0 仅用于未配置内核默认值;可运行规则必须由发布层注入 normal/advanced 门槛。 {Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0}, - {Name: StageNormal, MinRecharge7DCoins: 0, MinRecharge30DCoins: 1}, - {Name: StageAdvanced, MinRecharge7DCoins: 1, MinRecharge30DCoins: 1}, + {Name: StageNormal, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0}, + {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 // 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 { if milestone <= 0 || previousSpend < 0 || currentSpend <= previousSpend { return 0 @@ -343,23 +343,28 @@ func MilestoneTokensEarned(previousSpend, currentSpend, milestone int64) int64 { return currentSpend/milestone - previousSpend/milestone } -// SelectLuckyGiftRechargeStage returns the highest explicitly configured stage whose -// two recharge floors pass. It never infers a stage from local time or current balance. +// SelectLuckyGiftRechargeStage 只用 normal/advanced 的版本化门槛向上提档;novice 是无条件兜底, +// 其 0/0 仅为协议和存储 sentinel。两维必须同时达到才能提档,所以任一维低于 normal 都是 novice,等于门槛则通过该维。 func SelectLuckyGiftRechargeStage(config StrategyConfig, recharge7D, recharge30D int64) string { - stages := append([]StrategyRechargeStage(nil), config.RechargeStages...) - sort.SliceStable(stages, func(i, j int) bool { - if stages[i].MinRecharge30DCoins == stages[j].MinRecharge30DCoins { - 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 - } + stages := make(map[string]StrategyRechargeStage, len(config.RechargeStages)) + for _, stage := range config.RechargeStages { + stages[stage.Name] = stage } - 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 diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go b/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go index 869441a3..346772fb 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go @@ -359,13 +359,25 @@ func TestLuckyGiftStrategySixRiskCapacitiesAreHardInclusiveBounds(t *testing.T) func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) { 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 { seven, thirty int64 want string }{ {seven: 0, thirty: 0, want: StageNovice}, - {seven: 0, thirty: 1, want: StageNormal}, - {seven: 1, thirty: 1, want: StageAdvanced}, + // 两个非零充值仍低于 normal,必须留在 novice;novice 不是“只有零充值”。 + {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 { if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want { diff --git a/services/lucky-gift-service/internal/service/luckygift/config.go b/services/lucky-gift-service/internal/service/luckygift/config.go index 450fcd1b..77cfbc7f 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config.go +++ b/services/lucky-gift-service/internal/service/luckygift/config.go @@ -13,8 +13,8 @@ const ( highTierMultiplierPPM int64 = 10_000_000 ) -// DefaultRuleConfig 返回 dynamic_v3 配置草稿。金额型风控上限和 50 美元等值金币门槛没有跨 App 通用换算, -// 因而保留为 0 且草稿默认 disabled;运营显式补齐这些值并启用后,才允许发布可运行版本。 +// DefaultRuleConfig 返回 dynamic_v3 配置草稿。大奖累计消费门槛、充值分层和金额型风控上限都没有跨 App 通用值, +// 因而保留未配置 sentinel 且草稿默认 disabled;运营显式补齐这些值并启用后,才允许发布可运行版本。 func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { poolID = normalizePoolID(poolID) return domain.RuleConfig{ @@ -47,10 +47,11 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, Stages: []domain.RuleStage{ + // novice 的 0/0 只是协议和存储 sentinel;运行时未同时达到 normal 两个下限的用户都回落 novice。 defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)), - // 产品尚未给出具体充值金额时,用 (0,1) 与 (1,1) 保证三个阶段可执行且无重叠;运营可按 App 金币口径上调。 - defaultRuleStage(domain.StageNormal, 0, 1, defaultSafeTierSeeds(domain.StageNormal)), - defaultRuleStage(domain.StageAdvanced, 1, 1, defaultSafeTierSeeds(domain.StageAdvanced)), + // disabled 草稿不猜测金币门槛;normal/advanced 必须由运营按 App 口径配置后才能启用。 + defaultRuleStage(domain.StageNormal, 0, 0, defaultSafeTierSeeds(domain.StageNormal)), + 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 { return err } - if err := validateDynamicStageThresholds(stages); err != nil { + if err := validateDynamicStageThresholds(stages, config.Enabled); err != nil { return err } if !config.Enabled { - // disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身金币汇率和风控预算显式补齐。 + // disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。 return nil } 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) } @@ -240,18 +241,25 @@ func validateJackpotMultipliers(multiplierPPMs []int64) error { return nil } -func validateDynamicStageThresholds(stages map[string]domain.RuleStage) error { +func validateDynamicStageThresholds(stages map[string]domain.RuleStage, requireConfigured bool) error { novice := stages[domain.StageNovice] normal := stages[domain.StageNormal] advanced := stages[domain.StageAdvanced] 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 { 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 { - 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 || (advanced.MinRecharge7DCoins == normal.MinRecharge7DCoins && advanced.MinRecharge30DCoins == normal.MinRecharge30DCoins) { diff --git a/services/lucky-gift-service/internal/service/luckygift/config_test.go b/services/lucky-gift-service/internal/service/luckygift/config_test.go index 3152e0b2..a86a3317 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config_test.go +++ b/services/lucky-gift-service/internal/service/luckygift/config_test.go @@ -33,7 +33,7 @@ func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) { 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 { if got := [2]int64{stage.MinRecharge7DCoins, stage.MinRecharge30DCoins}; 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 { 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) { @@ -63,9 +67,13 @@ func TestValidateDynamicV3RejectsImplicitInitialFunding(t *testing.T) { func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testing.T) { 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 - if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "USD 50 equivalent") { - t.Fatalf("enabled draft error=%v, want explicit USD 50 equivalent coin threshold guidance", err) + if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "cumulative spend threshold") { + t.Fatalf("enabled draft error=%v, want explicit cumulative spend coin threshold guidance", err) } config.JackpotSpendThresholdCoins = 5_000 @@ -87,6 +95,10 @@ func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testi func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) { 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].MinRecharge30DCoins = config.Stages[1].MinRecharge30DCoins 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.StrategyVersion = "" config.Enabled = true - // 升级前请求没有任何 dynamic_v3 字段;空 strategy_version 必须按 fixed_v2 解释,不能要求 50 美元门槛或六维 cap。 + // 升级前请求没有任何 dynamic_v3 字段;空 strategy_version 必须按 fixed_v2 解释,不能要求动态消费门槛或六维 cap。 config.ProfitRatePPM = 0 config.AnchorRatePPM = 0 config.InitialPoolCoins = 0