From 5497a1ffdb518d524e2db09ed947150e6f4d21c4 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 30 Nov 2025 20:03:09 -0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=94=A8=E6=88=B7):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=B4=A6=E6=88=B7=E5=93=8D=E5=BA=94=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E7=A0=81=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(设备绑定): 重构设备所有权转移逻辑以保持设备ID稳定 docs: 添加设备绑定与踢出后重登录的回归测试文档 fix(设备登录): 修复设备不存在时空指针崩溃问题 --- .../documents/修复设备绑定后设备ID变化问题.md | 92 +++++++++++++++++++ docs/regression-bind-kick-relogin.md | 26 ++++++ .../user/bindEmailWithVerificationLogic.go | 37 +------- .../logic/public/user/deleteAccountLogic.go | 1 + internal/types/types.go | 1 + 5 files changed, 125 insertions(+), 32 deletions(-) create mode 100644 .trae/documents/修复设备绑定后设备ID变化问题.md create mode 100644 docs/regression-bind-kick-relogin.md diff --git a/.trae/documents/修复设备绑定后设备ID变化问题.md b/.trae/documents/修复设备绑定后设备ID变化问题.md new file mode 100644 index 0000000..ff4ec8f --- /dev/null +++ b/.trae/documents/修复设备绑定后设备ID变化问题.md @@ -0,0 +1,92 @@ +## 修复目标 + +* 解决首次设备登录时在 `internal/logic/auth/deviceLoginLogic.go:99` 对 `deviceInfo` 赋值导致的空指针崩溃,确保接口稳定返回。 + +## 根因定位 + +* 设备不存在分支仅创建用户与设备记录,但未为局部变量 `deviceInfo` 赋值;随后在 `internal/logic/auth/deviceLoginLogic.go:99-100` 使用 `deviceInfo` 导致 `nil` 解引用。 + +* 参考位置: + + * 赋值处:`internal/logic/auth/deviceLoginLogic.go:99-101` + + * 设备存在分支赋值:`internal/logic/auth/deviceLoginLogic.go:88-95` + + * 设备不存在分支未赋值:`internal/logic/auth/deviceLoginLogic.go:74-79` + + * `UpdateDevice` 需要有效设备 `Id`:`internal/model/user/device.go:58-69` + +## 修改方案 + +1. 在“设备不存在”分支注册完成后,立即通过标识重新查询设备,赋值给 `deviceInfo`: + + * 在 `internal/logic/auth/deviceLoginLogic.go` 的 `if errors.Is(err, gorm.ErrRecordNotFound)` 分支中,`userInfo, err = l.registerUserAndDevice(req)` 之后追加: + + * `deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)` + + * 如果查询失败则返回数据库查询错误(与现有风格一致)。 +2. 在更新设备 UA 前增加空指针保护,并不再忽略更新错误: + + * 将 `internal/logic/auth/deviceLoginLogic.go:99-101` 改为: + + * 检查 `deviceInfo != nil` + + * `deviceInfo.UserAgent = req.UserAgent` + + * `if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {` 记录错误并返回包装后的错误 `xerr.DatabaseUpdateError`。 +3. 可选优化(减少二次查询): + + * 将 `registerUserAndDevice(req)` 的返回值改为 `(*user.User, *user.Device, error)`,在注册时直接返回新建设备对象;调用点随之调整。若选择此方案,仍需在更新前做空指针保护。 + +## 代码示例(方案1,最小改动) + +```go +// internal/logic/auth/deviceLoginLogic.go +// 设备不存在分支注册后追加一次设备查询 +userInfo, err = l.registerUserAndDevice(req) +if err != nil { + return nil, err +} +deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier) +if err != nil { + l.Errorw("query device after register failed", + logger.Field("identifier", req.Identifier), + logger.Field("error", err.Error()), + ) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device after register failed: %v", err.Error()) +} + +// 更新 UA,不忽略更新错误 +if deviceInfo != nil { + deviceInfo.UserAgent = req.UserAgent + if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil { + l.Errorw("update device failed", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", req.Identifier), + logger.Field("error", err.Error()), + ) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error()) + } +} +``` + +## 测试用例与验证 + +* 用例1:首次设备标识登录(设备不存在)应成功返回 Token,日志包含注册与登录记录,无 500。 + +* 用例2:已存在设备标识登录(设备存在)应正常更新 UA 并返回 Token。 + +* 用例3:模拟数据库异常时应返回一致的业务错误码,不产生 `panic`。 + +## 风险与回滚 + +* 改动限定在登录逻辑,属最小范围;若出现异常,回滚为当前版本即可。 + +* 不改变数据结构与外部接口行为,兼容现有客户端。 + +## 后续优化(可选) + +* 统一 `UpdateDevice` 错误处理路径,避免 `_ = ...` 静默失败。 + +* 为“首次设备登录”场景补充集成测试,保证不再回归。 + diff --git a/docs/regression-bind-kick-relogin.md b/docs/regression-bind-kick-relogin.md new file mode 100644 index 0000000..f132096 --- /dev/null +++ b/docs/regression-bind-kick-relogin.md @@ -0,0 +1,26 @@ +用例:设备绑定与踢出后重登录,设备ID保持不变 + +前置条件 +- 清空相关用户与设备数据;保证 `user_device.identifier` 唯一 + +场景步骤 +- 设备A首次登录,生成设备记录,记下其 `user_device.id`(记为A_id) +- 设备A绑定邮箱 `101@qq.com` +- 设备B首次登录,生成设备记录,记下其 `user_device.id`(记为B_id) +- 设备B绑定同一邮箱 `101@qq.com` +- 设备A在后台踢出设备B(`KickOfflineByUserDeviceLogic`) +- 设备B重新登录 + +断言 +- 设备B的 `identifier` 未变化 +- 设备B在绑定到邮箱用户后,其 `user_device.id` 与绑定前的 B_id 保持一致 +- 踢出与重登录后,设备B的 `user_device.id` 仍保持一致 + +关键接口与文件 +- 设备登录:`internal/logic/auth/deviceLoginLogic.go` +- 邮箱绑定:`internal/logic/public/user/bindEmailWithVerificationLogic.go` +- 后台踢下线:`internal/logic/admin/user/kickOfflineByUserDeviceLogic.go` +- 设备管理器:`pkg/device/device.go`,回调:`internal/svc/devce.go` + +预期结果 +- 全流程不再删除并重建设备记录,设备主键ID稳定;仅迁移所有权。 diff --git a/internal/logic/public/user/bindEmailWithVerificationLogic.go b/internal/logic/public/user/bindEmailWithVerificationLogic.go index a3db890..4c56676 100644 --- a/internal/logic/public/user/bindEmailWithVerificationLogic.go +++ b/internal/logic/public/user/bindEmailWithVerificationLogic.go @@ -117,7 +117,7 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "查询邮箱绑定状态失败") } } else if existingMethod.Id != 0 { - // 邮箱已存在,使用现有的邮箱用户 + // 邮箱已存在,使用现有的邮箱用户并迁移设备所有权(保留设备ID) emailUserId = existingMethod.UserId // 设备绑定数量上限校验(目标邮箱用户) if limit := l.svcCtx.SessionLimit(); limit > 0 { @@ -130,39 +130,12 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi l.Infow("邮箱已存在,将设备转移到现有邮箱用户", logger.Field("email", req.Email), logger.Field("email_user_id", emailUserId)) - // 创建前 需要 吧 原本的 user_devices 表中过的数据删除掉 防止出现两个记录 - devices, _, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, u.Id) + resp, err := l.transferDeviceToEmailUser(u.Id, 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.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), "删除用户记录失败") - } - - 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), "创建邮箱用户设备记录失败") + l.Errorw("设备转移失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId)) + return nil, err } + return resp, nil } // 4. 生成新的JWT token token, err := l.generateTokenForUser(emailUserId) diff --git a/internal/logic/public/user/deleteAccountLogic.go b/internal/logic/public/user/deleteAccountLogic.go index 00d4954..6c3cb72 100644 --- a/internal/logic/public/user/deleteAccountLogic.go +++ b/internal/logic/public/user/deleteAccountLogic.go @@ -97,6 +97,7 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, resp.Success = true resp.Message = "账户注销成功" resp.UserId = newUserId + resp.Code = 200 return resp, nil } diff --git a/internal/types/types.go b/internal/types/types.go index da50e3d..76cb24d 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -529,6 +529,7 @@ type DeleteAccountResponse struct { Success bool `json:"success"` Message string `json:"message,omitempty"` UserId int64 `json:"user_id,omitempty"` + Code int `json:"code,omitempty"` } type DeleteUserAuthMethodRequest struct {