From 9f4d71770bc89285df9ea494e5b687c5f2ce5223 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Thu, 5 Mar 2026 02:13:28 -0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=B6=E5=BA=AD=E7=BB=84=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=94=AF=E4=BB=98=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/user/deleteAccountHandler.go | 4 +- .../user/deleteCurrentUserAccountHandler.go | 2 + .../public/user/unbindDeviceHandler.go | 2 +- .../console/queryServerTotalDataLogic.go | 39 +- .../logic/public/user/deleteAccountLogic.go | 546 +++++++----------- .../public/user/deleteAccountLogic_test.go | 109 ++++ internal/middleware/deviceMiddleware.go | 1 + 7 files changed, 349 insertions(+), 354 deletions(-) create mode 100644 internal/logic/public/user/deleteAccountLogic_test.go diff --git a/internal/handler/public/user/deleteAccountHandler.go b/internal/handler/public/user/deleteAccountHandler.go index c33514b..c745253 100644 --- a/internal/handler/public/user/deleteAccountHandler.go +++ b/internal/handler/public/user/deleteAccountHandler.go @@ -17,8 +17,8 @@ import ( ) // DeleteAccountHandler 注销账号处理器 -// 根据当前token删除所有关联设备,然后根据各自设备ID重新新建账号 -// 新增:需携带邮箱验证码,验证通过后执行注销 +// 仅执行注销登录与家庭退出/解散,不删除账号主体。 +// 需携带邮箱验证码,验证通过后执行。 type deleteAccountReq struct { Email string `json:"email" binding:"required,email"` // 用户邮箱 Code string `json:"code" binding:"required"` // 邮箱验证码 diff --git a/internal/handler/public/user/deleteCurrentUserAccountHandler.go b/internal/handler/public/user/deleteCurrentUserAccountHandler.go index 9b80a36..40b26ef 100644 --- a/internal/handler/public/user/deleteCurrentUserAccountHandler.go +++ b/internal/handler/public/user/deleteCurrentUserAccountHandler.go @@ -8,6 +8,8 @@ import ( ) // Delete Current User Account +// Legacy destructive endpoint: this physically deletes account data. +// Keep this behavior for backward compatibility. func DeleteCurrentUserAccountHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { diff --git a/internal/handler/public/user/unbindDeviceHandler.go b/internal/handler/public/user/unbindDeviceHandler.go index 9429d72..62b3e11 100644 --- a/internal/handler/public/user/unbindDeviceHandler.go +++ b/internal/handler/public/user/unbindDeviceHandler.go @@ -12,7 +12,7 @@ import ( func UnbindDeviceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { var req types.UnbindDeviceRequest - _ = c.ShouldBind(&req) + _ = c.ShouldBindJSON(&req) validateErr := svcCtx.Validate(&req) if validateErr != nil { result.ParamErrorResult(c, validateErr) diff --git a/internal/logic/admin/console/queryServerTotalDataLogic.go b/internal/logic/admin/console/queryServerTotalDataLogic.go index 5bdafca..f717269 100644 --- a/internal/logic/admin/console/queryServerTotalDataLogic.go +++ b/internal/logic/admin/console/queryServerTotalDataLogic.go @@ -69,14 +69,17 @@ func (l *QueryServerTotalDataLogic) QueryServerTotalData() (resp *types.ServerTo yesterday := todayStart.Add(-24 * time.Hour).Format(time.DateOnly) var yesterdayLog log.SystemLog - err = query.Model(&log.SystemLog{}).Where("`date` = ? AND `type` = ?", yesterday, log.TypeUserTrafficRank).First(&yesterdayLog).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - l.Errorw("[QueryServerTotalDataLogic] Query yesterday user traffic rank log error", logger.Field("error", err.Error())) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query yesterday user traffic rank log error: %v", err) + yesterdayUserRankQuery := query.Model(&log.SystemLog{}). + Where("`date` = ? AND `type` = ?", yesterday, log.TypeUserTrafficRank). + Limit(1). + Find(&yesterdayLog) + if yesterdayUserRankQuery.Error != nil { + l.Errorw("[QueryServerTotalDataLogic] Query yesterday user traffic rank log error", logger.Field("error", yesterdayUserRankQuery.Error.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query yesterday user traffic rank log error: %v", yesterdayUserRankQuery.Error) } var yesterdayUserRankData []types.UserTrafficData - if yesterdayLog.Id > 0 { + if yesterdayUserRankQuery.RowsAffected > 0 { var rank log.UserTrafficRank err = rank.Unmarshal([]byte(yesterdayLog.Content)) if err != nil { @@ -122,12 +125,15 @@ func (l *QueryServerTotalDataLogic) QueryServerTotalData() (resp *types.ServerTo // query server traffic rank yesterday var yesterdayTop10Server []types.ServerTrafficData var yesterdayServerTrafficLog log.SystemLog - err = query.Model(&log.SystemLog{}).Where("`date` = ? AND `type` = ?", yesterday, log.TypeServerTrafficRank).First(&yesterdayServerTrafficLog).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - l.Errorw("[QueryServerTotalDataLogic] Query yesterday server traffic rank log error", logger.Field("error", err.Error())) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query yesterday server traffic rank log error: %v", err) + yesterdayServerRankQuery := query.Model(&log.SystemLog{}). + Where("`date` = ? AND `type` = ?", yesterday, log.TypeServerTrafficRank). + Limit(1). + Find(&yesterdayServerTrafficLog) + if yesterdayServerRankQuery.Error != nil { + l.Errorw("[QueryServerTotalDataLogic] Query yesterday server traffic rank log error", logger.Field("error", yesterdayServerRankQuery.Error.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query yesterday server traffic rank log error: %v", yesterdayServerRankQuery.Error) } - if yesterdayServerTrafficLog.Id > 0 { + if yesterdayServerRankQuery.RowsAffected > 0 { var rank log.ServerTrafficRank err = rank.Unmarshal([]byte(yesterdayServerTrafficLog.Content)) if err != nil { @@ -195,12 +201,15 @@ func (l *QueryServerTotalDataLogic) QueryServerTotalData() (resp *types.ServerTo for i := now.Day() - 1; i >= 1; i-- { var logInfo log.SystemLog date := time.Date(now.Year(), now.Month(), i, 0, 0, 0, 0, now.Location()).Format(time.DateOnly) - err = query.Model(&log.SystemLog{}).Where("`date` = ? AND `type` = ?", date, log.TypeTrafficStat).First(&logInfo).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - l.Errorw("[QueryServerTotalDataLogic] Query daily traffic stat log error", logger.Field("error", err.Error()), logger.Field("date", date)) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query daily traffic stat log error: %v", err) + trafficStatQuery := query.Model(&log.SystemLog{}). + Where("`date` = ? AND `type` = ?", date, log.TypeTrafficStat). + Limit(1). + Find(&logInfo) + if trafficStatQuery.Error != nil { + l.Errorw("[QueryServerTotalDataLogic] Query daily traffic stat log error", logger.Field("error", trafficStatQuery.Error.Error()), logger.Field("date", date)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query daily traffic stat log error: %v", trafficStatQuery.Error) } - if logInfo.Id > 0 { + if trafficStatQuery.RowsAffected > 0 { var stat log.TrafficStat err = stat.Unmarshal([]byte(logInfo.Content)) if err != nil { diff --git a/internal/logic/public/user/deleteAccountLogic.go b/internal/logic/public/user/deleteAccountLogic.go index 00a0841..eab7d95 100644 --- a/internal/logic/public/user/deleteAccountLogic.go +++ b/internal/logic/public/user/deleteAccountLogic.go @@ -2,9 +2,8 @@ package user import ( "context" - "crypto/rand" - "encoding/hex" "fmt" + "strconv" "strings" "github.com/perfect-panel/server/internal/config" @@ -13,7 +12,6 @@ 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" @@ -34,379 +32,255 @@ func NewDeleteAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del } } -// DeleteAccount 注销当前设备账号逻辑 (改为精准解绑) +// DeleteAccount 保留兼容入口,统一走全量注销登录逻辑 func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, err error) { - // 获取当前用户 - currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) - if !ok { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") - } - - // 获取当前调用设备 ID - currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64) - - resp = &types.DeleteAccountResponse{} - var newUserId int64 - - // 如果没有识别到设备 ID (可能是旧版 Token),则执行安全注销:仅清除 Session - if currentDeviceId == 0 { - l.Infow("未识别到设备 ID,仅清理当前会话", logger.Field("user_id", currentUser.Id)) - l.clearCurrentSession(currentUser.Id) - resp.Success = true - resp.Message = "会话已清除" - return resp, nil - } - - // 开始数据库事务 - err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { - // 1. 查找当前设备 - var currentDevice user.Device - if err := tx.Where("id = ? AND user_id = ?", currentDeviceId, currentUser.Id).First(¤tDevice).Error; err != nil { - l.Infow("当前请求设备记录不存在或归属不匹配", logger.Field("device_id", currentDeviceId), logger.Field("error", err.Error())) - return nil // 不抛错,直接走清理 Session 流程 - } - - // 2. 检查用户是否有其他认证方式 (如邮箱) 或 其他设备 - var authMethodsCount int64 - tx.Model(&user.AuthMethods{}).Where("user_id = ?", currentUser.Id).Count(&authMethodsCount) - - var devicesCount int64 - tx.Model(&user.Device{}).Where("user_id = ?", currentUser.Id).Count(&devicesCount) - - // 判定是否是主账号解绑:如果除了当前设备外,还有邮箱或其他设备,则只解绑当前设备 - isMainAccount := authMethodsCount > 1 || devicesCount > 1 - - if isMainAccount { - l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId)) - - // 【重要】先删除旧的认证记录,再创建新用户,避免唯一键冲突 - // 从原用户删除当前设备的认证方式 (按 Identifier 准确删除) - if err := tx.Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", currentDevice.Identifier).Delete(&user.AuthMethods{}).Error; err != nil { - return errors.Wrap(err, "删除原设备认证失败") - } - - // 从原用户删除当前设备记录 - if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil { - return errors.Wrap(err, "删除原设备记录失败") - } - - // 为当前设备创建新用户并迁移 - newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent) - if err != nil { - return err - } - newUserId = newUser.Id - - } else { - l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId)) - - exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx) - if err := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); err != nil { - return err - } - - // 完全删除原用户相关资产 - tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{}) - tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{}) - tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{}) - tx.Delete(&user.User{}, currentUser.Id) - - // 重新注册一个新用户 - newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent) - if err != nil { - return err - } - newUserId = newUser.Id - } - - return nil - }) - - if err != nil { - return nil, err - } - - // 最终清理当前 Session - l.clearCurrentSession(currentUser.Id) - - resp.Success = true - resp.Message = "注销成功" - resp.UserId = newUserId - resp.Code = 200 - - return resp, nil + return l.DeleteAccountAll() } -// DeleteAccountAll 注销账号逻辑 (全部解绑默认创建账号) +// DeleteAccountAll 注销登录 + 退出家庭/解散家庭,不删除账号主体 func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountResponse, err error) { - // 获取当前用户 currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) - if !ok { + if !ok || currentUser == nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } - // 获取当前调用设备 ID - currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64) - currentIdentifier, _ := l.ctx.Value(constant.CtxKeyIdentifier).(string) - currentIdentifier = strings.TrimSpace(currentIdentifier) - - if currentDeviceId == 0 && currentIdentifier != "" { - deviceInfo, deviceErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, currentIdentifier) - switch { - case deviceErr == nil: - if deviceInfo.UserId == currentUser.Id { - currentDeviceId = deviceInfo.Id - } else { - l.Infow("当前标识符设备归属与用户不匹配", - logger.Field("user_id", currentUser.Id), - logger.Field("device_id", deviceInfo.Id), - logger.Field("identifier", currentIdentifier), - ) - } - case errors.Is(deviceErr, gorm.ErrRecordNotFound): - l.Infow("未通过标识符找到当前设备", logger.Field("user_id", currentUser.Id), logger.Field("identifier", currentIdentifier)) - default: - l.Errorw("通过标识符查找当前设备失败", - logger.Field("user_id", currentUser.Id), - logger.Field("identifier", currentIdentifier), - logger.Field("error", deviceErr.Error()), - ) - } - } - - resp = &types.DeleteAccountResponse{} - var newUserId int64 - var firstMigratedUserId int64 - - // 开始数据库事务 + affectedUserIDs := []int64{currentUser.Id} err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { - // 1. 预先查找该用户下的所有设备记录 (因为稍后要迁移) - var userDevices []user.Device - if err := tx.Where("user_id = ?", currentUser.Id).Find(&userDevices).Error; err != nil { - l.Errorw("查询用户设备列表失败", logger.Field("user_id", currentUser.Id), logger.Field("error", err.Error())) - return err - } - - // 如果没有识别到调用设备 ID,记录日志但继续执行 (全量注销不应受限) - if currentDeviceId == 0 { - l.Infow("未识别到当前设备 ID,将执行全量注销并尝试迁移所有已知设备", logger.Field("user_id", currentUser.Id), logger.Field("found_devices", len(userDevices))) + familyUserIDs, collectErr := l.collectAffectedFamilyUserIDs(tx, currentUser.Id) + if collectErr != nil { + return collectErr } + affectedUserIDs = familyUserIDs exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx) - if err := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); err != nil { - return err + if removeErr := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); removeErr != nil { + return removeErr } - - l.Infow("执行账号全量注销-迁移设备并删除旧数据", logger.Field("user_id", currentUser.Id), logger.Field("device_count", len(userDevices))) - - // 2. 循环为每个设备创建新用户并迁移记录 (保留设备ID) - for _, dev := range userDevices { - // A. 创建新匿名用户 - newUser, err := l.createAnonymousUser(tx) - if err != nil { - l.Errorw("为设备分配新用户主体失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error())) - return err - } - if firstMigratedUserId == 0 { - firstMigratedUserId = newUser.Id - } - - // B. 迁移设备记录 (Update user_id) - if err := tx.Model(&user.Device{}).Where("id = ?", dev.Id).Update("user_id", newUser.Id).Error; err != nil { - l.Errorw("迁移设备记录失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error())) - return errors.Wrap(err, "迁移设备记录失败") - } - - // C. 迁移设备认证方式 (Update user_id) - if err := tx.Model(&user.AuthMethods{}). - Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", dev.Identifier). - Update("user_id", newUser.Id).Error; err != nil { - l.Errorw("迁移设备认证失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error())) - return errors.Wrap(err, "迁移设备认证失败") - } - - // 如果是当前请求的设备,记录其新 UserID 返回给前端 - isCurrentRequestDevice := currentDeviceId > 0 && dev.Id == currentDeviceId - if !isCurrentRequestDevice && currentIdentifier != "" { - isCurrentRequestDevice = dev.Identifier == currentIdentifier - } - if isCurrentRequestDevice { - newUserId = newUser.Id - } - l.Infow("旧设备已迁移至新匿名账号", - logger.Field("old_user_id", currentUser.Id), - logger.Field("new_user_id", newUser.Id), - logger.Field("device_id", dev.Id), - logger.Field("identifier", dev.Identifier)) - } - - // 3. 删除旧账号的剩余数据 - // 删除剩余的认证方式 (排除已迁移的device类型,剩下的如email/mobile等) - // 注意:刚才已经把由currentUser拥有的device类型auth都迁移走了,所以这里直接删剩下的即可 - if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error; err != nil { - return errors.Wrap(err, "删除剩余认证方式失败") - } - - // 设备记录已经全部迁移,理论上 user_id = currentUser.Id 的 device 应该没了,但为了保险可以删一下(或者是0) - if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{}).Error; err != nil { - return errors.Wrap(err, "删除残留设备记录失败") - } - - // 删除所有订阅 - if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{}).Error; err != nil { - return errors.Wrap(err, "删除订阅失败") - } - - // 删除用户主体 - if err := tx.Delete(&user.User{}, currentUser.Id).Error; err != nil { - return errors.Wrap(err, "删除用户失败") - } - return nil }) - if err != nil { return nil, err } - // 最终清理所有 Session (踢掉所有设备) l.clearAllSessions(currentUser.Id) - if newUserId == 0 { - newUserId = firstMigratedUserId + if cacheErr := l.clearUserAndSubscribeCaches(affectedUserIDs); cacheErr != nil { + l.Errorw("clear user related cache failed", + logger.Field("user_id", currentUser.Id), + logger.Field("affected_user_ids", affectedUserIDs), + logger.Field("error", cacheErr.Error()), + ) } - resp.Success = true - resp.Message = "注销成功" - resp.UserId = newUserId - resp.Code = 200 - - return resp, nil + return &types.DeleteAccountResponse{ + Success: true, + Message: "注销成功", + UserId: currentUser.Id, + Code: 200, + }, nil } -// 辅助方法:通过 ID 查找 Identifier (以防 currentDeviceId 只是 ID) -func (l *DeleteAccountLogic) getIdentifierByDeviceID(devices []user.Device, id int64) string { - for _, d := range devices { - if d.Id == id { - return d.Identifier +func (l *DeleteAccountLogic) collectAffectedFamilyUserIDs(tx *gorm.DB, userID int64) ([]int64, error) { + affected := []int64{userID} + + var relation struct { + FamilyId int64 `gorm:"column:family_id"` + } + err := tx.Model(&user.UserFamilyMember{}). + Select("user_family_member.family_id"). + Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL AND user_family.status = ?", user.FamilyStatusActive). + Where("user_family_member.user_id = ? AND user_family_member.status = ? AND user_family_member.deleted_at IS NULL", userID, user.FamilyMemberActive). + First(&relation).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return affected, nil + } + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family relation failed") + } + + var memberIDs []int64 + if err = tx.Model(&user.UserFamilyMember{}). + Where("family_id = ? AND status = ? AND deleted_at IS NULL", relation.FamilyId, user.FamilyMemberActive). + Pluck("user_id", &memberIDs).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family members failed") + } + + affected = append(affected, memberIDs...) + idSet := make(map[int64]struct{}, len(affected)) + unique := make([]int64, 0, len(affected)) + for _, id := range affected { + if id <= 0 { + continue + } + if _, exists := idSet[id]; exists { + continue + } + idSet[id] = struct{}{} + unique = append(unique, id) + } + + if len(unique) == 0 { + return []int64{userID}, nil + } + return unique, nil +} + +func (l *DeleteAccountLogic) clearUserAndSubscribeCaches(userIDs []int64) error { + if len(userIDs) == 0 { + return nil + } + + idSet := make(map[int64]struct{}, len(userIDs)) + uniqueIDs := make([]int64, 0, len(userIDs)) + for _, userID := range userIDs { + if userID <= 0 { + continue + } + if _, exists := idSet[userID]; exists { + continue + } + idSet[userID] = struct{}{} + uniqueIDs = append(uniqueIDs, userID) + } + if len(uniqueIDs) == 0 { + return nil + } + + userModels := make([]*user.User, 0, len(uniqueIDs)) + subscribeModels := make([]*user.Subscribe, 0, len(uniqueIDs)) + for _, userID := range uniqueIDs { + u, findErr := l.svcCtx.UserModel.FindOne(l.ctx, userID) + switch { + case findErr == nil: + userModels = append(userModels, u) + case errors.Is(findErr, gorm.ErrRecordNotFound): + // no-op + default: + l.Errorw("find user for cache clearing failed", + logger.Field("user_id", userID), + logger.Field("error", findErr.Error()), + ) + } + + subscribeModels = append(subscribeModels, &user.Subscribe{UserId: userID}) + subscribes, queryErr := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, userID) + if queryErr != nil { + l.Errorw("query user subscribes for cache clearing failed", + logger.Field("user_id", userID), + logger.Field("error", queryErr.Error()), + ) + continue + } + for _, subscribe := range subscribes { + subscribeModels = append(subscribeModels, &user.Subscribe{ + Id: subscribe.Id, + UserId: subscribe.UserId, + SubscribeId: subscribe.SubscribeId, + Token: subscribe.Token, + }) } } - return "" -} -// clearCurrentSession 清理当前请求的会话 -func (l *DeleteAccountLogic) clearCurrentSession(userId int64) { - if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" { - sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) - _ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err() - // 从用户会话集合中移除当前session - sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId) - _ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err() - - l.Infow("[SessionMonitor] 注销账号清除 Session", - logger.Field("user_id", userId), - logger.Field("session_id", sessionId)) + if len(userModels) > 0 { + if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, userModels...); err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "clear user cache failed") + } } + if len(subscribeModels) > 0 { + if err := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, subscribeModels...); err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "clear subscribe cache failed") + } + } + + return nil } // clearAllSessions 清理指定用户的所有会话 func (l *DeleteAccountLogic) clearAllSessions(userId int64) { sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId) + sessionSet := make(map[string]struct{}) - // 获取所有 session id sessions, err := l.svcCtx.Redis.ZRange(l.ctx, sessionsKey, 0, -1).Result() if err != nil { - l.Errorw("获取用户会话列表失败", logger.Field("user_id", userId), logger.Field("error", err.Error())) - return + l.Errorw("获取用户会话索引失败", logger.Field("user_id", userId), logger.Field("error", err.Error())) + } else { + for _, sessionID := range sessions { + if sessionID != "" { + sessionSet[sessionID] = struct{}{} + } + } } - // 删除每个 session 的详情 key - for _, sid := range sessions { - sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sid) - _ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err() + userIDText := strconv.FormatInt(userId, 10) + pattern := fmt.Sprintf("%s:*", config.SessionIdKey) + var cursor uint64 + for { + keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, pattern, 200).Result() + if scanErr != nil { + l.Errorw("扫描会话键失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error())) + break + } - // 同时尝试删除 detail key (如果存在) - detailKey := fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid) - _ = l.svcCtx.Redis.Del(l.ctx, detailKey).Err() + for _, sessionKey := range keys { + value, getErr := l.svcCtx.Redis.Get(l.ctx, sessionKey).Result() + if getErr != nil || value != userIDText { + continue + } + sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":") + if sessionID == "" || strings.HasPrefix(sessionID, "detail:") { + continue + } + sessionSet[sessionID] = struct{}{} + } + + cursor = nextCursor + if cursor == 0 { + break + } } - // 删除用户的 session 集合 key - _ = l.svcCtx.Redis.Del(l.ctx, sessionsKey).Err() + deviceKeySet := make(map[string]struct{}) + devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey) + cursor = 0 + for { + keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, devicePattern, 200).Result() + if scanErr != nil { + l.Errorw("扫描设备会话映射失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error())) + break + } + for _, deviceKey := range keys { + sessionID, getErr := l.svcCtx.Redis.Get(l.ctx, deviceKey).Result() + if getErr != nil { + continue + } + if _, exists := sessionSet[sessionID]; exists { + deviceKeySet[deviceKey] = struct{}{} + } + } + cursor = nextCursor + if cursor == 0 { + break + } + } - l.Infow("[SessionMonitor] 注销账号-清除所有Session", logger.Field("user_id", userId), logger.Field("count", len(sessions))) -} - -// generateReferCode 生成推荐码 -func generateReferCode() string { - bytes := make([]byte, 4) - rand.Read(bytes) - return hex.EncodeToString(bytes) -} - -func (l *DeleteAccountLogic) registerUserAndDevice(tx *gorm.DB, identifier, ip, userAgent string) (*user.User, error) { - // 1. 创建新用户 - userInfo := &user.User{ - Salt: "default", - OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase, - } - if err := tx.Create(userInfo).Error; err != nil { - l.Errorw("failed to create user", logger.Field("error", err.Error())) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err) - } - - // 2. 更新推荐码 - userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id) - if err := tx.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 nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err) - } - - // 3. 创建设备认证方式 - authMethod := &user.AuthMethods{ - UserId: userInfo.Id, - AuthType: "device", - AuthIdentifier: identifier, - Verified: true, - } - if err := tx.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 nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err) - } - - // 4. 插入设备记录 - deviceInfo := &user.Device{ - Ip: ip, - UserId: userInfo.Id, - UserAgent: userAgent, - Identifier: identifier, - Enabled: true, - Online: false, - } - if err := tx.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 nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err) - } - - return userInfo, nil -} - -// createAnonymousUser 创建一个新的匿名用户主体 (仅User表) -func (l *DeleteAccountLogic) createAnonymousUser(tx *gorm.DB) (*user.User, error) { - // 1. 创建新用户 - userInfo := &user.User{ - Salt: "default", - OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase, - } - if err := tx.Create(userInfo).Error; err != nil { - l.Errorw("failed to create user", logger.Field("error", err.Error())) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err) - } - - // 2. 更新推荐码 - userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id) - if err := tx.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 nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err) - } - - return userInfo, nil + pipe := l.svcCtx.Redis.TxPipeline() + for sessionID := range sessionSet { + sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID) + pipe.Del(l.ctx, sessionKey) + pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID)) + pipe.ZRem(l.ctx, sessionsKey, sessionID) + } + pipe.Del(l.ctx, sessionsKey) + + for deviceKey := range deviceKeySet { + pipe.Del(l.ctx, deviceKey) + } + + if _, err = pipe.Exec(l.ctx); err != nil { + l.Errorw("清理会话缓存失败", logger.Field("user_id", userId), logger.Field("error", err.Error())) + } + + l.Infow("[SessionMonitor] 注销账号-清除所有Session", + logger.Field("user_id", userId), + logger.Field("count", len(sessionSet)), + ) } diff --git a/internal/logic/public/user/deleteAccountLogic_test.go b/internal/logic/public/user/deleteAccountLogic_test.go new file mode 100644 index 0000000..a531208 --- /dev/null +++ b/internal/logic/public/user/deleteAccountLogic_test.go @@ -0,0 +1,109 @@ +package user + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/perfect-panel/server/internal/svc" + "github.com/redis/go-redis/v9" +) + +func TestClearAllSessions_RemovesUserSessionsAndDeviceMappings(t *testing.T) { + logic, redisClient, cleanup := newDeleteAccountRedisTestLogic(t) + defer cleanup() + + mustRedisSet(t, redisClient, "auth:session_id:sid-user-1", "1001") + mustRedisSet(t, redisClient, "auth:session_id:sid-user-2", "1001") + mustRedisSet(t, redisClient, "auth:session_id:sid-other", "2002") + + mustRedisSet(t, redisClient, "auth:session_id:detail:sid-user-1", "detail") + mustRedisSet(t, redisClient, "auth:session_id:detail:sid-other", "detail") + + mustRedisSet(t, redisClient, "auth:device_identifier:dev-user-1", "sid-user-1") + mustRedisSet(t, redisClient, "auth:device_identifier:dev-user-2", "sid-user-2") + mustRedisSet(t, redisClient, "auth:device_identifier:dev-other", "sid-other") + + mustRedisZAdd(t, redisClient, "auth:user_sessions:1001", "sid-user-3", 1) + mustRedisSet(t, redisClient, "auth:session_id:sid-user-3", "1001") + + logic.clearAllSessions(1001) + + mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-1") + mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-2") + mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-3") + mustRedisNotExist(t, redisClient, "auth:session_id:detail:sid-user-1") + mustRedisNotExist(t, redisClient, "auth:user_sessions:1001") + mustRedisNotExist(t, redisClient, "auth:device_identifier:dev-user-1") + mustRedisNotExist(t, redisClient, "auth:device_identifier:dev-user-2") + + mustRedisExist(t, redisClient, "auth:session_id:sid-other") + mustRedisExist(t, redisClient, "auth:session_id:detail:sid-other") + mustRedisExist(t, redisClient, "auth:device_identifier:dev-other") +} + +func TestClearAllSessions_ScanFallbackWorksWithoutUserSessionIndex(t *testing.T) { + logic, redisClient, cleanup := newDeleteAccountRedisTestLogic(t) + defer cleanup() + + mustRedisSet(t, redisClient, "auth:session_id:sid-a", "3003") + mustRedisSet(t, redisClient, "auth:session_id:sid-b", "3003") + mustRedisSet(t, redisClient, "auth:session_id:sid-c", "4004") + + logic.clearAllSessions(3003) + + mustRedisNotExist(t, redisClient, "auth:session_id:sid-a") + mustRedisNotExist(t, redisClient, "auth:session_id:sid-b") + mustRedisExist(t, redisClient, "auth:session_id:sid-c") +} + +func newDeleteAccountRedisTestLogic(t *testing.T) (*DeleteAccountLogic, *redis.Client, func()) { + t.Helper() + + miniRedis := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()}) + logic := NewDeleteAccountLogic(context.Background(), &svc.ServiceContext{Redis: redisClient}) + + cleanup := func() { + _ = redisClient.Close() + miniRedis.Close() + } + return logic, redisClient, cleanup +} + +func mustRedisSet(t *testing.T, redisClient *redis.Client, key, value string) { + t.Helper() + if err := redisClient.Set(context.Background(), key, value, time.Hour).Err(); err != nil { + t.Fatalf("redis set %s failed: %v", key, err) + } +} + +func mustRedisZAdd(t *testing.T, redisClient *redis.Client, key, member string, score float64) { + t.Helper() + if err := redisClient.ZAdd(context.Background(), key, redis.Z{Member: member, Score: score}).Err(); err != nil { + t.Fatalf("redis zadd %s failed: %v", key, err) + } +} + +func mustRedisExist(t *testing.T, redisClient *redis.Client, key string) { + t.Helper() + exists, err := redisClient.Exists(context.Background(), key).Result() + if err != nil { + t.Fatalf("redis exists %s failed: %v", key, err) + } + if exists == 0 { + t.Fatalf("expected redis key %s to exist", key) + } +} + +func mustRedisNotExist(t *testing.T, redisClient *redis.Client, key string) { + t.Helper() + exists, err := redisClient.Exists(context.Background(), key).Result() + if err != nil { + t.Fatalf("redis exists %s failed: %v", key, err) + } + if exists != 0 { + t.Fatalf("expected redis key %s to be deleted", key) + } +} diff --git a/internal/middleware/deviceMiddleware.go b/internal/middleware/deviceMiddleware.go index f36e62b..935c1c6 100644 --- a/internal/middleware/deviceMiddleware.go +++ b/internal/middleware/deviceMiddleware.go @@ -228,6 +228,7 @@ func (rw *ResponseWriter) Decrypt() bool { return false } rw.c.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(decrypt))) + rw.c.Request.Header.Set("Content-Type", "application/json") rw.c.Set(ctxDecryptedBodyKey, decrypt) rw.c.Set(ctxDeviceDecryptStatusKey, "success") return true