From 954b19c3328b84578171ceeae63b5c2b22283016 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 12 Apr 2026 18:43:47 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=AE=E7=AE=B1=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96(NormalizeEmail)=E4=B8=8E=E5=9F=9F=E5=90=8D=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E6=A3=80=E6=9F=A5(IsEmailDomainWhitelisted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/auth/emailLoginLogic.go | 2 +- .../auth/oauth/oAuthLoginGetTokenLogic.go | 2 +- internal/logic/auth/trialEmailWhitelist.go | 77 +++++++++++++++++++ internal/logic/auth/userRegisterLogic.go | 2 +- internal/logic/public/user/emailTrialGrant.go | 10 +++ queue/logic/order/activateOrderLogic.go | 25 +++--- 6 files changed, 105 insertions(+), 13 deletions(-) diff --git a/internal/logic/auth/emailLoginLogic.go b/internal/logic/auth/emailLoginLogic.go index e6034cd..2392bee 100644 --- a/internal/logic/auth/emailLoginLogic.go +++ b/internal/logic/auth/emailLoginLogic.go @@ -126,7 +126,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types. return err } rc := l.svcCtx.Config.Register - if ShouldGrantTrialForEmail(rc, req.Email) { + if ShouldGrantTrialForEmail(rc, req.Email) && !NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, req.Email, rc.TrialSubscribe) { if err = l.activeTrial(userInfo.Id); err != nil { return err } diff --git a/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go b/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go index c05220d..c89273c 100644 --- a/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go +++ b/internal/logic/auth/oauth/oAuthLoginGetTokenLogic.go @@ -396,7 +396,7 @@ func (l *OAuthLoginGetTokenLogic) register(email, avatar, method, openid, reques } rc := l.svcCtx.Config.Register - shouldActivateTrial := email != "" && authlogic.ShouldGrantTrialForEmail(rc, email) + shouldActivateTrial := email != "" && authlogic.ShouldGrantTrialForEmail(rc, email) && !authlogic.NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, email, rc.TrialSubscribe) if shouldActivateTrial { l.Debugw("activating trial subscription", diff --git a/internal/logic/auth/trialEmailWhitelist.go b/internal/logic/auth/trialEmailWhitelist.go index 90a2071..ae15855 100644 --- a/internal/logic/auth/trialEmailWhitelist.go +++ b/internal/logic/auth/trialEmailWhitelist.go @@ -1,9 +1,12 @@ package auth import ( + "context" "strings" "github.com/perfect-panel/server/internal/config" + usermodel "github.com/perfect-panel/server/internal/model/user" + "gorm.io/gorm" ) // IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist. @@ -37,3 +40,77 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool } return IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist) } + +// NormalizeEmail returns a canonical form of the email for trial deduplication. +// Strips "+" aliases universally (user+tag@any.com → user@any.com). +// Removes dots from local part for Gmail-like providers (gmail.com, googlemail.com). +func NormalizeEmail(email string) string { + email = strings.ToLower(strings.TrimSpace(email)) + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 { + return email + } + local, domain := parts[0], parts[1] + + // Strip + alias + if idx := strings.IndexByte(local, '+'); idx != -1 { + local = local[:idx] + } + + // Remove dots for Gmail-like providers that ignore dots in local part + if isGmailLikeDomain(domain) { + local = strings.ReplaceAll(local, ".", "") + } + + return local + "@" + domain +} + +func isGmailLikeDomain(domain string) bool { + switch domain { + case "gmail.com", "googlemail.com": + return true + } + return false +} + +// NormalizedEmailHasTrial returns true if any user with the same normalized email +// already holds a trial subscription. Only performs the cross-user DB check when +// normalization actually changes the email (i.e., dots removed or + alias stripped). +func NormalizedEmailHasTrial(ctx context.Context, db *gorm.DB, email string, trialSubscribeId int64) bool { + normalized := NormalizeEmail(email) + if normalized == strings.ToLower(strings.TrimSpace(email)) { + return false // normalization changed nothing, skip cross-user check + } + + parts := strings.SplitN(normalized, "@", 2) + if len(parts) != 2 { + return false + } + domain := parts[1] + + var authMethods []usermodel.AuthMethods + if err := db.WithContext(ctx). + Model(&usermodel.AuthMethods{}). + Select("user_id, auth_identifier"). + Where("auth_type = ? AND auth_identifier LIKE ?", "email", "%@"+domain). + Find(&authMethods).Error; err != nil { + return false + } + + for _, am := range authMethods { + if NormalizeEmail(am.AuthIdentifier) != normalized { + continue + } + var count int64 + if err := db.WithContext(ctx). + Model(&usermodel.Subscribe{}). + Where("user_id = ? AND subscribe_id = ?", am.UserId, trialSubscribeId). + Count(&count).Error; err != nil { + continue + } + if count > 0 { + return true + } + } + return false +} diff --git a/internal/logic/auth/userRegisterLogic.go b/internal/logic/auth/userRegisterLogic.go index a38cdde..5fd97de 100644 --- a/internal/logic/auth/userRegisterLogic.go +++ b/internal/logic/auth/userRegisterLogic.go @@ -148,7 +148,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp * // Activate trial subscription after transaction success (moved outside transaction to reduce lock time) rc := l.svcCtx.Config.Register - if ShouldGrantTrialForEmail(rc, req.Email) { + if ShouldGrantTrialForEmail(rc, req.Email) && !NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, req.Email, rc.TrialSubscribe) { trialSubscribe, err = l.activeTrial(userInfo.Id) if err != nil { l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error())) diff --git a/internal/logic/public/user/emailTrialGrant.go b/internal/logic/public/user/emailTrialGrant.go index c59ec53..602e0ad 100644 --- a/internal/logic/public/user/emailTrialGrant.go +++ b/internal/logic/public/user/emailTrialGrant.go @@ -39,6 +39,16 @@ func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, l return } + // Cross-user check: prevent the same real inbox (via dot trick / + alias) from + // getting multiple trials across different accounts. + if auth.NormalizedEmailHasTrial(ctx, svcCtx.DB, email, rc.TrialSubscribe) { + log.Infow("normalized email already has trial via another account, skip", + logger.Field("email", email), + logger.Field("owner_user_id", ownerUserId), + ) + return + } + sub, err := svcCtx.SubscribeModel.FindOne(ctx, rc.TrialSubscribe) if err != nil { log.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error())) diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 9c4afdb..876ea1e 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -500,13 +500,13 @@ func (l *ActivateOrderLogic) patchOrderParentID(ctx context.Context, orderID int return l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("id = ? AND (parent_id = 0 OR parent_id IS NULL)", orderID).Update("parent_id", parentID).Error } -// findGiftSubscription 查找用户指定套餐的赠送订阅(order_id=0),包括已过期的 -// 返回找到的赠送订阅记录,如果没有则返回 nil -func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, subscribeId int64) (*user.Subscribe, error) { - // 直接查询数据库,查找 order_id=0(赠送)且同套餐的订阅,不限制过期状态 +// findGiftSubscription 查找用户的赠送订阅(order_id=0),包括已过期的。 +// 单订阅模式下,用户若以不同套餐首次购买,需要将赠送订阅合并为付费订阅, +// 因此不再过滤 subscribe_id,避免套餐不同时绕过合并路径创建重复订阅。 +func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, _ int64) (*user.Subscribe, error) { var giftSub user.Subscribe err := l.svc.DB.Model(&user.Subscribe{}). - Where("user_id = ? AND order_id = 0 AND subscribe_id = ?", userId, subscribeId). + Where("user_id = ? AND order_id = 0", userId). Order("created_at DESC"). First(&giftSub).Error if err != nil { @@ -515,23 +515,28 @@ func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId in return &giftSub, nil } -// extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token 不变 -// 将购买的天数叠加到赠送订阅的到期时间上,并更新 order_id 为新订单 ID +// extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token/UUID 不变。 +// 若购买套餐与赠送套餐不同,同步更新套餐 ID 和流量配额并重置已用量(套餐变更语义)。 func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) { now := time.Now() - // 计算基准时间:取赠送订阅到期时间和当前时间的较大值 baseTime := giftSub.ExpireTime if baseTime.Before(now) { baseTime = now } - // 在基准时间上增加购买的天数 newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime) - // 更新赠送订阅的信息 giftSub.OrderId = orderInfo.Id giftSub.ExpireTime = newExpireTime giftSub.Status = 1 + // 套餐变更:更新套餐 ID 和流量配额,重置已用流量(与 updateSubscriptionForRenewal 逻辑一致) + if giftSub.SubscribeId != orderInfo.SubscribeId { + giftSub.SubscribeId = orderInfo.SubscribeId + giftSub.Traffic = sub.Traffic + giftSub.Download = 0 + giftSub.Upload = 0 + } + if err := l.svc.UserModel.UpdateSubscribe(ctx, giftSub); err != nil { logger.WithContext(ctx).Error("Update gift subscription failed", logger.Field("error", err.Error()),