Files
hi-server/internal/model/lottery/weighted_picker.go
T
shanshanzhong147 9933d34bdd 新功能(#3): 抽奖活动 Stage 1 骨架(DB + 规则引擎 + Handler 抽象)
Closes HIF-3

Stage 1 骨架:7 张表迁移 + 门槛规则引擎 + 加权选奖 + 次数入账/消耗(幂等)+ 发奖 handler 抽象与注册表。

- 单测覆盖 75.5%(未覆盖行 = stub handler ErrNotImplemented,合理)
- CI 全绿(构建/Vet/测试 + golangci-lint)
- 骨架不接入真实业务,vpn_duration/commission handler 在 Dispatch 中返回 ErrNotImplemented;合并后线上零变更

架构师 review 通过,4 项决策已在 issue 上给出:
1. 邀请转化语义 = 首次付款激活
2. 佣金日志类型 = 新增 CommissionTypeLottery=339
3. 家庭组归属 = 穿透到 owner
4. 管理端 IP 白名单 = 不做(推到 nginx/ingress 层)

后续 PR B/C 补真实业务对接 + 用户 API + 后台 CRUD + 集成/并发/概率测试。
2026-07-08 20:05:05 -07:00

67 lines
1.5 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 的奖品不参与随机;累计权重
// 为 0(如所有奖品 weight 都是 0)返回 ErrEmptyPool。
//
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
// 不需要预分配,并对小池(<20 项)足够快。
func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
if len(candidates) == 0 {
return 0, ErrEmptyPool
}
var total int64
for _, c := range candidates {
if c.Weight > 0 {
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 c.Weight <= 0 {
continue
}
cum += int64(c.Weight)
if roll < cum {
return i, nil
}
}
// 走到这里说明浮点/累加异常,回退到最后一个非 0 权重项。
for i := len(candidates) - 1; i >= 0; i-- {
if candidates[i].Weight > 0 {
return i, nil
}
}
return 0, ErrEmptyPool
}