diff --git a/internal/logic/lottery/draw/service_test.go b/internal/logic/lottery/draw/service_test.go index f23d831..a088749 100644 --- a/internal/logic/lottery/draw/service_test.go +++ b/internal/logic/lottery/draw/service_test.go @@ -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) + } +} diff --git a/internal/logic/lottery/handler/vpn_duration.go b/internal/logic/lottery/handler/vpn_duration.go index c4f68c8..24081a2 100644 --- a/internal/logic/lottery/handler/vpn_duration.go +++ b/internal/logic/lottery/handler/vpn_duration.go @@ -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 { diff --git a/internal/logic/lottery/handler/vpn_duration_test.go b/internal/logic/lottery/handler/vpn_duration_test.go index 0aeb667..4064fef 100644 --- a/internal/logic/lottery/handler/vpn_duration_test.go +++ b/internal/logic/lottery/handler/vpn_duration_test.go @@ -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) + } +} diff --git a/internal/model/lottery/weighted_picker.go b/internal/model/lottery/weighted_picker.go index 4cb2973..85fe787 100644 --- a/internal/model/lottery/weighted_picker.go +++ b/internal/model/lottery/weighted_picker.go @@ -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 } } diff --git a/internal/model/lottery/weighted_picker_test.go b/internal/model/lottery/weighted_picker_test.go index a303098..7f73323 100644 --- a/internal/model/lottery/weighted_picker_test.go +++ b/internal/model/lottery/weighted_picker_test.go @@ -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},