package wheel import ( "fmt" "strings" "hyapp/pkg/xerr" lotteryengine "hyapp/services/activity-service/internal/domain/lotteryengine" domain "hyapp/services/activity-service/internal/domain/wheel" ) const defaultWheelID = "default" var wheelTierCounts = map[string]int{ "classic": 8, "luxury": 8, "advanced": 12, } func NormalizeWheelID(wheelID string) string { wheelID = strings.TrimSpace(wheelID) if wheelID == "" { return defaultWheelID } return wheelID } // NormalizeRuleConfig 是转盘配置发布前的唯一收口。 // 这里强制道具/装扮的 RTP 价值为 0,避免后台展示价值、运营估值或资源原价污染真实返奖率。 func NormalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { config.AppCode = strings.TrimSpace(config.AppCode) config.WheelID = NormalizeWheelID(config.WheelID) normalized := make([]domain.Tier, 0, len(config.Tiers)) for _, tier := range config.Tiers { tier.TierID = strings.TrimSpace(tier.TierID) tier.DisplayName = strings.TrimSpace(tier.DisplayName) tier.RewardType = lotteryengine.NormalizePrizeKind(tier.RewardType) tier.RewardID = strings.TrimSpace(tier.RewardID) tier.MetadataJSON = strings.TrimSpace(tier.MetadataJSON) if tier.MetadataJSON == "" { tier.MetadataJSON = "{}" } if tier.RewardCount <= 0 { tier.RewardCount = 1 } tier.RTPValueCoins = lotteryengine.NormalizeRTPValueCoins(tier.RewardType, tier.RTPValueCoins) normalized = append(normalized, tier) } config.Tiers = normalized return config } // ValidateRuleConfig 只验证运行所需的硬边界;概率是否满足产品预期由后台配置页负责展示和二次确认。 func ValidateRuleConfig(config domain.RuleConfig) error { config = NormalizeRuleConfig(config) if config.WheelID == "" { return xerr.New(xerr.InvalidArgument, "wheel_id is required") } if config.DrawPriceCoins <= 0 { return xerr.New(xerr.InvalidArgument, "draw_price_coins must be positive") } if config.TargetRTPPPM < 0 { return xerr.New(xerr.InvalidArgument, "target_rtp_ppm must be non-negative") } if config.PoolRatePPM < 0 { return xerr.New(xerr.InvalidArgument, "pool_rate_ppm must be non-negative") } if config.SettlementWindowDraws <= 0 { return xerr.New(xerr.InvalidArgument, "settlement_window_draws must be positive") } if len(config.Tiers) == 0 { return xerr.New(xerr.InvalidArgument, "wheel tiers are required") } if expectedCount, ok := wheelTierCounts[config.WheelID]; ok && len(config.Tiers) != expectedCount { // H5 每个转盘的视觉格子数是固定契约;服务端发布前强校验数量,避免后台或脚本写入多余档位后, // 抽奖结果命中一个 H5 没有位置展示的奖品,造成动画、中奖弹窗和实际发奖对不上。 return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel %s requires %d tiers", config.WheelID, expectedCount)) } seen := map[string]bool{} var enabled bool for _, tier := range config.Tiers { if tier.TierID == "" { return xerr.New(xerr.InvalidArgument, "wheel tier_id is required") } if seen[tier.TierID] { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("duplicate wheel tier_id: %s", tier.TierID)) } seen[tier.TierID] = true if tier.WeightPPM < 0 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s weight_ppm must be non-negative", tier.TierID)) } switch tier.RewardType { case lotteryengine.PrizeKindCoin: if tier.RewardCoins < 0 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s reward_coins must be non-negative", tier.TierID)) } case lotteryengine.PrizeKindGift: if tier.RewardID == "" { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s gift reward_id is required", tier.TierID)) } case lotteryengine.PrizeKindProp, lotteryengine.PrizeKindDress: if tier.RewardID == "" { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s prop reward_id is required", tier.TierID)) } if tier.RTPValueCoins != 0 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s prop rtp_value_coins must be zero", tier.TierID)) } default: return xerr.New(xerr.InvalidArgument, fmt.Sprintf("unsupported wheel reward_type: %s", tier.RewardType)) } enabled = enabled || tier.Enabled } if !enabled { return xerr.New(xerr.InvalidArgument, "wheel enabled tiers are required") } return nil }