OpenIM 源码深度解析系列(三):多设备登录剔除跟感知机制解析

670 阅读25分钟

OpenIM 源码深度解析系列(三):设备用户登录多设备策略深度解析

概述

OpenIM系统在设备用户登录时实现了复杂的多设备策略管理机制,支持不同的登录策略来控制用户在多个设备上的登录行为。本文档前半段从设备端用户登录流程中的auth GetUserToken开始,深度解析整个Token创建、验证、剔除的完整链路。后半段主要分析系统通过三种机制确保被剔除设备能够及时感知到状态变更。

前置阅读: 登录的其他环节请参考前一篇文章《OpenIM 源码深度解析系列(二):双Token认证机制与接入流程》

登录流程架构图

sequenceDiagram
    participant NewDevice as 新设备
    participant OldDevice as 老设备
    participant SDK as open-im-sdk
    participant ChatAPI as Chat User API
    participant ChatRPC as Chat User RPC
    participant AuthRPC as OpenIM Auth RPC
    participant Redis as Redis缓存
    participant TokenCache as Token缓存
    participant WSGateway as 消息网关

    Note over NewDevice,WSGateway: 设备用户登录多设备策略与剔除感知完整流程

    %% 阶段1:新设备登录Token创建
    rect rgb(240, 248, 255)
        Note over NewDevice,TokenCache: 阶段1:新设备登录Token创建
        NewDevice->>SDK: 发起登录请求
        SDK->>ChatAPI: 调用登录接口
        ChatAPI->>ChatRPC: 转发RPC调用
        ChatRPC->>AuthRPC: GetUserToken(userID, platformID)
        
        Note over AuthRPC: 1. 权限验证与用户检查
        AuthRPC->>AuthRPC: CheckAdmin() & GetUserInfo()
        
        Note over AuthRPC: 2. 调用CreateToken创建令牌
        AuthRPC->>TokenCache: GetAllTokensWithoutError(userID)
        TokenCache->>Redis: 获取所有平台Token映射
        Redis-->>TokenCache: 返回Token状态映射
        TokenCache-->>AuthRPC: 返回所有平台Token
        
        Note over AuthRPC: 3. 执行多设备策略检查
        AuthRPC->>AuthRPC: checkToken(tokens, platformID)
        
        Note over AuthRPC: 4. 清理过期和冲突Token
        AuthRPC->>TokenCache: DeleteTokenByUidPid(deleteTokens)
        AuthRPC->>TokenCache: SetTokenFlagEx(kickedTokens, KICKED)
        
        Note over AuthRPC: 5. 生成新JWT Token
        AuthRPC->>AuthRPC: JWT.SignedString(claims)
        
        Note over AuthRPC: 6. 存储新Token到缓存
        AuthRPC->>TokenCache: SetTokenFlagEx(newToken, NORMAL)
        TokenCache->>Redis: 保存Token状态
        
        AuthRPC-->>ChatRPC: 返回新Token
        ChatRPC-->>ChatAPI: 返回登录结果
        ChatAPI-->>SDK: 返回Token
        SDK-->>NewDevice: 登录成功,获得新Token
    end

    %% 阶段2:新设备建立WebSocket连接触发剔除感知
    rect rgb(255, 248, 240)
        Note over NewDevice,WSGateway: 阶段2:新设备WebSocket连接建立与主动踢除通知
        NewDevice->>WSGateway: 建立WebSocket连接(新Token)
        WSGateway->>AuthRPC: ParseToken验证新Token
        AuthRPC-->>WSGateway: Token验证通过
        
        Note over WSGateway: 触发registerClient事件
        WSGateway->>WSGateway: 检查本地user_map缓存
        WSGateway->>WSGateway: 执行多端登录策略检查
        
        Note over WSGateway: 发现老设备冲突,主动踢除
        WSGateway->>OldDevice: 发送KickOnlineMessage
        Note over OldDevice: 实时感知:收到踢除消息
        
        Note over WSGateway: 跨节点踢除协调
        WSGateway->>WSGateway: sendUserOnlineInfoToOtherNode
        WSGateway->>WSGateway: MultiTerminalLoginCheck其他节点
    end

    %% 阶段3:老设备的三种感知方式
    rect rgb(248, 255, 248)
        Note over OldDevice,Redis: 阶段3:老设备多重感知机制
        
        %% 感知方式1:API调用验证失败
        Note over OldDevice: 感知方式1:API调用验证
        OldDevice->>ChatAPI: 发起API请求(老Token)
        ChatAPI->>AuthRPC: ParseToken(老Token)
        AuthRPC->>Redis: 检查Token状态
        Redis-->>AuthRPC: Token状态=KICKED(1)
        AuthRPC-->>ChatAPI: ErrTokenKicked错误
        ChatAPI-->>OldDevice: 返回被踢错误
        Note over OldDevice: 延迟感知:API调用时发现被踢
        
        %% 感知方式2:WebSocket连接失败
        Note over OldDevice: 感知方式2:WebSocket连接检测
        OldDevice->>WSGateway: WebSocket连接(老Token)
        WSGateway->>AuthRPC: ParseToken(老Token)
        AuthRPC->>Redis: 检查Token状态
        Redis-->>AuthRPC: Token状态=KICKED(1)
        AuthRPC-->>WSGateway: ErrTokenKicked错误
        WSGateway->>OldDevice: 断开WebSocket连接
        Note over OldDevice: 延迟感知:连接检测时发现被踢
    end

完整流程说明

上述架构图展示了从新设备登录到老设备感知被踢除的完整流程,分为三个关键阶段:

阶段1:新设备登录Token创建(蓝色区域)
  • Token生成过程:新设备通过Chat API获取Token,Auth服务执行多设备策略检查
  • Redis状态更新:冲突的老Token被标记为KICKED状态,新Token被标记为NORMAL状态
  • 关键点:此时老设备的Token已在Redis中被标记为被踢状态,但老设备尚未感知
阶段2:新设备WebSocket连接与主动踢除(橙色区域)
  • 连接建立:新设备使用新Token建立WebSocket连接,触发registerClient事件
  • 本地检查:消息网关检查本地user_map缓存,发现老设备连接冲突
  • 主动通知:立即向老设备发送KickOnlineMessage,实现实时踢除通知
  • 跨节点协调:通知其他消息网关节点执行相同的踢除检查
阶段3:老设备多重感知机制(绿色区域)
  • 感知方式1:老设备发起API调用时,Token验证失败,感知到被踢除
  • 感知方式2:老设备WebSocket连接检测时,Token验证失败,连接被断开
  • 感知方式3:新设备连接时,老设备直接收到KickOnlineMessage(最快感知)

