package luckygift import ( "fmt" "sort" "strings" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/luckygift" ) const ppmScale int64 = 1_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, GlobalWindowDraws: 100_000, GiftWindowDraws: 100_000, NoviceDrawLimit: 2_000, IntermediateDrawLimit: 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), } } // 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.NoviceDrawLimit < 0 || config.IntermediateDrawLimit < config.NoviceDrawLimit { return xerr.New(xerr.InvalidArgument, "experience pool 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, // 权重固定为 0 是有意设计:线上概率由 RTP 缺口、奖池容量和风控容量运行时生成。 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 }