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" "gorm.io/gorm/clause" ) 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 := lastSubscribeExpireQuery(s.db.WithContext(ctx), userID). 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 lastSubscribeExpireQuery(db *gorm.DB, userID int64) *gorm.DB { return db. Model(&user.Subscribe{}). Where("user_id = ?", userID). Order(clause.OrderBy{Expression: clause.Expr{ SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END ASC, expire_time DESC", Vars: []interface{}{time.UnixMilli(0)}, }}). Limit(1) } 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() }