From 47696b9e685dac46443296d68c805d4633fb4c08 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 29 Apr 2026 20:43:18 -0700 Subject: [PATCH] fix(order): reconcile subscriptions and grant device trials --- internal/logic/auth/deviceLoginLogic.go | 108 +++++++++++++++++++++ internal/logic/auth/trialEmailWhitelist.go | 15 ++- queue/logic/order/activateOrderLogic.go | 33 ++++--- 3 files changed, 139 insertions(+), 17 deletions(-) diff --git a/internal/logic/auth/deviceLoginLogic.go b/internal/logic/auth/deviceLoginLogic.go index 81594cb..01a66d4 100644 --- a/internal/logic/auth/deviceLoginLogic.go +++ b/internal/logic/auth/deviceLoginLogic.go @@ -12,6 +12,7 @@ import ( "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/jwt" "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" @@ -135,6 +136,8 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ } } + l.tryGrantTrialForDeviceLogin(userInfo, req.Identifier) + // Generate session id sessionId := uuidx.NewUUID().String() @@ -291,3 +294,108 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) return userInfo, nil } + +func (l *DeviceLoginLogic) tryGrantTrialForDeviceLogin(userInfo *user.User, identifier string) { + if userInfo == nil || userInfo.Id == 0 { + return + } + if !IsTrialConfigReady(l.svcCtx.Config.Register) { + l.Debugw("skip device trial grant because trial config is not ready", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("enable_trial", l.svcCtx.Config.Register.EnableTrial), + logger.Field("trial_subscribe_id", l.svcCtx.Config.Register.TrialSubscribe), + logger.Field("trial_time", l.svcCtx.Config.Register.TrialTime), + logger.Field("trial_time_unit", l.svcCtx.Config.Register.TrialTimeUnit), + ) + return + } + if userInfo.CreatedAt.IsZero() || time.Since(userInfo.CreatedAt) > 24*time.Hour { + l.Debugw("skip device trial grant because user is outside trial backfill window", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("user_created_at", userInfo.CreatedAt), + ) + return + } + + var count int64 + if err := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", userInfo.Id). + Count(&count).Error; err != nil { + l.Errorw("failed to query existing subscriptions before device trial grant", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("error", err.Error()), + ) + return + } + if count > 0 { + l.Debugw("skip device trial grant because user already has subscriptions", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("subscription_count", count), + ) + return + } + + trialSubscribe, err := l.activeTrial(userInfo.Id) + if err != nil { + l.Errorw("failed to activate trial subscription for device login", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("trial_subscribe_id", l.svcCtx.Config.Register.TrialSubscribe), + logger.Field("error", err.Error()), + ) + return + } + + if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, trialSubscribe); clearErr != nil { + l.Errorw("ClearSubscribeCache failed", + logger.Field("error", clearErr.Error()), + logger.Field("userSubscribeId", trialSubscribe.Id), + ) + } + if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, trialSubscribe.SubscribeId); clearErr != nil { + l.Errorw("ClearSubscribeCache failed", + logger.Field("error", clearErr.Error()), + logger.Field("subscribeId", trialSubscribe.SubscribeId), + ) + } + if clearErr := l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); clearErr != nil { + l.Errorf("ClearServerAllCache error: %v", clearErr.Error()) + } + l.Infow("device trial subscription granted", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("user_subscribe_id", trialSubscribe.Id), + logger.Field("trial_subscribe_id", trialSubscribe.SubscribeId), + logger.Field("expire_time", trialSubscribe.ExpireTime), + ) +} + +func (l *DeviceLoginLogic) activeTrial(uid int64) (*user.Subscribe, error) { + sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe) + if err != nil { + return nil, err + } + startTime := time.Now() + userSub := &user.Subscribe{ + UserId: uid, + OrderId: 0, + SubscribeId: sub.Id, + StartTime: startTime, + ExpireTime: tool.AddTime(l.svcCtx.Config.Register.TrialTimeUnit, l.svcCtx.Config.Register.TrialTime, startTime), + Traffic: sub.Traffic, + Download: 0, + Upload: 0, + Token: uuidx.NewUUID().String(), + UUID: uuidx.NewUUID().String(), + Status: 1, + } + if err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, userSub); err != nil { + return nil, err + } + return userSub, nil +} diff --git a/internal/logic/auth/trialEmailWhitelist.go b/internal/logic/auth/trialEmailWhitelist.go index 8d9e6c3..36c0e26 100644 --- a/internal/logic/auth/trialEmailWhitelist.go +++ b/internal/logic/auth/trialEmailWhitelist.go @@ -54,12 +54,19 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool return true } +// IsTrialConfigReady verifies that trial auto-grant has all required config. +func IsTrialConfigReady(register config.RegisterConfig) bool { + return register.EnableTrial && + register.TrialSubscribe > 0 && + register.TrialTime > 0 && + strings.TrimSpace(register.TrialTimeUnit) != "" +} + // ShouldAutoGrantTrialOnPublicEmailFlows defines whether browser/email-originated -// flows may auto-create a trial subscription. The current policy disables trial -// creation for email registration, email login auto-register, OAuth-with-email, -// and email binding/verification to avoid abuse through public email channels. +// flows may auto-create a trial subscription. Email-specific abuse protection +// is still handled by ShouldGrantTrialForEmail and NormalizedEmailHasTrial. func ShouldAutoGrantTrialOnPublicEmailFlows(register config.RegisterConfig) bool { - return false + return IsTrialConfigReady(register) } // IsDisposableAlias detects Gmail dot trick and + alias abuse. diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index c19743c..e98b5b1 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -304,20 +304,17 @@ func (l *ActivateOrderLogic) reconcilePostOrderSubscriptions(ctx context.Context return nil } - maxExpire := survivor.ExpireTime + now := time.Now() + accumulatedExpire := now for i := range ownerSubs { item := ownerSubs[i] - if item.Id == survivor.Id { - if item.ExpireTime.After(maxExpire) { - maxExpire = item.ExpireTime - } - continue + if (item.Id == survivor.Id || orderMergeRemainingTimeStatus(item.Status)) && item.ExpireTime.After(now) { + accumulatedExpire = accumulatedExpire.Add(item.ExpireTime.Sub(now)) } - losers = append(losers, item) - mergedIDs = append(mergedIDs, item.Id) - if item.ExpireTime.After(maxExpire) { - maxExpire = item.ExpireTime + if item.Id != survivor.Id { + losers = append(losers, item) + mergedIDs = append(mergedIDs, item.Id) } if item.SubscribeId > 0 { subscribeIDsToClear[item.SubscribeId] = struct{}{} @@ -341,9 +338,9 @@ func (l *ActivateOrderLogic) reconcilePostOrderSubscriptions(ctx context.Context "status": 1, "finished_at": nil, } - if maxExpire.After(survivor.ExpireTime) { - survivor.ExpireTime = maxExpire - updateFields["expire_time"] = maxExpire + if accumulatedExpire.After(survivor.ExpireTime) { + survivor.ExpireTime = accumulatedExpire + updateFields["expire_time"] = accumulatedExpire } if identitySource != nil { if identitySource.Token != "" { @@ -441,6 +438,15 @@ func shouldReconcilePostOrderSubscriptions(orderInfo *order.Order) bool { } } +func orderMergeRemainingTimeStatus(status uint8) bool { + switch status { + case 0, 1, 2: + return true + default: + return false + } +} + func pickSubscriptionIdentitySource(candidates []user.Subscribe) *user.Subscribe { if len(candidates) == 0 { return nil @@ -1434,6 +1440,7 @@ func (l *ActivateOrderLogic) updateSubscriptionWithIAPExpire(ctx context.Context userSub.FinishedAt = nil } + userSub.OrderId = orderInfo.Id userSub.ExpireTime = newExpire userSub.Status = 1