diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go index f50a4bb..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 { @@ -27,13 +28,13 @@ type promoRuleParams struct { InactiveMonths int `json:"inactive_months"` } -func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) { +func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) { result := &PromoResult{} - if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 { + if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 || quantity <= 0 { return result, nil } - rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID) + rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID, quantity) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo rules failed: %v", err.Error()) } @@ -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.go b/internal/logic/public/order/promoPricing.go index 119328e..77b6e93 100644 --- a/internal/logic/public/order/promoPricing.go +++ b/internal/logic/public/order/promoPricing.go @@ -36,7 +36,7 @@ func calculatePurchasePrice( } if allowPromo { - promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID) + promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity) if err != nil { return nil, err } diff --git a/internal/logic/public/order/promoPricing_test.go b/internal/logic/public/order/promoPricing_test.go index 2c3f579..a073ff9 100644 --- a/internal/logic/public/order/promoPricing_test.go +++ b/internal/logic/public/order/promoPricing_test.go @@ -11,31 +11,34 @@ import ( ) type fakePromoModel struct { - rules []*promo.RuleWithPrice + rules []*promo.RuleWithPrice + gotQuantity int64 } -func (m fakePromoModel) QueryEligibleRules(context.Context, int64) ([]*promo.RuleWithPrice, error) { +func (m *fakePromoModel) QueryEligibleRules(_ context.Context, _ int64, quantity int64) ([]*promo.RuleWithPrice, error) { + m.gotQuantity = quantity return m.rules, nil } -func (m fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error { +func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error { return nil } 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( @@ -68,22 +71,26 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { if result.PromoDiscount != 1200 { t.Fatalf("PromoDiscount = %d, want 1200", result.PromoDiscount) } + if model.gotQuantity != 3 { + t.Fatalf("promo query quantity = %d, want 3", model.gotQuantity) + } } 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 1456d3c..a867d87 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 ( @@ -171,11 +172,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 { @@ -190,6 +187,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 ff48935..c2858e6 100644 --- a/internal/model/promo/model.go +++ b/internal/model/promo/model.go @@ -13,7 +13,7 @@ type RuleWithPrice struct { } type Model interface { - QueryEligibleRules(ctx context.Context, subscribeId int64) ([]*RuleWithPrice, error) + QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error } @@ -25,18 +25,22 @@ func NewModel(db *gorm.DB, _ *redis.Client) Model { return &defaultPromoModel{db: db} } -func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64) ([]*RuleWithPrice, error) { +func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) { var list []*RuleWithPrice - err := m.db.WithContext(ctx). + 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"). + 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"). - Find(&list).Error - return list, err + Order("pr.id ASC") } func (m *defaultPromoModel) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error { 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 1a6f26f..5483891 100644 --- a/internal/model/promo/promo.go +++ b/internal/model/promo/promo.go @@ -34,6 +34,7 @@ type SubscribePromo struct { Id int64 `gorm:"primaryKey"` SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` 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"`