diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index 1f532914..9ec48743 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -518,7 +518,7 @@ type LuckyGiftRuleConfig struct { JackpotGlobalRtpMaxPpm int64 `protobuf:"varint,28,opt,name=jackpot_global_rtp_max_ppm,json=jackpotGlobalRtpMaxPpm,proto3" json:"jackpot_global_rtp_max_ppm,omitempty"` JackpotUserRoundRtpMaxPpm int64 `protobuf:"varint,29,opt,name=jackpot_user_round_rtp_max_ppm,json=jackpotUserRoundRtpMaxPpm,proto3" json:"jackpot_user_round_rtp_max_ppm,omitempty"` JackpotUser_48HRtpMaxPpm int64 `protobuf:"varint,30,opt,name=jackpot_user_48h_rtp_max_ppm,json=jackpotUser48hRtpMaxPpm,proto3" json:"jackpot_user_48h_rtp_max_ppm,omitempty"` - // 历史消费门槛字段只用于旧配置回读;dynamic_v3 当前大奖资格不再消费该值。 + // 用户在同一应用、奖池和 UTC 自然日每累计消费满该金币数,生成一次可跨日保留的消费大奖资格。 JackpotSpendThresholdCoins int64 `protobuf:"varint,31,opt,name=jackpot_spend_threshold_coins,json=jackpotSpendThresholdCoins,proto3" json:"jackpot_spend_threshold_coins,omitempty"` MaxJackpotHitsPerUserDay int64 `protobuf:"varint,32,opt,name=max_jackpot_hits_per_user_day,json=maxJackpotHitsPerUserDay,proto3" json:"max_jackpot_hits_per_user_day,omitempty"` MaxSinglePayout int64 `protobuf:"varint,33,opt,name=max_single_payout,json=maxSinglePayout,proto3" json:"max_single_payout,omitempty"` diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index 1678ed8f..9f8c52c0 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -96,7 +96,7 @@ message LuckyGiftRuleConfig { int64 jackpot_global_rtp_max_ppm = 28; int64 jackpot_user_round_rtp_max_ppm = 29; int64 jackpot_user_48h_rtp_max_ppm = 30; - // 历史消费门槛字段只用于旧配置回读;dynamic_v3 当前大奖资格不再消费该值。 + // 用户在同一应用、奖池和 UTC 自然日每累计消费满该金币数,生成一次可跨日保留的消费大奖资格。 int64 jackpot_spend_threshold_coins = 31; int64 max_jackpot_hits_per_user_day = 32; int64 max_single_payout = 33; diff --git a/services/lucky-gift-service/cmd/strategy-sim/main.go b/services/lucky-gift-service/cmd/strategy-sim/main.go index b3f6f9fc..232c5a09 100644 --- a/services/lucky-gift-service/cmd/strategy-sim/main.go +++ b/services/lucky-gift-service/cmd/strategy-sim/main.go @@ -103,6 +103,7 @@ func runSimulation(options simulationOptions) simulationReport { simulateSixRiskCapacities(config), simulateRechargeStages(config), simulateCombinationPriority(config, options.Seed+70), + simulateDualJackpotSpendStrategy(config, options.Seed+75), simulateConcurrencyAndIdempotency(config, options.Seed+80), simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN), } diff --git a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go index d1f81551..106a748b 100644 --- a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go +++ b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go @@ -938,6 +938,10 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena config.HighWaterFactorPPM = 1_300_000 config.RechargeFactorPPM = 1_100_000 state := qualifiedSettledRoundState(10_000, 100*60*60*1000) + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + state.PendingSpendJackpotTokens = 1 + state.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800} state.ConsecutiveZeroDraws = 5 input := domain.StrategyInput{ GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true, @@ -947,8 +951,8 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena if err != nil { return failedScenario(name, err) } - // 已结算轮次资格在普通概率、第6抽保底和动态因子之前尝试;独立消费里程碑机制已经停用。 - priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.Stage == domain.StageAdvanced + // 两套资格同时存在时,48h 补偿先支付,消费 token 保留到下一个子抽。 + priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.NextState.PendingSpendJackpotTokens == 1 && priority.Stage == domain.StageAdvanced blockedState := state blockedState.PoolBalanceCoins = 50 @@ -956,15 +960,118 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena if err != nil { return failedScenario(name, err) } - // 大奖资格不能透支奖池;该轮资格被消费后,同一抽仍可按普通第6抽保底支付恰好可负担的5x。 - blockedPass := !blocked.NextState.SettledRoundPending && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0 + // 大奖资格不能透支奖池;48h 资格本轮结束,消费 token 则等待后续补水。 + blockedPass := !blocked.NextState.SettledRoundPending && blocked.NextState.PendingSpendJackpotTokens == 1 && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0 return scenarioResult{ Name: name, Passed: priorityPass && blockedPass, - Summary: fmt.Sprintf("优先级=已结算轮次资格>普通P/W/第6抽;大奖池不足时资格消费,本抽仅赔可支付%s", blocked.SelectedTier.ID), + Summary: fmt.Sprintf("优先级=48h补偿>消费达标>普通P/W;48h成功不扣消费token,池不足时消费token保留,本抽仅赔%s", blocked.SelectedTier.ID), Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked}, } } +func simulateDualJackpotSpendStrategy(config domain.StrategyConfig, seed int64) scenarioResult { + const name = "双大奖消费资格矩阵" + config = settledRoundJackpotConfig(config) + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + input := domain.StrategyInput{GiftPriceCoins: 10} + base := domain.StrategyState{ + PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, + SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}, + } + type row struct { + Case string `json:"case"` + GlobalWager int64 `json:"global_wager"` + GlobalPayout int64 `json:"global_payout"` + TokensBefore int64 `json:"tokens_before"` + TokensAfter int64 `json:"tokens_after"` + Mechanism string `json:"mechanism"` + Jackpot bool `json:"jackpot"` + Payout int64 `json:"payout"` + PoolAfter int64 `json:"pool_after"` + Reason string `json:"reason"` + } + rows := make([]row, 0, 8) + run := func(caseName string, state domain.StrategyState, drawInput domain.StrategyInput, offset int64) (domain.StrategyDecision, error) { + decision, err := domain.DecideLuckyGiftStrategy(config, state, drawInput, domain.NewSeededStrategyRandom(seed+offset)) + if err == nil { + rows = append(rows, row{ + Case: caseName, GlobalWager: state.SpendGlobalRTP.WagerCoins, GlobalPayout: state.SpendGlobalRTP.PayoutCoins, + TokensBefore: state.PendingSpendJackpotTokens, TokensAfter: decision.NextState.PendingSpendJackpotTokens, + Mechanism: decision.JackpotMechanism, Jackpot: decision.Jackpot, Payout: decision.PayoutCoins, + PoolAfter: decision.PoolAfterCoins, Reason: decision.Trace.BlockedReason, + }) + } + return decision, err + } + + equal, err := run("大盘等于98%", base, input, 0) + if err != nil { + return failedScenario(name, err) + } + aboveState := base + aboveState.SpendGlobalRTP.PayoutCoins = 9_801 + above, err := run("大盘高于98%", aboveState, input, 1) + if err != nil { + return failedScenario(name, err) + } + noSampleState := base + noSampleState.SpendGlobalRTP = domain.StrategyRTP{} + noSample, err := run("无已结算大盘", noSampleState, input, 2) + if err != nil { + return failedScenario(name, err) + } + + // 40->150 crosses 50/100/150 in one paid child draw. A scripted zero proves those three new + // tokens cannot influence the current selection; the following child draw consumes only one. + crossState := base + crossState.PendingSpendJackpotTokens = 0 + crossState.UserDaySpendCoins = 40 + crossInput := domain.StrategyInput{GiftPriceCoins: 110} + crossed, err := domain.DecideLuckyGiftStrategy(config, crossState, crossInput, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}}) + if err != nil { + return failedScenario(name, err) + } + rows = append(rows, row{Case: "当前子抽跨三档", GlobalWager: crossState.SpendGlobalRTP.WagerCoins, GlobalPayout: crossState.SpendGlobalRTP.PayoutCoins, TokensBefore: 0, TokensAfter: crossed.NextState.PendingSpendJackpotTokens, Mechanism: crossed.JackpotMechanism, Jackpot: crossed.Jackpot, Payout: crossed.PayoutCoins, PoolAfter: crossed.PoolAfterCoins, Reason: crossed.Trace.BlockedReason}) + next, err := run("下一子抽消费一档", crossed.NextState, input, 3) + if err != nil { + return failedScenario(name, err) + } + + qualified := qualifiedSettledRoundState(10_000, 100*60*60*1000) + qualified.PendingSpendJackpotTokens = 1 + qualified.SpendGlobalRTP = base.SpendGlobalRTP + priority, err := run("双资格48h优先", qualified, input, 4) + if err != nil { + return failedScenario(name, err) + } + qualified.UserRoundRTP.PayoutCoins = 9_600 + fallback, err := run("48h不合格消费接续", qualified, input, 5) + if err != nil { + return failedScenario(name, err) + } + poolBlockedState := base + poolBlockedState.PoolBalanceCoins = 49 + poolBlocked, err := run("奖池不足资格保留", poolBlockedState, input, 6) + if err != nil { + return failedScenario(name, err) + } + + passed := equal.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && equal.NextState.PendingSpendJackpotTokens == 0 && + !above.Jackpot && above.NextState.PendingSpendJackpotTokens == 1 && + !noSample.Jackpot && noSample.NextState.PendingSpendJackpotTokens == 1 && + !crossed.Jackpot && crossed.NextState.PendingSpendJackpotTokens == 3 && + next.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && next.NextState.PendingSpendJackpotTokens == 2 && + priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && priority.NextState.PendingSpendJackpotTokens == 1 && + fallback.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && fallback.NextState.PendingSpendJackpotTokens == 0 && + !poolBlocked.Jackpot && poolBlocked.NextState.PendingSpendJackpotTokens == 1 + return scenarioResult{ + Name: name, Passed: passed, + Summary: "验证大盘<=边界、无样本关闭、每档累计、下一子抽生效、48h优先/接续和资金不足跨轮保留", + Data: rows, + } +} + // simulationStore models the repository contract that the pure kernel expects: // command idempotency and state locking wrap the draw in one critical section. The // simulator does not claim an in-memory mutex is the production implementation; it 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 9a9fc2fe..026188af 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 @@ -280,7 +280,8 @@ CREATE TABLE IF NOT EXISTS lucky_user_states ( cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币', equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数', loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零', - pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '未消费大奖机会;按UTC日消费产生,资金不足时允许跨日保留', + pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '旧版消费资格;新版运行时仅惰性清零,永不参与开奖', + pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '新版累计消费大奖资格;受阻时跨轮次和UTC日保留,成功开奖仅扣1', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, user_id, gift_id) diff --git a/services/lucky-gift-service/deploy/mysql/migrations/007_dual_jackpot_spend_tokens.sql b/services/lucky-gift-service/deploy/mysql/migrations/007_dual_jackpot_spend_tokens.sql new file mode 100644 index 00000000..2cd03f85 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/007_dual_jackpot_spend_tokens.sql @@ -0,0 +1,17 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_lucky_gift; + +-- 性能边界:lucky_user_states 是热状态表,本迁移只添加一个常量默认列并强制 +-- ALGORITHM=INSTANT。若目标 MySQL 不支持即时 DDL 就直接失败,绝不退化成 COPY; +-- 上线前仍需检查长事务,避免短暂 MDL 等待。迁移不扫描、不更新历史用户行。 +-- 历史 pending_jackpot_tokens 保持原值,业务在该用户下一次付费抽奖事务内按主键惰性清零, +-- 且从不把旧值复制到新列,因此停用期间的资格和消费都不会被追溯补发。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_states' + AND COLUMN_NAME = 'pending_spend_jackpot_tokens') = 0, + 'ALTER TABLE lucky_user_states ADD COLUMN pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''新版累计消费大奖资格;受阻时跨轮次和UTC日保留,成功开奖仅扣1'' AFTER pending_jackpot_tokens, ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy.go b/services/lucky-gift-service/internal/domain/luckygift/strategy.go index ab5104e7..0cd4ffd6 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy.go @@ -26,7 +26,8 @@ const ( StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p" StrategyReasonRiskCapacity = "risk_capacity_exceeded" StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate" - StrategyJackpotMechanismMilestone = "milestone_token" + StrategyReasonSpendGlobalRTPBlocked = "spend_global_rtp_gate_failed" + StrategyJackpotMechanismMilestone = "spend_milestone" StrategyJackpotMechanismRTPCompensation = "rtp_compensation" StrategyDrawKindOriginal = "original" StrategyDrawKindRedraw = "redraw" @@ -35,6 +36,8 @@ const ( StrategyRemovalMissProtection = "miss_protection_excludes_zero" StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero" StrategyConditionGlobalRTP = "global_rtp" + StrategyConditionSpendGlobalRTP = "spend_global_rtp" + StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit" StrategyConditionSettledRound = "settled_round_qualification" StrategyConditionUserRoundRTP = "user_round_rtp" StrategyConditionUser48HourRTP = "user_48h_rtp" @@ -152,11 +155,18 @@ type StrategyRTP struct { // must update it atomically with the draw record; Version is provided for CAS/optimistic // locking but the kernel itself intentionally performs no I/O. type StrategyState struct { - PoolBalanceCoins int64 `json:"pool_balance_coins"` - ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"` - PendingMilestoneTokens int64 `json:"pending_milestone_tokens"` - UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"` - UserDaySpendCoins int64 `json:"user_day_spend_coins"` + PoolBalanceCoins int64 `json:"pool_balance_coins"` + ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"` + // PendingMilestoneTokens is the legacy pure-kernel field. Production dynamic_v3 never reads it: + // the separate field below prevents tokens created by the retired implementation from becoming + // a jackpot promise after the dual-strategy rollout. + PendingMilestoneTokens int64 `json:"pending_milestone_tokens"` + PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"` + UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"` + UserDaySpendCoins int64 `json:"user_day_spend_coins"` + // SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is + // deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification. + SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"` GlobalRTP StrategyRTP `json:"global_rtp"` UserRoundRTP StrategyRTP `json:"user_round_rtp"` User48HourRTP StrategyRTP `json:"user_48h_rtp"` @@ -255,9 +265,14 @@ type DecisionTrace struct { FinalReason string `json:"final_reason"` BlockedReason string `json:"blocked_reason,omitempty"` JackpotMechanism string `json:"jackpot_mechanism,omitempty"` - MilestoneTokenConsumed bool `json:"milestone_token_consumed"` - MilestoneTokenRetained bool `json:"milestone_token_retained"` - MilestoneTokensEarned int64 `json:"milestone_tokens_earned"` + MilestoneTokenConsumed bool `json:"spend_milestone_token_consumed"` + MilestoneTokenRetained bool `json:"spend_milestone_token_retained"` + MilestoneTokensEarned int64 `json:"spend_milestone_tokens_earned"` + // The exact before/after balance is persisted for every draw, including a successful 48h + // compensation that deliberately leaves an existing spend token untouched. The boolean fields + // above keep their event meaning; these counters make multi-token incident replay unambiguous. + SpendMilestoneTokensBefore int64 `json:"pending_spend_jackpot_tokens_before"` + SpendMilestoneTokensAfter int64 `json:"pending_spend_jackpot_tokens_after"` } type StrategyDecision struct { @@ -401,10 +416,10 @@ func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState, return stage, traces, err } -// DecideLuckyGiftStrategy is the shared production/simulation kernel. Its ordering is -// intentional and traceable: persisted mechanism-2 token -> mechanism-1 RTP gates -> -// ordinary weighted P/W draw and redraw -> state transition. A blocked special -// mechanism falls through to an ordinary draw while preserving its token. +// DecideLuckyGiftStrategy is the shared production/simulation kernel. Production V3 evaluates the +// one-shot closed-round/48h qualification first, then the independent durable spend milestone, and +// finally the ordinary weighted draw. A spend token is consumed only by a paid spend jackpot; all +// failed global-RTP, daily-limit, pool and risk checks leave it available for a later paid draw. func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input StrategyInput, random StrategyRandomSource) (StrategyDecision, error) { if random == nil { return StrategyDecision{}, ErrStrategyRandomSource @@ -425,48 +440,41 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins, PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available, EffectiveRiskCapacityCoins: input.RiskCapacity.capacity(), Weights: weightTrace, + SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state), + SpendMilestoneTokensAfter: pendingSpendJackpotTokens(config, state), } - // A persisted milestone token is a promise about the next draw, so it outranks the - // opportunistic RTP compensation mechanism. Insufficient pool/risk capacity keeps - // the token durable and lets the user retain the ordinary draw they paid for. - if config.JackpotMechanism2Enabled && state.PendingMilestoneTokens > 0 { - tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) + if config.SettledRoundRTPEligibility { + decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace) if err != nil { return StrategyDecision{}, err } - trace.Removed = append(trace.Removed, removed...) - if ok { - trace.JackpotMechanism = StrategyJackpotMechanismMilestone - trace.MilestoneTokenConsumed = true - trace.FinalReason = StrategyReasonMilestoneJackpot - trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) - return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, trace) + if decided { + return decision, nil } - trace.MilestoneTokenRetained = true - trace.BlockedReason = reason - trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: false, Reason: reason}) - } - - if config.JackpotMechanism1Enabled { - conditions, eligible := mechanismOneConditions(config, state) - trace.Conditions = append(trace.Conditions, conditions...) - if eligible { - tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) - if err != nil { - return StrategyDecision{}, err - } - trace.Removed = append(trace.Removed, removed...) - trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason}) - if ok { - trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation - trace.FinalReason = StrategyReasonRTPCompensationJackpot - trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) - return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, trace) - } - if trace.BlockedReason == "" { - trace.BlockedReason = reason - } + decision, decided, err = trySpendMilestoneJackpot(config, state, input, available, random, true, &trace) + if err != nil { + return StrategyDecision{}, err + } + if decided { + return decision, nil + } + } else { + // The isolated legacy kernel keeps its historical order and gate shape. Runtime dynamic_v3 + // always takes the branch above and therefore never reads PendingMilestoneTokens. + decision, decided, err := trySpendMilestoneJackpot(config, state, input, available, random, false, &trace) + if err != nil { + return StrategyDecision{}, err + } + if decided { + return decision, nil + } + decision, decided, err = tryRTPCompensationJackpot(config, state, input, available, random, &trace) + if err != nil { + return StrategyDecision{}, err + } + if decided { + return decision, nil } } @@ -485,11 +493,91 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S return finalizeStrategyDecision(config, state, input, available, tier, payout, "", false, trace) } +func tryRTPCompensationJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, trace *DecisionTrace) (StrategyDecision, bool, error) { + if !config.JackpotMechanism1Enabled { + return StrategyDecision{}, false, nil + } + conditions, eligible := mechanismOneConditions(config, state) + trace.Conditions = append(trace.Conditions, conditions...) + if !eligible { + return StrategyDecision{}, false, nil + } + tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) + if err != nil { + return StrategyDecision{}, false, err + } + trace.Removed = append(trace.Removed, removed...) + trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason}) + if !ok { + if trace.BlockedReason == "" { + trace.BlockedReason = reason + } + return StrategyDecision{}, false, nil + } + trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation + trace.FinalReason = StrategyReasonRTPCompensationJackpot + trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) + decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, *trace) + return decision, err == nil, err +} + +func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, requireSettledGlobal bool, trace *DecisionTrace) (StrategyDecision, bool, error) { + if !config.JackpotMechanism2Enabled || pendingSpendJackpotTokens(config, state) <= 0 { + return StrategyDecision{}, false, nil + } + if requireSettledGlobal { + ratio, valid := state.SpendGlobalRTP.RatioPPM() + globalPassed := valid && rtpAtOrBelowExact(state.SpendGlobalRTP, config.GlobalRTPMaxPPM) + trace.Conditions = append(trace.Conditions, rtpAtOrBelowConditionTrace(StrategyConditionSpendGlobalRTP, state.SpendGlobalRTP, ratio, config.GlobalRTPMaxPPM, valid, globalPassed)) + dailyPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit + trace.Conditions = append(trace.Conditions, StrategyConditionTrace{ + Name: StrategyConditionSpendDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, + Passed: dailyPassed, Reason: boolReason(dailyPassed, "below_limit", StrategyReasonDailyJackpotLimit), + }) + if !globalPassed || !dailyPassed { + trace.MilestoneTokenRetained = true + if trace.BlockedReason == "" { + trace.BlockedReason = StrategyReasonSpendGlobalRTPBlocked + if globalPassed { + trace.BlockedReason = StrategyReasonDailyJackpotLimit + } + } + return StrategyDecision{}, false, nil + } + } + tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) + if err != nil { + return StrategyDecision{}, false, err + } + trace.Removed = append(trace.Removed, removed...) + trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason}) + if !ok { + trace.MilestoneTokenRetained = true + if trace.BlockedReason == "" { + trace.BlockedReason = reason + } + return StrategyDecision{}, false, nil + } + trace.JackpotMechanism = StrategyJackpotMechanismMilestone + trace.MilestoneTokenConsumed = true + trace.FinalReason = StrategyReasonMilestoneJackpot + trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) + decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, *trace) + return decision, err == nil, err +} + +func pendingSpendJackpotTokens(config StrategyConfig, state StrategyState) int64 { + if config.SettledRoundRTPEligibility { + return state.PendingSpendJackpotTokens + } + return state.PendingMilestoneTokens +} + func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error { - 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.PendingSpendJackpotTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 { return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput) } - for _, rtp := range []StrategyRTP{state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.UserDayRTP, state.User72HourRTP} { + for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.UserDayRTP, state.User72HourRTP} { if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 { return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput) } @@ -748,6 +836,16 @@ func rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason} } +func rtpAtOrBelowConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace { + reason := "within_limit" + if !valid { + reason = "denominator_zero" + } else if !passed { + reason = "above_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 } @@ -908,10 +1006,14 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input next.ConsecutiveZeroDraws = 0 } if consumeToken { - if next.PendingMilestoneTokens <= 0 { + if pendingSpendJackpotTokens(config, next) <= 0 { return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput) } - next.PendingMilestoneTokens-- + if config.SettledRoundRTPEligibility { + next.PendingSpendJackpotTokens-- + } else { + next.PendingMilestoneTokens-- + } } jackpot := payout > 0 && tier.Jackpot if jackpot { @@ -922,21 +1024,28 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input if next.UserDaySpendCoins, err = safeAdd(next.UserDaySpendCoins, input.GiftPriceCoins); err != nil { return StrategyDecision{}, err } - // A threshold crossed by this draw is persisted after selection, so the newly - // earned token can affect only the next locked draw and never the current one. Production V3's - // settled-round mode has no independent spend-token qualification, so it must neither earn nor - // carry a hidden third jackpot condition even if an old rule row still contains the legacy value. + // A threshold crossed by this draw is persisted after selection, so the newly earned token can + // affect only the next locked subdraw and never the current one. Production V3 stores it in the + // dedicated field; the retired legacy token is neither read nor migrated into the new strategy. earnedTokens := int64(0) - if !config.SettledRoundRTPEligibility { + if config.JackpotMechanism2Enabled { earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins) } if earnedTokens > 0 { - next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens) + if config.SettledRoundRTPEligibility { + next.PendingSpendJackpotTokens, err = safeAdd(next.PendingSpendJackpotTokens, earnedTokens) + } else { + next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens) + } if err != nil { return StrategyDecision{}, err } trace.MilestoneTokensEarned = earnedTokens } + // Set the final balance after both the optional consumption and threshold-crossing accrual. + // This assignment runs for ordinary, 48h and spend-jackpot decisions alike, so a 48h win with + // one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed. + trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next) if !config.SettledRoundRTPEligibility { if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil { return StrategyDecision{}, err 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 39c79c0d..cb098bdb 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go @@ -294,6 +294,179 @@ func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) { } } +func TestLuckyGiftStrategyDualJackpotSpendGlobalGateAndDurability(t *testing.T) { + config := strategyTestConfig() + config.SettledRoundRTPEligibility = true + config.JackpotMechanism1Enabled = true + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + config.GlobalRTPMaxPPM = 980_000 + config.UserRoundRTPMaxPPM = 960_000 + config.User48HourRTPMaxPPM = 960_000 + config.DailyJackpotLimit = 5 + + base := StrategyState{PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}} + paid, err := DecideLuckyGiftStrategy(config, base, strategyTestInput(10), NewSeededStrategyRandom(80)) + if err != nil { + t.Fatal(err) + } + if paid.JackpotMechanism != StrategyJackpotMechanismMilestone || !paid.ConsumedMilestoneToken || paid.NextState.PendingSpendJackpotTokens != 0 || + paid.Trace.SpendMilestoneTokensBefore != 1 || paid.Trace.SpendMilestoneTokensAfter != 0 { + t.Fatalf("global equality should pay exactly one spend token: %+v", paid) + } + + for _, test := range []struct { + name string + rtp StrategyRTP + }{ + {name: "no closed platform sample", rtp: StrategyRTP{}}, + {name: "platform above configured maximum", rtp: StrategyRTP{WagerCoins: 100, PayoutCoins: 99}}, + } { + t.Run(test.name, func(t *testing.T) { + state := base + state.SpendGlobalRTP = test.rtp + blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(81)) + if err != nil { + t.Fatal(err) + } + if blocked.JackpotMechanism != "" || blocked.NextState.PendingSpendJackpotTokens != 1 || !blocked.Trace.MilestoneTokenRetained { + t.Fatalf("blocked spend token must remain durable: %+v", blocked) + } + }) + } + + capState := base + capState.UserDailyJackpotWins = config.DailyJackpotLimit + blockedBySharedCap, err := DecideLuckyGiftStrategy(config, capState, strategyTestInput(10), NewSeededStrategyRandom(82)) + if err != nil { + t.Fatal(err) + } + if blockedBySharedCap.NextState.PendingSpendJackpotTokens != 1 || blockedBySharedCap.Jackpot { + t.Fatalf("daily cap must be shared without consuming spend token: %+v", blockedBySharedCap) + } +} + +func TestLuckyGiftStrategyDualJackpotCrossingAndPriority(t *testing.T) { + config := strategyTestConfig() + config.SettledRoundRTPEligibility = true + config.JackpotMechanism1Enabled = true + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + config.GlobalRTPMaxPPM = 980_000 + config.UserRoundRTPMaxPPM = 960_000 + config.User48HourRTPMaxPPM = 960_000 + config.DailyJackpotLimit = 5 + + // One paid subdraw can cross several exact multiples, but its new promises are appended only + // after selection. Even with a valid global sample this draw must stay on the ordinary path. + crossingState := StrategyState{ + PoolBalanceCoins: 10_000, UserDaySpendCoins: 40, + SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + } + crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(110), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}}) + if err != nil { + t.Fatal(err) + } + if crossing.Jackpot || crossing.NextState.PendingSpendJackpotTokens != 3 || crossing.Trace.MilestoneTokensEarned != 3 || + crossing.Trace.SpendMilestoneTokensBefore != 0 || crossing.Trace.SpendMilestoneTokensAfter != 3 { + t.Fatalf("current draw must only earn three next-draw tokens: %+v", crossing) + } + + const hourMS = int64(60 * 60 * 1000) + qualified := StrategyState{ + PoolBalanceCoins: 10_000, + PendingSpendJackpotTokens: 1, + SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + SettledRoundPending: true, + GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + UserRoundRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 95}, + User48HourRTP: StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500}, + UserRegisteredAtMS: hourMS, + QualificationClosedAtMS: 49 * hourMS, + } + compensated, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(83)) + if err != nil { + t.Fatal(err) + } + if compensated.JackpotMechanism != StrategyJackpotMechanismRTPCompensation || compensated.NextState.PendingSpendJackpotTokens != 1 || compensated.NextState.SettledRoundPending || + compensated.Trace.SpendMilestoneTokensBefore != 1 || compensated.Trace.SpendMilestoneTokensAfter != 1 { + t.Fatalf("48h qualification must win first without consuming spend token: %+v", compensated) + } + + // A failed personal RTP condition consumes only the one-shot 48h qualification. The same child + // draw may still use the independent spend promise because registration and personal RTP do not gate it. + qualified.UserRoundRTP.PayoutCoins = 96 + spendFallback, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(84)) + if err != nil { + t.Fatal(err) + } + if spendFallback.JackpotMechanism != StrategyJackpotMechanismMilestone || spendFallback.NextState.PendingSpendJackpotTokens != 0 || spendFallback.NextState.SettledRoundPending { + t.Fatalf("ineligible 48h path should fall through to spend jackpot: %+v", spendFallback) + } + + qualified.PoolBalanceCoins = 49 + qualified.UserRoundRTP.PayoutCoins = 95 + poolBlocked, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(85)) + if err != nil { + t.Fatal(err) + } + if poolBlocked.Jackpot || poolBlocked.NextState.SettledRoundPending || poolBlocked.NextState.PendingSpendJackpotTokens != 1 { + t.Fatalf("48h is one-shot while pool-blocked spend token remains: %+v", poolBlocked) + } +} + +func TestLuckyGiftStrategySpendJackpotSharesAllSixInclusiveRiskCaps(t *testing.T) { + config := strategyTestConfig() + config.SettledRoundRTPEligibility = true + config.JackpotMechanism1Enabled = true + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + config.GlobalRTPMaxPPM = 980_000 + config.UserRoundRTPMaxPPM = 960_000 + config.User48HourRTPMaxPPM = 960_000 + config.DailyJackpotLimit = 5 + setters := []struct { + name string + set func(*StrategyRiskCapacity, int64) + }{ + {name: "single", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }}, + {name: "user-hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }}, + {name: "user-day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }}, + {name: "device-day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }}, + {name: "room-hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }}, + {name: "anchor-day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }}, + } + for _, test := range setters { + t.Run(test.name, func(t *testing.T) { + state := StrategyState{ + PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, + SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + } + capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 10_000, UserHourCoins: 10_000, UserDayCoins: 10_000, DeviceDayCoins: 10_000, RoomHourCoins: 10_000, AnchorDayCoins: 10_000} + test.set(&capacity, 1_999) + input := strategyTestInput(10) + input.RiskCapacity = capacity + blocked, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(90)) + if err != nil { + t.Fatal(err) + } + if blocked.Jackpot || blocked.NextState.PendingSpendJackpotTokens != 1 { + t.Fatalf("capacity below minimum jackpot must retain token: %+v", blocked) + } + + test.set(&capacity, 2_000) + input.RiskCapacity = capacity + paid, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(91)) + if err != nil { + t.Fatal(err) + } + if !paid.Jackpot || paid.PayoutCoins != 2_000 || paid.NextState.PendingSpendJackpotTokens != 0 { + t.Fatalf("capacity equality must permit the 200x jackpot: %+v", paid) + } + }) + } +} + func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) { config := strategyTestConfig() config.JackpotMechanism2Enabled = true diff --git a/services/lucky-gift-service/internal/service/luckygift/config.go b/services/lucky-gift-service/internal/service/luckygift/config.go index 1152ad7e..0acbc24d 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config.go +++ b/services/lucky-gift-service/internal/service/luckygift/config.go @@ -244,9 +244,9 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai // disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。 return nil } - 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. + if config.JackpotSpendThresholdCoins <= 0 { + // Enabled V3 runs the 48h compensation and spend-milestone strategies together. A zero amount + // would silently disable one of the two published promises, so only disabled drafts may keep 0. 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) diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go index 8b04352c..5853de09 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go @@ -94,6 +94,12 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s if err != nil { return nil, err } + // Consumption milestones use the newest fully closed platform sample, independent of whether + // this user participated in that round. Missing history deliberately leaves a zero denominator. + spendGlobalWindow, hasSpendGlobalWindow, err := r.getLatestClosedLuckyDynamicRTPWindow(ctx, tx, appCode, windowScopeID, globalWindow.WindowIndex) + if err != nil { + return nil, err + } giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS) if err != nil { return nil, err @@ -151,16 +157,19 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s Strategy: domain.StrategyState{ PoolBalanceCoins: basePool.Balance, ConsecutiveZeroDraws: userState.LossStreak, - // Consumption milestones were retired from dynamic_v3 jackpot eligibility. Old durable tokens - // are intentionally cleared on the next paid draw rather than becoming a hidden third gate. - PendingMilestoneTokens: 0, - UserDailyJackpotWins: dayState.JackpotHits, - UserDaySpendCoins: dayState.SpendCoins, - UserRegisteredAtMS: cmd.UserRegisteredAtMS, - UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins}, - Version: userState.PaidDraws, + // The old pending_jackpot_tokens column is intentionally not copied. It is cleared in the same + // transaction while only the new column can authorize the restored spend strategy. + PendingSpendJackpotTokens: userState.PendingSpendJackpotTokens, + UserDailyJackpotWins: dayState.JackpotHits, + UserDaySpendCoins: dayState.SpendCoins, + UserRegisteredAtMS: cmd.UserRegisteredAtMS, + UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins}, + Version: userState.PaidDraws, }, } + if hasSpendGlobalWindow { + state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: spendGlobalWindow.WagerCoins, PayoutCoins: spendGlobalWindow.ActualPayoutCoins} + } if hasQualification { if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil { return nil, err @@ -241,6 +250,9 @@ func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, ap closed.Status = "closed" closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS) } + // The next subdraw in this batch must immediately see the just-closed platform sample. The draw + // that completed the window was already selected before this assignment and cannot use it. + state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: closed.WagerCoins, PayoutCoins: closed.ActualPayoutCoins} closedUser := state.UserWindow if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil { return err @@ -365,7 +377,8 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con state.UserState.CumulativeWagerCoins += cmd.CoinSpent state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice) state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws - state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens + state.UserState.PendingJackpotTokens = 0 + state.UserState.PendingSpendJackpotTokens = decision.NextState.PendingSpendJackpotTokens state.DayState.WagerCoins += cmd.CoinSpent state.DayState.PayoutCoins += decision.PayoutCoins state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins @@ -509,10 +522,10 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a if _, err := tx.ExecContext(ctx, ` UPDATE lucky_user_states SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?, - pending_jackpot_tokens = ?, updated_at_ms = ? + pending_jackpot_tokens = 0, pending_spend_jackpot_tokens = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND gift_id = ?`, state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak, - state.UserState.PendingJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID, + state.UserState.PendingSpendJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID, ); err != nil { return err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go index 560b4fa9..c9863001 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go @@ -131,6 +131,8 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) { } assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1) assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now) + assertDynamicSpendBatchRollbackAndRestart(t, repo, schema.DB, ctx, now+10_000) + assertExternalNewUserSpendBatch(t, repo, schema.DB, ctx, now+20_000) externalRule := dynamicMySQLTestRule("external-dynamic") if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil { @@ -244,9 +246,10 @@ func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID s if _, err := db.Exec(` INSERT INTO lucky_rtp_windows ( app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws, - paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,created_at_ms,updated_at_ms - ) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?)`, - scopeType, scopeID, nowMS, nowMS, + paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status, + started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms + ) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?,0,?,?)`, + scopeType, scopeID, nowMS-1_000, nowMS, nowMS, nowMS, ); err != nil { t.Fatalf("seed %s RTP window: %v", scopeType, err) } @@ -316,6 +319,11 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db t.Fatalf("earn milestone token: %v", err) } assertPendingTokenCount(t, db, rule.PoolID, userID, 1) + // A retired token is deliberately planted after the new token was earned. The next transaction + // must clear it without copying or adding it to the new durable balance. + if _, err := db.Exec(`UPDATE lucky_user_states SET pending_jackpot_tokens=7 WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID); err != nil { + t.Fatalf("seed retired pending token: %v", err) + } if result, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil { t.Fatalf("drain token test pool: %v", err) } else if rows, _ := result.RowsAffected(); rows != 1 { @@ -331,11 +339,27 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db t.Fatalf("retain token across UTC day: %v", err) } assertPendingTokenCount(t, db, rule.PoolID, userID, 1) + var retired int64 + if err := db.QueryRow(`SELECT pending_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID).Scan(&retired); err != nil || retired != 0 { + t.Fatalf("retired token was not lazily cleared: got=%d err=%v", retired, err) + } if result, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil { t.Fatalf("refill token test pool: %v", err) } else if rows, _ := result.RowsAffected(); rows != 1 { t.Fatalf("refill token test pool affected=%d, want 1", rows) } + // Freeze the current platform window as a valid <=98% sample. Until this close, the spend token + // correctly remains blocked because an open/current RTP can never authorize a spend jackpot. + if result, err := db.Exec(` + UPDATE lucky_rtp_windows + SET wager_coins=1000000, actual_base_payout_coins=980000, status='closed', + started_at_ms=?, last_paid_at_ms=?, closed_at_ms=? + WHERE app_code='lalu' AND scope_type='pool' AND scope_id LIKE CONCAT(?, ':v%') AND status='open'`, + dayOneMS, dayTwoMS, dayTwoMS, rule.PoolID); err != nil { + t.Fatalf("close platform RTP sample: %v", err) + } else if rows, _ := result.RowsAffected(); rows != 1 { + t.Fatalf("closed platform RTP windows=%d want=1", rows) + } consume := retain consume.CommandID = "token-consume-next-day" consume.PaidAtMS++ @@ -349,11 +373,228 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db assertPendingTokenCount(t, db, rule.PoolID, userID, 0) } +// assertDynamicSpendBatchRollbackAndRestart exercises the production transaction rather than only +// the pure kernel: child 2 earns the token after selection and child 3 consumes it. A trigger fails +// the first attempt at the final fact insert, proving all earlier in-transaction state transitions +// roll back; rebuilding Repository and retrying the same command must then apply the sequence once. +func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) { + t.Helper() + rule := dynamicMySQLTestRule("dual-spend-batch") + rule.JackpotSpendThresholdCoins = 20 + published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000) + if err != nil { + t.Fatalf("publish dual spend batch rule: %v", err) + } + fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-batch-credit", nowMS-1_900) + seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500) + + command := domain.DrawCommand{ + CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1, + DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky", + GiftCount: 3, CoinSpent: 30, GiftIncomeCoins: 0, PaidAtMS: nowMS, + } + const rollbackTrigger = "trg_lucky_dual_spend_rollback" + if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil { + t.Fatalf("drop stale dual spend rollback trigger: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger) }) + if _, err := db.Exec(` + CREATE TRIGGER trg_lucky_dual_spend_rollback + BEFORE INSERT ON lucky_draw_records + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced dual spend rollback'`); err != nil { + t.Fatalf("create dual spend rollback trigger: %v", err) + } + if _, err := repo.ExecuteLuckyGiftDraw(ctx, command, nowMS); err == nil { + t.Fatal("forced persistence failure unexpectedly committed dual spend batch") + } + if _, err := db.Exec("DROP TRIGGER " + rollbackTrigger); err != nil { + t.Fatalf("drop dual spend rollback trigger: %v", err) + } + + var stateRows, drawRows, dayRows, balanceAfterRollback int64 + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&stateRows); err != nil { + t.Fatalf("query rolled-back spend state: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&drawRows); err != nil { + t.Fatalf("query rolled-back spend draws: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=?`, published.PoolID, command.UserID).Scan(&dayRows); err != nil { + t.Fatalf("query rolled-back spend day: %v", err) + } + if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterRollback); err != nil { + t.Fatalf("query rolled-back spend pool: %v", err) + } + if stateRows != 0 || drawRows != 0 || dayRows != 0 || balanceAfterRollback != 1_000_000 { + t.Fatalf("failed spend batch leaked state/draw/day/pool=%d/%d/%d/%d", stateRows, drawRows, dayRows, balanceAfterRollback) + } + + restarted := &Repository{db: db} + first, err := restarted.ExecuteLuckyGiftDraw(ctx, command, nowMS+1) + if err != nil { + t.Fatalf("retry dual spend batch after repository restart: %v", err) + } + assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 1, 3), 0, 0, 0, "") + assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 2, 3), 0, 1, 1, "") + assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 3, 3), 1, 0, 0, domain.StrategyJackpotMechanismMilestone) + + var paidDraws, pendingTokens, daySpend, committedDraws, balanceBeforeReplay int64 + if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil { + t.Fatalf("query committed dual spend state: %v", err) + } + if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&daySpend); err != nil { + t.Fatalf("query committed dual spend day: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&committedDraws); err != nil { + t.Fatalf("query committed dual spend draws: %v", err) + } + if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceBeforeReplay); err != nil { + t.Fatalf("query committed dual spend pool: %v", err) + } + if paidDraws != 3 || pendingTokens != 0 || daySpend != 30 || committedDraws != 3 { + t.Fatalf("committed spend batch state paid/token/day/draws=%d/%d/%d/%d", paidDraws, pendingTokens, daySpend, committedDraws) + } + + replayed, err := (&Repository{db: db}).ExecuteLuckyGiftDraw(ctx, command, nowMS+2) + if err != nil { + t.Fatalf("replay committed dual spend batch: %v", err) + } + var replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay int64 + if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&replayPaidDraws, &replayPendingTokens); err != nil { + t.Fatalf("query replayed dual spend state: %v", err) + } + if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&replayDaySpend); err != nil { + t.Fatalf("query replayed dual spend day: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&replayDraws); err != nil { + t.Fatalf("query replayed dual spend draws: %v", err) + } + if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterReplay); err != nil { + t.Fatalf("query replayed dual spend pool: %v", err) + } + if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayPendingTokens != 0 || replayDaySpend != 30 || replayDraws != 3 || balanceAfterReplay != balanceBeforeReplay { + t.Fatalf("replay duplicated spend batch: first/replay=%s/%s paid/token/day/draws/pool=%d/%d/%d/%d/%d want pool=%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay, balanceBeforeReplay) + } +} + +// assertExternalNewUserSpendBatch proves the external path needs neither recharge facts nor a +// 48-hour-old account: a one-hour-old authenticated user earns at child 2 and spends at child 3. +func assertExternalNewUserSpendBatch(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) { + t.Helper() + rule := dynamicMySQLTestRule("dual-spend-external-new-user") + rule.JackpotSpendThresholdCoins = 20 + published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000) + if err != nil { + t.Fatalf("publish external new-user spend rule: %v", err) + } + fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-external-credit", nowMS-1_900) + seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500) + + command := domain.ExternalDrawCommand{ + AppCode: "lalu", ExternalUserID: "dual-spend-new-external-user", DeviceID: "dual-spend-new-external-device", + RequestID: "dual-spend-new-external-request", GiftCount: 3, UnitAmount: 10, TotalAmount: 30, + Currency: "COIN", PaidAtMS: nowMS, UserRegisteredAtMS: nowMS - int64(time.Hour/time.Millisecond), PoolID: published.PoolID, + } + first, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS) + if err != nil { + t.Fatalf("execute external new-user spend batch: %v", err) + } + assertSpendTraceSnapshot(t, db, "external_lucky_gift_draw_items", "request_id", command.RequestID, 1, 0, 0, domain.StrategyJackpotMechanismMilestone) + + externalUserID := externalLuckyUserID(command.AppCode, command.ExternalUserID) + var paidDraws, pendingTokens, itemCount, aggregateCount int64 + if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil { + t.Fatalf("query external new-user spend state: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&itemCount); err != nil { + t.Fatalf("query external new-user spend items: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&aggregateCount); err != nil { + t.Fatalf("query external new-user spend aggregate: %v", err) + } + if first.RewardAmount <= 0 || paidDraws != 3 || pendingTokens != 0 || itemCount != 3 || aggregateCount != 1 { + t.Fatalf("external new-user spend result reward/paid/token/items/aggregate=%d/%d/%d/%d/%d", first.RewardAmount, paidDraws, pendingTokens, itemCount, aggregateCount) + } + + replayed, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS+1) + if err != nil { + t.Fatalf("replay external new-user spend batch: %v", err) + } + var replayPaidDraws, replayItems int64 + if err := db.QueryRow(`SELECT paid_draws FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&replayPaidDraws); err != nil { + t.Fatalf("query replayed external new-user state: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&replayItems); err != nil { + t.Fatalf("query replayed external new-user items: %v", err) + } + if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayItems != 3 { + t.Fatalf("external retry duplicated spend batch: first/replay=%s/%s paid/items=%d/%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayItems) + } +} + +func fundDynamicSpendTestPool(t *testing.T, repo *Repository, ctx context.Context, poolID, adjustmentID string, nowMS int64) { + t.Helper() + result, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{ + AppCode: "lalu", PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3, + AdjustmentID: adjustmentID, Direction: domain.PoolAdjustmentIn, AmountCoins: 1_000_000, + Reason: "dual spend integration fixture", OperatorAdminID: 1, + }, nowMS) + if err != nil { + t.Fatalf("fund dynamic spend test pool: %v", err) + } + if result.Pool.Balance != 1_000_000 { + t.Fatalf("funded dynamic spend pool balance=%d want=1000000", result.Pool.Balance) + } +} + +func seedClosedDynamicSpendGlobalWindow(t *testing.T, db *sql.DB, rule domain.RuleConfig, nowMS int64) { + t.Helper() + runtimeConfig, err := luckyRuntimeConfigFromRuleConfig(rule) + if err != nil { + t.Fatalf("build dynamic spend runtime config: %v", err) + } + scopeID := luckyRuleWindowScopeID(runtimeConfig) + if _, err := db.Exec(` + INSERT INTO lucky_rtp_windows ( + app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws, + paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status, + started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms + ) VALUES ('lalu','pool',?,1,980000,1000,100,1000,980,980,0,'closed',?,?,?,?,?)`, + scopeID, nowMS-1_000, nowMS, nowMS, nowMS-1_000, nowMS, + ); err != nil { + t.Fatalf("seed closed dynamic spend global window: %v", err) + } +} + +func assertSpendTraceSnapshot(t *testing.T, db *sql.DB, table, keyColumn, key string, wantBefore, wantAfter, wantEarned int64, wantMechanism string) { + t.Helper() + jsonColumn := "candidate_tiers_json" + itemPredicate := "" + if table == "external_lucky_gift_draw_items" { + jsonColumn = "candidate_snapshot_json" + itemPredicate = " AND item_index = 3" + } + query := fmt.Sprintf(` + SELECT + CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_before') AS SIGNED), + CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_after') AS SIGNED), + CAST(JSON_EXTRACT(%s, '$.dynamic_v3.spend_milestone_tokens_earned') AS SIGNED), + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s, '$.dynamic_v3.jackpot_mechanism')), '') + FROM %s WHERE app_code='lalu' AND %s=?%s`, jsonColumn, jsonColumn, jsonColumn, jsonColumn, table, keyColumn, itemPredicate) + var before, after, earned int64 + var mechanism string + if err := db.QueryRow(query, key).Scan(&before, &after, &earned, &mechanism); err != nil { + t.Fatalf("query %s spend trace for %s: %v", table, key, err) + } + if before != wantBefore || after != wantAfter || earned != wantEarned || mechanism != wantMechanism { + t.Fatalf("%s spend trace for %s before/after/earned/mechanism=%d/%d/%d/%q want=%d/%d/%d/%q", table, key, before, after, earned, mechanism, wantBefore, wantAfter, wantEarned, wantMechanism) + } +} + func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) { t.Helper() var got int64 if err := db.QueryRow(` - SELECT pending_jackpot_tokens FROM lucky_user_states + SELECT pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID, ).Scan(&got); err != nil { t.Fatalf("query pending token: %v", err) @@ -556,6 +797,9 @@ func TestLuckyDynamicStrategyConfigSeparatesOrdinaryAndJackpotSources(t *testing if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 { t.Fatalf("v5 runtime target/cap=%d/%d want=890000/10", runtimeConfig.TargetRTPPPM, strategy.DailyJackpotLimit) } + if !strategy.JackpotMechanism2Enabled || strategy.MilestoneSpendCoins != rule.JackpotSpendThresholdCoins { + t.Fatalf("runtime spend strategy enabled/threshold=%v/%d want=true/%d", strategy.JackpotMechanism2Enabled, strategy.MilestoneSpendCoins, rule.JackpotSpendThresholdCoins) + } ordinary := make(map[int64]*domain.StrategyTier) jackpot := make(map[int64]*domain.StrategyTier) for index := range strategy.Tiers { diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go index dab12c4b..5104ace5 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go @@ -127,6 +127,33 @@ func (r *Repository) getLatestPendingLuckyUserRTPWindow(ctx context.Context, tx return userWindow, global, true, nil } +// getLatestClosedLuckyDynamicRTPWindow returns the platform sample used only by the durable spend +// strategy. It intentionally does not join a user window: a user may have been inactive throughout +// the latest platform round. Legacy rows without an exact start/close boundary are excluded, because +// treating an administrative migration close as a settled sample could manufacture jackpot access. +func (r *Repository) getLatestClosedLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, globalScopeID string, beforeWindowIndex int64) (luckyRTPWindow, bool, error) { + var window luckyRTPWindow + err := tx.QueryRowContext(ctx, ` + SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws, + paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, + carry_ppm, status, started_at_ms, last_paid_at_ms, closed_at_ms + FROM lucky_rtp_windows + WHERE app_code = ? AND scope_type = 'pool' AND scope_id = ? AND status = 'closed' + AND window_index < ? AND wager_coins > 0 AND started_at_ms > 0 AND closed_at_ms > 0 + ORDER BY window_index DESC + LIMIT 1`, appCode, globalScopeID, beforeWindowIndex, + ).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws, + &window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, + &window.CarryPPM, &window.Status, &window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS) + if err == sql.ErrNoRows { + return luckyRTPWindow{}, false, nil + } + if err != nil { + return luckyRTPWindow{}, false, err + } + return window, 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 diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go index 624bde35..3fbc8174 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go @@ -131,9 +131,9 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig { RechargeBoostEndMS: config.RechargeBoostWindowMS, MissProtectionZeroDraws: config.LossStreakGuarantee, DailyJackpotLimit: config.MaxJackpotHitsPerUserDay, - MilestoneSpendCoins: 0, + MilestoneSpendCoins: config.JackpotSpendThresholdCoins, JackpotMechanism1Enabled: true, - JackpotMechanism2Enabled: false, + JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0, GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM, UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM, User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM, diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go index f5b2a51f..cb646068 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go @@ -78,9 +78,12 @@ type luckyUserState struct { CumulativeWagerCoins int64 EquivalentDraws int64 LossStreak int64 - // PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。 - // 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。 + // PendingJackpotTokens is the retired token column. Dynamic V3 reads it only so the next paid + // transaction can clear it; its value must never become a new spend jackpot qualification. PendingJackpotTokens int64 + // PendingSpendJackpotTokens belongs to the app/pool/user lifetime. UTC-day spend creates tokens, + // but a blocked token survives midnight until one spend jackpot is actually paid. + PendingSpendJackpotTokens int64 } type luckyCandidate struct { @@ -2421,11 +2424,13 @@ func luckyCeilDiv(value, divisor int64) int64 { func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) { var state luckyUserState err := r.db.QueryRowContext(ctx, ` - SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens + SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, + pending_jackpot_tokens, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code = ? AND user_id = ? AND gift_id = ?`, appCode, userID, giftID, - ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens) + ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, + &state.PendingJackpotTokens, &state.PendingSpendJackpotTokens) if errors.Is(err, sql.ErrNoRows) { return luckyUserState{}, nil } @@ -2438,8 +2443,8 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, if _, err := tx.ExecContext(ctx, ` INSERT INTO lucky_user_states ( app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, - pending_jackpot_tokens, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?) + pending_jackpot_tokens, pending_spend_jackpot_tokens, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`, appCode, userID, giftID, nowMS, nowMS, ); err != nil { @@ -2447,12 +2452,14 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, } var state luckyUserState err := tx.QueryRowContext(ctx, ` - SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens + SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, + pending_jackpot_tokens, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code = ? AND user_id = ? AND gift_id = ? FOR UPDATE`, appCode, userID, giftID, - ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens) + ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, + &state.PendingJackpotTokens, &state.PendingSpendJackpotTokens) return state, err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go index c2d9f648..a5244ae9 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go @@ -153,6 +153,14 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) { // initdb 已包含最终结构,004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。 runLuckyGiftSQLScript(t, schema.DB, migrationPath) runLuckyGiftSQLScript(t, schema.DB, migrationPath) + // 007 must also work on a pre-dual-strategy schema and remain replay-safe. Dropping only the new + // constant-default column reproduces that shape without scanning or rewriting any user row. + if _, err := schema.DB.Exec(`ALTER TABLE lucky_user_states DROP COLUMN pending_spend_jackpot_tokens, ALGORITHM=INSTANT`); err != nil { + t.Fatalf("prepare pre-007 schema: %v", err) + } + spendTokenMigrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "007_dual_jackpot_spend_tokens.sql") + runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath) + runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath) for _, table := range []string{ "lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events", @@ -171,6 +179,7 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) { minLength int64 }{ {table: "lucky_user_states", name: "pending_jackpot_tokens"}, + {table: "lucky_user_states", name: "pending_spend_jackpot_tokens"}, {table: "lucky_rtp_windows", name: "scope_id", minLength: 128}, {table: "lucky_risk_counters", name: "scope_id", minLength: 255}, {table: "lucky_gift_command_locks", name: "command_id", minLength: 128},