package luckygift import ( "fmt" "strings" "hyapp/pkg/xerr" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" ) const ( ppmScale int64 = 1_000_000 highTierMultiplierPPM int64 = 10_000_000 ) // DefaultRuleConfig 返回 dynamic_v3 配置草稿。大奖累计消费门槛、充值分层和金额型风控上限都没有跨 App 通用值, // 因而保留未配置 sentinel 且草稿默认 disabled;运营显式补齐这些值并启用后,才允许发布可运行版本。 func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { poolID = normalizePoolID(poolID) return domain.RuleConfig{ AppCode: appCode, PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3, Enabled: false, TargetRTPPPM: 980_000, PoolRatePPM: 980_000, ProfitRatePPM: 10_000, AnchorRatePPM: 10_000, SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 100, // dynamic_v3 的账本从 0 开始,任何启动资金都必须经过带管理员和原因的人工注资事实。 InitialPoolCoins: 0, LossStreakGuarantee: 5, LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000, HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000, RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000, JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000}, JackpotGlobalRTPMaxPPM: 980_000, JackpotUserDayRTPMaxPPM: 960_000, JackpotUser72hRTPMaxPPM: 960_000, MaxJackpotHitsPerUserDay: 5, // 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。 NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, Stages: []domain.RuleStage{ // novice 的 0/0 只是协议和存储 sentinel;运行时未同时达到 normal 两个下限的用户都回落 novice。 defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)), // disabled 草稿不猜测金币门槛;normal/advanced 必须由运营按 App 口径配置后才能启用。 defaultRuleStage(domain.StageNormal, 0, 0, defaultSafeTierSeeds(domain.StageNormal)), defaultRuleStage(domain.StageAdvanced, 0, 0, defaultSafeTierSeeds(domain.StageAdvanced)), }, } } type ruleTierSeed struct { id string multiplierPPM int64 weightPPM int64 } // defaultSafeTierSeeds 保留明确的 0x 和少量 0.5x 小奖,并让每个阶段的静态期望精确为 98%。 // 200x/500x/1000x 只登记为 jackpot 倍率,不在缺少产品概率时照搬图片示例塞进基础 tier。 func defaultSafeTierSeeds(stage string) []ruleTierSeed { return []ruleTierSeed{ {id: stage + "_none", multiplierPPM: 0, weightPPM: 50_000}, {id: stage + "_0_5x", multiplierPPM: 500_000, weightPPM: 40_000}, {id: stage + "_1x", multiplierPPM: 1_000_000, weightPPM: 860_000}, {id: stage + "_2x", multiplierPPM: 2_000_000, weightPPM: 50_000}, } } func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int64, tiers []ruleTierSeed) domain.RuleStage { out := domain.RuleStage{ Stage: stage, MinRecharge7DCoins: minRecharge7DCoins, MinRecharge30DCoins: minRecharge30DCoins, Tiers: make([]domain.RuleTier, 0, len(tiers)), } for _, tier := range tiers { out.Tiers = append(out.Tiers, domain.RuleTier{ Stage: stage, TierID: tier.id, MultiplierPPM: tier.multiplierPPM, BaseWeightPPM: tier.weightPPM, HighWaterOnly: tier.multiplierPPM >= highTierMultiplierPPM, Enabled: true, }) } return out } func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { config.PoolID = normalizePoolID(config.PoolID) config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion)) if config.StrategyVersion == "" { // 空值只可能来自升级前的客户端或旧持久行;归一为 fixed_v2 才不会让新动态校验改变历史发布语义。 config.StrategyVersion = domain.StrategyFixedV2 } for stageIndex, stage := range config.Stages { stage.Stage = strings.TrimSpace(stage.Stage) for tierIndex, tier := range stage.Tiers { tier.Stage = strings.TrimSpace(tier.Stage) if tier.Stage == "" { tier.Stage = stage.Stage } tier.TierID = strings.TrimSpace(tier.TierID) stage.Tiers[tierIndex] = tier } config.Stages[stageIndex] = stage } return config } func validateRuleConfig(config domain.RuleConfig) error { if normalizePoolID(config.PoolID) == "" { return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required") } if config.TargetRTPPPM < 100_000 || config.TargetRTPPPM > 990_000 { return xerr.New(xerr.InvalidArgument, "target RTP must be between 10% and 99%") } if config.PoolRatePPM < config.TargetRTPPPM || config.PoolRatePPM > ppmScale { return xerr.New(xerr.InvalidArgument, "pool rate must be between target RTP and 100%") } if config.SettlementWindowWager <= 0 { return xerr.New(xerr.InvalidArgument, "settlement window wager must be positive") } if config.ControlBandPPM <= 0 || config.ControlBandPPM > 50_000 { return xerr.New(xerr.InvalidArgument, "control band must be greater than 0 and no more than 5%") } if config.GiftPriceReference <= 0 { return xerr.New(xerr.InvalidArgument, "gift price reference must be positive") } if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws { return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid") } strategyVersion := strings.ToLower(strings.TrimSpace(config.StrategyVersion)) if strategyVersion == "" { strategyVersion = domain.StrategyFixedV2 } if strategyVersion != domain.StrategyFixedV2 && strategyVersion != domain.StrategyDynamicV3 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("unsupported lucky gift strategy version: %s", config.StrategyVersion)) } stageMap := make(map[string]domain.RuleStage, len(config.Stages)) for _, stage := range config.Stages { if !validRuleStage(stage.Stage) { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("invalid lucky gift stage: %s", stage.Stage)) } if _, exists := stageMap[stage.Stage]; exists { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("duplicate lucky gift stage: %s", stage.Stage)) } stageMap[stage.Stage] = stage } for _, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} { stage, exists := stageMap[stageName] if !exists { return xerr.New(xerr.InvalidArgument, "lucky gift stages must include novice, normal and advanced") } if err := validateRuleStage(stage); err != nil { return err } if err := validateRuleStageExpectedRTP(stage, config.TargetRTPPPM, config.ControlBandPPM); err != nil { return err } } if strategyVersion == domain.StrategyDynamicV3 { if err := validateDynamicRuleConfig(config, stageMap); err != nil { return err } } return nil } // validateDynamicRuleConfig 只约束 dynamic_v3;fixed_v2 的历史不可变版本即使没有这些字段也必须可以继续读取和重发。 func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domain.RuleStage) error { if config.ProfitRatePPM < 0 || config.AnchorRatePPM < 0 || config.PoolRatePPM+config.ProfitRatePPM+config.AnchorRatePPM != ppmScale { return xerr.New(xerr.InvalidArgument, "dynamic_v3 pool, profit and anchor rates must be non-negative and sum to 100%") } if config.InitialPoolCoins != 0 { return xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero; fund the pool through an audited credit adjustment") } if config.LossStreakGuarantee <= 0 { return xerr.New(xerr.InvalidArgument, "dynamic_v3 loss streak guarantee must be positive") } if config.LowWatermarkCoins <= 0 || config.HighWatermarkCoins <= config.LowWatermarkCoins { return xerr.New(xerr.InvalidArgument, "dynamic_v3 watermarks must be positive and high watermark must exceed low watermark") } if config.LowWaterNonzeroFactorPPM <= 0 || config.LowWaterNonzeroFactorPPM >= ppmScale { return xerr.New(xerr.InvalidArgument, "dynamic_v3 low-water nonzero factor must be between 0% and 100%") } if config.HighWaterNonzeroFactorPPM <= ppmScale { return xerr.New(xerr.InvalidArgument, "dynamic_v3 high-water nonzero factor must exceed 100%") } if config.RechargeBoostWindowMS <= 0 || config.RechargeBoostFactorPPM <= ppmScale { return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge boost window must be positive and factor must exceed 100%") } if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil { return err } if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale || config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM || config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM { return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit") } if config.MaxJackpotHitsPerUserDay <= 0 { return xerr.New(xerr.InvalidArgument, "dynamic_v3 daily jackpot hit cap must be positive") } if config.JackpotSpendThresholdCoins < 0 { return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins cannot be negative") } if err := validateDynamicPayoutCaps(config, false); err != nil { return err } if err := validateDynamicStageThresholds(stages, config.Enabled); err != nil { return err } if !config.Enabled { // disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。 return nil } if config.JackpotSpendThresholdCoins <= 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) } func validateJackpotMultipliers(multiplierPPMs []int64) error { if len(multiplierPPMs) == 0 { return xerr.New(xerr.InvalidArgument, "dynamic_v3 must configure at least one jackpot multiplier") } var previous int64 for _, multiplierPPM := range multiplierPPMs { if multiplierPPM <= ppmScale || multiplierPPM <= previous { return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot multipliers must exceed 1x and be strictly increasing") } previous = multiplierPPM } return nil } 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 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 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) { return xerr.New(xerr.InvalidArgument, "dynamic_v3 advanced recharge thresholds must be component-wise monotonic and exceed normal in at least one dimension") } return nil } func validateDynamicPayoutCaps(config domain.RuleConfig, requirePositive bool) error { caps := []struct { name string value int64 }{ {name: "max_single_payout", value: config.MaxSinglePayout}, {name: "user_hourly_payout_cap", value: config.UserHourlyPayoutCap}, {name: "user_daily_payout_cap", value: config.UserDailyPayoutCap}, {name: "device_daily_payout_cap", value: config.DeviceDailyPayoutCap}, {name: "room_hourly_payout_cap", value: config.RoomHourlyPayoutCap}, {name: "anchor_daily_payout_cap", value: config.AnchorDailyPayoutCap}, } for _, cap := range caps { if cap.value < 0 || (requirePositive && cap.value == 0) { if !requirePositive { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s cannot be negative", cap.name)) } return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s must be positive before enabling", cap.name)) } } return nil } func validateRuleStage(stage domain.RuleStage) error { var weightSum int64 hasBaseTier := false hasZeroTier := false tierIDs := make(map[string]struct{}, len(stage.Tiers)) for _, tier := range stage.Tiers { if tier.Stage != stage.Stage { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("tier %s stage does not match %s", tier.TierID, stage.Stage)) } if tier.TierID == "" { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier id is required", stage.Stage)) } // 同阶段奖档 ID 是 lucky_gift_stage_tiers 的主键维度;发布前拦截重复值,避免运营配置错误落到数据库唯一键后变成 opaque internal error。 if _, exists := tierIDs[tier.TierID]; exists { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier id is duplicated: %s", stage.Stage, tier.TierID)) } tierIDs[tier.TierID] = struct{}{} if tier.MultiplierPPM < 0 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier multiplier is invalid", tier.TierID)) } if tier.BaseWeightPPM < 0 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier probability is invalid", tier.TierID)) } if tier.Enabled { weightSum += tier.BaseWeightPPM hasBaseTier = true hasZeroTier = hasZeroTier || tier.MultiplierPPM == 0 } if tier.Enabled && tier.MultiplierPPM >= highTierMultiplierPPM && !tier.HighWaterOnly { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s high multiplier tier must be high-water only", tier.TierID)) } } if weightSum != ppmScale { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage probability must sum to 100%%", stage.Stage)) } if !hasBaseTier { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage must include at least one base RTP tier", stage.Stage)) } if !hasZeroTier { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage must include explicit 0x tier", stage.Stage)) } return nil } func validateRuleStageExpectedRTP(stage domain.RuleStage, targetRTPPPM int64, controlBandPPM int64) error { var expectedPPM int64 for _, tier := range stage.Tiers { if !tier.Enabled { continue } expectedPPM += tier.MultiplierPPM * tier.BaseWeightPPM / ppmScale } lower := targetRTPPPM - controlBandPPM upper := targetRTPPPM + controlBandPPM if expectedPPM < lower || expectedPPM > upper { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage expected RTP %.2f%% must be within target band %.2f%%-%.2f%%", stage.Stage, float64(expectedPPM)/10_000, float64(lower)/10_000, float64(upper)/10_000, )) } return nil } func validRuleStage(stage string) bool { return stage == domain.StageNovice || stage == domain.StageNormal || stage == domain.StageAdvanced } func normalizePoolID(poolID string) string { poolID = strings.TrimSpace(poolID) if poolID == "" { return domain.DefaultPoolID } return poolID }