131 lines
3.8 KiB
Go
131 lines
3.8 KiB
Go
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
|
||
}
|