这种设计确保了:

  1. 实时性:主动踢除消息提供最快的感知机制
  2. 可靠性:API和连接验证提供兜底的感知机制
  3. 一致性:多种感知方式确保设备最终一定能感知到状态变更

核心流程详解

1. GetUserToken - 用户Token获取入口

文件位置: open-im-server/internal/rpc/auth/auth.go

// GetUserToken 获取普通用户Token
// 为普通用户生成访问令牌,仅限管理员调用,用于代理用户操作或系统集成。
//
// 权限验证:
// 1. 管理员权限:只有管理员才能为其他用户生成Token
// 2. 平台限制:不能为管理员平台生成Token
// 3. 用户类型检查:不能为系统管理员用户生成普通Token
// 4. 应用账号限制:应用级账号不能获取Token
//
// 应用场景:
// - 系统集成:第三方系统代理用户操作
// - 客户端登录:移动端/Web端用户登录
// - 测试环境:测试时模拟用户登录
// - 客服系统:客服代理用户进行操作
func (s *authServer) GetUserToken(ctx context.Context, req *pbauth.GetUserTokenReq) (*pbauth.GetUserTokenResp, error) {
	// 验证调用者是否为管理员(Chat系统调用时会传入管理员Token)
	if err := authverify.CheckAdmin(ctx, s.config.Share.IMAdminUserID); err != nil {
		return nil, err
	}

	// 禁止为管理员平台生成Token
	if req.PlatformID == constant.AdminPlatformID {
		return nil, errs.ErrNoPermission.WrapMsg("platformID invalid. platformID must not be adminPlatformID")
	}

	resp := pbauth.GetUserTokenResp{}

	// 禁止为管理员用户生成普通Token
	if authverify.IsManagerUserID(req.UserID, s.config.Share.IMAdminUserID) {
		return nil, errs.ErrNoPermission.WrapMsg("don't get Admin token")
	}

	// 获取用户信息并验证用户存在性
	user, err := s.userClient.GetUserInfo(ctx, req.UserID)
	if err != nil {
		return nil, err
	}

	// 应用级账号(如通知账号)不能获取普通用户Token
	if user.AppMangerLevel >= constant.AppNotificationAdmin {
		return nil, errs.ErrArgs.WrapMsg("app account can`t get token")
	}

	// 核心:创建用户Token,包含多设备策略处理
	token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(req.PlatformID))
	if err != nil {
		return nil, err
	}

	resp.Token = token
	resp.ExpireTimeSeconds = s.config.RpcConfig.TokenPolicy.Expire * 24 * 60 * 60
	return &resp, nil
}

2. CreateToken - 核心Token创建逻辑

文件位置: open-im-server/pkg/common/storage/controller/auth.go

// CreateToken 创建用户认证Token
// 这是认证系统的核心方法,处理Token生成、多端登录控制、Token踢下线等复杂逻辑
//
// 主要功能:
// 1. 多端登录策略检查:根据配置决定是否踢掉其他设备的Token
// 2. Token生成:使用JWT生成包含用户信息的Token
// 3. 缓存管理:将新Token状态保存到Redis缓存
// 4. 管理员特权:管理员用户不受多端登录限制
//
// 多端登录策略说明:
// - DefalutNotKick: 默认不踢人,但有数量限制
// - AllLoginButSameTermKick: 允许多端登录但同类型设备互踢
// - PCAndOther: PC和其他设备可以共存
// - AllLoginButSameClassKick: 同类设备互踢
//
// userID: 用户唯一标识
// platformID: 平台ID(iOS=1, Android=2, Windows=3, Web=4等)
// 返回: 生成的JWT Token字符串和错误信息
func (a *authDatabase) CreateToken(ctx context.Context, userID string, platformID int) (string, error) {
	// 检查是否为管理员用户
	// 管理员用户不受多端登录策略限制,可以无限制登录
	isAdmin := authverify.IsManagerUserID(userID, a.adminUserIDs)

	if !isAdmin {
		// 非管理员用户需要进行多端登录控制
		// 获取用户所有平台的现有Token
		tokens, err := a.cache.GetAllTokensWithoutError(ctx, userID)
		if err != nil {
			return "", err
		}

		// 执行多端登录策略检查
		// deleteTokenKey: 需要删除的无效Token
		// kickedTokenKey: 需要踢下线的Token
		deleteTokenKey, kickedTokenKey, err := a.checkToken(ctx, tokens, platformID)
		if err != nil {
			return "", err
		}

		// 删除无效Token(过期、格式错误等)
		if len(deleteTokenKey) != 0 {
			err = a.cache.DeleteTokenByUidPid(ctx, userID, platformID, deleteTokenKey)
			if err != nil {
				return "", err
			}
		}

		// 踢下线冲突Token(根据多端登录策略)
		if len(kickedTokenKey) != 0 {
			for _, k := range kickedTokenKey {
				err := a.cache.SetTokenFlagEx(ctx, userID, platformID, k, constant.KickedToken)
				if err != nil {
					return "", err
				}
				log.ZDebug(ctx, "kicked token in create token", "token", k)
			}
		}
	}

	// 生成新的JWT Token
	// 创建Token声明,包含用户ID、平台ID、过期时间等信息
	claims := tokenverify.BuildClaims(userID, platformID, a.accessExpire)

	// 使用HS256算法签名Token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString([]byte(a.accessSecret))
	if err != nil {
		return "", errs.WrapMsg(err, "token.SignedString")
	}

	// 非管理员用户需要将Token状态保存到缓存
	if !isAdmin {
		if err = a.cache.SetTokenFlagEx(ctx, userID, platformID, tokenString, constant.NormalToken); err != nil {
			return "", err
		}
	}

	return tokenString, nil
}

3. GetAllTokensWithoutError - 获取用户所有平台Token

文件位置: open-im-server/pkg/common/storage/cache/redis/token.go

