package subscribe import ( "context" "encoding/json" stderrors "errors" "strings" "time" "github.com/go-sql-driver/mysql" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" "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" ) type subscribePromoCandidate struct { SubscribeId int64 `gorm:"column:subscribe_id"` Quantity int64 `gorm:"column:quantity"` RuleName string `gorm:"column:rule_name"` RuleType string `gorm:"column:rule_type"` PromoPrice int64 `gorm:"column:promo_price"` Params string `gorm:"column:params"` StartTime *time.Time `gorm:"column:start_time"` EndTime *time.Time `gorm:"column:end_time"` } type promoRuleParams struct { WindowHours int64 `json:"window_hours"` InactiveMonths int `json:"inactive_months"` } func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]map[int64]*types.SubscribePromo, error) { result := make(map[int64]map[int64]*types.SubscribePromo) if len(subscribeIDs) == 0 || svcCtx == nil || svcCtx.DB == nil { return result, nil } userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User) candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil) if err != nil { if isMissingPromoTableError(err) { return result, nil } return nil, err } evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo} now := time.Now() for _, candidate := range candidates { if candidate.Quantity <= 0 { continue } if result[candidate.SubscribeId] == nil { result[candidate.SubscribeId] = make(map[int64]*types.SubscribePromo) } if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists { continue } if !candidate.isActive(now) { continue } ok, expiresAt, err := evaluator.match(candidate, now) if err != nil { return nil, err } if !ok { continue } result[candidate.SubscribeId][candidate.Quantity] = &types.SubscribePromo{ RuleName: candidate.RuleName, RuleType: candidate.RuleType, PromoPrice: candidate.PromoPrice, ExpiresAt: unixSeconds(expiresAt), } } return result, nil } func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64, loggedIn bool) ([]subscribePromoCandidate, error) { var candidates []subscribePromoCandidate 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"). Where("sp.subscribe_id IN ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeIDs, true) if !loggedIn { query = query.Where("pr.type = ?", promoRuleTypeCampaign) } return query. Order("sp.subscribe_id ASC"). Order("sp.quantity ASC"). Order("pr.priority DESC"). Order("pr.id ASC") } func (c subscribePromoCandidate) isActive(now time.Time) bool { if c.PromoPrice <= 0 { return false } if c.StartTime != nil && now.Before(*c.StartTime) { return false } if c.EndTime != nil && now.After(*c.EndTime) { return false } return true } type promoEligibilityEvaluator struct { ctx context.Context db *gorm.DB userInfo *user.User lastExpire *time.Time } func (e *promoEligibilityEvaluator) match(candidate subscribePromoCandidate, now time.Time) (bool, time.Time, error) { switch candidate.RuleType { case promoRuleTypeCampaign: return true, candidate.expiresAt(), nil case promoRuleTypeNewUser: if e.userInfo == nil { return false, time.Time{}, nil } params, err := candidate.params() if err != nil { return false, time.Time{}, err } if params.WindowHours <= 0 || e.userInfo.CreatedAt.IsZero() { return false, time.Time{}, nil } expiresAt := e.userInfo.CreatedAt.Add(time.Duration(params.WindowHours) * time.Hour) return now.Before(expiresAt), expiresAt, nil case promoRuleTypeInactiveUser: if e.userInfo == nil { return false, time.Time{}, nil } params, err := candidate.params() if err != nil { return false, time.Time{}, err } if params.InactiveMonths <= 0 { return false, time.Time{}, nil } lastExpire, err := e.lastSubscribeExpireAt() if err != nil { return false, time.Time{}, err } if lastExpire.Equal(time.UnixMilli(0)) || lastExpire.After(now) { return false, time.Time{}, nil } if lastExpire.IsZero() { return true, candidate.expiresAt(), nil } threshold := now.AddDate(0, -params.InactiveMonths, 0) return !lastExpire.After(threshold), candidate.expiresAt(), nil default: return false, time.Time{}, nil } } func (e *promoEligibilityEvaluator) lastSubscribeExpireAt() (time.Time, error) { if e.lastExpire != nil { return *e.lastExpire, nil } var item user.Subscribe err := e.lastSubscribeExpireQuery(). Limit(1). Take(&item).Error if err != nil { if stderrors.Is(err, gorm.ErrRecordNotFound) { zero := time.Time{} e.lastExpire = &zero return zero, nil } return time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user last subscription failed") } e.lastExpire = &item.ExpireTime 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{} } return *c.EndTime } func (c subscribePromoCandidate) params() (promoRuleParams, error) { if c.Params == "" { return promoRuleParams{}, nil } var params promoRuleParams if err := json.Unmarshal([]byte(c.Params), ¶ms); err != nil { return promoRuleParams{}, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse promo rule params failed") } return params, nil } func unixSeconds(t time.Time) int64 { if t.IsZero() { return 0 } return t.Unix() } func isMissingPromoTableError(err error) bool { var mysqlErr *mysql.MySQLError if stderrors.As(err, &mysqlErr) { return mysqlErr.Number == 1146 } return strings.Contains(err.Error(), "Error 1146") }