Files
hi-server/internal/model/lottery/weighted_picker.go
T
shanshanzhong147 abd8c068b6 修复(#4): 抽奖发奖修正 — 保底奖不参与随机 + 无订阅时按套餐自动新建订阅
- 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>
2026-07-14 09:31:08 -07:00

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
}