Compare commits

...

2 Commits

Author SHA1 Message Date
800f9c8460 x
Some checks failed
Build docker and publish / build (20.15.1) (push) Failing after 5m7s
2026-04-12 18:44:37 -07:00
954b19c332 feat: 邮箱规范化(NormalizeEmail)与域名白名单检查(IsEmailDomainWhitelisted)
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-12 18:43:47 -07:00
7 changed files with 106 additions and 14 deletions

View File

@ -21,7 +21,7 @@ env:
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }} SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }}
# TG通知 # TG通知
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0 TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
TG_CHAT_ID: "-4940243803" TG_CHAT_ID: "-49402438031"
# Go构建变量 # Go构建变量
SERVICE: vpn SERVICE: vpn
SERVICE_STYLE: vpn SERVICE_STYLE: vpn

View File

@ -126,7 +126,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
return err return err
} }
rc := l.svcCtx.Config.Register 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 { if err = l.activeTrial(userInfo.Id); err != nil {
return err return err
} }

View File

@ -396,7 +396,7 @@ func (l *OAuthLoginGetTokenLogic) register(email, avatar, method, openid, reques
} }
rc := l.svcCtx.Config.Register 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 { if shouldActivateTrial {
l.Debugw("activating trial subscription", l.Debugw("activating trial subscription",

View File

@ -1,9 +1,12 @@
package auth package auth
import ( import (
"context"
"strings" "strings"
"github.com/perfect-panel/server/internal/config" "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. // 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) 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
}

View File

@ -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) // Activate trial subscription after transaction success (moved outside transaction to reduce lock time)
rc := l.svcCtx.Config.Register 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) trialSubscribe, err = l.activeTrial(userInfo.Id)
if err != nil { if err != nil {
l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error())) l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error()))

View File

@ -39,6 +39,16 @@ func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, l
return 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) sub, err := svcCtx.SubscribeModel.FindOne(ctx, rc.TrialSubscribe)
if err != nil { if err != nil {
log.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error())) log.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error()))

View File

@ -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 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包括已过期的 // findGiftSubscription 查找用户的赠送订阅order_id=0包括已过期的
// 返回找到的赠送订阅记录,如果没有则返回 nil // 单订阅模式下,用户若以不同套餐首次购买,需要将赠送订阅合并为付费订阅,
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, subscribeId int64) (*user.Subscribe, error) { // 因此不再过滤 subscribe_id避免套餐不同时绕过合并路径创建重复订阅。
// 直接查询数据库,查找 order_id=0赠送且同套餐的订阅不限制过期状态 func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, _ int64) (*user.Subscribe, error) {
var giftSub user.Subscribe var giftSub user.Subscribe
err := l.svc.DB.Model(&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"). Order("created_at DESC").
First(&giftSub).Error First(&giftSub).Error
if err != nil { if err != nil {
@ -515,23 +515,28 @@ func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId in
return &giftSub, nil return &giftSub, nil
} }
// extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token 不变 // extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token/UUID 不变
// 将购买的天数叠加到赠送订阅的到期时间上,并更新 order_id 为新订单 ID // 若购买套餐与赠送套餐不同,同步更新套餐 ID 和流量配额并重置已用量(套餐变更语义)。
func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) { func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) {
now := time.Now() now := time.Now()
// 计算基准时间:取赠送订阅到期时间和当前时间的较大值
baseTime := giftSub.ExpireTime baseTime := giftSub.ExpireTime
if baseTime.Before(now) { if baseTime.Before(now) {
baseTime = now baseTime = now
} }
// 在基准时间上增加购买的天数
newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime) newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime)
// 更新赠送订阅的信息
giftSub.OrderId = orderInfo.Id giftSub.OrderId = orderInfo.Id
giftSub.ExpireTime = newExpireTime giftSub.ExpireTime = newExpireTime
giftSub.Status = 1 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 { if err := l.svc.UserModel.UpdateSubscribe(ctx, giftSub); err != nil {
logger.WithContext(ctx).Error("Update gift subscription failed", logger.WithContext(ctx).Error("Update gift subscription failed",
logger.Field("error", err.Error()), logger.Field("error", err.Error()),