修复(#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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user