// GetAllTokensWithoutError 获取用户在所有平台的Token映射
// 这个方法会获取用户在所有注册平台上的Token状态,用于多设备登录策略判断
//
// 返回数据结构:map[platformID]map[token]status
// - platformID: 平台ID(1=iOS, 2=Android, 3=Windows, 4=Web等)
// - token: JWT Token字符串
// - status: Token状态(0=正常, 1=被踢, 2=过期等)
//
// Redis存储结构:
// - Key: "UID_PID_TOKEN_STATUS:{userID}:{platformID}"
// - Value: Hash {token1: status1, token2: status2, ...}
//
// 性能优化:
// - 使用Pipeline批量获取减少网络往返
// - 按Redis Slot分组处理提高并发性能
// - 使用协程安全的结果聚合
func (c *tokenCache) GetAllTokensWithoutError(ctx context.Context, userID string) (map[int]map[string]int, error) {
	var (
		res     = make(map[int]map[string]int) // 结果映射表
		resLock = sync.Mutex{}                // 并发安全锁
	)

	// 构建用户所有平台的Redis Key列表
	// 支持的平台:iOS、Android、Windows、OSX、Web、小程序、Linux、Ubuntu、AndroidPad、iPad
	keys := cachekey.GetAllPlatformTokenKey(userID)
	
	// 使用Redis Slot优化批量查询性能
	// ProcessKeysBySlot将keys按照Redis Slot分组,提高集群环境下的查询效率
	if err := ProcessKeysBySlot(ctx, c.rdb, keys, func(ctx context.Context, slot int64, keys []string) error {
		// 创建Pipeline批量执行命令
		pipe := c.rdb.Pipeline()
		mapRes := make([]*redis.MapStringStringCmd, len(keys))
		
		// 批量添加HGETALL命令到Pipeline
		for i, key := range keys {
			mapRes[i] = pipe.HGetAll(ctx, key)
		}
		
		// 执行Pipeline中的所有命令
		_, err := pipe.Exec(ctx)
		if err != nil {
			return err
		}
		
		// 处理Pipeline执行结果
		for i, m := range mapRes {
			mm := make(map[string]int)
			
			// 将Redis Hash结果转换为map[string]int格式
			for k, v := range m.Val() {
				state, err := strconv.Atoi(v)
				if err != nil {
					return errs.WrapMsg(err, "redis token value is not int", "value", v, "userID", userID)
				}
				mm[k] = state
			}
			
			// 线程安全地添加到结果映射中
			resLock.Lock()
			res[cachekey.GetPlatformIDByTokenKey(keys[i])] = mm
			resLock.Unlock()
		}
		return nil
	}); err != nil {
		return nil, err
	}
	
	return res, nil
}

4. checkToken - 多设备登录策略核心逻辑

文件位置: open-im-server/pkg/common/storage/controller/auth.go

// checkToken 多端登录策略检查
// 根据配置的多端登录策略,决定哪些Token需要删除或踢下线
//
// 策略类型详解:
// 1. DefalutNotKick (0): 默认不踢人策略
//    - 每个平台最多允许MaxNumOneEnd个Token
//    - 超出限制时踢掉最旧的Token
//    - 适用于宽松的多设备使用场景
//
// 2. AllLoginButSameTermKick (1): 同终端类型互踢策略
//    - 不同终端类型可以共存(如iOS + Windows)
//    - 相同终端类型只保留最新登录(如多个iOS设备互踢)
//    - 适用于个人用户的多平台使用
//
// 3. PCAndOther (2): PC和其他设备共存策略
//    - PC端设备可以与一个移动端设备共存
//    - 移动端设备之间互踢
//    - 适用于办公场景(PC + 手机)
//
// 4. AllLoginButSameClassKick (3): 同类设备互踢策略
//    - 按设备类别分组(Mobile、PC、Web等)
//    - 每个类别只保留一个设备
//    - 适用于企业安全要求较高的场景
//
// tokens: 用户现有的所有Token映射 map[platformID]map[token]status
// platformID: 当前登录的平台ID
// 返回: 需要删除的Token列表、需要踢下线的Token列表、错误信息
func (a *authDatabase) checkToken(ctx context.Context, tokens map[int]map[string]int, platformID int) ([]string, []string, error) {
	var (
		loginTokenMap  = make(map[int][]string) // 有效登录Token映射 map[platformID][]token
		deleteToken    = make([]string, 0)      // 需要删除的无效Token列表
		kickToken      = make([]string, 0)      // 需要踢下线的Token列表
		adminToken     = make([]string, 0)      // 管理员Token列表
		unkickTerminal = ""                     // 不踢下线的终端类型
	)

	// 第一阶段:分类现有Token,清理无效Token
	// 遍历所有平台的Token,验证有效性并按类型分类
	for plfID, tks := range tokens {
		for k, v := range tks {
			// 验证Token有效性(JWT格式、签名、过期时间等)
			_, err := tokenverify.GetClaimFromToken(k, authverify.Secret(a.accessSecret))
			if err != nil || v != constant.NormalToken {
				// Token无效或状态异常,加入删除列表
				// 无效原因:JWT格式错误、签名无效、已过期、状态非正常等
				deleteToken = append(deleteToken, k)
			} else {
				// Token有效,按平台类型分类
				if plfID != constant.AdminPlatformID {
					// 普通平台Token
					loginTokenMap[plfID] = append(loginTokenMap[plfID], k)
				} else {
					// 管理员平台Token(目前不参与踢下线逻辑)
					adminToken = append(adminToken, k)
				}
			}
		}
	}

	// 第二阶段:根据多端登录策略执行踢下线逻辑
	switch a.multiLogin.Policy {
	case constant.DefalutNotKick:
		// 策略1:默认不踢人,但有数量限制
		// 每个平台维护独立的Token数量限制
		for plt, ts := range loginTokenMap {
			l := len(ts)
			if platformID == plt {
				l++ // 当前登录会增加一个Token
			}
			limit := a.multiLogin.MaxNumOneEnd
			if l > limit {
				// 超出限制,踢掉最旧的Token(FIFO策略)
				// 假设Redis中的Token顺序反映了登录时间顺序
				kickToken = append(kickToken, ts[:l-limit]...)
			}
		}

	case constant.AllLoginButSameTermKick:
		// 策略2:同终端类型互踢
		// 相同平台只保留最新的一个Token,其他全部踢下线
		for plt, ts := range loginTokenMap {
			if len(ts) > 1 {
				// 保留最新Token(数组最后一个),踢掉其他
				kickToken = append(kickToken, ts[:len(ts)-1]...)
			}
			
			if plt == platformID {
				// 如果当前登录平台已有Token,也要踢掉最新的
				// 为新登录让路
				kickToken = append(kickToken, ts[len(ts)-1])
			}
		}

	case constant.PCAndOther:
		// 策略3:PC和其他设备共存
		// PC端可以与一个其他端设备共存,其他端之间互踢
		unkickTerminal = constant.TerminalPC
		
		if constant.PlatformIDToClass(platformID) != unkickTerminal {
			// 当前登录的是非PC端
			// 踢掉所有其他非PC端Token
			for plt, ts := range loginTokenMap {
				if constant.PlatformIDToClass(plt) != unkickTerminal {
					kickToken = append(kickToken, ts...)
				}
			}
		} else {
			// 当前登录的是PC端
			// 复杂的共存逻辑:PC可以与一个其他端共存
			var (
				preKick   []string // 预踢列表
				isReserve = true   // 是否还可以保留其他端
			)
			
			for plt, ts := range loginTokenMap {
				if constant.PlatformIDToClass(plt) != unkickTerminal {
					// 处理非PC端Token
					if isReserve {
						// 第一个非PC端:保留最新的一个,踢掉其他
						isReserve = false
						kickToken = append(kickToken, ts[:len(ts)-1]...)
						preKick = append(preKick, ts[len(ts)-1])
						continue
					} else {
						// 后续非PC端:根据平台优先级决定
						if plt == constant.AndroidPlatformID {
							// Android优先级高,踢掉之前保留的,保留Android
							kickToken = append(kickToken, preKick...)
							kickToken = append(kickToken, ts[:len(ts)-1]...)
							// 更新预踢列表为当前Android最新Token
							preKick = []string{ts[len(ts)-1]}
						} else {
							// 其他平台优先级低,全部踢掉
							kickToken = append(kickToken, ts...)
						}
					}
				}
			}
		}

	case constant.AllLoginButSameClassKick:
		// 策略4:同类设备互踢
		// 按设备类别(Mobile、PC、Web等)分组,每类只保留一个
		var (
			reserved = make(map[string]struct{}) // 已保留的设备类别
		)

		for plt, ts := range loginTokenMap {
			currentClass := constant.PlatformIDToClass(plt)
			loginClass := constant.PlatformIDToClass(platformID)
			
			if currentClass == loginClass {
				// 与当前登录设备同类别,全部踢掉为新登录让路
				kickToken = append(kickToken, ts...)
			} else {
				// 不同类别设备
				if _, ok := reserved[currentClass]; !ok {
					// 该类别还未保留设备,保留最新的一个
					reserved[currentClass] = struct{}{}
					kickToken = append(kickToken, ts[:len(ts)-1]...)
				} else {
					// 该类别已有保留设备,全部踢掉
					kickToken = append(kickToken, ts...)
				}
			}
		}

	default:
		return nil, nil, errs.New("unknown multiLogin policy").Wrap()
	}

	return deleteToken, kickToken, nil
}

