package user import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/logic/auth" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" "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" "gorm.io/gorm" ) type BindEmailWithVerificationLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } // NewBindEmailWithVerificationLogic Bind Email With Verification func NewBindEmailWithVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindEmailWithVerificationLogic { return &BindEmailWithVerificationLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.BindEmailWithVerificationRequest) (*types.BindEmailWithVerificationResponse, error) { req.Email = strings.ToLower(strings.TrimSpace(req.Email)) u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) if !ok { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } type payload struct { Code string `json:"code"` LastAt int64 `json:"lastAt"` } var verified bool scenes := []string{constant.Security.String(), constant.Register.String()} for _, scene := range scenes { cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email) value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result() if getErr != nil || value == "" { continue } var p payload if err := json.Unmarshal([]byte(value), &p); err != nil { continue } if p.Code == req.Code && time.Now().Unix()-p.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime { _ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err() verified = true break } } if !verified { return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired") } familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx) currentEmailMethod, err := familyHelper.getUserEmailMethod(u.Id) if err != nil { return nil, err } if currentEmailMethod != nil { if currentEmailMethod.AuthIdentifier == req.Email { return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user") } return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "email already bound to another account") } existingMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query email bind status failed") } // Create a new email user and establish family relationship var emailUser *user.User if txErr := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { emailUser = &user.User{ Salt: "default", OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase, } if err := tx.Create(emailUser).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create email user failed: %v", err) } emailUser.ReferCode = uuidx.UserInviteCode(emailUser.Id) if err := tx.Model(&user.User{}).Where("id = ?", emailUser.Id).Update("refer_code", emailUser.ReferCode).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err) } authInfo := &user.AuthMethods{ UserId: emailUser.Id, AuthType: "email", AuthIdentifier: req.Email, Verified: true, } if err := tx.Create(authInfo).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create email auth method failed: %v", err) } return nil }); txErr != nil { return nil, txErr } // Join family: email user as owner, device user as member if err = familyHelper.validateJoinFamily(emailUser.Id, u.Id); err != nil { return nil, err } joinResult, err := familyHelper.joinFamily(emailUser.Id, u.Id, "bind_email_with_verification") if err != nil { return nil, err } token, err := l.refreshBindSessionToken(u.Id) if err != nil { return nil, err } // Grant trial subscription if email domain is whitelisted l.tryGrantTrialOnEmailBind(emailUser.Id, req.Email) return &types.BindEmailWithVerificationResponse{ Success: true, Message: "email user created and joined family", Token: token, UserId: u.Id, FamilyJoined: true, FamilyId: joinResult.FamilyId, OwnerUserId: joinResult.OwnerUserId, }, nil } if existingMethod.UserId == u.Id { return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user") } if err = familyHelper.validateJoinFamily(existingMethod.UserId, u.Id); err != nil { return nil, err } joinResult, err := familyHelper.joinFamily(existingMethod.UserId, u.Id, "bind_email_with_verification") if err != nil { return nil, err } token, err := l.refreshBindSessionToken(u.Id) if err != nil { return nil, err } // Grant trial subscription if email domain is whitelisted l.tryGrantTrialOnEmailBind(existingMethod.UserId, req.Email) return &types.BindEmailWithVerificationResponse{ Success: true, Message: "joined family successfully", Token: token, UserId: u.Id, FamilyJoined: true, FamilyId: joinResult.FamilyId, OwnerUserId: joinResult.OwnerUserId, }, nil } func (l *BindEmailWithVerificationLogic) refreshBindSessionToken(userId int64) (string, error) { sessionId, _ := l.ctx.Value(constant.CtxKeySessionID).(string) if sessionId == "" { sessionId = uuidx.NewUUID().String() } opts := []jwt.Option{ jwt.WithOption("UserId", userId), jwt.WithOption("SessionId", sessionId), } if loginType, ok := l.ctx.Value(constant.CtxLoginType).(string); ok && loginType != "" { opts = append(opts, jwt.WithOption("CtxLoginType", loginType)) } if identifier, ok := l.ctx.Value(constant.CtxKeyIdentifier).(string); ok && identifier != "" { opts = append(opts, jwt.WithOption("identifier", identifier)) } token, err := jwt.NewJwtToken( l.svcCtx.Config.JwtAuth.AccessSecret, time.Now().Unix(), l.svcCtx.Config.JwtAuth.AccessExpire, opts..., ) if err != nil { return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error()) } expire := time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire) * time.Second sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userId, expire).Err(); err != nil { return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error()) } return token, nil } // tryGrantTrialOnEmailBind grants trial subscription to the email user (family owner) // if EnableTrial is on and (if whitelist is enabled, email domain must match). func (l *BindEmailWithVerificationLogic) tryGrantTrialOnEmailBind(ownerUserId int64, email string) { rc := l.svcCtx.Config.Register if !rc.EnableTrial { return } if rc.EnableTrialEmailWhitelist && !auth.IsEmailDomainWhitelisted(email, rc.TrialEmailDomainWhitelist) { l.Infow("email domain not in trial whitelist, skip", logger.Field("email", email), logger.Field("owner_user_id", ownerUserId), ) return } // Anti-duplicate: check if owner already has trial subscription var count int64 if err := l.svcCtx.DB.WithContext(l.ctx). Model(&user.Subscribe{}). Where("user_id = ? AND subscribe_id = ?", ownerUserId, rc.TrialSubscribe). Count(&count).Error; err != nil { l.Errorw("failed to check existing trial", logger.Field("error", err.Error())) return } if count > 0 { l.Infow("trial already granted, skip", logger.Field("owner_user_id", ownerUserId), ) return } sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, rc.TrialSubscribe) if err != nil { l.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error())) return } userSub := &user.Subscribe{ UserId: ownerUserId, OrderId: 0, SubscribeId: sub.Id, StartTime: time.Now(), ExpireTime: tool.AddTime(rc.TrialTimeUnit, rc.TrialTime, time.Now()), 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 { l.Errorw("failed to insert trial subscribe", logger.Field("error", err.Error()), logger.Field("owner_user_id", ownerUserId), ) return } // InsertSubscribe auto-clears user subscribe cache via execSubscribeMutation. // Clear server cache so nodes pick up the new subscription. if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil { l.Errorw("ClearServerAllCache error", logger.Field("error", err.Error())) } l.Infow("trial granted on email bind", logger.Field("owner_user_id", ownerUserId), logger.Field("email", email), logger.Field("subscribe_id", sub.Id), ) }