hi-server/internal/logic/public/user/bindEmailWithVerificationLogic.go
shanshanzhong a3cc23bbd4
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m5s
feat: 绑定新邮箱时创建独立邮箱用户并转移订阅,而非挂在设备用户上
- bindEmailWithVerificationLogic: 新邮箱路径改为创建独立 email user + joinFamily
- familyBindingHelper: clearMemberSubscribes → transferMemberSubscribesToOwner,订阅转移给 owner 而非删除
- accountMergeHelper: 同步更新调用点

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 00:52:50 -07:00

203 lines
6.6 KiB
Go

package user
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/perfect-panel/server/internal/config"
"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/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
}
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
}
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
}