feat(用户): 实现邮箱绑定功能并返回登录凭证
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m41s

修改绑定邮箱接口返回登录凭证,优化用户数据迁移流程
添加用户缓存清理逻辑,确保设备绑定后数据一致性
完善邮箱验证和绑定逻辑的注释和错误处理
This commit is contained in:
shanshanzhong 2025-10-23 10:07:59 -07:00
parent 38655c0d38
commit fde3210a88
8 changed files with 217 additions and 72 deletions

View File

@ -209,7 +209,7 @@ service ppanel {
@doc "Bind Email With Password"
@handler BindEmailWithPassword
post /bind_email_with_password (BindEmailWithPasswordRequest)
post /bind_email_with_password (BindEmailWithPasswordRequest) returns (LoginResponse)
@doc "Bind Invite Code"
@handler BindInviteCode

View File

@ -20,7 +20,7 @@ func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Contex
}
l := user.NewBindEmailWithPasswordLogic(c.Request.Context(), svcCtx)
err := l.BindEmailWithPassword(&req)
result.HttpResult(c, nil, err)
resp, err := l.BindEmailWithPassword(&req)
result.HttpResult(c, resp, err)
}
}

View File

@ -187,6 +187,28 @@ func (l *BindDeviceLogic) createDeviceForUser(identifier, ip, userAgent string,
return err
}
// 清理用户缓存,确保新设备能正确显示在用户的设备列表中
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, userId)
if err != nil {
l.Errorw("failed to find user for cache clearing",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
// 不因为缓存清理失败而返回错误,因为设备创建已经成功
} else {
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); err != nil {
l.Errorw("failed to clear user cache",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
// 不因为缓存清理失败而返回错误,因为设备创建已经成功
} else {
l.Infow("cleared user cache after device creation",
logger.Field("user_id", userId),
)
}
}
// 记录设备创建成功的日志
l.Infow("device created successfully",
logger.Field("identifier", identifier),
@ -321,6 +343,51 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
return err
}
// 清理新用户的缓存,确保用户信息能正确更新
// 这是关键步骤:设备迁移后必须清理缓存,否则用户看到的还是旧的设备列表
newUser, err := l.svcCtx.UserModel.FindOne(l.ctx, newUserId)
if err != nil {
l.Errorw("failed to find new user for cache clearing",
logger.Field("new_user_id", newUserId),
logger.Field("error", err.Error()),
)
// 不因为缓存清理失败而返回错误,因为数据迁移已经成功
} else {
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, newUser); err != nil {
l.Errorw("failed to clear new user cache",
logger.Field("new_user_id", newUserId),
logger.Field("error", err.Error()),
)
// 不因为缓存清理失败而返回错误,因为数据迁移已经成功
} else {
l.Infow("cleared new user cache after device rebinding",
logger.Field("new_user_id", newUserId),
)
}
}
// 清理原用户的缓存(如果原用户没有被禁用的话)
oldUser, err := l.svcCtx.UserModel.FindOne(l.ctx, oldUserId)
if err != nil {
l.Errorw("failed to find old user for cache clearing",
logger.Field("old_user_id", oldUserId),
logger.Field("error", err.Error()),
)
// 不因为缓存清理失败而返回错误
} else {
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, oldUser); err != nil {
l.Errorw("failed to clear old user cache",
logger.Field("old_user_id", oldUserId),
logger.Field("error", err.Error()),
)
// 不因为缓存清理失败而返回错误
} else {
l.Infow("cleared old user cache after device rebinding",
logger.Field("old_user_id", oldUserId),
)
}
}
// 记录设备重新绑定成功的日志
l.Infow("device rebound successfully",
logger.Field("identifier", deviceInfo.Identifier),

View File

@ -2,12 +2,17 @@ package user
import (
"context"
"fmt"
"time"
"github.com/perfect-panel/server/internal/logic/auth"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/jwt"
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
@ -32,53 +37,56 @@ func NewBindEmailWithPasswordLogic(ctx context.Context, svcCtx *svc.ServiceConte
}
}
func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailWithPasswordRequest) error {
func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailWithPasswordRequest) (*types.LoginResponse, error) {
// 获取当前设备用户
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 验证邮箱和密码是否匹配现有用户
emailUser, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "email not registered: %v", req.Email)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "email not registered: %v", req.Email)
}
logger.WithContext(l.ctx).Error(err)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user by email failed: %v", err.Error())
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user by email failed: %v", err.Error())
}
// 验证密码
if !tool.VerifyPassWord(req.Password, emailUser.Password) {
return errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "password incorrect")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "password incorrect")
}
// 检查当前用户是否已经绑定了邮箱
currentEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", currentUser.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId error")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId error")
}
// 最终用户ID可能是当前用户或邮箱用户
finalUserId := currentUser.Id
// 如果当前用户已经绑定了邮箱,检查是否是同一个邮箱
if currentEmailMethod.Id > 0 {
// 如果绑定的是同一个邮箱,直接返回成功
// 如果绑定的是同一个邮箱,直接生成Token返回
if currentEmailMethod.AuthIdentifier == req.Email {
l.Infow("user is binding the same email that is already bound",
logger.Field("user_id", currentUser.Id),
logger.Field("email", req.Email),
)
return nil
}
// 直接使用当前用户ID生成Token
} else {
// 如果是不同的邮箱,不允许重复绑定
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "current user already has email bound")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "current user already has email bound")
}
} else {
// 检查该邮箱是否已经被其他用户绑定
existingEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// 如果邮箱已经被其他用户绑定,需要进行数据迁移
@ -89,11 +97,11 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
// 获取当前用户的设备标识符
deviceMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "device", currentUser.Id)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId device error")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId device error")
}
if deviceMethod.Id == 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "current user has no device identifier")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "current user has no device identifier")
}
// 执行设备重新绑定,这会触发数据迁移
@ -104,7 +112,7 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
logger.Field("device_identifier", deviceMethod.AuthIdentifier),
logger.Field("error", err.Error()),
)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind device to email user failed")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind device to email user failed")
}
l.Infow("successfully bound device to email user with data migration",
@ -112,6 +120,9 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
logger.Field("email_user_id", emailUser.Id),
logger.Field("device_identifier", deviceMethod.AuthIdentifier),
)
// 数据迁移后使用邮箱用户的ID
finalUserId = emailUser.Id
} else {
// 邮箱未被绑定,直接为当前用户创建邮箱绑定
emailMethod := &user.AuthMethods{
@ -122,7 +133,7 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
}
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, emailMethod); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
}
l.Infow("successfully bound email to current user",
@ -130,6 +141,35 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
logger.Field("email", req.Email),
)
}
return nil
}
// 生成新的Token
sessionId := uuidx.NewUUID().String()
loginType := "device"
if l.ctx.Value(constant.LoginType) != nil {
loginType = l.ctx.Value(constant.LoginType).(string)
}
token, err := jwt.NewJwtToken(
l.svcCtx.Config.JwtAuth.AccessSecret,
time.Now().Unix(),
l.svcCtx.Config.JwtAuth.AccessExpire,
jwt.WithOption("UserId", finalUserId),
jwt.WithOption("SessionId", sessionId),
jwt.WithOption("LoginType", loginType),
)
if err != nil {
l.Logger.Error("[BindEmailWithPassword] token generate error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
}
// 设置session缓存
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, finalUserId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
}
return &types.LoginResponse{
Token: token,
}, nil
}

