feat(用户绑定): 实现邮箱绑定功能并优化设备解绑逻辑
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m20s

添加邮箱绑定错误码和消息
修改解绑设备逻辑,解绑后创建新用户设备记录
重构邮箱绑定逻辑,支持检测已绑定邮箱并处理设备转移
This commit is contained in:
shanshanzhong 2025-10-31 00:14:22 -07:00
parent e23809b32e
commit c5d59b86b0
4 changed files with 269 additions and 20 deletions

View File

@ -38,7 +38,6 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
// 获取当前用户
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
@ -49,15 +48,30 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "获取用户设备信息失败: %v", err)
}
// 检测当设备是否已经绑定过邮箱
existingDevice, err := l.svcCtx.UserModel.FindUserAuthMethods(l.ctx, u.Id)
if err != nil {
l.Errorw("查询用户设备信息失败", logger.Field("error", err.Error()), logger.Field("device_identifier", deviceIdentifier))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "查询用户设备信息失败")
}
for _, method := range existingDevice {
if method.AuthType == "email" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.EmailBindError), "该设备已绑定邮箱")
}
}
// 检查邮箱是否已被其他用户绑定
existingMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
var emailUserId int64
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 邮箱不存在,创建新的邮箱用户
l.Infow("邮箱未绑定,将创建新的邮箱用户", logger.Field("email", req.Email))
emailUserId, err = l.createEmailUser(req.Email)
// 邮箱不存在,创建新的邮箱用户: 不需要创建邮箱用户
l.Infow(" 为当前设备做 邮箱绑定操作; 在 user_auth_methods 中添加记录", logger.Field("email", req.Email))
err = l.addAuthMethodForEmailUser(u.Id, req.Email)
if err != nil {
l.Errorw("添加邮箱用户认证方法失败", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "添加邮箱用户认证方法失败")
}
} else {
// 数据库查询错误
l.Errorw("查询邮箱绑定状态失败", logger.Field("error", err.Error()))
@ -69,21 +83,52 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
l.Infow("邮箱已存在,将设备转移到现有邮箱用户",
logger.Field("email", req.Email),
logger.Field("email_user_id", emailUserId))
} else {
// 这种情况理论上不应该发生(查询成功但返回零值结构体)
l.Infow("查询到邮箱记录但ID为0将创建新的邮箱用户", logger.Field("email", req.Email))
emailUserId, err = l.createEmailUser(req.Email)
// 创建前 需要 吧 原本的 user_devices 表中过的数据删除掉 防止出现两个记录
devices, _, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, u.Id)
if err != nil {
l.Errorw("创建邮箱用户失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "创建邮箱用户失败: %v", err)
l.Errorw("查询用户设备列表失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "查询用户设备列表失败")
}
for _, device := range devices {
// 删除原本的设备记录
err = l.svcCtx.UserModel.DeleteDevice(l.ctx, device.Id)
if err != nil {
l.Errorw("删除邮箱用户设备记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "删除原本的设备记录失败")
}
}
// 再次更新 user_auth_method 因为之前 默认 设备登录的时候 创建了一个设备认证数据
// 现在需要 更新 为 邮箱认证
err = l.updateAuthMethodForEmailUser(emailUserId, deviceIdentifier)
if err != nil {
l.Errorw("更新邮箱用户认证方法失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "更新邮箱用户认证方法失败")
}
// 需要删除原本 user 表中的 记录: 根据 设备 ID
err = l.deleteUserRecordForEmailUser(u.Id)
if err != nil {
l.Errorw("删除用户记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "删除用户记录失败")
}
l.Infow("创建新的邮箱用户",
logger.Field("email", req.Email),
logger.Field("email_user_id", emailUserId))
}
// 执行设备转移到邮箱用户
return l.transferDeviceToEmailUser(u.Id, emailUserId, deviceIdentifier)
err = l.createDeviceRecordForEmailUser(emailUserId, deviceIdentifier, "")
if err != nil {
l.Errorw("创建邮箱用户设备记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "创建邮箱用户设备记录失败")
}
}
// 4. 生成新的JWT token
token, err := l.generateTokenForUser(emailUserId)
if err != nil {
l.Errorw("生成JWT token失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "生成JWT token失败")
}
return &types.BindEmailWithVerificationResponse{
Success: true,
Message: "设备关联成功",
Token: token,
UserId: emailUserId,
}, nil
}
// getCurrentUserDeviceIdentifier 获取当前用户的设备标识符
@ -119,7 +164,57 @@ func (l *BindEmailWithVerificationLogic) checkIfPureDeviceUser(ctx context.Conte
return false, "", nil
}
// transferDeviceToEmailUser 将设备从设备用户转移到邮箱用户
// 邮箱存在的情况:在 user_devices 中创建一条设备记录
func (l *BindEmailWithVerificationLogic) createDeviceRecordForEmailUser(emailUserId int64, deviceIdentifier string, userAgent string) error {
// online 默认 0 enabled 默认 1
l.Infow("创建邮箱用户设备记录",
logger.Field("email_user_id", emailUserId),
logger.Field("device_identifier", deviceIdentifier),
logger.Field("online", true),
logger.Field("enabled", false),
logger.Field("user_agent", userAgent))
err := l.svcCtx.UserModel.InsertDevice(l.ctx, &user.Device{
UserId: emailUserId,
Identifier: deviceIdentifier,
Online: false,
Enabled: false,
UserAgent: userAgent,
})
if err != nil {
l.Errorw("创建邮箱用户设备记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "创建邮箱用户设备记录失败")
}
l.Infow("成功创建邮箱用户设备记录",
logger.Field("email_user_id", emailUserId),
logger.Field("device_identifier", deviceIdentifier))
return nil
}
// 邮箱不存在的情况: 在 user_auth_methods 中添加记录 以当前设备ID 做关联, 此时 user_devices 中不需要变动
func (l *BindEmailWithVerificationLogic) addAuthMethodForEmailUser(userId int64, email string) error {
l.Infow("添加邮箱用户认证方法",
logger.Field("user_id", userId),
logger.Field("email", email))
// 插入邮箱用户认证方法
err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, &user.AuthMethods{
UserId: userId,
AuthType: "email",
AuthIdentifier: email,
})
if err != nil {
l.Errorw("插入邮箱用户认证方法失败", logger.Field("error", err.Error()), logger.Field("user_id", userId))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "插入邮箱用户认证方法失败")
}
l.Infow("成功添加邮箱用户认证方法",
logger.Field("user_id", userId),
logger.Field("email", email))
return nil
}
// transferDeviceToEmailUser
func (l *BindEmailWithVerificationLogic) transferDeviceToEmailUser(deviceUserId, emailUserId int64, deviceIdentifier string) (*types.BindEmailWithVerificationResponse, error) {
l.Infow("开始设备转移",
logger.Field("device_user_id", deviceUserId),
@ -448,3 +543,60 @@ func (l *BindEmailWithVerificationLogic) activeTrial(userId int64, tx *gorm.DB)
return nil
}
// updateAuthMethodForEmailUser 根据 设备ID 找到原本的记录 然后 调整 user_id
func (l *BindEmailWithVerificationLogic) updateAuthMethodForEmailUser(userId int64, deviceIdentifier string) error {
var userAuth user.AuthMethods
if err := l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
// 查询设备认证方法
if err := tx.Model(&user.AuthMethods{}).
Where("auth_identifier = ? AND auth_type = ?", deviceIdentifier, "device").
First(&userAuth).Error; err != nil {
l.Errorw("查询设备认证方法失败",
logger.Field("device_identifier", deviceIdentifier),
logger.Field("error", err.Error()))
return err
}
// 更新用户认证方法为 email
if err := tx.Model(&user.AuthMethods{}).
Where("id = ?", userAuth.Id).
Update("user_id", userId).Error; err != nil {
l.Errorw("更新用户设备 用户关联 失败",
logger.Field("user_id", userId),
logger.Field("device_identifier", deviceIdentifier),
logger.Field("error", err.Error()))
return err
}
l.Infow("更新用户设备 用户关联 成功",
logger.Field("user_id", userId),
logger.Field("device_identifier", deviceIdentifier))
return nil
}); err != nil {
return err
}
return nil
}
// 根据用户iD 删除 user 表中的记录
func (l *BindEmailWithVerificationLogic) deleteUserRecordForEmailUser(userId int64) error {
if err := l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
// 删除用户记录
if err := tx.Delete(&user.User{}, userId).Error; err != nil {
l.Errorw("删除用户记录失败",
logger.Field("user_id", userId),
logger.Field("error", err.Error()))
return err
}
l.Infow("删除用户记录成功", logger.Field("user_id", userId))
return nil
}); err != nil {
return err
}
return nil
}

