diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go index 885c7c0..706dcfc 100644 --- a/internal/logic/common/promoEligibility.go +++ b/internal/logic/common/promoEligibility.go @@ -11,6 +11,7 @@ import ( "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type PromoResult struct { @@ -148,7 +149,12 @@ func evaluateInactiveUserPromo( err := db.WithContext(ctx). Model(&user.Subscribe{}). Where("user_id = ?", userID). - Order("expire_time DESC"). + Order(clause.OrderBy{ + Expression: clause.Expr{ + SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC", + Vars: []interface{}{time.UnixMilli(0)}, + }, + }). Limit(1). Take(&lastSub).Error if err != nil { diff --git a/internal/logic/public/order/promoPricing_test.go b/internal/logic/public/order/promoPricing_test.go index 4daf226..244b6c3 100644 --- a/internal/logic/public/order/promoPricing_test.go +++ b/internal/logic/public/order/promoPricing_test.go @@ -32,19 +32,20 @@ func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) } func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { - svcCtx := &svc.ServiceContext{ - DB: &gorm.DB{}, - PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{ - { - Rule: promo.Rule{ - Id: 9, - Name: "campaign", - Type: promo.RuleTypeCampaign, - Enabled: true, - }, - PromoPrice: 600, + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 9, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, }, - }}, + PromoPrice: 600, + }, + }} + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: model, } result, err := calculatePurchasePrice( @@ -77,22 +78,26 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { if result.PromoDiscount != 1200 { t.Fatalf("PromoDiscount = %d, want 1200", result.PromoDiscount) } + if model.lastQuantity != 3 { + t.Fatalf("promo query quantity = %d, want 3", model.lastQuantity) + } } func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) { - svcCtx := &svc.ServiceContext{ - DB: &gorm.DB{}, - PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{ - { - Rule: promo.Rule{ - Id: 10, - Name: "invalid campaign", - Type: promo.RuleTypeCampaign, - Enabled: true, - }, - PromoPrice: 1000, + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 10, + Name: "invalid campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, }, - }}, + PromoPrice: 1000, + }, + }} + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: model, } result, err := calculatePurchasePrice( diff --git a/internal/logic/public/subscribe/promo.go b/internal/logic/public/subscribe/promo.go index 276e005..ae4d711 100644 --- a/internal/logic/public/subscribe/promo.go +++ b/internal/logic/public/subscribe/promo.go @@ -15,6 +15,7 @@ import ( "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" "gorm.io/gorm" + "gorm.io/gorm/clause" ) const ( @@ -179,11 +180,7 @@ func (e *promoEligibilityEvaluator) lastSubscribeExpireAt() (time.Time, error) { return *e.lastExpire, nil } var item user.Subscribe - err := e.db.WithContext(e.ctx). - Model(&user.Subscribe{}). - Where("user_id = ?", e.userInfo.Id). - Where("expire_time != ?", time.UnixMilli(0)). - Order("expire_time DESC"). + err := e.lastSubscribeExpireQuery(). Limit(1). Take(&item).Error if err != nil { @@ -198,6 +195,18 @@ func (e *promoEligibilityEvaluator) lastSubscribeExpireAt() (time.Time, error) { return item.ExpireTime, nil } +func (e *promoEligibilityEvaluator) lastSubscribeExpireQuery() *gorm.DB { + return e.db.WithContext(e.ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", e.userInfo.Id). + Order(clause.OrderBy{ + Expression: clause.Expr{ + SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC", + Vars: []interface{}{time.UnixMilli(0)}, + }, + }) +} + func (c subscribePromoCandidate) expiresAt() time.Time { if c.EndTime == nil { return time.Time{} diff --git a/internal/logic/public/subscribe/promo_test.go b/internal/logic/public/subscribe/promo_test.go index f62e75b..1f432b9 100644 --- a/internal/logic/public/subscribe/promo_test.go +++ b/internal/logic/public/subscribe/promo_test.go @@ -1,10 +1,13 @@ package subscribe import ( + "strings" "testing" "time" "github.com/perfect-panel/server/internal/model/user" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) func TestPromoEligibilityEvaluatorMatch(t *testing.T) { @@ -73,3 +76,34 @@ func TestSubscribePromoCandidateActiveWindow(t *testing.T) { t.Fatal("candidate after end time should not be active") } } + +func TestLastSubscribeExpireAtPrioritizesPermanentSubscription(t *testing.T) { + db, err := gorm.Open(mysql.New(mysql.Config{ + DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local", + SkipInitializeWithVersion: true, + }), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) + if err != nil { + t.Fatalf("open dry-run db: %v", err) + } + + evaluator := &promoEligibilityEvaluator{ + db: db, + userInfo: &user.User{Id: 7}, + } + var item user.Subscribe + tx := evaluator.lastSubscribeExpireQuery().Limit(1).Take(&item) + + sql := tx.Statement.SQL.String() + if !strings.Contains(sql, "CASE WHEN expire_time = ? THEN 0 ELSE 1 END") { + t.Fatalf("SQL missing permanent subscription priority order: %s", sql) + } + if strings.Contains(sql, "expire_time !=") { + t.Fatalf("SQL should not filter out permanent subscriptions: %s", sql) + } + if len(tx.Statement.Vars) < 2 { + t.Fatalf("SQL vars length = %d, want at least 2; vars=%v", len(tx.Statement.Vars), tx.Statement.Vars) + } + if got, want := tx.Statement.Vars[1], time.UnixMilli(0); got != want { + t.Fatalf("permanent subscription order var = %v, want %v; vars=%v", got, want, tx.Statement.Vars) + } +} diff --git a/internal/model/promo/model.go b/internal/model/promo/model.go index 4ab95bd..c2858e6 100644 --- a/internal/model/promo/model.go +++ b/internal/model/promo/model.go @@ -27,18 +27,22 @@ func NewModel(db *gorm.DB, _ *redis.Client) Model { func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) { var list []*RuleWithPrice - err := m.db.WithContext(ctx). - Table("promo_rule AS pr"). - Select("pr.*, sp.promo_price"). - Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id"). - Where("sp.subscribe_id = ? AND sp.quantity = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, quantity, true). - Where("pr.deleted_at IS NULL"). - Order("pr.priority DESC"). - Order("pr.id ASC"). + err := m.eligibleRulesQuery(ctx, subscribeId, quantity). Find(&list).Error return list, err } +func (m *defaultPromoModel) eligibleRulesQuery(ctx context.Context, subscribeId int64, quantity int64) *gorm.DB { + return m.db.WithContext(ctx). + Table("promo_rule AS pr"). + Select("pr.*, sp.promo_price"). + Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id AND sp.quantity = ?", quantity). + Where("sp.subscribe_id = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, true). + Where("pr.deleted_at IS NULL"). + Order("pr.priority DESC"). + Order("pr.id ASC") +} + func (m *defaultPromoModel) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error { db := m.db.WithContext(ctx) if len(tx) > 0 { diff --git a/internal/model/promo/model_test.go b/internal/model/promo/model_test.go new file mode 100644 index 0000000..8b44ca5 --- /dev/null +++ b/internal/model/promo/model_test.go @@ -0,0 +1,39 @@ +package promo + +import ( + "context" + "strings" + "testing" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestQueryEligibleRulesFiltersByQuantity(t *testing.T) { + db, err := gorm.Open(mysql.New(mysql.Config{ + DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local", + SkipInitializeWithVersion: true, + }), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) + if err != nil { + t.Fatalf("open dry-run db: %v", err) + } + + model := &defaultPromoModel{db: db} + var list []*RuleWithPrice + tx := model.eligibleRulesQuery(context.Background(), 11, 3).Find(&list) + stmt := tx.Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, "JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id AND sp.quantity = ?") { + t.Fatalf("SQL missing quantity join condition: %s", sql) + } + + if len(stmt.Vars) < 2 { + t.Fatalf("SQL vars length = %d, want at least 2; vars=%v", len(stmt.Vars), stmt.Vars) + } + if got, want := stmt.Vars[0], int64(3); got != want { + t.Fatalf("first SQL var = %v, want quantity %d; vars=%v", got, want, stmt.Vars) + } + if got, want := stmt.Vars[1], int64(11); got != want { + t.Fatalf("second SQL var = %v, want subscribe_id %d; vars=%v", got, want, stmt.Vars) + } +} diff --git a/internal/model/promo/promo.go b/internal/model/promo/promo.go index 5218d29..053878f 100644 --- a/internal/model/promo/promo.go +++ b/internal/model/promo/promo.go @@ -35,6 +35,7 @@ type SubscribePromo struct { SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` + Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Subscribe Quantity"` PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` UpdatedAt time.Time `gorm:"comment:Update Time"`