5. DeleteTokenByUidPid - 删除无效Token

文件位置: open-im-server/pkg/common/storage/cache/redis/token.go

// DeleteTokenByUidPid 删除用户指定平台的无效Token
// 从Redis Hash中删除指定的Token字段,用于清理过期、格式错误等无效Token
//
// Redis操作:HDEL key field1 field2 ...
// - key: "UID_PID_TOKEN_STATUS:{userID}:{platformID}"
// - fields: 需要删除的Token列表
//
// 使用场景:
// - 清理过期Token:JWT过期时间已到
// - 清理格式错误Token:JWT解析失败的Token
// - 清理状态异常Token:状态不是正常状态的Token
//
// userID: 用户ID
// platformID: 平台ID
// fields: 需要删除的Token字段列表
// 返回: 错误信息
func (c *tokenCache) DeleteTokenByUidPid(ctx context.Context, userID string, platformID int, fields []string) error {
	// 构建Redis Hash Key
	key := cachekey.GetTokenKey(userID, platformID)
	
	// 执行HDEL命令删除指定Token字段
	// HDEL key field1 field2 ... fieldN
	return errs.Wrap(c.rdb.HDel(ctx, key, fields...).Err())
}

6. SetTokenFlagEx - 设置Token状态并更新过期时间

文件位置: open-im-server/pkg/common/storage/cache/redis/token.go

// SetTokenFlagEx 设置Token状态并更新过期时间
// 这是Token状态管理的核心方法,支持设置Token状态并自动更新过期时间
//
// Token状态类型:
// - constant.NormalToken (0): 正常状态,可以正常使用
// - constant.KickedToken (1): 被踢下线状态,禁止使用
// - constant.ExpiredToken (2): 过期状态(系统自动设置)
//
// Redis操作序列:
// 1. HSET key token status - 设置Token状态
// 2. EXPIRE key seconds - 更新Key过期时间
//
// 原子性保证:
// - 虽然是两个Redis命令,但在单线程Redis中具有顺序一致性
// - 如果EXPIRE失败,Token状态已设置但Key可能永不过期(需要监控)
//
// userID: 用户ID
// platformID: 平台ID
// token: JWT Token字符串
// flag: Token状态标志
// 返回: 错误信息
func (c *tokenCache) SetTokenFlagEx(ctx context.Context, userID string, platformID int, token string, flag int) error {
	// 构建Redis Hash Key: "UID_PID_TOKEN_STATUS:{userID}:{platformID}"
	key := cachekey.GetTokenKey(userID, platformID)
	
	// 第一步:设置Token状态到Hash中
	// HSET key token flag
	if err := c.rdb.HSet(ctx, key, token, flag).Err(); err != nil {
		return errs.Wrap(err)
	}
	
	// 第二步:更新整个Key的过期时间
	// EXPIRE key seconds
	// accessExpire = tokenExpireDays * 24 * 3600 seconds
	if err := c.rdb.Expire(ctx, key, c.accessExpire).Err(); err != nil {
		return errs.Wrap(err)
	}
	
	return nil
}

// getExpireTime 计算Token过期时间
// 将配置的天数转换为time.Duration格式
// t: 过期天数
// 返回: time.Duration格式的过期时间
func (c *tokenCache) getExpireTime(t int64) time.Duration {
	return time.Hour * 24 * time.Duration(t)
}

多设备登录策略详解

策略对比表

策略类型策略值适用场景踢下线规则设备限制
DefalutNotKick0宽松多设备使用超出数量限制踢最旧每平台最多N个
AllLoginButSameTermKick1个人多平台使用同平台互踢每平台最多1个
PCAndOther2办公场景PC+1个其他端共存PC无限制,其他端1个
AllLoginButSameClassKick3企业安全场景同类设备互踢每类设备最多1个

平台ID与设备类别映射

// 平台ID定义
const (
    IOSPlatformID     = 1  // iOS设备
    AndroidPlatformID = 2  // Android设备
    WindowsPlatformID = 3  // Windows PC
    OSXPlatformID     = 4  // macOS
    WebPlatformID     = 5  // Web浏览器
    MiniWebPlatformID = 6  // 小程序
    LinuxPlatformID   = 7  // Linux
    UbuntuPlatformID  = 8  // Ubuntu
    AndroidPadPlatformID = 9  // Android平板
    IPadPlatformID    = 10 // iPad
    AdminPlatformID   = 200 // 管理员平台
)

