package user import ( "context" "time" modelUser "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" "gorm.io/gorm" "gorm.io/gorm/clause" ) type accountMergeResult struct { OwnerUserID int64 DeviceUserID int64 MovedDevices []modelUser.Device RemovedSubscribes []modelUser.Subscribe } type accountMergeHelper struct { ctx context.Context svcCtx *svc.ServiceContext } func newAccountMergeHelper(ctx context.Context, svcCtx *svc.ServiceContext) *accountMergeHelper { return &accountMergeHelper{ ctx: ctx, svcCtx: svcCtx, } } func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, source string) (*accountMergeResult, error) { if ownerUserID == 0 || deviceUserID == 0 { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "merge user id is empty") } if ownerUserID == deviceUserID { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "cannot merge same user id") } result := &accountMergeResult{ OwnerUserID: ownerUserID, DeviceUserID: deviceUserID, } // Capture device user's auth methods BEFORE the transaction migrates them deviceAuthMethods, _ := h.svcCtx.UserModel.FindUserAuthMethods(h.ctx, deviceUserID) err := h.svcCtx.DB.WithContext(h.ctx).Transaction(func(tx *gorm.DB) error { var owner modelUser.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", ownerUserID). First(&owner).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "owner user %d not found", ownerUserID) } return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner user failed") } var device modelUser.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", deviceUserID). First(&device).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "device user %d not found", deviceUserID) } return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device user failed") } exitHelper := newFamilyExitHelper(h.ctx, h.svcCtx) if err := exitHelper.removeUserFromActiveFamily(tx, deviceUserID, false); err != nil { return err } removedSubscribes, err := clearMemberSubscribes(tx, deviceUserID) if err != nil { return err } result.RemovedSubscribes = removedSubscribes var devices []modelUser.Device if err := tx.Where("user_id = ?", deviceUserID).Find(&devices).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device list failed") } if len(devices) > 0 { if err := tx.Model(&modelUser.Device{}). Where("user_id = ?", deviceUserID). Updates(map[string]interface{}{ "user_id": ownerUserID, "updated_at": time.Now(), }).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device ownership failed") } } result.MovedDevices = devices if err := tx.Model(&modelUser.AuthMethods{}). Where("user_id = ?", deviceUserID). Update("user_id", ownerUserID).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "migrate auth methods failed") } if err := tx.Model(&modelUser.User{}). Where("id = ?", deviceUserID). Update("enable", false).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "disable device user failed") } if err := tx.Where("id = ?", deviceUserID).Delete(&modelUser.User{}).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "soft delete device user failed") } return nil }) if err != nil { return nil, err } if err := h.clearCaches(result, deviceAuthMethods); err != nil { return nil, err } logger.WithContext(h.ctx).Infow("device account merged into owner", logger.Field("owner_user_id", ownerUserID), logger.Field("device_user_id", deviceUserID), logger.Field("moved_devices", len(result.MovedDevices)), logger.Field("removed_subscribes", len(result.RemovedSubscribes)), logger.Field("source", source), ) return result, nil } func (h *accountMergeHelper) clearCaches(result *accountMergeResult, deviceAuthMethods []*modelUser.AuthMethods) error { if result == nil { return nil } // Fetch owner user with AuthMethods for proper cache key generation var users []*modelUser.User if u, err := h.svcCtx.UserModel.FindOne(h.ctx, result.OwnerUserID); err == nil { users = append(users, u) } // For device user, FindOne won't have AuthMethods anymore (migrated in tx), // so we build a minimal User with the pre-captured auth methods deviceUser := &modelUser.User{Id: result.DeviceUserID} if len(deviceAuthMethods) > 0 { authMethods := make([]modelUser.AuthMethods, len(deviceAuthMethods)) for i, am := range deviceAuthMethods { authMethods[i] = *am } deviceUser.AuthMethods = authMethods } users = append(users, deviceUser) if len(users) > 0 { if err := h.svcCtx.UserModel.ClearUserCache(h.ctx, users...); err != nil { return err } } if len(result.MovedDevices) > 0 { deviceModels := make([]*modelUser.Device, 0, len(result.MovedDevices)) for i := range result.MovedDevices { device := result.MovedDevices[i] deviceModels = append(deviceModels, &device) } if err := h.svcCtx.UserModel.ClearDeviceCache(h.ctx, deviceModels...); err != nil { return err } } if len(result.RemovedSubscribes) > 0 { familyHelper := newFamilyBindingHelper(h.ctx, h.svcCtx) if err := familyHelper.clearRemovedMemberSubscribeCache(result.RemovedSubscribes); err != nil { return err } } return nil }