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