From fc8daaef56e2275f91798b49794a35dd6aa183e9 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 26 May 2026 22:05:23 -0700 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD(#75):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E4=BF=83=E9=94=80=E8=B5=84=E6=A0=BC=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- internal/logic/common/promoEligibility.go | 334 ++++++++++++++++++ .../logic/common/promoEligibility_test.go | 150 ++++++++ 2 files changed, 484 insertions(+) create mode 100644 internal/logic/common/promoEligibility.go create mode 100644 internal/logic/common/promoEligibility_test.go diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go new file mode 100644 index 0000000..6512a07 --- /dev/null +++ b/internal/logic/common/promoEligibility.go @@ -0,0 +1,334 @@ +package common + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "time" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +const ( + PromoRuleTypeNewUser = "new_user" + PromoRuleTypeInactiveUser = "inactive_user" + PromoRuleTypeCampaign = "campaign" + + promoRulesEnabledCacheKey = "promo:rules:enabled" + promoSubscribeCachePrefix = "promo:subscribe:" + promoCacheTTL = 5 * time.Minute +) + +type PromoResult struct { + Eligible bool + RuleID int64 + RuleName string + RuleType string + PromoPrice int64 + ExpiresAt time.Time +} + +type promoRule struct { + Id int64 `gorm:"column:id" json:"id"` + Name string `gorm:"column:name" json:"name"` + Type string `gorm:"column:type" json:"type"` + Params string `gorm:"column:params" json:"params"` + Priority int64 `gorm:"column:priority" json:"priority"` + Enabled bool `gorm:"column:enabled" json:"enabled"` + StartTime *time.Time `gorm:"column:start_time" json:"start_time,omitempty"` + EndTime *time.Time `gorm:"column:end_time" json:"end_time,omitempty"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"` +} + +func (promoRule) TableName() string { + return "promo_rule" +} + +type subscribePromo struct { + Id int64 `gorm:"column:id" json:"id"` + SubscribeId int64 `gorm:"column:subscribe_id" json:"subscribe_id"` + PromoRuleId int64 `gorm:"column:promo_rule_id" json:"promo_rule_id"` + PromoPrice int64 `gorm:"column:promo_price" json:"promo_price"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (subscribePromo) TableName() string { + return "subscribe_promo" +} + +type promoRuleParams struct { + WindowHours int64 `json:"window_hours"` + InactiveMonths int `json:"inactive_months"` +} + +type promoEligibilitySource interface { + UserCreatedAt(ctx context.Context, userID int64) (time.Time, error) + LastSubscribeExpireAt(ctx context.Context, userID int64) (time.Time, error) +} + +type gormPromoEligibilitySource struct { + db *gorm.DB +} + +func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) { + if svcCtx == nil || svcCtx.DB == nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "service context is empty") + } + if userID <= 0 || subscribeID <= 0 { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "user id or subscribe id is empty") + } + + rules, err := loadEnabledPromoRules(ctx, svcCtx) + if err != nil { + return nil, err + } + if len(rules) == 0 { + return &PromoResult{}, nil + } + + prices, err := loadSubscribePromos(ctx, svcCtx, subscribeID) + if err != nil { + return nil, err + } + if len(prices) == 0 { + return &PromoResult{}, nil + } + + return evaluatePromoRulesAt(ctx, rules, prices, &gormPromoEligibilitySource{db: svcCtx.DB}, userID, time.Now()) +} + +func evaluatePromoRulesAt( + ctx context.Context, + rules []promoRule, + prices []subscribePromo, + source promoEligibilitySource, + userID int64, + now time.Time, +) (*PromoResult, error) { + priceByRuleID := make(map[int64]int64, len(prices)) + for _, item := range prices { + if item.PromoRuleId <= 0 || item.PromoPrice <= 0 { + continue + } + priceByRuleID[item.PromoRuleId] = item.PromoPrice + } + + var userCreatedAt time.Time + var userCreatedAtLoaded bool + var lastExpireAt time.Time + var lastExpireAtLoaded bool + + for _, rule := range rules { + promoPrice, ok := priceByRuleID[rule.Id] + if !ok || !rule.isAvailableAt(now) { + continue + } + + params, err := rule.params() + if err != nil { + return nil, err + } + + var eligible bool + var expiresAt time.Time + switch rule.Type { + case PromoRuleTypeNewUser: + if !userCreatedAtLoaded { + userCreatedAt, err = source.UserCreatedAt(ctx, userID) + if err != nil { + return nil, err + } + userCreatedAtLoaded = true + } + eligible, expiresAt = evaluatePromoNewUser(userCreatedAt, params, now) + case PromoRuleTypeInactiveUser: + if !lastExpireAtLoaded { + lastExpireAt, err = source.LastSubscribeExpireAt(ctx, userID) + if err != nil { + return nil, err + } + lastExpireAtLoaded = true + } + eligible, expiresAt = evaluatePromoInactiveUser(lastExpireAt, params, rule, now) + case PromoRuleTypeCampaign: + eligible, expiresAt = evaluatePromoCampaign(rule) + default: + continue + } + + if !eligible { + continue + } + return &PromoResult{ + Eligible: true, + RuleID: rule.Id, + RuleName: rule.Name, + RuleType: rule.Type, + PromoPrice: promoPrice, + ExpiresAt: expiresAt, + }, nil + } + + return &PromoResult{}, nil +} + +func evaluatePromoNewUser(userCreatedAt time.Time, params promoRuleParams, now time.Time) (bool, time.Time) { + if userCreatedAt.IsZero() || params.WindowHours <= 0 { + return false, time.Time{} + } + expiresAt := userCreatedAt.Add(time.Duration(params.WindowHours) * time.Hour) + return now.Before(expiresAt), expiresAt +} + +func evaluatePromoInactiveUser(lastExpireAt time.Time, params promoRuleParams, rule promoRule, now time.Time) (bool, time.Time) { + if params.InactiveMonths <= 0 { + return false, time.Time{} + } + if lastExpireAt.Equal(time.UnixMilli(0)) || lastExpireAt.After(now) { + return false, time.Time{} + } + if lastExpireAt.IsZero() { + return true, rule.expiresAt() + } + + threshold := now.AddDate(0, -params.InactiveMonths, 0) + return !lastExpireAt.After(threshold), rule.expiresAt() +} + +func evaluatePromoCampaign(rule promoRule) (bool, time.Time) { + return true, rule.expiresAt() +} + +func (r promoRule) isAvailableAt(now time.Time) bool { + if r.Id <= 0 || !r.Enabled || r.DeletedAt.Valid { + return false + } + if r.StartTime != nil && now.Before(*r.StartTime) { + return false + } + if r.EndTime != nil && now.After(*r.EndTime) { + return false + } + return true +} + +func (r promoRule) expiresAt() time.Time { + if r.EndTime == nil { + return time.Time{} + } + return *r.EndTime +} + +func (r promoRule) params() (promoRuleParams, error) { + if r.Params == "" { + return promoRuleParams{}, nil + } + var params promoRuleParams + if err := json.Unmarshal([]byte(r.Params), ¶ms); err != nil { + return promoRuleParams{}, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse promo rule params failed") + } + return params, nil +} + +func (s *gormPromoEligibilitySource) UserCreatedAt(ctx context.Context, userID int64) (time.Time, error) { + var item user.User + err := s.db.WithContext(ctx). + Model(&user.User{}). + Where("id = ?", userID). + Take(&item).Error + if err != nil { + return time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user failed") + } + return item.CreatedAt, nil +} + +func (s *gormPromoEligibilitySource) LastSubscribeExpireAt(ctx context.Context, userID int64) (time.Time, error) { + var item user.Subscribe + err := s.db.WithContext(ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", userID). + Where("expire_time != ?", time.UnixMilli(0)). + Order("expire_time DESC"). + Limit(1). + Take(&item).Error + if err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return time.Time{}, nil + } + return time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user last subscription failed") + } + return item.ExpireTime, nil +} + +func loadEnabledPromoRules(ctx context.Context, svcCtx *svc.ServiceContext) ([]promoRule, error) { + if cached, ok := getPromoCache[[]promoRule](ctx, svcCtx, promoRulesEnabledCacheKey); ok { + return cached, nil + } + + var rules []promoRule + if err := svcCtx.DB.WithContext(ctx). + Model(&promoRule{}). + Where("enabled = ?", true). + Order("priority DESC"). + Order("id ASC"). + Find(&rules).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query enabled promo rules failed") + } + + setPromoCache(ctx, svcCtx, promoRulesEnabledCacheKey, rules) + return rules, nil +} + +func loadSubscribePromos(ctx context.Context, svcCtx *svc.ServiceContext, subscribeID int64) ([]subscribePromo, error) { + cacheKey := fmt.Sprintf("%s%d", promoSubscribeCachePrefix, subscribeID) + if cached, ok := getPromoCache[[]subscribePromo](ctx, svcCtx, cacheKey); ok { + return cached, nil + } + + var promos []subscribePromo + if err := svcCtx.DB.WithContext(ctx). + Model(&subscribePromo{}). + Where("subscribe_id = ?", subscribeID). + Find(&promos).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promos failed") + } + + setPromoCache(ctx, svcCtx, cacheKey, promos) + return promos, nil +} + +func getPromoCache[T any](ctx context.Context, svcCtx *svc.ServiceContext, key string) (T, bool) { + var zero T + if svcCtx == nil || svcCtx.Redis == nil { + return zero, false + } + + value, err := svcCtx.Redis.Get(ctx, key).Result() + if err != nil { + return zero, false + } + + var data T + if err = json.Unmarshal([]byte(value), &data); err != nil { + _ = svcCtx.Redis.Del(ctx, key).Err() + return zero, false + } + return data, true +} + +func setPromoCache(ctx context.Context, svcCtx *svc.ServiceContext, key string, value any) { + if svcCtx == nil || svcCtx.Redis == nil { + return + } + payload, err := json.Marshal(value) + if err != nil { + return + } + _ = svcCtx.Redis.Set(ctx, key, string(payload), promoCacheTTL).Err() +} diff --git a/internal/logic/common/promoEligibility_test.go b/internal/logic/common/promoEligibility_test.go new file mode 100644 index 0000000..9b69654 --- /dev/null +++ b/internal/logic/common/promoEligibility_test.go @@ -0,0 +1,150 @@ +package common + +import ( + "context" + "testing" + "time" +) + +type fakePromoEligibilitySource struct { + userCreatedAt time.Time + lastExpireAt time.Time +} + +func (s fakePromoEligibilitySource) UserCreatedAt(context.Context, int64) (time.Time, error) { + return s.userCreatedAt, nil +} + +func (s fakePromoEligibilitySource) LastSubscribeExpireAt(context.Context, int64) (time.Time, error) { + return s.lastExpireAt, nil +} + +func TestEvaluatePromoRulesAtPriorityFirstMatch(t *testing.T) { + now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + campaignEnd := now.Add(24 * time.Hour) + rules := []promoRule{ + { + Id: 2, + Name: "campaign", + Type: PromoRuleTypeCampaign, + Priority: 20, + Enabled: true, + EndTime: &campaignEnd, + }, + { + Id: 1, + Name: "new user", + Type: PromoRuleTypeNewUser, + Params: `{"window_hours":168}`, + Priority: 10, + Enabled: true, + }, + } + prices := []subscribePromo{ + {SubscribeId: 100, PromoRuleId: 1, PromoPrice: 599}, + {SubscribeId: 100, PromoRuleId: 2, PromoPrice: 499}, + } + + got, err := evaluatePromoRulesAt(context.Background(), rules, prices, fakePromoEligibilitySource{ + userCreatedAt: now.Add(-time.Hour), + }, 10, now) + if err != nil { + t.Fatalf("evaluatePromoRulesAt error: %v", err) + } + if !got.Eligible || got.RuleID != 2 || got.PromoPrice != 499 || got.RuleType != PromoRuleTypeCampaign { + t.Fatalf("unexpected promo result: %+v", got) + } + if !got.ExpiresAt.Equal(campaignEnd) { + t.Fatalf("expires_at = %v, want %v", got.ExpiresAt, campaignEnd) + } +} + +func TestEvaluatePromoRulesAtSkipsUnavailableRules(t *testing.T) { + now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + futureStart := now.Add(time.Hour) + expiredEnd := now.Add(-time.Hour) + rules := []promoRule{ + {Id: 1, Type: PromoRuleTypeCampaign, Enabled: true, StartTime: &futureStart}, + {Id: 2, Type: PromoRuleTypeCampaign, Enabled: true, EndTime: &expiredEnd}, + {Id: 3, Type: PromoRuleTypeCampaign, Enabled: false}, + {Id: 4, Type: "unknown", Enabled: true}, + {Id: 5, Type: PromoRuleTypeCampaign, Enabled: true}, + } + prices := []subscribePromo{ + {SubscribeId: 100, PromoRuleId: 1, PromoPrice: 100}, + {SubscribeId: 100, PromoRuleId: 2, PromoPrice: 100}, + {SubscribeId: 100, PromoRuleId: 3, PromoPrice: 100}, + {SubscribeId: 100, PromoRuleId: 4, PromoPrice: 100}, + {SubscribeId: 100, PromoRuleId: 5, PromoPrice: 88}, + } + + got, err := evaluatePromoRulesAt(context.Background(), rules, prices, fakePromoEligibilitySource{}, 10, now) + if err != nil { + t.Fatalf("evaluatePromoRulesAt error: %v", err) + } + if !got.Eligible || got.RuleID != 5 || got.PromoPrice != 88 { + t.Fatalf("unexpected promo result: %+v", got) + } +} + +func TestEvaluatePromoNewUser(t *testing.T) { + now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + createdAt := now.Add(-23 * time.Hour) + + eligible, expiresAt := evaluatePromoNewUser(createdAt, promoRuleParams{WindowHours: 24}, now) + if !eligible { + t.Fatal("new user should be eligible inside configured window") + } + if !expiresAt.Equal(createdAt.Add(24 * time.Hour)) { + t.Fatalf("expires_at = %v, want %v", expiresAt, createdAt.Add(24*time.Hour)) + } + + eligible, _ = evaluatePromoNewUser(createdAt, promoRuleParams{WindowHours: 12}, now) + if eligible { + t.Fatal("new user should not be eligible after configured window") + } + + eligible, _ = evaluatePromoNewUser(createdAt, promoRuleParams{}, now) + if eligible { + t.Fatal("new user should not be eligible without positive window_hours") + } +} + +func TestEvaluatePromoInactiveUser(t *testing.T) { + now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + endTime := now.Add(48 * time.Hour) + rule := promoRule{EndTime: &endTime} + + eligible, expiresAt := evaluatePromoInactiveUser(time.Time{}, promoRuleParams{InactiveMonths: 3}, rule, now) + if !eligible { + t.Fatal("never purchased user should be eligible for inactive promo") + } + if !expiresAt.Equal(endTime) { + t.Fatalf("expires_at = %v, want %v", expiresAt, endTime) + } + + eligible, _ = evaluatePromoInactiveUser(now.AddDate(0, -4, 0), promoRuleParams{InactiveMonths: 3}, rule, now) + if !eligible { + t.Fatal("expired before inactive threshold should be eligible") + } + + eligible, _ = evaluatePromoInactiveUser(now.AddDate(0, -1, 0), promoRuleParams{InactiveMonths: 3}, rule, now) + if eligible { + t.Fatal("recently expired subscription should not be eligible") + } + + eligible, _ = evaluatePromoInactiveUser(time.UnixMilli(0), promoRuleParams{InactiveMonths: 3}, rule, now) + if eligible { + t.Fatal("unlimited active subscription should not be eligible") + } + + eligible, _ = evaluatePromoInactiveUser(now.Add(time.Hour), promoRuleParams{InactiveMonths: 3}, rule, now) + if eligible { + t.Fatal("currently active subscription should not be eligible") + } + + eligible, _ = evaluatePromoInactiveUser(now.AddDate(0, -4, 0), promoRuleParams{}, rule, now) + if eligible { + t.Fatal("inactive promo should require positive inactive_months") + } +}