修复(#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
@@ -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)
}
}