修复(#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
+164
View File
@@ -13,7 +13,9 @@ import (
"time"
"github.com/DATA-DOG/go-sqlmock"
handler "github.com/perfect-panel/server/internal/logic/lottery/handler"
"github.com/perfect-panel/server/internal/model/lottery"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/xerr"
"gorm.io/driver/mysql"
"gorm.io/gorm"
@@ -915,3 +917,165 @@ func (m claimDataIsValidJSON) Match(v driver.Value) bool {
}
return true
}
// ---- E2E: real VPNDurationHandler wired into the draw flow -----------------
// fakeE2ELedger 让真实 VPNDurationHandler 的幂等账本 Reserve 永远返回“新建行”,
// 从而走真实发放分支(而非幂等命中)。
type fakeE2ELedger struct{}
func (fakeE2ELedger) Reserve(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 77, ExternalRef: entry.ExternalRef}, false, nil
}
// fakeNoSubUserModel 嵌入 usermodel.Model(其余方法不会被调用),仅覆盖
// FindActiveSubscribe 返回“无活跃订阅”,模拟问题2 的复现前提。
type fakeNoSubUserModel struct {
usermodel.Model
}
func (fakeNoSubUserModel) FindActiveSubscribe(context.Context, int64) (*usermodel.Subscribe, error) {
return nil, gorm.ErrRecordNotFound
}
// TestDraw_E2E_VPNDurationAutoCreatesSubscription 串起完整链路(问题2 的端到端回归):
// 抽中 vpn_duration → 真实 handler 派发 → 用户无活跃订阅 + 奖品配了 subscribe_id →
// 同一抽奖事务内加载套餐、新建订阅、发放时长、回写账本,最终整体 COMMIT。
func TestDraw_E2E_VPNDurationAutoCreatesSubscription(t *testing.T) {
db, mock, cleanup := newTestDB(t)
defer cleanup()
expectRunningActivity(mock, 100)
expectPrizePool(mock, 100,
lottery.Prize{
Id: 30, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration,
Name: "5 天",
Config: `{"duration_days":5,"subscribe_id":7}`,
Weight: 100,
},
)
realHandler := handler.NewVPNDurationHandler(handler.VPNDurationDeps{
UserModel: fakeNoSubUserModel{},
Ledger: fakeE2ELedger{},
DB: db,
ResolveEffectiveUser: func(_ context.Context, uid int64) (int64, error) { return uid, nil },
})
mock.ExpectBegin()
expectExistingDrawEmpty(mock)
// unlimited stock → no lottery_prize UPDATE
mock.ExpectExec("INSERT INTO `lottery_draw`").
WillReturnResult(sqlmock.NewResult(1234, 1))
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
WillReturnResult(sqlmock.NewResult(1, 1))
// handler.Dispatch: findActiveSubscribe 的 DB 回退查询 → 无行
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
// 加载 subscribe 套餐计划
mock.ExpectQuery("FROM `subscribe`").
WillReturnRows(sqlmock.NewRows([]string{"id", "traffic", "node_group_id"}).
AddRow(int64(7), int64(2048), int64(3)))
// 新建 user_subscribe
mock.ExpectExec("INSERT INTO `user_subscribe`").
WillReturnResult(sqlmock.NewResult(900, 1))
// 回写账本 payload
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 1))
// finalize draw.dispatch_state
mock.ExpectExec("UPDATE `lottery_draw`").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
svc := NewService(Deps{
DB: db,
Enabled: true,
Chance: &fakeChance{consumeRemaining: 1},
Evaluator: &fakeEvaluator{passed: true},
Picker: &fakePicker{idx: 0},
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: realHandler}},
ContextBuilder: fakeContextBuilder{},
})
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "e2e-1"})
if err != nil {
t.Fatalf("Draw: %v", err)
}
if !res.IsWin {
t.Fatalf("expected IsWin=true for vpn_duration")
}
if !res.Claim.AutoClaimed {
t.Fatalf("expected AutoClaimed=true, got %+v", res.Claim)
}
if res.Message != "已新建订阅并加 5 天" {
t.Fatalf("expected auto-create message, got %q", res.Message)
}
if res.Prize == nil || res.Prize.Type != lottery.PrizeTypeVPNDuration {
t.Fatalf("expected vpn_duration prize summary, got %+v", res.Prize)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// TestDraw_E2E_VPNDurationNoPlanSkipsGrant 覆盖对照组:同样无活跃订阅,但奖品未配
// subscribe_id → 真实 handler 走“跳过发放”,不产生任何订阅相关写操作,抽奖照常提交。
func TestDraw_E2E_VPNDurationNoPlanSkipsGrant(t *testing.T) {
db, mock, cleanup := newTestDB(t)
defer cleanup()
expectRunningActivity(mock, 100)
expectPrizePool(mock, 100,
lottery.Prize{
Id: 31, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration,
Name: "5 天",
Config: `{"duration_days":5}`,
Weight: 100,
},
)
realHandler := handler.NewVPNDurationHandler(handler.VPNDurationDeps{
UserModel: fakeNoSubUserModel{},
Ledger: fakeE2ELedger{},
DB: db,
ResolveEffectiveUser: func(_ context.Context, uid int64) (int64, error) { return uid, nil },
})
mock.ExpectBegin()
expectExistingDrawEmpty(mock)
mock.ExpectExec("INSERT INTO `lottery_draw`").
WillReturnResult(sqlmock.NewResult(1235, 1))
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
WillReturnResult(sqlmock.NewResult(1, 1))
// findActiveSubscribe fallback → none; then skip (no subscribe/insert)
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `lottery_draw`").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
svc := NewService(Deps{
DB: db,
Enabled: true,
Chance: &fakeChance{consumeRemaining: 1},
Evaluator: &fakeEvaluator{passed: true},
Picker: &fakePicker{idx: 0},
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: realHandler}},
ContextBuilder: fakeContextBuilder{},
})
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "e2e-2"})
if err != nil {
t.Fatalf("Draw: %v", err)
}
if !strings.Contains(res.Message, "跳过") {
t.Fatalf("expected skip message, got %q", res.Message)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
+60 -1
View File
@@ -16,8 +16,11 @@ import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/perfect-panel/server/internal/model/lottery"
subscribemodel "github.com/perfect-panel/server/internal/model/subscribe"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/uuidx"
"gorm.io/gorm"
)
@@ -88,6 +91,9 @@ func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil }
// vpnDurationConfig 是奖品 Config JSON 的解码目标。
type vpnDurationConfig struct {
DurationDays int `json:"duration_days"`
// SubscribeId 指定“无活跃订阅时新建订阅”所用的套餐计划 ID。
// 0 表示不新建:延续历史行为(无活跃订阅则记录 skipped 不发放)。
SubscribeId int64 `json:"subscribe_id"`
}
// vpnDurationPayload 落库到 lottery_grant_ledger.payload,用于幂等重放时返回同一
@@ -151,7 +157,25 @@ func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lott
// 未存在 → 真实发放。查用户的活跃订阅。
activeSub, findErr := h.findActiveSubscribe(ctx, effectiveUserID)
if errors.Is(findErr, gorm.ErrRecordNotFound) {
// 与 grantGiftDays 一致:无活跃订阅时记录 skipped 但不失败。
// 无活跃订阅:
// - 若奖品配置了 subscribe_id,则按该套餐新建一条订阅并发放时长;
// - 否则延续旧行为:记录 skipped 但不失败(避免抽奖事务因无处发放而回滚)。
if cfg.SubscribeId > 0 {
newSub, createErr := h.createSubscription(ctx, tx, effectiveUserID, req.DrawId, cfg)
if createErr != nil {
return lottery.DispatchResult{}, fmt.Errorf("auto-create subscribe for user %d: %w", effectiveUserID, createErr)
}
payload := vpnDurationPayload{
EffectiveUserID: effectiveUserID,
SubscribeID: newSub.Id,
Days: cfg.DurationDays,
Message: fmt.Sprintf("已新建订阅并加 %d 天", cfg.DurationDays),
}
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
return lottery.DispatchResult{}, err
}
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
}
payload := vpnDurationPayload{
EffectiveUserID: effectiveUserID,
Days: cfg.DurationDays,
@@ -225,6 +249,41 @@ func (h *VPNDurationHandler) findActiveSubscribe(ctx context.Context, userID int
return &fallback, nil
}
// createSubscription 在无活跃订阅时,按奖品配置的 subscribe_id 套餐为用户新建一条
// 订阅,时长为 cfg.DurationDays 天。套餐属性(流量、节点组)继承自计划,token/uuid
// 现场生成。整个操作在 caller 的事务内完成,随抽奖事务一起提交/回滚。
func (h *VPNDurationHandler) createSubscription(ctx context.Context, tx *gorm.DB, userID, drawID int64, cfg vpnDurationConfig) (*usermodel.Subscribe, error) {
if tx == nil {
return nil, errors.New("createSubscription requires a transaction")
}
var plan subscribemodel.Subscribe
if err := tx.WithContext(ctx).Where("id = ?", cfg.SubscribeId).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("subscribe plan %d not found", cfg.SubscribeId)
}
return nil, err
}
now := time.Now()
// token 需全局唯一:用 lottery draw 维度做种子,避免与订单 token 冲突。
tokenSeed := fmt.Sprintf("lottery:%d:%d:%d", cfg.SubscribeId, userID, drawID)
newSub := &usermodel.Subscribe{
UserId: userID,
OrderId: 0,
SubscribeId: plan.Id,
NodeGroupId: plan.NodeGroupId,
StartTime: now,
ExpireTime: now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour),
Traffic: plan.Traffic,
Token: uuidx.SubscribeToken(tokenSeed),
UUID: uuid.New().String(),
Status: 1,
}
if err := tx.WithContext(ctx).Create(newSub).Error; err != nil {
return nil, err
}
return newSub, nil
}
func (h *VPNDurationHandler) writeBackPayload(ctx context.Context, tx *gorm.DB, ledgerID int64, payload vpnDurationPayload) error {
raw, err := json.Marshal(payload)
if err != nil {
@@ -343,3 +343,144 @@ func TestVPNDuration_BadConfigRejected(t *testing.T) {
}
_ = json.Unmarshal
}
// TestVPNDuration_NoActiveSubscribeCreatesSubscription 覆盖“无活跃订阅 + 奖品配置了
// subscribe_id”时按该套餐新建订阅并发放时长的路径(问题2 的修复)。
func TestVPNDuration_NoActiveSubscribeCreatesSubscription(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
},
}
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
return nil, gorm.ErrRecordNotFound
},
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
t.Fatal("must NOT UpdateSubscribe when creating a new subscription")
return nil
},
}
// 1) findActiveSubscribe 的 DB 回退查询 → 无行
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
// 2) 加载 subscribe 套餐计划
mock.ExpectQuery("FROM `subscribe`").
WillReturnRows(sqlmock.NewRows([]string{"id", "traffic", "node_group_id"}).
AddRow(int64(7), int64(1024), int64(3)))
// 3) 新建 user_subscribe
mock.ExpectExec("INSERT INTO `user_subscribe`").
WillReturnResult(sqlmock.NewResult(555, 1))
// 4) 回写 ledger payload
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
})
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
ActivityId: 100,
DrawId: 200,
Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":7}`},
IdempotencyKey: "lottery:100:200",
})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if res.State != lottery.DispatchStateAutoClaimed {
t.Fatalf("expected auto_claimed, got %q", res.State)
}
if res.Message != "已新建订阅并加 5 天" {
t.Fatalf("unexpected message: %q", res.Message)
}
}
// TestVPNDuration_NoActiveSubscribeNoPlanStillSkips 确认未配置 subscribe_id 时,
// 仍沿用旧的“跳过发放”行为(不新建订阅),保持向后兼容。
func TestVPNDuration_NoActiveSubscribeNoPlanStillSkips(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
},
}
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
return nil, gorm.ErrRecordNotFound
},
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
t.Fatal("must NOT touch subscription when no plan configured")
return nil
},
}
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
})
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
ActivityId: 100,
DrawId: 200,
Prize: lottery.Prize{Config: `{"duration_days":5}`},
IdempotencyKey: "lottery:100:200",
})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if !strings.Contains(res.Message, "跳过") {
t.Fatalf("expected skip message, got %q", res.Message)
}
}
// TestVPNDuration_NoActiveSubscribePlanNotFound 确认配置的 subscribe_id 不存在时,
// Dispatch 返回错误(让抽奖事务回滚),而不是静默成功。
func TestVPNDuration_NoActiveSubscribePlanNotFound(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
},
}
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
return nil, gorm.ErrRecordNotFound
},
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { return nil },
}
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
mock.ExpectQuery("FROM `subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
})
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
ActivityId: 100,
DrawId: 200,
Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":999}`},
IdempotencyKey: "lottery:100:200",
})
if err == nil {
t.Fatalf("expected error when configured plan is missing")
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected 'not found' error, got %v", err)
}
}
+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},