25811526bd
Co-authored-by: multica-agent <github@multica.ai>
342 lines
9.4 KiB
Go
342 lines
9.4 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"`
|
|
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()
|
|
}
|