diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go index 885c7c0..6270bfe 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 { @@ -158,8 +164,16 @@ func evaluateInactiveUserPromo( return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed") } + return evaluateInactiveUserExpire(lastSub.ExpireTime, params, now), ruleExpiresAt, nil +} + +func evaluateInactiveUserExpire(lastExpire time.Time, params promoRuleParams, now time.Time) bool { + if lastExpire.Equal(time.UnixMilli(0)) { + return false + } + threshold := now.AddDate(0, -params.InactiveMonths, 0) - return lastSub.ExpireTime.Before(threshold) || lastSub.ExpireTime.Equal(threshold), ruleExpiresAt, nil + return lastExpire.Before(threshold) || lastExpire.Equal(threshold) } func promoRuleExpiresAt(rule *promo.RuleWithPrice) time.Time { diff --git a/internal/logic/common/promoEligibility_test.go b/internal/logic/common/promoEligibility_test.go new file mode 100644 index 0000000..d583c06 --- /dev/null +++ b/internal/logic/common/promoEligibility_test.go @@ -0,0 +1,46 @@ +package common + +import ( + "testing" + "time" +) + +func TestEvaluateInactiveUserExpire(t *testing.T) { + now := time.Date(2026, 5, 27, 8, 0, 0, 0, time.UTC) + params := promoRuleParams{InactiveMonths: 3} + + tests := []struct { + name string + lastExpire time.Time + want bool + }{ + { + name: "permanent subscription is not inactive", + lastExpire: time.UnixMilli(0), + want: false, + }, + { + name: "active subscription is not inactive", + lastExpire: now.Add(time.Hour), + want: false, + }, + { + name: "expire at threshold is inactive", + lastExpire: now.AddDate(0, -3, 0), + want: true, + }, + { + name: "expire before threshold is inactive", + lastExpire: now.AddDate(0, -3, -1), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := evaluateInactiveUserExpire(tt.lastExpire, params, now); got != tt.want { + t.Fatalf("evaluateInactiveUserExpire() = %v, want %v", got, tt.want) + } + }) + } +} 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..1c0865c 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 ( @@ -89,7 +90,16 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64, loggedIn bool) ([]subscribePromoCandidate, error) { var candidates []subscribePromoCandidate - query := svcCtx.DB.WithContext(ctx). + err := subscribePromoCandidatesQuery(ctx, svcCtx.DB, subscribeIDs, loggedIn). + Scan(&candidates).Error + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promo candidates failed: %v", err) + } + return candidates, nil +} + +func subscribePromoCandidatesQuery(ctx context.Context, db *gorm.DB, subscribeIDs []int64, loggedIn bool) *gorm.DB { + query := db.WithContext(ctx). Table("subscribe_promo AS sp"). Select("sp.subscribe_id, sp.quantity, sp.promo_price, pr.name AS rule_name, pr.type AS rule_type, pr.params, pr.start_time, pr.end_time"). Joins("JOIN promo_rule AS pr ON pr.id = sp.promo_rule_id AND pr.deleted_at IS NULL"). @@ -97,16 +107,11 @@ func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceConte if !loggedIn { query = query.Where("pr.type = ?", promoRuleTypeCampaign) } - err := query. + return query. Order("sp.subscribe_id ASC"). Order("sp.quantity ASC"). Order("pr.priority DESC"). - Order("pr.id ASC"). - Scan(&candidates).Error - if err != nil { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promo candidates failed: %v", err) - } - return candidates, nil + Order("pr.id ASC") } func (c subscribePromoCandidate) isActive(now time.Time) bool { @@ -179,11 +184,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 +199,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..4a20e0c 100644 --- a/internal/logic/public/subscribe/promo_test.go +++ b/internal/logic/public/subscribe/promo_test.go @@ -1,10 +1,15 @@ package subscribe import ( + "context" + "strings" "testing" "time" "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/types" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) func TestPromoEligibilityEvaluatorMatch(t *testing.T) { @@ -73,3 +78,83 @@ 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) + } +} + +func TestQuerySubscribePromoCandidatesIncludesQuantity(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) + } + + var candidates []subscribePromoCandidate + tx := subscribePromoCandidatesQuery(context.Background(), db, []int64{11, 12}, true).Scan(&candidates) + stmt := tx.Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, "sp.subscribe_id, sp.quantity, sp.promo_price") { + t.Fatalf("SQL missing quantity select: %s", sql) + } + if !strings.Contains(sql, "ORDER BY sp.subscribe_id ASC,sp.quantity ASC,pr.priority DESC,pr.id ASC") { + t.Fatalf("SQL missing quantity order: %s", sql) + } +} + +func TestApplySubscribeDiscountPromosMatchesQuantity(t *testing.T) { + subscribe := types.Subscribe{Discount: []types.SubscribeDiscount{ + {Quantity: 1}, + {Quantity: 12}, + }} + promos := map[int64]*types.SubscribePromo{ + 3: {RuleName: "季度优惠", PromoPrice: 2900}, + 12: {RuleName: "年度优惠", PromoPrice: 9900}, + } + + applySubscribeDiscountPromos(&subscribe, promos) + if subscribe.Discount[0].Promo != nil { + t.Fatalf("quantity 1 promo should be nil, got %+v", subscribe.Discount[0].Promo) + } + if subscribe.Discount[1].Promo == nil { + t.Fatal("quantity 12 promo should match") + } + if got, want := subscribe.Discount[1].Promo.RuleName, "年度优惠"; got != want { + t.Fatalf("promo rule name = %q, want %q", got, want) + } + + subscribe = types.Subscribe{Discount: []types.SubscribeDiscount{{Quantity: 6}}} + applySubscribeDiscountPromos(&subscribe, promos) + if subscribe.Discount[0].Promo != nil { + t.Fatalf("promo should be nil when quantity does not match, got %+v", subscribe.Discount[0].Promo) + } +} 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) + } +}