Files
hi-server/internal/logic/common/promoEligibility.go
T
2026-05-27 00:53:24 -07:00

347 lines
9.7 KiB
Go

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"`
Quantity int64 `gorm:"column:quantity" json:"quantity"`
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, quantity 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 || quantity <= 0 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "user id, subscribe id or quantity 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, quantity)
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), &params); 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, quantity int64) ([]subscribePromo, error) {
cacheKey := fmt.Sprintf("%s%d:%d", promoSubscribeCachePrefix, subscribeID, quantity)
if cached, ok := getPromoCache[[]subscribePromo](ctx, svcCtx, cacheKey); ok {
return cached, nil
}
var promos []subscribePromo
if err := subscribePromoQuery(svcCtx.DB.WithContext(ctx), subscribeID, quantity).
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 subscribePromoQuery(db *gorm.DB, subscribeID int64, quantity int64) *gorm.DB {
return db.
Model(&subscribePromo{}).
Where("subscribe_id = ? AND quantity = ?", subscribeID, quantity)
}
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()
}