// 设备类别分组
// Mobile类: iOS, Android, AndroidPad, iPad
// PC类: Windows, OSX, Linux, Ubuntu  
// Web类: Web, MiniWeb

Redis存储结构

# Token状态存储结构
Key: "UID_PID_TOKEN_STATUS:{userID}:{platformID}"
Type: Hash
Fields:
  "eyJhbGciOiJIUzI1NiIs..." => 0  # JWT Token => 状态值
  "eyJhbGciOiJIUzI1NiJt..." => 1  # 另一个Token => 被踢状态
TTL: 7天 (可配置)

# 示例:用户user123在Android平台的Token
Key: "UID_PID_TOKEN_STATUS:user123:2"
Value: {
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." => 0,
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ8..." => 1
}

# 状态值含义
0 = NormalToken   # 正常可用
1 = KickedToken   # 被踢下线
2 = ExpiredToken  # 已过期

设备剔除感知机制深度解析

在多设备登录策略执行完毕,新Token创建并保存到Redis后,被剔除的老设备如何感知到自己被踢下线是一个关键问题。OpenIM通过三种机制确保被剔除设备能够及时感知到状态变更。

剔除感知流程架构图

sequenceDiagram
    participant NewDevice as 新设备
    participant OldDevice as 老设备  
    participant WSGateway1 as 消息网关节点1
    participant WSGateway2 as 消息网关节点2
    participant AuthRPC as Auth RPC
    participant Redis as Redis缓存

    Note over NewDevice,Redis: 设备剔除感知机制完整流程

    %% 场景1:老设备主动API调用感知
    Note over OldDevice,Redis: 感知方式1:API调用时Token验证失败
    OldDevice->>WSGateway1: 发起API请求 (老Token)
    WSGateway1->>AuthRPC: ParseToken(老Token)
    AuthRPC->>Redis: 检查Token状态
    Redis-->>AuthRPC: Token状态=KICKED(1)
    AuthRPC-->>WSGateway1: ErrTokenKicked错误
    WSGateway1-->>OldDevice: 返回Token被踢错误
    Note over OldDevice: 感知到被踢下线

    %% 场景2:老设备WebSocket连接感知
    Note over OldDevice,Redis: 感知方式2:WebSocket连接检测失败  
    OldDevice->>WSGateway1: WebSocket连接 (老Token)
    WSGateway1->>AuthRPC: ParseToken(老Token)
    AuthRPC->>Redis: 检查Token状态
    Redis-->>AuthRPC: Token状态=KICKED(1)
    AuthRPC-->>WSGateway1: ErrTokenKicked错误
    WSGateway1->>OldDevice: 断开WebSocket连接
    Note over OldDevice: 连接断开,感知被踢

    %% 场景3:新设备连接触发的主动通知
    Note over NewDevice,Redis: 感知方式3:新设备连接触发主动踢除
    NewDevice->>WSGateway2: 建立WebSocket连接 (新Token)
    WSGateway2->>WSGateway2: 验证Token成功,触发注册事件
    Note over WSGateway2: registerClient处理新连接
    WSGateway2->>WSGateway2: 检查本地user_map缓存
    WSGateway2->>WSGateway2: 执行多端登录策略检查
    WSGateway2->>OldDevice: 发送KickOnlineMessage
    Note over OldDevice: 主动收到踢除消息

    %% 跨节点踢除通知
    Note over WSGateway2,WSGateway1: 跨节点踢除协调
    WSGateway2->>WSGateway1: sendUserOnlineInfoToOtherNode
    WSGateway1->>WSGateway1: MultiTerminalLoginCheck
    WSGateway1->>WSGateway1: SetKickHandlerInfo
    WSGateway1->>OldDevice: 发送KickOnlineMessage
    Note over OldDevice: 收到跨节点踢除消息

感知机制详细分析

感知方式1:API调用时Token验证失败

当被踢除的设备发起任何API调用时,都会通过Token验证机制感知到被踢除状态。

流程入口: open-im-server/internal/api/router.go

// GinParseToken Token解析中间件
// 在每个API请求中验证Token的有效性,被踢Token会在此时被检测到
//
// 验证流程:
// 1. 从HTTP Header提取Token
// 2. 调用Auth服务的ParseToken接口
// 3. 如果Token被踢,返回ErrTokenKicked错误
// 4. 客户端收到错误码,感知到被踢下线
func GinParseToken(authClient *rpcli.AuthClient) gin.HandlerFunc {
	return func(c *gin.Context) {
		switch c.Request.Method {
		case http.MethodPost:
			// 检查是否为白名单API
			for _, wApi := range Whitelist {
				if strings.HasPrefix(c.Request.URL.Path, wApi) {
					c.Next()
					return
				}
			}

			// 从请求头获取Token
			token := c.Request.Header.Get(constant.Token)
			if token == "" {
				log.ZWarn(c, "header get token error", servererrs.ErrArgs.WrapMsg("header must have token"))
				apiresp.GinError(c, servererrs.ErrArgs.WrapMsg("header must have token"))
				c.Abort()
				return
			}

			// 关键:调用Auth服务验证Token
			// 被踢的Token在此处会返回ErrTokenKicked错误
			resp, err := authClient.ParseToken(c, token)
			if err != nil {
				// 客户端收到此错误,感知到Token失效
				apiresp.GinError(c, err)
				c.Abort()
				return
			}

			// Token有效,设置用户上下文
			c.Set(constant.OpUserPlatform, constant.PlatformIDToName(int(resp.PlatformID)))
			c.Set(constant.OpUserID, resp.UserID)
			c.Next()
		}
	}
}

核心验证逻辑: open-im-server/internal/rpc/auth/auth.go

