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 是抽奖引擎唯一关心的奖档视图。 // 业务模块负责在进入引擎前完成资格、奖池和风控过滤;引擎只保证权重随机和 RTP 计值口径一致。 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 把后台和活动配置里的同义奖品类型收敛到引擎内部口径。 // prop 和 dress 都表示装扮/道具类权益,后续发放可以不同,但 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 计值入口。 // 装扮/道具无论后台配置了多少展示价值,都不能进入 RTP 返奖分子;金币和礼物保留配置值,负数按 0 处理。 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 }