View File

@ -10,6 +10,7 @@ import (
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"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"
@ -31,7 +32,9 @@ func NewUnbindDeviceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Unbi
}
func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
// 获取当前 token 登录的用户
userInfo := l.ctx.Value(constant.CtxKeyUser).(*user.User)
// 查询解绑设备是否存在
device, err := l.svcCtx.UserModel.FindOneDevice(l.ctx, req.Id)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DeviceNotExist), "find device")
@ -40,9 +43,11 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
if device.UserId != userInfo.Id {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device not belong to user")
}
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
identifier := device.Identifier
l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
// 业务逻辑修改: 如果解绑; 那么 就把 设备关系 和 邮箱关系 拆开
var deleteDevice user.Device
// 删除了 设备 记录
err = tx.Model(&deleteDevice).Where("id = ?", req.Id).First(&deleteDevice).Error
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.QueueEnqueueError), "find device err: %v", err)
@ -72,6 +77,94 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
_ = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err()
}
return nil
})
// 最后 创建一个 新的 设备 用户信息 绕过 赠送套餐
l.registerUserAndDevice(identifier)
return nil
}
func (l *UnbindDeviceLogic) registerUserAndDevice(identifier string) (*user.User, error) {
l.Infow("删除新建 设备 用户",
logger.Field("identifier", identifier),
)
var userInfo *user.User
err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
// Create new user
userInfo = &user.User{
Salt: "default",
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
}
if err := db.Create(userInfo).Error; err != nil {
l.Errorw("failed to create user",
logger.Field("error", err.Error()),
)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err)
}
// Update refer code
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
if err := db.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
l.Errorw("failed to update refer code",
logger.Field("user_id", userInfo.Id),
logger.Field("error", err.Error()),
)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
}
// Create device auth method
authMethod := &user.AuthMethods{
UserId: userInfo.Id,
AuthType: "device",
AuthIdentifier: identifier,
Verified: true,
}
if err := db.Create(authMethod).Error; err != nil {
l.Errorw("failed to create device auth method",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", identifier),
logger.Field("error", err.Error()),
)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err)
}
// Insert device record
deviceInfo := &user.Device{
Ip: "",
UserId: userInfo.Id,
UserAgent: "",
Identifier: identifier,
Enabled: true,
Online: false,
}
if err := db.Create(deviceInfo).Error; err != nil {
l.Errorw("failed to insert device",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", identifier),
logger.Field("error", err.Error()),
)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err)
}
return nil
})
if err != nil {
l.Errorw("device registration failed",
logger.Field("identifier", identifier),
logger.Field("error", err.Error()),
)
return nil, err
}
l.Infow("device registration completed successfully",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", identifier),
logger.Field("refer_code", userInfo.ReferCode),
)
return userInfo, nil
}

View File

@ -27,6 +27,8 @@ const (
TelegramNotBound uint32 = 20007
UserNotBindOauth uint32 = 20008
InviteCodeError uint32 = 20009
// 邮箱绑定错误
EmailBindError uint32 = 20010
)
// Node error

View File

@ -33,6 +33,8 @@ func init() {
TelegramNotBound: "Telegram not bound ",
UserNotBindOauth: "User not bind oauth method",
InviteCodeError: "Invite code error",
// 邮箱绑定错误
EmailBindError: "已经绑定该邮箱",
// Node error
NodeExist: "Node already exists",