// parseToken 解析并验证Token
// 这里是Token状态检查的核心逻辑,被踢Token会在此处被识别
//
// 检查逻辑:
// 1. 解析JWT获取Claims信息
// 2. 管理员Token跳过Redis状态检查  
// 3. 普通Token需要从Redis检查状态
// 4. 根据状态返回相应错误码
func (s *authServer) parseToken(ctx context.Context, tokensString string) (claims *tokenverify.Claims, err error) {
	// 第一步:解析JWT Token结构
	claims, err = tokenverify.GetClaimFromToken(tokensString, authverify.Secret(s.config.Share.Secret))
	if err != nil {
		return nil, err
	}

	// 第二步:检查是否为管理员Token(管理员Token无需Redis验证)
	isAdmin := authverify.IsManagerUserID(claims.UserID, s.config.Share.IMAdminUserID)
	if isAdmin {
		return claims, nil
	}

	// 第三步:从Redis获取Token状态映射
	// 这里会查询Token在Redis中的实际状态
	m, err := s.authDatabase.GetTokensWithoutError(ctx, claims.UserID, claims.PlatformID)
	if err != nil {
		return nil, err
	}

	// 第四步:检查Token是否存在于Redis中
	if len(m) == 0 {
		return nil, servererrs.ErrTokenNotExist.Wrap()
	}

	// 第五步:检查特定Token的状态
	if v, ok := m[tokensString]; ok {
		switch v {
		case constant.NormalToken:
			// Token状态正常,验证通过
			return claims, nil
		case constant.KickedToken:
			// 关键:Token被踢状态,返回被踢错误
			// 客户端收到此错误码,立即感知到被踢下线
			return nil, servererrs.ErrTokenKicked.Wrap()
		default:
			// 其他未知状态
			return nil, errs.Wrap(errs.ErrTokenUnknown)
		}
	}

	// Token不存在于当前状态映射中
	return nil, servererrs.ErrTokenNotExist.Wrap()
}
感知方式2:WebSocket连接连接检测失败

对于保持WebSocket长连接的设备,当连接进行连接检测或消息发送时,也会触发Token验证,从而感知到被踢状态。

WebSocket握手验证: open-im-server/internal/msggateway/ws_server.go

// wsHandler WebSocket请求处理器
// 在WebSocket连接建立和维持过程中验证Token状态
//
// 验证时机:
// 1. 连接建立时的初始验证
// 2. 连接维持期间的周期性验证
// 3. 消息发送时的实时验证
func (ws *WsServer) wsHandler(w http.ResponseWriter, r *http.Request) {
	// 创建连接上下文
	connContext := newContext(w, r)

	// 检查连接数限制
	if ws.onlineUserConnNum.Load() >= ws.wsMaxConnNum {
		httpError(connContext, servererrs.ErrConnOverMaxNumLimit.WrapMsg("over max conn num limit"))
		return
	}

	// 解析必要参数(用户ID、令牌等)
	err := connContext.ParseEssentialArgs()
	if err != nil {
		httpError(connContext, err)
		return
	}

	// 关键:调用认证服务解析令牌
	// 如果Token被踢,这里会返回错误,连接建立失败
	resp, err := ws.authClient.ParseToken(connContext, connContext.GetToken())
	if err != nil {
		// 根据上下文判断是否需要通过WebSocket发送错误
		shouldSendError := connContext.ShouldSendResp()
		if shouldSendError {
			// 尝试建立WebSocket连接发送错误消息
			wsLongConn := newGWebSocket(WebSocket, ws.handshakeTimeout, ws.writeBufferSize)
			if err := wsLongConn.RespondWithError(err, w, r); err == nil {
				return
			}
		}
		// 通过HTTP返回错误,客户端感知到Token失效
		httpError(connContext, err)
		return
	}

	// 验证认证响应的匹配性
	err = ws.validateRespWithRequest(connContext, resp)
	if err != nil {
		httpError(connContext, err)
		return
	}

	// Token验证通过,继续建立WebSocket连接...
}
感知方式3:新设备连接触发的主动踢除通知

这是最重要的主动通知机制,当新设备建立连接时,会主动向被踢除的老设备发送踢除消息。

新连接注册处理: open-im-server/internal/msggateway/ws_server.go

// registerClient 注册新的客户端连接
// 当新设备连接成功后,会检查并主动踢除冲突的老连接
//
// 主动踢除流程:
// 1. 检查用户在指定平台的现有连接
// 2. 根据多端登录策略判断是否需要踢除
// 3. 主动向被踢连接发送踢除消息
// 4. 通知其他节点执行跨节点踢除
func (ws *WsServer) registerClient(client *Client) {
	var (
		userOK     bool      // 用户是否已存在
		clientOK   bool      // 同平台是否有连接
		oldClients []*Client // 同平台的旧连接
	)

	// 第一步:检查用户在指定平台的连接状态
	oldClients, userOK, clientOK = ws.clients.Get(client.UserID, client.PlatformID)

	if !userOK {
		// 新用户首次连接,直接注册
		ws.clients.Set(client.UserID, client)
		log.ZDebug(client.ctx, "user not exist", "userID", client.UserID, "platformID", client.PlatformID)

		// 更新统计数据
		prommetrics.OnlineUserGauge.Add(1)
		ws.onlineUserNum.Add(1)
		ws.onlineUserConnNum.Add(1)
	} else {
		// 第二步:用户已存在,执行多端登录策略检查
		// 这里会根据配置的策略决定是否踢除老连接
		ws.multiTerminalLoginChecker(clientOK, oldClients, client)
		log.ZDebug(client.ctx, "user exist", "userID", client.UserID, "platformID", client.PlatformID)

		if clientOK {
			// 同平台有连接,替换老连接
			ws.clients.Set(client.UserID, client)
			log.ZDebug(client.ctx, "repeat login", "userID", client.UserID, "platformID",
				client.PlatformID, "old remote addr", getRemoteAdders(oldClients))
			ws.onlineUserConnNum.Add(1)
		} else {
			// 新平台连接
			ws.clients.Set(client.UserID, client)
			ws.onlineUserConnNum.Add(1)
		}
	}

	// 第三步:异步执行跨节点状态同步
	wg := sync.WaitGroup{}
	log.ZDebug(client.ctx, "ws.msgGatewayConfig.Discovery.Enable", "discoveryEnable", ws.msgGatewayConfig.Discovery.Enable)

	// 如果不是k8s环境,执行跨节点状态同步
	if ws.msgGatewayConfig.Discovery.Enable != "k8s" {
		wg.Add(1)
		go func() {
			defer wg.Done()
			// 关键:向其他节点发送用户上线信息,触发跨节点踢除检查
			_ = ws.sendUserOnlineInfoToOtherNode(client.ctx, client)
		}()
	}

	wg.Wait()
	log.ZDebug(client.ctx, "user online", "online user Num", ws.onlineUserNum.Load(), "online user conn Num", ws.onlineUserConnNum.Load())
}

多端登录策略检查与主动踢除: open-im-server/internal/msggateway/ws_server.go

