hi-server/internal/logic/public/user/unbindDeviceLogic.go
shanshanzhong 26f6400e74
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m3s
feat: 苹果支付uuid 及设备逻辑
2026-03-10 19:53:19 -07:00

253 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package user
import (
"context"
"fmt"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"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/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type UnbindDeviceLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Unbind Device
func NewUnbindDeviceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UnbindDeviceLogic {
return &UnbindDeviceLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
userInfo, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
device, err := l.svcCtx.UserModel.FindOneDevice(l.ctx, req.Id)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DeviceNotExist), "find device")
}
isSelf := (device.UserId == userInfo.Id)
if !isSelf {
// Not own device — check if in the same family
if !l.isInSameFamily(userInfo.Id, device.UserId) {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "not in same family")
}
}
targetUser, err := l.svcCtx.UserModel.FindOne(l.ctx, device.UserId)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find target user")
}
// If kicking another user (not self), check if subscription transfer is needed
if !isSelf {
if err := l.transferSubscriptionsIfNeeded(userInfo, targetUser); err != nil {
return err
}
}
currentSessionID, _ := l.ctx.Value(constant.CtxKeySessionID).(string)
return l.logoutUnbind(targetUser, device, currentSessionID)
}
func (l *UnbindDeviceLogic) logoutUnbind(userInfo *user.User, device *user.Device, currentSessionID string) error {
// 1. 事务前查出 AuthMethods用于事务后清邮箱缓存
authMethods, _ := l.svcCtx.UserModel.FindUserAuthMethods(l.ctx, userInfo.Id)
// 2. 事务前收集受影响的家庭成员 ID事务会改变家庭关系之后查不到
familyMemberIDs := l.collectFamilyMemberIDs(userInfo.Id)
// 3. 事务:解绑家庭组 + 解绑非 device 的登录方式
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
// 解绑家庭组owner 解散整个家庭member 退出家庭)
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
if err := exitHelper.removeUserFromActiveFamily(tx, userInfo.Id, true); err != nil {
return err
}
// 解绑邮箱等非 device 类型的 auth_methods保留 device 绑定)
if err := tx.Where("user_id = ? AND auth_type != ?", userInfo.Id, "device").
Delete(&user.AuthMethods{}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete non-device auth methods failed")
}
return nil
})
if err != nil {
return err
}
// 4. 事务提交后立即清缓存(避免 KickDevice/clearAllSessions 触发重连时命中旧缓存)
for _, am := range authMethods {
if am.AuthType == "email" && am.AuthIdentifier != "" {
cacheKey := fmt.Sprintf("cache:user:email:%s", am.AuthIdentifier)
if delErr := l.svcCtx.Redis.Del(l.ctx, cacheKey).Err(); delErr != nil {
l.Errorw("clear email cache failed",
logger.Field("user_id", userInfo.Id),
logger.Field("email", am.AuthIdentifier),
logger.Field("error", delErr.Error()),
)
}
}
}
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); clearErr != nil {
l.Errorw("clear user cache failed",
logger.Field("user_id", userInfo.Id),
logger.Field("error", clearErr.Error()),
)
}
// 5. Kick 设备(缓存已清,重连时 FindOne 会查到最新数据)
l.svcCtx.DeviceManager.KickDevice(device.UserId, device.Identifier)
// 6. 清除该用户所有 session旧 token 全部失效)
l.clearAllSessions(userInfo.Id)
// 7. 清理受影响的家庭成员缓存(家庭解散后成员需感知变化)
for _, memberID := range familyMemberIDs {
if memberUser, findErr := l.svcCtx.UserModel.FindOne(l.ctx, memberID); findErr == nil {
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, memberUser); clearErr != nil {
l.Errorw("clear family member cache failed",
logger.Field("member_id", memberID),
logger.Field("error", clearErr.Error()),
)
}
}
}
return nil
}
// collectFamilyMemberIDs 收集当前用户所在家庭的其他成员 ID需在事务前调用
func (l *UnbindDeviceLogic) collectFamilyMemberIDs(userID int64) []int64 {
var result struct {
FamilyId int64
}
err := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.UserFamilyMember{}).
Select("family_id").
Where("user_id = ? AND status = ?", userID, user.FamilyMemberActive).
First(&result).Error
if err != nil || result.FamilyId == 0 {
return nil
}
var memberIDs []int64
l.svcCtx.DB.WithContext(l.ctx).
Model(&user.UserFamilyMember{}).
Where("family_id = ? AND status = ? AND user_id != ?", result.FamilyId, user.FamilyMemberActive, userID).
Pluck("user_id", &memberIDs)
return memberIDs
}
// clearAllSessions 清除指定用户的所有会话
func (l *UnbindDeviceLogic) clearAllSessions(userId int64) {
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
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
}
if len(sessions) == 0 {
return
}
pipe := l.svcCtx.Redis.TxPipeline()
for _, sessionID := range sessions {
if sessionID == "" {
continue
}
pipe.Del(l.ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
}
pipe.Del(l.ctx, sessionsKey)
if _, err = pipe.Exec(l.ctx); err != nil {
l.Errorw("清理会话缓存失败",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
}
l.Infow("退出登录-清除所有Session",
logger.Field("user_id", userId),
logger.Field("count", len(sessions)),
)
}
// isInSameFamily checks whether two users belong to the same active family
func (l *UnbindDeviceLogic) isInSameFamily(userID1, userID2 int64) bool {
var relation1 struct{ FamilyId int64 }
err := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.UserFamilyMember{}).
Select("family_id").
Where("user_id = ? AND status = ?", userID1, user.FamilyMemberActive).
First(&relation1).Error
if err != nil || relation1.FamilyId == 0 {
return false
}
var count int64
l.svcCtx.DB.WithContext(l.ctx).
Model(&user.UserFamilyMember{}).
Where("family_id = ? AND user_id = ? AND status = ?",
relation1.FamilyId, userID2, user.FamilyMemberActive).
Count(&count)
return count > 0
}
// transferSubscriptionsIfNeeded transfers subscriptions from the kicked user to the kicker
func (l *UnbindDeviceLogic) transferSubscriptionsIfNeeded(kicker *user.User, kicked *user.User) error {
// Query kicked user's subscriptions
var subscribes []user.Subscribe
if err := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Subscribe{}).
Where("user_id = ?", kicked.Id).
Find(&subscribes).Error; err != nil {
return nil // query error, skip transfer
}
if len(subscribes) == 0 {
return nil // no subscriptions to transfer
}
// Transfer subscriptions: UPDATE user_subscribe SET user_id = kicker WHERE user_id = kicked
if err := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Subscribe{}).
Where("user_id = ?", kicked.Id).
Update("user_id", kicker.Id).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "transfer subscriptions failed")
}
// Clear subscription caches for both users
for _, sub := range subscribes {
_ = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, &sub)
}
l.Infow("subscriptions transferred",
logger.Field("from_user_id", kicked.Id),
logger.Field("to_user_id", kicker.Id),
logger.Field("count", len(subscribes)),
)
return nil
}