View File

@ -30,37 +30,50 @@ func NewUpdateBindEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U
}
}
// UpdateBindEmail 更新用户绑定的邮箱地址
// 该方法用于用户更新或绑定新的邮箱地址,支持首次绑定和修改已绑定邮箱
func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error {
// 从上下文中获取当前用户信息
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 查询当前用户是否已有邮箱认证方式
method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// 检查要绑定的邮箱是否已被其他用户使用
m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// email already bind
// 如果邮箱已被绑定,返回错误
if m.Id > 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
}
// 如果用户还没有邮箱认证方式,创建新的认证记录
if method.Id == 0 {
method = &user.AuthMethods{
UserId: u.Id,
AuthType: "email",
AuthIdentifier: req.Email,
Verified: false,
UserId: u.Id, // 用户ID
AuthType: "email", // 认证类型为邮箱
AuthIdentifier: req.Email, // 邮箱地址
Verified: false, // 初始状态为未验证
}
// 插入新的认证方式记录
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, method); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
}
} else {
method.Verified = false
method.AuthIdentifier = req.Email
// 如果用户已有邮箱认证方式,更新邮箱地址
method.Verified = false // 重置验证状态
method.AuthIdentifier = req.Email // 更新邮箱地址
// 更新认证方式记录
if err := l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
}

View File

@ -15,13 +15,16 @@ import (
"github.com/pkg/errors"
)
// VerifyEmailLogic 邮箱验证逻辑结构体
// 用于处理用户邮箱验证码验证的业务逻辑
type VerifyEmailLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Verify Email
// NewVerifyEmailLogic 创建邮箱验证逻辑实例
// 用于初始化邮箱验证处理器
func NewVerifyEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyEmailLogic {
return &VerifyEmailLogic{
Logger: logger.WithContext(ctx),
@ -30,46 +33,68 @@ func NewVerifyEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Verif
}
}
// CacheKeyPayload Redis缓存中验证码的数据结构
// 用于存储验证码和最后发送时间
type CacheKeyPayload struct {
Code string `json:"code"`
LastAt int64 `json:"lastAt"`
Code string `json:"code"` // 验证码
LastAt int64 `json:"lastAt"` // 最后发送时间戳
}
// VerifyEmail 验证邮箱验证码
// 该方法用于验证用户输入的邮箱验证码是否正确,并将邮箱标记为已验证状态
func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
// 构建Redis缓存键格式认证码缓存前缀:安全标识:邮箱地址
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email)
// 从Redis中获取验证码缓存数据
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
if err != nil {
l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
}
// 解析缓存中的验证码数据
var payload CacheKeyPayload
err = json.Unmarshal([]byte(value), &payload)
if err != nil {
l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
}
// 验证用户输入的验证码是否与缓存中的验证码匹配
if payload.Code != req.Code {
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
}
// 验证成功后删除Redis中的验证码缓存一次性使用
l.svcCtx.Redis.Del(l.ctx, cacheKey)
// 从上下文中获取当前用户信息
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 根据邮箱地址查找用户的邮箱认证方式记录
method, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// 验证邮箱认证记录是否属于当前用户(安全检查)
if method.UserId != u.Id {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
// 将邮箱标记为已验证状态
method.Verified = true
// 更新数据库中的认证方式记录
err = l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
}
return nil
}

View File

@ -172,8 +172,8 @@ func bindEmailWithPassword(serverURL, secret, token, email, password, userAgent
func main() {
secret := "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
serverURL := "http://localhost:8080"
identifier := "AP4A.241205.013"
serverURL := "http://127.0.0.1:8080"
identifier := "AP4A.241205.A17"
userAgent := "ppanel-go-test/1.0"
plain := map[string]interface{}{

BIN
server Executable file

Binary file not shown.