// multiTerminalLoginChecker 多端登录策略检查器
// 在新连接注册时,检查并主动踢除冲突的老连接
//
// 主动踢除逻辑:
// 1. 根据配置的多端登录策略决定踢除范围
// 2. 调用KickOnlineMessage主动通知被踢设备
// 3. 更新Redis中Token状态为被踢状态
// 4. 确保被踢设备能够立即感知到状态变更
func (ws *WsServer) multiTerminalLoginChecker(clientOK bool, oldClients []*Client, newClient *Client) {
	// 踢下线令牌处理函数
	kickTokenFunc := func(kickClients []*Client) {
		var kickTokens []string

		// 第一步:删除被踢的连接映射
		ws.clients.DeleteClients(newClient.UserID, kickClients)

		// 第二步:向被踢连接发送踢下线消息并收集令牌
		for _, c := range kickClients {
			kickTokens = append(kickTokens, c.token)
			
			// 关键:主动向被踢设备发送踢除消息
			// 这是最直接的感知机制,设备立即收到踢除通知
			err := c.KickOnlineMessage()
			if err != nil {
				log.ZWarn(c.ctx, "KickOnlineMessage", err)
			}
		}

		// 第三步:调用认证服务更新Token状态
		ctx := mcontext.WithMustInfoCtx(
			[]string{newClient.ctx.GetOperationID(), newClient.ctx.GetUserID(),
				constant.PlatformIDToName(newClient.PlatformID), newClient.ctx.GetConnID()},
		)

		// 批量踢除Token,更新Redis状态
		if err := ws.authClient.KickTokens(ctx, kickTokens); err != nil {
			log.ZWarn(newClient.ctx, "kickTokens err", err)
		}
	}

	// 根据多端登录策略执行相应的踢除逻辑
	switch ws.msgGatewayConfig.Share.MultiLogin.Policy {
	case constant.DefalutNotKick:
		// 默认策略:不踢任何连接

	case constant.PCAndOther:
		// PC和其他策略:PC端不踢,其他端按终端处理
		if constant.PlatformIDToClass(newClient.PlatformID) == constant.TerminalPC {
			return
		}
		fallthrough

	case constant.AllLoginButSameTermKick:
		// 同终端踢下线策略
		if !clientOK {
			return
		}

		// 删除旧连接并发送踢除消息
		ws.clients.DeleteClients(newClient.UserID, oldClients)
		for _, c := range oldClients {
			// 主动向老设备发送踢除消息
			err := c.KickOnlineMessage()
			if err != nil {
				log.ZWarn(c.ctx, "KickOnlineMessage", err)
			}
		}

		// 失效旧令牌,保留新令牌
		ctx := mcontext.WithMustInfoCtx(
			[]string{newClient.ctx.GetOperationID(), newClient.ctx.GetUserID(),
				constant.PlatformIDToName(newClient.PlatformID), newClient.ctx.GetConnID()},
		)
		req := &pbAuth.InvalidateTokenReq{
			PreservedToken: newClient.token,
			UserID:         newClient.UserID,
			PlatformID:     int32(newClient.PlatformID),
		}
		if err := ws.authClient.InvalidateToken(ctx, req); err != nil {
			log.ZWarn(newClient.ctx, "InvalidateToken err", err, "userID", newClient.UserID,
				"platformID", newClient.PlatformID)
		}

	case constant.AllLoginButSameClassKick:
		// 同类别踢下线策略
		clients, ok := ws.clients.GetAll(newClient.UserID)
		if !ok {
			return
		}

		var kickClients []*Client
		// 查找同类别的连接
		for _, client := range clients {
			if constant.PlatformIDToClass(client.PlatformID) == constant.PlatformIDToClass(newClient.PlatformID) {
				kickClients = append(kickClients, client)
			}
		}
		// 执行踢除逻辑
		kickTokenFunc(kickClients)
	}
}

跨节点踢除协调: open-im-server/internal/msggateway/ws_server.go

// sendUserOnlineInfoToOtherNode 向其他节点发送用户上线信息
// 当新用户在当前节点连接后,需要通知其他节点检查是否有需要踢除的连接
//
// 跨节点协调逻辑:
// 1. 获取集群中所有消息网关节点
// 2. 并发向其他节点发送多端登录检查请求
// 3. 其他节点接收到请求后执行本地踢除检查
// 4. 确保集群范围内的多端登录策略一致性
func (ws *WsServer) sendUserOnlineInfoToOtherNode(ctx context.Context, client *Client) error {
	// 获取集群中所有节点的连接
	conns, err := ws.disCov.GetConns(ctx, ws.msgGatewayConfig.Share.RpcRegisterName.MessageGateway)
	if err != nil {
		return err
	}

	// 如果只有当前节点或无其他节点,直接返回
	if len(conns) == 0 || (len(conns) == 1 && ws.disCov.IsSelfNode(conns[0])) {
		return nil
	}

	// 使用错误组控制并发数量
	wg := errgroup.Group{}
	wg.SetLimit(concurrentRequest)

	// 向每个其他节点发送在线信息
	for _, v := range conns {
		v := v
		log.ZDebug(ctx, "sendUserOnlineInfoToOtherNode conn")

		// 过滤掉当前节点
		if ws.disCov.IsSelfNode(v) {
			log.ZDebug(ctx, "Filter out this node")
			continue
		}

		wg.Go(func() error {
			// 创建消息网关客户端
			msgClient := msggateway.NewMsgGatewayClient(v)

			// 关键:发送多端登录检查请求到其他节点
			// 其他节点收到此请求后,会检查本地是否有该用户的连接需要踢除
			_, err := msgClient.MultiTerminalLoginCheck(ctx, &msggateway.MultiTerminalLoginCheckReq{
				UserID:     client.UserID,
				PlatformID: int32(client.PlatformID),
				Token:      client.token,
			})
			if err != nil {
				log.ZWarn(ctx, "MultiTerminalLoginCheck err", err)
			}
			return nil
		})
	}

	// 等待所有请求完成
	_ = wg.Wait()
	return nil
}

其他节点的踢除检查处理: open-im-server/internal/msggateway/hub_server.go

// MultiTerminalLoginCheck 多终端登录检查
// 当其他节点发送踢除检查请求时,本节点的处理逻辑
//
// 处理流程:
// 1. 检查本地是否有该用户在指定平台的连接
// 2. 如果有连接,创建虚拟客户端用于踢除处理
// 3. 设置踢除处理器,执行本地踢除逻辑
// 4. 确保跨节点的多端登录策略一致性
func (s *Server) MultiTerminalLoginCheck(ctx context.Context, req *msggateway.MultiTerminalLoginCheckReq) (*msggateway.MultiTerminalLoginCheckResp, error) {
	// 检查用户在指定平台是否已有连接
	if oldClients, userOK, clientOK := s.LongConnServer.GetUserPlatformCons(req.UserID, int(req.PlatformID)); userOK {
		// 创建临时上下文和虚拟客户端
		tempUserCtx := newTempContext()
		tempUserCtx.SetToken(req.Token)
		tempUserCtx.SetOperationID(mcontext.GetOperationID(ctx))

		client := &Client{}
		client.ctx = tempUserCtx
		client.UserID = req.UserID
		client.PlatformID = int(req.PlatformID)

		// 关键:设置踢下线处理器信息
		// 这会触发本节点的多端登录策略检查和踢除逻辑
		i := &kickHandler{
			clientOK:   clientOK,
			oldClients: oldClients,
			newClient:  client,
		}
		s.LongConnServer.SetKickHandlerInfo(i)
	}
	return &msggateway.MultiTerminalLoginCheckResp{}, nil
}

