merge: update lucky gift threshold strategy
This commit is contained in:
commit
9c1dd0e3a4
@ -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%。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user