abd8c068b6
- weighted_picker: Pick 排除 is_fallback 保底奖,避免真实奖被"谢谢参与"挤占 - vpn_duration handler: 无活跃订阅且奖品配置 subscribe_id 时,按该套餐在抽奖 事务内新建订阅并发放时长;未配置则沿用旧的安全跳过 - 补充单测:picker 排除保底奖、vpn_duration 自动新建订阅、draw 端到端链路 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
74 lines
1.8 KiB
Go
74 lines
1.8 KiB
Go
package lottery
|
|
|
|
import (
|
|
"math/rand"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// weightedPicker 是 WeightedPicker 的默认实现。使用累计权重 O(log n) 二分选取。
|
|
// 注入的 rand.Source 允许测试固定种子。
|
|
type weightedPicker struct {
|
|
mu sync.Mutex
|
|
rng *rand.Rand
|
|
}
|
|
|
|
// NewWeightedPicker 返回默认加权选取器。传 seed=0 使用当前时间纳秒。
|
|
func NewWeightedPicker(seed int64) WeightedPicker {
|
|
if seed == 0 {
|
|
seed = time.Now().UnixNano()
|
|
}
|
|
return &weightedPicker{
|
|
rng: rand.New(rand.NewSource(seed)),
|
|
}
|
|
}
|
|
|
|
// Pick 从 candidates 中返回一个索引。以下奖品不参与随机:
|
|
// - 权重 <= 0
|
|
// - is_fallback=true 的保底奖(只在限量奖售罄时兜底发放,绝不能被随机抽中,
|
|
// 否则真实奖品会被“谢谢参与”类保底项挤占)
|
|
//
|
|
// 累计权重为 0(无任何可抽奖品)返回 ErrEmptyPool。
|
|
//
|
|
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
|
|
// 不需要预分配,并对小池(<20 项)足够快。
|
|
func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
|
|
if len(candidates) == 0 {
|
|
return 0, ErrEmptyPool
|
|
}
|
|
// eligible 判定:非保底 && 权重为正,才计入随机池。
|
|
eligible := func(c Prize) bool { return !c.IsFallback && c.Weight > 0 }
|
|
|
|
var total int64
|
|
for _, c := range candidates {
|
|
if eligible(c) {
|
|
total += int64(c.Weight)
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return 0, ErrEmptyPool
|
|
}
|
|
|
|
p.mu.Lock()
|
|
roll := p.rng.Int63n(total)
|
|
p.mu.Unlock()
|
|
|
|
var cum int64
|
|
for i, c := range candidates {
|
|
if !eligible(c) {
|
|
continue
|
|
}
|
|
cum += int64(c.Weight)
|
|
if roll < cum {
|
|
return i, nil
|
|
}
|
|
}
|
|
// 走到这里说明浮点/累加异常,回退到最后一个参与随机的项。
|
|
for i := len(candidates) - 1; i >= 0; i-- {
|
|
if eligible(candidates[i]) {
|
|
return i, nil
|
|
}
|
|
}
|
|
return 0, ErrEmptyPool
|
|
}
|