package luckygift import ( "fmt" "sort" "strings" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/luckygift" ) const ( ppmScale int64 = 1_000_000 highTierMultiplierPPM int64 = 10_000_000 ) // DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。 func DefaultConfig(appCode, poolID string) domain.Config { const giftPrice int64 = 10_000 poolID = normalizePoolID(poolID) return domain.Config{ AppCode: appCode, GiftID: poolID, PoolID: poolID, Enabled: false, GiftPrice: giftPrice, TargetRTPPPM: 950_000, PoolRatePPM: 950_000, ControlBandPPM: 10_000, GlobalWindowDraws: 100_000, GiftWindowDraws: 100_000, NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, HighMultiplier: 100, HighWaterPoolMultiple: 2, PlatformPoolWeightPPM: 200_000, RoomPoolWeightPPM: 300_000, GiftPoolWeightPPM: 500_000, InitialPlatformPool: 9_500_000, InitialGiftPool: 23_750_000, InitialRoomPool: 5_000_000, PlatformReserve: 1_000_000, GiftReserve: 1_000_000, RoomReserve: 100_000, MaxSinglePayout: giftPrice * 500, UserHourlyPayoutCap: 3_420_000, UserDailyPayoutCap: 61_560_000, DeviceDailyPayoutCap: 102_600_000, RoomHourlyPayoutCap: 68_400_000, AnchorDailyPayoutCap: 1_231_200_000, RoomAtmosphereRatePPM: 10_000, RoomAtmosphereInitial: 100_000, RoomAtmosphereReserve: 10_000, ActivityBudget: 0, ActivityDailyLimit: 0, LargeTierEnabled: true, MultiplierPPMs: defaultMultiplierPPMs(), Tiers: defaultTiers(giftPrice), } } // DefaultRuleConfig 返回 v2 配置草稿。草稿默认 disabled,只有后台发布后才写入不可变版本表。 func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { poolID = normalizePoolID(poolID) return domain.RuleConfig{ AppCode: appCode, PoolID: poolID, Enabled: false, TargetRTPPPM: 950_000, PoolRatePPM: 960_000, SettlementWindowWager: 1_000_000, ControlBandPPM: 10_000, GiftPriceReference: 100, // 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。 NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, // v2 风控上限是绝对金币额,运行时只累计真实返奖;小额礼物不能把窗口上限按比例缩小。 MaxSinglePayout: 50_000, UserHourlyPayoutCap: 34_200, UserDailyPayoutCap: 615_600, DeviceDailyPayoutCap: 1_026_000, RoomHourlyPayoutCap: 684_000, AnchorDailyPayoutCap: 12_312_000, Stages: []domain.RuleStage{ defaultRuleStage(domain.StageNovice, []ruleTierSeed{ {id: "novice_none", multiplierPPM: 0, weightPPM: 100_000}, {id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 200_000}, {id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 550_000}, {id: "novice_2x", multiplierPPM: 2_000_000, weightPPM: 150_000}, }), defaultRuleStage(domain.StageNormal, []ruleTierSeed{ {id: "normal_none", multiplierPPM: 0, weightPPM: 100_000}, {id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 200_000}, {id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 550_000}, {id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 150_000}, }), defaultRuleStage(domain.StageAdvanced, []ruleTierSeed{ {id: "advanced_none", multiplierPPM: 0, weightPPM: 270_000}, {id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 600_000}, {id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 100_000}, {id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 30_000}, }), }, } } type ruleTierSeed struct { id string multiplierPPM int64 weightPPM int64 } func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage { out := domain.RuleStage{Stage: stage, 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, RewardSource: domain.SourceBaseRTP, HighWaterOnly: tier.multiplierPPM >= highTierMultiplierPPM, BroadcastLevel: domain.BroadcastNone, Enabled: true, }) } return out } func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { config.PoolID = normalizePoolID(config.PoolID) 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) tier.RewardSource = strings.TrimSpace(tier.RewardSource) if tier.RewardSource == "" { tier.RewardSource = domain.SourceBaseRTP } tier.BroadcastLevel = strings.TrimSpace(tier.BroadcastLevel) if tier.BroadcastLevel == "" { tier.BroadcastLevel = domain.BroadcastNone } 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") } // 风控上限是 v2 抽奖候选被 risk_cap 剪掉的直接来源,必须随规则版本固化,避免运行时继续吃代码硬编码。 if config.MaxSinglePayout <= 0 || config.UserHourlyPayoutCap <= 0 || config.UserDailyPayoutCap <= 0 || config.DeviceDailyPayoutCap <= 0 || config.RoomHourlyPayoutCap <= 0 || config.AnchorDailyPayoutCap <= 0 { return xerr.New(xerr.InvalidArgument, "risk payout caps must be positive") } 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 } } return nil } func validateRuleStage(stage domain.RuleStage) error { var weightSum int64 hasBaseTier := false hasZeroTier := false 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)) } 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 !validRewardSource(tier.RewardSource) { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s reward source is invalid", tier.TierID)) } if !validBroadcastLevel(tier.BroadcastLevel) { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s broadcast level is invalid", tier.TierID)) } if tier.Enabled { weightSum += tier.BaseWeightPPM hasBaseTier = hasBaseTier || tier.RewardSource == domain.SourceBaseRTP 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 || tier.RewardSource != domain.SourceBaseRTP { 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 validRewardSource(source string) bool { return source == domain.SourceBaseRTP || source == domain.SourceActivity || source == domain.SourcePresentation } func validBroadcastLevel(level string) bool { return level == domain.BroadcastNone || level == domain.BroadcastRoom || level == domain.BroadcastRegion || level == domain.BroadcastGlobal } // validateConfig 只校验会破坏线上抽奖不变量的字段;展示类默认值留给后台表单处理。 func validateConfig(config domain.Config) error { if normalizePoolID(config.PoolID) == "" { return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required") } // 当前 DB 历史列名仍叫 gift_id,但业务语义已经是 pool_id;这里强制二者一致,避免后台提交真实礼物 ID 后把窗口和奖池切散。 config.GiftID = normalizePoolID(config.PoolID) if config.GiftPrice <= 0 || config.TargetRTPPPM <= 0 || config.TargetRTPPPM > ppmScale { return xerr.New(xerr.InvalidArgument, "gift price or target RTP is invalid") } // 入池比例低于目标 RTP 时,即使长期抽数足够也无法靠奖池支付基础返奖,必须在发布前拒绝。 if config.PoolRatePPM < config.TargetRTPPPM || config.PoolRatePPM > ppmScale { return xerr.New(xerr.InvalidArgument, "pool rate must be between target RTP and 100%") } if config.GlobalWindowDraws <= 0 || config.GiftWindowDraws <= 0 { return xerr.New(xerr.InvalidArgument, "RTP window draws must be positive") } if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws { return xerr.New(xerr.InvalidArgument, "experience pool equivalent draw limits are invalid") } if config.HighMultiplier <= 0 || config.HighWaterPoolMultiple <= 0 { return xerr.New(xerr.InvalidArgument, "high multiplier config is invalid") } if config.PlatformPoolWeightPPM < 0 || config.RoomPoolWeightPPM < 0 || config.GiftPoolWeightPPM < 0 || config.PlatformPoolWeightPPM+config.RoomPoolWeightPPM+config.GiftPoolWeightPPM != ppmScale { return xerr.New(xerr.InvalidArgument, "pool weights must sum to 1000000") } if config.MaxSinglePayout <= 0 || config.UserHourlyPayoutCap <= 0 || config.UserDailyPayoutCap <= 0 || config.DeviceDailyPayoutCap <= 0 || config.RoomHourlyPayoutCap <= 0 || config.AnchorDailyPayoutCap <= 0 { return xerr.New(xerr.InvalidArgument, "risk payout caps must be positive") } // tiers 是倍率列表归一化后的运行时候选集合;即使后台只提交 multiplier_ppms,发布前也必须先生成 tiers。 if len(config.Tiers) == 0 { return xerr.New(xerr.InvalidArgument, "reward tiers are required") } maxMultiplierPPM := int64(0) for _, tier := range config.Tiers { if tier.Pool == "" || tier.TierID == "" || tier.Weight < 0 || tier.RewardCoins < 0 || tier.MultiplierPPM < 0 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("invalid reward tier: %s", tier.TierID)) } if tier.Enabled && tier.MultiplierPPM > maxMultiplierPPM { maxMultiplierPPM = tier.MultiplierPPM } } // 最大倍率低于目标 RTP 时,窗口最后几抽可能没有足够高的基础候选追平目标,只能提前拒绝这类配置。 if maxMultiplierPPM < config.TargetRTPPPM { return xerr.New(xerr.InvalidArgument, "max lucky gift multiplier must not be lower than target RTP") } return nil } // normalizeTiers 把后台“可中倍率”转换成三档体验池候选;后台传入的权重不会成为线上概率。 func normalizeTiers(tiers []domain.Tier, multipliers []int64, giftPrice, highMultiplier int64) ([]domain.Tier, []int64) { multiplierPPMs := normalizeMultiplierPPMs(multipliers, tiers, giftPrice) return buildMultiplierTiers(giftPrice, highMultiplier, multiplierPPMs), multiplierPPMs } func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64) []int64 { // 新后台直接提交 multiplier_ppms;旧调用方如果仍提交 tiers,则从 reward_coins 反推倍率以减少迁移断点。 values := append([]int64(nil), input...) if len(values) == 0 { for _, tier := range tiers { if tier.MultiplierPPM > 0 || tier.RewardCoins == 0 { values = append(values, tier.MultiplierPPM) continue } if giftPrice > 0 && tier.RewardCoins > 0 { values = append(values, tier.RewardCoins*ppmScale/giftPrice) } } } // 没有任何输入时使用保守默认倍率,保证默认草稿可被后台查看和复制,但仍默认 disabled。 if len(values) == 0 { values = defaultMultiplierPPMs() } seen := map[int64]bool{} out := make([]int64, 0, len(values)) for _, value := range values { // 负倍率没有业务含义;重复倍率会造成同一返奖点被重复计权,因此在入口处去重。 if value < 0 || seen[value] { continue } seen[value] = true out = append(out, value) } // 全部被过滤时回退默认列表,避免构造出空 tiers 后在更深层事务里失败。 if len(out) == 0 { out = defaultMultiplierPPMs() } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } func buildMultiplierTiers(cost, highMultiplier int64, multipliers []int64) []domain.Tier { out := make([]domain.Tier, 0, len(multipliers)*3) for _, pool := range []string{domain.PoolNovice, domain.PoolIntermediate, domain.PoolAdvanced} { for _, multiplierPPM := range multipliers { out = append(out, domain.Tier{ Pool: pool, TierID: pool + "_" + multiplierTierID(multiplierPPM), RewardCoins: cost * multiplierPPM / ppmScale, MultiplierPPM: multiplierPPM, // 旧 multiplier_ppms 入口没有显式概率;运行侧会退化为等权随机,正式 v2 发布应使用阶段奖档概率。 Weight: 0, HighWaterOnly: highMultiplier > 0 && multiplierPPM >= highMultiplier*ppmScale, Enabled: true, }) } } return out } func defaultMultiplierPPMs() []int64 { return []int64{ 0, 200_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000, 20_000_000, 50_000_000, 100_000_000, 500_000_000, } } func defaultTiers(cost int64) []domain.Tier { return buildMultiplierTiers(cost, 100, defaultMultiplierPPMs()) } func multiplierTierID(multiplierPPM int64) string { if multiplierPPM == 0 { return "none" } whole := multiplierPPM / ppmScale fraction := multiplierPPM % ppmScale if fraction == 0 { return fmt.Sprintf("%dx", whole) } // 小数倍率用下划线表达,避免 tier_id 出现点号后和后台路径/筛选语法冲突。 text := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%d_%06dx", whole, fraction), "0"), "_") return text } func normalizePoolID(poolID string) string { poolID = strings.TrimSpace(poolID) if poolID == "" { return domain.DefaultPoolID } return poolID }