踢除处理器的执行: open-im-server/internal/msggateway/ws_server.go

// SetKickHandlerInfo 设置踢下线处理信息
// 通过异步通道处理踢除事件,避免阻塞主流程
//
// 异步处理逻辑:
// 1. 将踢除信息发送到专用通道
// 2. 后台协程接收并处理踢除事件
// 3. 执行具体的踢除逻辑和消息通知
func (ws *WsServer) SetKickHandlerInfo(i *kickHandler) {
	// 将踢除处理信息发送到异步通道
	// 这个通道在ws.Run()方法中被监听和处理
	ws.kickHandlerChan <- i
}

// Run方法中的踢除处理逻辑
func (ws *WsServer) Run(done chan error) error {
	// ... 省略其他代码 ...

	// 启动事件处理协程
	go func() {
		for {
			select {
			case <-shutdownDone:
				return
			case client = <-ws.registerChan:
				// 处理客户端注册事件
				ws.registerClient(client)
			case client = <-ws.unregisterChan:
				// 处理客户端注销事件
				ws.unregisterClient(client)
			case onlineInfo := <-ws.kickHandlerChan:
				// 关键:处理踢下线事件
				// 这里会调用multiTerminalLoginChecker执行实际的踢除逻辑
				ws.multiTerminalLoginChecker(onlineInfo.clientOK, onlineInfo.oldClients, onlineInfo.newClient)
			}
		}
	}()

	// ... 省略其他代码 ...
}

用户连接映射管理

在设备踢除感知机制中,用户连接映射管理起到了关键作用,它维护了本地节点的用户连接状态。

用户映射查询: open-im-server/internal/msggateway/user_map.go

// Get 获取指定用户在特定平台的连接
// 在多端登录检查时,通过此方法查找本地的冲突连接
//
// 查询逻辑:
// 1. 使用读锁保护并发访问
// 2. 返回匹配平台的所有连接
// 3. 提供详细的查询结果状态
func (u *userMap) Get(userID string, platformID int) ([]*Client, bool, bool) {
	u.lock.RLock()
	defer u.lock.RUnlock()

	result, ok := u.data[userID]
	if !ok {
		return nil, false, false
	}

	var clients []*Client
	// 遍历所有连接,筛选匹配的平台
	for _, client := range result.Clients {
		if client.PlatformID == platformID {
			clients = append(clients, client)
		}
	}
	return clients, true, len(clients) > 0
}

// DeleteClients 删除用户的指定连接列表
// 在踢除连接时,从本地映射中移除被踢的连接
//
// 删除逻辑:
// 1. 基于连接地址进行精确匹配删除
// 2. 记录离线平台信息用于通知
// 3. 自动清理空用户记录
// 4. 发送状态变更事件
func (u *userMap) DeleteClients(userID string, clients []*Client) (isDeleteUser bool) {
	if len(clients) == 0 {
		return false
	}

	u.lock.Lock()
	defer u.lock.Unlock()

	result, ok := u.data[userID]
	if !ok {
		return false
	}

	// 记录要删除的平台ID
	offline := make([]int32, 0, len(clients))

	// 创建待删除连接的地址集合,用于快速查找
	deleteAddr := datautil.SliceSetAny(clients, func(client *Client) string {
		return client.ctx.GetRemoteAddr()
	})

	// 重新构建连接列表,排除要删除的连接
	tmp := result.Clients
	result.Clients = result.Clients[:0] // 重置切片但保留容量

	for _, client := range tmp {
		if _, delCli := deleteAddr[client.ctx.GetRemoteAddr()]; delCli {
			// 记录离线的平台ID
			offline = append(offline, int32(client.PlatformID))
		} else {
			// 保留未删除的连接
			result.Clients = append(result.Clients, client)
		}
	}

	// 延迟发送状态变更通知
	defer u.push(userID, result, offline)

	// 检查是否需要删除用户记录
	if len(result.Clients) > 0 {
		return false // 还有剩余连接
	}

	// 删除用户记录,释放内存
	delete(u.data, userID)
	return true
}

感知机制总结

OpenIM的设备剔除感知机制通过三种互补的方式确保被踢设备能够及时感知状态变更:

1. 被动感知(延迟感知)
  • API调用验证:设备发起API请求时,Token验证失败
  • WebSocket连接:连接连接检测时,Token状态检查失败
  • 感知延迟:取决于设备的API调用频率和连接间隔
2. 主动通知(实时感知)
  • 新连接触发:新设备连接时主动向老设备发送踢除消息
  • 跨节点协调:确保集群范围内的踢除通知
  • 感知延迟:几乎实时,延迟在秒级别
3. 状态一致性保证
  • Redis状态同步:所有Token状态变更都同步到Redis
  • 多层验证机制:API、WebSocket、连接注册多层验证
  • 集群协调机制:跨节点的状态同步和踢除协调

这套机制确保了在任何场景下,被踢除的设备都能够及时感知到状态变更,从而维护系统的安全性和用户体验的一致性。

总结

OpenIM的多设备登录策略和设备剔除感知机制通过精巧的设计实现了完整的设备管理闭环:

登录阶段

  1. 四种策略覆盖不同使用场景,从宽松的多设备共存到严格的企业安全控制
  2. Redis缓存提供高性能的Token状态管理,支持原子性操作
  3. Pipeline优化和Slot分组查询保证在高并发场景下的性能表现
  4. JWT标准确保Token的安全性和可验证性
  5. 多层权限校验防止权限提升和非法访问

感知阶段

  1. 三重感知机制确保被踢设备能够及时感知状态变更
  2. 主动通知提供实时的踢除感知,延迟在秒级别
  3. 被动验证提供兜底的感知机制,确保最终一致性
  4. 跨节点协调保证集群环境下的状态同步
  5. 状态映射管理提供高效的本地连接状态维护

这套完整的机制为OpenIM系统提供了强大而灵活的多设备管理能力,既满足了用户便利性需求,又保证了系统安全性要求,同时确保了被踢设备能够及时感知状态变更,维护了良好的用户体验。