package lotteryengine import ( crand "crypto/rand" "errors" "math/big" "strings" ) const ( PrizeKindCoin = "coin" PrizeKindGift = "gift" PrizeKindProp = "prop" PrizeKindDress = "dress" ) var ( ErrNoCandidate = errors.New("lottery candidate is empty") ErrInvalidWeight = errors.New("lottery candidate weight is invalid") ) // Candidate 是抽奖引擎唯一关心的奖档视图。 // lucky-gift-service 在进入引擎前已经完成规则版本、单池水位和用户阶段过滤; // 引擎只负责不可预测的权重随机,不能再读取数据库或感知商户、房间、钱包等业务边界。 type Candidate struct { ID string Weight int64 PrizeKind string RTPValueCoins int64 } // NormalizeCandidates 复制并清洗候选集,避免调用方的事务内规则快照被引擎兜底逻辑改写。 // 全部候选权重为 0 时退化为等权随机,用于兼容旧配置;负权重直接拒绝,避免错误配置反向影响概率。 func NormalizeCandidates(candidates []Candidate) ([]Candidate, error) { if len(candidates) == 0 { return nil, ErrNoCandidate } out := make([]Candidate, 0, len(candidates)) var totalWeight int64 for _, candidate := range candidates { if candidate.Weight < 0 { return nil, ErrInvalidWeight } candidate.PrizeKind = NormalizePrizeKind(candidate.PrizeKind) candidate.RTPValueCoins = NormalizeRTPValueCoins(candidate.PrizeKind, candidate.RTPValueCoins) if candidate.Weight > 0 { totalWeight += candidate.Weight out = append(out, candidate) } } if totalWeight > 0 { return out, nil } out = out[:0] for _, candidate := range candidates { candidate.PrizeKind = NormalizePrizeKind(candidate.PrizeKind) candidate.RTPValueCoins = NormalizeRTPValueCoins(candidate.PrizeKind, candidate.RTPValueCoins) candidate.Weight = 1 out = append(out, candidate) } return out, nil } // SelectWeighted 从归一化后的候选集中按权重抽一档。 // 使用 crypto/rand 是为了让高价值奖档不可被时间种子、并发顺序或请求重放预测。 func SelectWeighted(candidates []Candidate) (Candidate, error) { normalized, err := NormalizeCandidates(candidates) if err != nil { return Candidate{}, err } var totalWeight int64 for _, candidate := range normalized { totalWeight += candidate.Weight } if totalWeight <= 0 { return Candidate{}, ErrNoCandidate } index, err := secureIndex(totalWeight) if err != nil { return Candidate{}, err } for _, candidate := range normalized { if index < candidate.Weight { return candidate, nil } index -= candidate.Weight } return normalized[len(normalized)-1], nil } // NormalizePrizeKind 把后台配置同义词收敛到引擎口径。 // 装扮/道具类可以有展示价值,但不能进入现金化 RTP 分子,避免非金币权益误伤奖池结算。 func NormalizePrizeKind(kind string) string { switch strings.ToLower(strings.TrimSpace(kind)) { case PrizeKindCoin: return PrizeKindCoin case PrizeKindGift: return PrizeKindGift case PrizeKindProp, "item", "decoration", "resource": return PrizeKindProp case PrizeKindDress, "dress_up", "costume": return PrizeKindDress default: return strings.ToLower(strings.TrimSpace(kind)) } } // NormalizeRTPValueCoins 是所有幸运礼物奖档进入 RTP 的唯一计值口。 // 金币和可折价礼物按配置值计入,装扮/道具固定归零,负数归零以阻断后台脏数据扩大池水。 func NormalizeRTPValueCoins(kind string, value int64) int64 { if value < 0 { value = 0 } switch NormalizePrizeKind(kind) { case PrizeKindProp, PrizeKindDress: return 0 default: return value } } func secureIndex(totalWeight int64) (int64, error) { if totalWeight <= 0 { return 0, ErrNoCandidate } n, err := crand.Int(crand.Reader, big.NewInt(totalWeight)) if err != nil { return 0, err } return n.Int64(), nil }