修复(#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>
This commit is contained in:
2026-07-14 09:24:35 -07:00
parent eac0137069
commit abd8c068b6
5 changed files with 406 additions and 7 deletions
+13 -6
View File
@@ -23,8 +23,12 @@ func NewWeightedPicker(seed int64) WeightedPicker {
}
}
// Pick 从 candidates 中返回一个索引。权重为 0 的奖品不参与随机;累计权重
// 为 0(如所有奖品 weight 都是 0)返回 ErrEmptyPool。
// Pick 从 candidates 中返回一个索引。以下奖品不参与随机
// - 权重 <= 0
// - is_fallback=true 的保底奖(只在限量奖售罄时兜底发放,绝不能被随机抽中,
// 否则真实奖品会被“谢谢参与”类保底项挤占)
//
// 累计权重为 0(无任何可抽奖品)返回 ErrEmptyPool。
//
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
// 不需要预分配,并对小池(<20 项)足够快。
@@ -32,9 +36,12 @@ 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 c.Weight > 0 {
if eligible(c) {
total += int64(c.Weight)
}
}
@@ -48,7 +55,7 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
var cum int64
for i, c := range candidates {
if c.Weight <= 0 {
if !eligible(c) {
continue
}
cum += int64(c.Weight)
@@ -56,9 +63,9 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
return i, nil
}
}
// 走到这里说明浮点/累加异常,回退到最后一个非 0 权重项。
// 走到这里说明浮点/累加异常,回退到最后一个参与随机的项。
for i := len(candidates) - 1; i >= 0; i-- {
if candidates[i].Weight > 0 {
if eligible(candidates[i]) {
return i, nil
}
}
@@ -58,6 +58,34 @@ func TestWeightedPicker_DistributionCloseToWeights(t *testing.T) {
}
}
func TestWeightedPicker_ExcludesFallback(t *testing.T) {
p := NewWeightedPicker(3)
pool := []Prize{
{Id: 1, Weight: 100, IsFallback: true}, // 保底奖:即便权重很高也绝不被随机抽中
{Id: 2, Weight: 5, Type: PrizeTypeVPNDuration}, // 唯一可抽真实奖品
}
for i := 0; i < 300; i++ {
idx, err := p.Pick(pool)
if err != nil {
t.Fatalf("Pick err: %v", err)
}
if idx != 1 {
t.Fatalf("fallback prize must never be picked; expected idx 1, got %d", idx)
}
}
}
func TestWeightedPicker_AllFallbackYieldsEmptyPool(t *testing.T) {
p := NewWeightedPicker(9)
pool := []Prize{
{Id: 1, Weight: 10, IsFallback: true},
{Id: 2, Weight: 20, IsFallback: true},
}
if _, err := p.Pick(pool); err != ErrEmptyPool {
t.Fatalf("expected ErrEmptyPool when only fallback prizes exist, got %v", err)
}
}
func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) {
pool := []Prize{
{Id: 1, Weight: 1},