OpenIM 源码深度解析系列(二):双Token认证机制与接入流程

双Token认证机制与接入流程

1. 系统架构概览

OpenIM权限验证机制采用分层架构设计,主要包含以下组件:

  • Chat系统:负责用户注册、登录认证、API Token管理
  • OpenIM Server:负责IM功能、WebSocket连接认证、用户Token管理
  • openim-sdk-core:客户端SDK,负责与服务端的通信和Token管理
  • 设备端应用:Android/iOS等客户端应用

涉及存储结构如下:

graph TB subgraph "应用层" ChatAPI[Chat API] OpenIMAPI[OpenIM API] WebSocket[WebSocket Gateway] SDK[OpenIM SDK] end subgraph "Redis缓存层" subgraph "Chat Token缓存" ChatTokenCache["CHAT_UID_TOKEN_STATUS:{userID}
Hash存储多Token状态
• token1: 0 (正常)
• token2: 1 (无效)
• token3: 2 (被踢)
• token4: 3 (过期)"] end subgraph "OpenIM Admin Token缓存" AdminTokenCache["CHAT:IM_TOKEN:{adminUserID}
String存储
TTL: 4分钟
管理员系统调用凭证"] end subgraph "OpenIM User Token缓存" UserTokenCache["UID_PID_TOKEN_STATUS:{userID}:{platform}
Hash存储平台Token
• token1: 1 (正常)
• token2: 2 (被踢)"] end subgraph "验证码缓存" VerifyCache["验证码临时缓存
TTL: 300秒
account:code 键值对"] end end subgraph "MongoDB持久化层" subgraph "Chat数据库" RegistersDB[("registers集合
用户注册记录
• user_id (主键)
• device_id
• ip
• platform
• account_type")] AccountsDB[("accounts集合
用户账户密码
• user_id (主键)
• password (MD5)
• create_time")] AttributesDB[("attributes集合
用户详细属性
• user_id (主键)
• phone_number
• email
• nickname")] CredentialsDB[("credentials集合
登录凭证管理
• user_id
• account
• type (手机/邮箱)")] VerifyCodesDB[("verify_codes集合
验证码记录
• _id (主键)
• account
• code
• used")] LoginRecordsDB[("user_login_records集合
登录历史记录
• user_id
• login_time
• ip
• platform")] end subgraph "OpenIM数据库" UsersDB[("users集合
IM用户基本信息
• user_id (主键)
• nickname
• face_url
• app_manger_level")] end end %% 数据流连接 ChatAPI --> ChatTokenCache ChatAPI --> RegistersDB ChatAPI --> AccountsDB ChatAPI --> AttributesDB OpenIMAPI --> AdminTokenCache OpenIMAPI --> UserTokenCache OpenIMAPI --> UsersDB WebSocket --> UserTokenCache SDK --> UserTokenCache ChatTokenCache -.-> AccountsDB AdminTokenCache -.-> UsersDB UserTokenCache -.-> UsersDB VerifyCache -.-> VerifyCodesDB style ChatTokenCache fill:#e1f5fe style AdminTokenCache fill:#f3e5f5 style UserTokenCache fill:#e8f5e8 style VerifyCache fill:#fff3e0

2. 核心数据库表结构

2.1 Chat系统数据库表

2.1.1 registers - 用户注册记录表

表作用: 记录用户注册时的详细信息,用于统计分析和安全追踪。

集合名称 : registers

字段名 类型 是否必需 描述
user_id string 用户ID,业务主键
device_id string 设备ID
ip string 注册IP地址
platform string 注册平台:iOS、Android、Web等
account_type string 账户类型:phone、email、account等
mode string 注册模式
create_time time.Time 注册时间

索引设计:

javascript 复制代码
db.registers.createIndex({"user_id": 1}, {unique: true})
db.registers.createIndex({"ip": 1})
2.1.2 accounts - 用户账户表

表作用: 存储用户的登录账户和密码信息,用于身份验证。

集合名称 : accounts

字段名 类型 是否必需 描述
user_id string 用户ID,业务主键
password string 用户密码(MD5加密存储)
create_time time.Time 账户创建时间
change_time time.Time 密码最后修改时间
operator_user_id string 操作者用户ID

索引设计:

javascript 复制代码
db.accounts.createIndex({"user_id": 1}, {unique: true})
2.1.3 attributes - 用户属性表

表作用: 存储用户的详细属性信息,包括个人信息、偏好设置、权限控制等。

集合名称 : attributes

字段名 类型 是否必需 描述
user_id string 用户ID,业务主键
account string 账号名称
phone_number string 手机号码
area_code string 区号
email string 邮箱地址
nickname string 用户昵称
face_url string 头像URL
gender int32 性别:1=男,2=女,0=未知
create_time time.Time 创建时间
change_time time.Time 最后修改时间
birth_time time.Time 出生日期
level int32 用户等级
allow_vibration int32 是否允许震动:0=不允许,1=允许
allow_beep int32 是否允许提示音:0=不允许,1=允许
allow_add_friend int32 是否允许被添加好友:0=不允许,1=允许
global_recv_msg_opt int32 全局接收消息选项:0=正常接收,1=不接收,2=仅在线接收
register_type int32 注册类型:1=手机号,2=邮箱,3=账号

索引设计:

javascript 复制代码
db.attributes.createIndex({"user_id": 1}, {unique: true})
db.attributes.createIndex({"phone_number": 1})
db.attributes.createIndex({"email": 1})
2.1.4 credentials - 用户凭证表

表作用: 管理用户的登录凭证信息,支持多种登录方式(手机号、邮箱等)。

集合名称 : credentials

字段名 类型 是否必需 描述
user_id string 用户ID
account string 登录账号(手机号或邮箱)
type int 凭证类型:1=手机号,2=邮箱,3=账户名
allow_change bool 是否允许修改
create_time time.Time 创建时间

索引设计:

javascript 复制代码
db.credentials.createIndex({"user_id": 1, "type": 1}, {unique: true})
db.credentials.createIndex({"account": 1}, {unique: true})
2.1.5 verify_codes - 验证码表

表作用: 存储各种验证码信息,用于用户注册、登录、修改密码等操作的安全验证。

集合名称 : verify_codes

字段名 类型 是否必需 描述
_id string 验证码ID,业务主键
account string 关联的账号(手机号或邮箱)
platform string 平台标识
code string 验证码内容
duration uint 有效期(秒)
count int 使用次数
used bool 是否已使用
create_time time.Time 创建时间

索引设计:

javascript 复制代码
db.verify_codes.createIndex({"_id": 1}, {unique: true})
db.verify_codes.createIndex({"account": 1})
db.verify_codes.createIndex({"create_time": 1}, {expireAfterSeconds: 300})
2.1.6 user_login_records - 用户登录记录表

表作用: 记录用户每次登录的详细信息,用于安全监控和行为分析。

集合名称 : user_login_records

字段名 类型 是否必需 描述
user_id string 用户ID
login_time time.Time 登录时间
ip string 登录IP地址
device_id string 设备ID
platform string 登录平台:iOS、Android、Web等

索引设计:

javascript 复制代码
db.user_login_records.createIndex({"user_id": 1})
db.user_login_records.createIndex({"login_time": 1})

2.2 OpenIM Server数据库表

2.2.1 users - 用户基本信息表

表作用: 存储用户的基本信息,包括昵称、头像、管理权限、全局消息接收设置等核心数据。

集合名称 : users

字段名 类型 是否必需 描述
user_id string 用户唯一标识符,业务主键
nickname string 用户昵称,显示名称
face_url string 用户头像URL地址
ex string 扩展字段,JSON格式存储自定义信息
app_manger_level int32 应用管理员级别:0=普通用户,1=管理员,2=超级管理员
global_recv_msg_opt int32 全局接收消息选项:0=正常接收,1=不接收,2=仅在线接收
create_time time.Time 账户创建时间

索引设计:

javascript 复制代码
db.users.createIndex({"user_id": 1}, {unique: true})

3. Token系统详解

OpenIM系统实现了三种不同类型的Token,每种Token都有其特定的用途和验证机制。

3.1 Chat Token - Chat系统API认证

3.1.1 类型和用途
  • 用途:Chat系统REST API认证,用于访问用户管理、配置管理等功能
  • 生成者:Chat Admin RPC服务
  • 验证者:Chat系统中间件
  • 适用场景:管理后台、Chat API调用
3.1.2 Token结构
go 复制代码
// chat/pkg/common/tokenverify/token_verify.go:29
type claims struct {
    UserID     string  // 用户ID
    UserType   int32   // 用户类型: 1-普通用户, 2-管理员
    PlatformID int32   // Chat系统中固定为0
    jwt.RegisteredClaims
}
3.1.3 缓存策略
go 复制代码
// Redis缓存键: CHAT_UID_TOKEN_STATUS:{userID}
// 存储结构: Hash表
{
    "token0": 0,  // 0-正常状态Token,表示该Token有效且用户会话处于活跃状态。用户可以使用此Token进行所有授权操作。
    "token1": 1,  // 1-无效Token,表示该Token已被系统标记为不可用。通常由于安全原因或管理员操作导致
    "token2": 2,  // 2-被踢下线的Token,表示该Token对应的会话已被新登录实例强制终止
    "token3": 3,  // 3-过期Token,表示该Token已超过其有效期限。系统会自动清理过期Token。

}

3.2 OpenIM Admin Token - 管理员系统调用

3.2.1 类型和用途
  • 用途:Chat系统调用OpenIM Server管理接口的凭证
  • 生成者:OpenIM Server Auth RPC服务
  • 验证者:OpenIM Server Auth中间件
  • 适用场景:系统间管理调用、用户注册、Token生成
3.2.2 Token结构
go 复制代码
// open-im-server/pkg/common/storage/controller/auth.go
// 使用tools/tokenverify包的Claims结构
type Claims struct {
    UserID     string  // 管理员用户ID(如: imAdmin)
    PlatformID int     // 管理员平台ID(AdminPlatformID)
    jwt.RegisteredClaims
}
3.2.3 缓存策略
go 复制代码
// Redis缓存键: CHAT:IM_TOKEN:{adminUserID}
// 存储结构: String
// TTL: 4分钟 (可配置)

3.3 OpenIM User Token - 客户端IM连接

3.3.1 类型和用途
  • 用途:客户端WebSocket连接认证,用于IM消息收发
  • 生成者:OpenIM Server Auth RPC服务
  • 验证者:OpenIM Server WebSocket网关
  • 适用场景:客户端SDK连接、消息通信
3.3.2 Token结构
go 复制代码
// 使用tools/tokenverify包的Claims结构
type Claims struct {
    UserID     string  // 用户ID
    PlatformID int     // 具体平台ID(iOS=1, Android=2, Web=5等)
    jwt.RegisteredClaims
}
3.3.3 缓存策略
go 复制代码
// Redis缓存键: UID_PID_TOKEN_STATUS:{userID}:{platformName}
// 存储结构: Hash表
{
    "token1": 1,  // 1-正常状态
    "token2": 2,  // 2-被踢下线
}
// 示例: UID_PID_TOKEN_STATUS:2752796792:Android

3.4 三种Token详细对比

Token类型 生成系统 验证系统 平台ID 缓存键格式 主要用途
Chat Token Chat Admin Chat中间件 0 CHAT_UID_TOKEN_STATUS:{userID} Chat API认证
OpenIM Admin Token OpenIM Auth OpenIM Auth AdminPlatformID CHAT:IM_TOKEN:{adminUserID} 系统管理调用
OpenIM User Token OpenIM Auth OpenIM Auth 具体平台ID UID_PID_TOKEN_STATUS:{userID}:{platform} WebSocket连接

3.5 Token使用场景矩阵

场景 Chat Token Admin Token User Token 备注
用户登录Chat系统 用户身份认证
管理后台登录 双Token机制
SDK连接WebSocket IM功能必需
Chat系统调用OpenIM 系统间调用
用户注册流程 完整注册需要
客户端消息收发 IM核心功能
用户信息管理 Chat API操作
强制下线操作 管理员权限

4. 注册流程源码分析

4.1 第一阶段:Android客户端用户交互

4.1.1 用户界面 - RegisterActivity.java
java 复制代码
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/ui/login/RegisterActivity.java:44
private void listener() {
    // 1. 监听文本变化,实时验证手机号格式
    view.edt1.addTextChangedListener(this);
    view.protocol.setOnCheckedChangeListener((buttonView, isChecked) -> submitEnabled());
    view.clear.setOnClickListener(v -> view.edt1.setText(""));
    
    // 2. 提交按钮点击事件 - 发送验证码
    view.submit.setOnClickListener(v -> {
        if (vm.isPhone.val()) {
            // 验证手机号格式
            if (!RegexValid.isValidPhoneNumber(vm.account.val())) {
                toast(getString(io.openim.android.ouicore.R.string.valid_phone_num));
                return;
            }
        }
        // 设置区号并发送验证码 (usedFor: 1-注册 2-重置密码)
        vm.areaCode.setValue("+" + view.countryCode.getSelectedCountryCode());
        vm.getVerificationCode(vm.isFindPassword ? 2 : 1);
    });
}
4.1.2 业务逻辑层 - LoginVM.java

发送验证码流程:

java 复制代码
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:119
public void getVerificationCode(int usedFor) {
    // 1. 构建验证码请求参数
    Parameter parameter = getParameter(null, usedFor);
    WaitDialog waitDialog = showWait();
    
    // 2. 调用Chat系统API发送验证码
    N.API(OpenIMService.class)
      .getVerificationCode(parameter.buildJsonBody())
      .map(OpenIMService.turn(Object.class))
      .compose(N.IOMain())
      .subscribe(new NetObserver<Object>(getContext()) {
          @Override
          public void onSuccess(Object o) {
              getIView().succ(o); // 通知UI发送成功
          }
          
          @Override
          protected void onFailure(Throwable e) {
              getIView().err(e.getMessage()); // 通知UI发送失败
          }
      });
}

用户注册流程:

java 复制代码
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:252
public void register() {
    String pwdValue = pwd.getValue();
    
    // 1. 客户端密码格式验证
    if (!RegexValid.isValidPassword(pwdValue)) {
        toast(BaseApp.inst().getString(R.string.password_valid_tips));
        return;
    }
    
    // 2. 构建注册请求参数
    Parameter parameter = new Parameter();
    parameter.add("verifyCode", verificationCode); // 验证码
    parameter.add("platform", Platform.ANDROID);   // 平台标识
    parameter.add("autoLogin", true);              // 自动登录标志
    
    // 3. 构建用户基本信息
    Map<String, String> user = new HashMap<>();
    user.put("password", md5(pwdValue));          // 密码MD5加密
    user.put("nickname", nickName.getValue());    // 昵称
    user.put("areaCode", areaCode.val());         // 区号
    
    if (isPhone.val()) {
        user.put("phoneNumber", account.getValue()); // 手机号
    } else {
        user.put("email", account.getValue());       // 邮箱
    }
    parameter.add("user", user);
    
    // 4. 调用Chat系统注册API
    WaitDialog waitDialog = showWait();
    N.API(OpenIMService.class)
      .register(parameter.buildJsonBody())
      .map(OpenIMService.turn(LoginCertificate.class))
      .compose(N.IOMain())
      .subscribe(new NetObserver<LoginCertificate>(context.get()) {
          @Override
          public void onSuccess(LoginCertificate o) {
              // 5. 注册成功,缓存登录凭证
              Log.d(TAG, "Register success, caching credentials");
              o.cache(getContext());
              getIView().jump(); // 跳转到主界面
          }
          
          @Override
          protected void onFailure(Throwable e) {
              getIView().toast(e.getMessage());
          }
      });
}
4.1.3 网络接口定义 - OpenIMService.java
java 复制代码
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/repository/OpenIMService.java
public interface OpenIMService {
    // 用户注册接口
    @POST("account/register")
    Observable<ResponseBody> register(@Body RequestBody requestBody);
    
    // 发送验证码接口
    @POST("account/code/send")
    Observable<ResponseBody> getVerificationCode(@Body RequestBody requestBody);
    
    // 验证验证码接口
    @POST("account/code/verify")
    Observable<ResponseBody> checkVerificationCode(@Body RequestBody requestBody);
    
    // 通用响应转换器
    static <T> Function<ResponseBody, T> turn(Class<T> tClass) {
        return responseBody -> {
            String body = responseBody.string();
            Base<T> base = GsonHel.dataObject(body, tClass);
            if (base.errCode == 0)
                return null == base.data ? tClass.newInstance() : base.data;
            throw new RXRetrofitException(base.errCode, base.errDlt);
        };
    }
}

4.2 第二阶段:Chat系统处理

4.2.1 验证码生成与管理流程

HTTP入口 - 发送验证码接口

go 复制代码
// chat/internal/api/chat/chat.go:45
func (o *Api) SendVerifyCode(c *gin.Context) {
    // 1. 解析HTTP请求参数
    req, err := a2r.ParseRequest[chatpb.SendVerifyCodeReq](c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    // 2. 获取客户端IP地址用于频率限制
    ip, err := o.GetClientIP(c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }
    req.Ip = ip

    // 3. 调用RPC服务发送验证码
    _, err = o.chatClient.SendVerifyCode(c, req)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    apiresp.GinSuccess(c, nil)
}

RPC服务 - 验证码生成逻辑

go 复制代码
// chat/internal/rpc/chat/login.go:89
func (o *chatSvr) SendVerifyCode(ctx context.Context, req *chat.SendVerifyCodeReq) (*chat.SendVerifyCodeResp, error) {
    // 1. IP频率限制检查
    if err := o.Admin.CheckSendCode(ctx, req.Ip, req.Account); err != nil {
        return nil, err
    }

    // 2. 根据使用场景验证账户状态
    switch req.UsedFor {
    case constant.VerificationCodeForRegister:
        // 注册场景:检查账户是否已存在
        _, err := o.Database.GetUserByAccount(ctx, req.Account)
        if err == nil {
            return nil, errs.ErrRegisteredAlready.WrapMsg("account already registered")
        }
        if !dbutil.IsDBNotFound(err) {
            return nil, err
        }
    case constant.VerificationCodeForResetPassword:
        // 重置密码场景:检查账户是否存在
        _, err := o.Database.GetUserByAccount(ctx, req.Account)
        if err != nil {
            if dbutil.IsDBNotFound(err) {
                return nil, errs.ErrUserNotFound.WrapMsg("account not found")
            }
            return nil, err
        }
    }

    // 3. 生成6位数字验证码
    code := o.genVerifyCode()
    
    // 4. 保存验证码到Redis(5分钟过期)
    if err := o.Database.SetVerifyCode(ctx, req.Account, code, req.UsedFor); err != nil {
        return nil, err
    }

    // 5. 发送验证码(短信/邮件)
    if err := o.sendVerifyCode(ctx, req.Account, code); err != nil {
        return nil, err
    }

    return &chat.SendVerifyCodeResp{}, nil
}

// 生成6位随机数字验证码
func (o *chatSvr) genVerifyCode() string {
    return fmt.Sprintf("%06d", rand.Intn(1000000))
}

验证码存储结构

go 复制代码
// chat/pkg/common/db/database/chat.go:156
func (o *ChatDatabase) SetVerifyCode(ctx context.Context, account string, code string, usedFor int32) error {
    // 构建Redis Key:verify_code:{account}:{usedFor}
    key := o.verifyCodeKey(account, usedFor)
    
    // 存储验证码,5分钟过期
    return o.cache.SetEx(ctx, key, code, time.Minute*5)
}

func (o *ChatDatabase) verifyCodeKey(account string, usedFor int32) string {
    return fmt.Sprintf("verify_code:%s:%d", account, usedFor)
}

// 验证码验证
func (o *ChatDatabase) VerifyCode(ctx context.Context, account string, code string, usedFor int32) error {
    key := o.verifyCodeKey(account, usedFor)
    
    // 从Redis获取验证码
    storedCode, err := o.cache.Get(ctx, key)
    if err != nil {
        if errors.Is(err, redis.Nil) {
            return errs.ErrVerifyCodeExpired.WrapMsg("verify code expired or not exist")
        }
        return err
    }
    
    // 验证码比对
    if storedCode != code {
        return errs.ErrVerifyCodeWrong.WrapMsg("verify code wrong")
    }
    
    // 验证成功后删除验证码(一次性使用)
    _ = o.cache.Del(ctx, key)
    return nil
}

验证码发送服务

go 复制代码
// chat/internal/rpc/chat/login.go:145
func (o *chatSvr) sendVerifyCode(ctx context.Context, account, code string) error {
    // 判断是手机号还是邮箱
    if strings.Contains(account, "@") {
        // 邮箱验证码发送
        return o.sendEmailCode(ctx, account, code)
    } else {
        // 短信验证码发送
        return o.sendSMSCode(ctx, account, code)
    }
}

func (o *chatSvr) sendSMSCode(ctx context.Context, phoneNumber, code string) error {
    // 调用第三方短信服务API
    // 这里可以集成阿里云、腾讯云等短信服务
    log.ZInfo(ctx, "Sending SMS code", "phone", phoneNumber, "code", code)
    
    // 实际项目中需要调用真实的短信API
    // return smsProvider.SendCode(phoneNumber, code)
    return nil
}

func (o *chatSvr) sendEmailCode(ctx context.Context, email, code string) error {
    // 调用邮件服务发送验证码
    log.ZInfo(ctx, "Sending email code", "email", email, "code", code)
    
    // 实际项目中需要调用真实的邮件API
    // return emailProvider.SendCode(email, code)
    return nil
}
4.2.2 用户注册完整流程

HTTP入口 - 用户注册接口

go 复制代码
// chat/internal/api/chat/chat.go:91
func (o *Api) RegisterUser(c *gin.Context) {
    // 1. 解析HTTP请求参数
    req, err := a2r.ParseRequest[chatpb.RegisterUserReq](c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    // 2. 获取客户端IP地址用于安全检查
    ip, err := o.GetClientIP(c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }
    req.Ip = ip

    // 3. 获取OpenIM管理员Token,用于后续调用OpenIM Server
    // ImAdminTokenWithDefaultAdmin 是Chat系统调用OpenIM Server的关键认证机制
    // 
    // **Token获取流程:**
    // 1. 首先检查本地内存缓存(4分钟TTL)
    // 2. 如果缓存未命中,检查Redis缓存(CHAT:IM_TOKEN:{adminUserID})
    // 3. 如果Redis也未命中,调用OpenIM Server获取新Token
    // 4. 将新Token同时缓存到内存和Redis中
    //
    // **Token存储结构:**
    // - 内存缓存:map[userID]*authToken{token, timeout}
    // - Redis缓存:Key="CHAT:IM_TOKEN:{adminUserID}", Value=token, TTL=4分钟
    //
    // **安全验证:**
    // - 使用系统配置的Secret验证身份
    // - 验证adminUserID是否在IMAdminUserID列表中
    // - 确认管理员用户在OpenIM Server中存在
    //
    // **Token特性:**
    // - JWT格式,包含UserID、PlatformID(AdminPlatformID)、过期时间
    // - 管理员Token不受多端登录策略限制
    // - 跳过Redis状态检查,提升性能
    imToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }
    apiCtx := mctx.WithApiToken(c, imToken)
    rpcCtx := o.WithAdminUser(c)

    // 4. 检查用户是否已经在Chat系统中注册
    checkResp, err := o.chatClient.CheckUserExist(rpcCtx, &chatpb.CheckUserExistReq{User: req.User})
    if err != nil {
        log.ZDebug(rpcCtx, "CheckUserExist error", errs.Unwrap(err))
        apiresp.GinError(c, err)
        return
    }

    // 5. 处理数据一致性问题
    if checkResp.IsRegistered {
        // 检查用户在OpenIM Server中是否存在
        isUserNotExist, err := o.imApiCaller.AccountCheckSingle(apiCtx, checkResp.Userid)
        if err != nil {
            apiresp.GinError(c, err)
            return
        }
        // 如果用户在Chat存在但OpenIM不存在,删除Chat中的记录
        if isUserNotExist {
            _, err := o.chatClient.DelUserAccount(rpcCtx, &chatpb.DelUserAccountReq{UserIDs: []string{checkResp.Userid}})
            log.ZDebug(c, "Deleted inconsistent user data", checkResp.Userid)
            if err != nil {
                apiresp.GinError(c, err)
                return
            }
        }
    }

    // 6. 在Chat系统中注册用户
    respRegisterUser, err := o.chatClient.RegisterUser(c, req)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    // 7. 在OpenIM Server中注册用户
    // 
    // **为什么需要在OpenIM Server中注册?**
    // Chat系统和OpenIM Server是两个独立的系统:
    // - Chat系统:负责用户账户管理、认证、业务逻辑
    // - OpenIM Server:负责实时消息通信、WebSocket连接、消息路由
    // 
    // **OpenIM Server注册的作用:**
    // 1. 创建用户在IM系统中的身份标识
    // 2. 建立用户与WebSocket网关的连接能力
    // 3. 初始化用户的消息接收和发送权限
    // 4. 为后续的好友关系、群组关系建立基础
    //
    // **注册数据结构:**
    // - UserID: 与Chat系统保持一致的用户唯一标识
    // - Nickname: 用户昵称,用于消息显示
    // - FaceURL: 用户头像,用于界面展示
    // - CreateTime: 创建时间戳
    // - AppMangerLevel: 应用管理级别(默认为普通用户)
    // - GlobalRecvMsgOpt: 全局消息接收选项
    //
    // **权限验证:**
    // - 只有管理员才能调用OpenIM Server的用户注册接口
    // - 使用ImAdminToken进行身份验证
    // - 检查用户ID格式(不能包含':'字符)
    // - 验证用户是否已在OpenIM Server中存在
    userInfo := &sdkws.UserInfo{
        UserID:     respRegisterUser.UserID,
        Nickname:   req.User.Nickname,
        FaceURL:    req.User.FaceURL,
        CreateTime: time.Now().UnixMilli(),
    }
    err = o.imApiCaller.RegisterUser(apiCtx, []*sdkws.UserInfo{userInfo})
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    // 8. 建立默认社交关系
    if resp, err := o.adminClient.FindDefaultFriend(rpcCtx, &admin.FindDefaultFriendReq{}); err == nil {
        _ = o.imApiCaller.ImportFriend(apiCtx, respRegisterUser.UserID, resp.UserIDs)
    }
    if resp, err := o.adminClient.FindDefaultGroup(rpcCtx, &admin.FindDefaultGroupReq{}); err == nil {
        _ = o.imApiCaller.InviteToGroup(apiCtx, respRegisterUser.UserID, resp.GroupIDs)
    }

    // 9. 生成用户ImToken(如果启用自动登录)
    var resp apistruct.UserRegisterResp
    if req.AutoLogin {
        resp.ImToken, err = o.imApiCaller.GetUserToken(apiCtx, respRegisterUser.UserID, req.Platform)
        if err != nil {
            apiresp.GinError(c, err)
            return
        }
    }
    resp.ChatToken = respRegisterUser.ChatToken
    resp.UserID = respRegisterUser.UserID
    apiresp.GinSuccess(c, &resp)
}

ImAdminTokenWithDefaultAdmin获取完整流程

go 复制代码
// chat/pkg/common/imapi/caller.go:75
func (c *Caller) ImAdminTokenWithDefaultAdmin(ctx context.Context) (string, error) {
    return c.GetAdminTokenCache(ctx, c.defaultIMUserID)
}

func (c *Caller) GetAdminTokenCache(ctx context.Context, userID string) (string, error) {
    // 1. 读锁检查内存缓存
    c.lock.RLock()
    t, ok := c.tokenCache[userID]
    c.lock.RUnlock()
    
    // 2. 内存缓存命中且未过期
    if ok && !t.timeout.Before(time.Now()) {
        return t.token, nil
    }
    
    // 3. 写锁更新缓存
    c.lock.Lock()
    defer c.lock.Unlock()
    
    // 4. 双重检查避免并发问题
    t, ok = c.tokenCache[userID]
    if ok && !t.timeout.Before(time.Now()) {
        return t.token, nil
    }
    
    // 5. 从Redis获取Token
    token, err := c.tokenDB.GetIMToken(ctx, userID)
    if err != nil && !errors.Is(err, redis.Nil) {
        return "", err
    }
    
    if errors.Is(err, redis.Nil) {
        // 6. Redis缓存未命中,调用OpenIM Server获取新Token
        token, err = c.GetAdminTokenServer(ctx, userID)
        if err != nil {
            return "", err
        }
    }
    
    // 7. 更新内存缓存(4分钟TTL)
    t = &authToken{token: token, timeout: time.Now().Add(time.Minute * 4)}
    c.tokenCache[userID] = t
    
    return t.token, nil
}

func (c *Caller) GetAdminTokenServer(ctx context.Context, userID string) (string, error) {
    // 8. 调用OpenIM Server Auth服务获取管理员Token
    resp, err := getAdminToken.Call(ctx, c.imApi, &auth.GetAdminTokenReq{
        Secret: c.imSecret,  // 系统密钥验证
        UserID: userID,      // 管理员用户ID
    })
    if err != nil {
        return "", err
    }
    
    // 9. 将Token缓存到Redis
    err = c.tokenDB.SetIMToken(ctx, userID, resp.Token)
    if err != nil {
        log.ZWarn(ctx, "set im admin token to redis failed", err, "userID", userID)
    }
    
    return resp.Token, nil
}

用户注册RPC服务主流程

go 复制代码
// chat/internal/rpc/chat/login.go:258
func (o *chatSvr) RegisterUser(ctx context.Context, req *chat.RegisterUserReq) (*chat.RegisterUserResp, error) {
    // 1. 权限检查和上下文设置
    isAdmin, err := o.Admin.CheckNilOrAdmin(ctx)
    ctx = o.WithAdminUser(ctx)
    if err != nil {
        return nil, err
    }
    
    // 2. 验证注册信息(手机号/邮箱/账户名格式检查)
    if err = o.checkRegisterInfo(ctx, req.User, isAdmin); err != nil {
        return nil, err
    }
    
    var usedInvitationCode bool
    if !isAdmin {
        // 3. 非管理员用户的注册权限检查
        if !o.AllowRegister {
            return nil, errs.ErrNoPermission.WrapMsg("register user is disabled")
        }
        if req.User.UserID != "" {
            return nil, errs.ErrNoPermission.WrapMsg("only admin can set user id")
        }
        
        // 4. IP频率限制检查
        if err := o.Admin.CheckRegister(ctx, req.Ip); err != nil {
            return nil, err
        }
        
        // 5. 邀请码检查(如果系统配置需要)
        conf, err := o.Admin.GetConfig(ctx)
        if err != nil {
            return nil, err
        }
        if val := conf[constant.NeedInvitationCodeRegisterConfigKey]; datautil.Contain(strings.ToLower(val), "1", "true", "yes") {
            usedInvitationCode = true
            if req.InvitationCode == "" {
                return nil, errs.ErrArgs.WrapMsg("invitation code is empty")
            }
            if err := o.Admin.CheckInvitationCode(ctx, req.InvitationCode); err != nil {
                return nil, err
            }
        }
        
        // 6. 验证码验证
        if req.User.Email == "" {
            // 手机号注册验证码检查
            if _, err := o.verifyCode(ctx, o.verifyCodeJoin(req.User.AreaCode, req.User.PhoneNumber), req.VerifyCode, phone); err != nil {
                return nil, err
            }
        } else {
            // 邮箱注册验证码检查
            if _, err := o.verifyCode(ctx, req.User.Email, req.VerifyCode, mail); err != nil {
                return nil, err
            }
        }
    }
    
    // 7. 生成用户ID(如果没有指定)
    if req.User.UserID == "" {
        for i := 0; i < 20; i++ {
            userID := o.genUserID() // 生成10位数字ID
            _, err := o.Database.GetUser(ctx, userID)
            if err == nil {
                continue // ID已存在,重新生成
            } else if dbutil.IsDBNotFound(err) {
                req.User.UserID = userID
                break
            } else {
                return nil, err
            }
        }
        if req.User.UserID == "" {
            return nil, errs.ErrInternalServer.WrapMsg("gen user id failed")
        }
    }
    
    // 8. 构建凭证信息
    var (
        credentials  []*chatdb.Credential
        registerType int32
    )

    if req.User.PhoneNumber != "" {
        registerType = constant.PhoneRegister
        credentials = append(credentials, &chatdb.Credential{
            UserID:      req.User.UserID,
            Account:     BuildCredentialPhone(req.User.AreaCode, req.User.PhoneNumber),
            Type:        constant.CredentialPhone,
            AllowChange: true,
        })
    }

    if req.User.Account != "" {
        credentials = append(credentials, &chatdb.Credential{
            UserID:      req.User.UserID,
            Account:     req.User.Account,
            Type:        constant.CredentialAccount,
            AllowChange: true,
        })
        registerType = constant.AccountRegister
    }

    if req.User.Email != "" {
        registerType = constant.EmailRegister
        credentials = append(credentials, &chatdb.Credential{
            UserID:      req.User.UserID,
            Account:     req.User.Email,
            Type:        constant.CredentialEmail,
            AllowChange: true,
        })
    }
    
    // 9. 构建数据库记录对象
    now := time.Now()
    register := &chatdb.Register{
        UserID:      req.User.UserID,
        DeviceID:    req.DeviceID,
        IP:          req.Ip,
        Platform:    constantpb.PlatformID2Name[int(req.Platform)],
        AccountType: "",
        Mode:        constant.UserMode,
        CreateTime:  now,
    }
    
    account := &chatdb.Account{
        UserID:         req.User.UserID,
        Password:       req.User.Password,
        OperatorUserID: mcontext.GetOpUserID(ctx),
        ChangeTime:     now,
        CreateTime:     now,
    }

    attribute := &chatdb.Attribute{
        UserID:         req.User.UserID,
        Account:        req.User.Account,
        PhoneNumber:    req.User.PhoneNumber,
        AreaCode:       req.User.AreaCode,
        Email:          req.User.Email,
        Nickname:       req.User.Nickname,
        FaceURL:        req.User.FaceURL,
        Gender:         req.User.Gender,
        BirthTime:      time.UnixMilli(req.User.Birth),
        ChangeTime:     now,
        CreateTime:     now,
        AllowVibration: constant.DefaultAllowVibration,
        AllowBeep:      constant.DefaultAllowBeep,
        AllowAddFriend: constant.DefaultAllowAddFriend,
        RegisterType:   registerType,
    }
    
    // 10. 事务性保存到数据库
    if err := o.Database.RegisterUser(ctx, register, account, attribute, credentials); err != nil {
        return nil, err
    }
    
    // 11. 处理邀请码使用记录
    if usedInvitationCode {
        if err := o.Admin.UseInvitationCode(ctx, req.User.UserID, req.InvitationCode); err != nil {
            log.ZError(ctx, "UseInvitationCode", err, "userID", req.User.UserID, "invitationCode", req.InvitationCode)
        }
    }
    
    // 12. 生成ChatToken(如果启用自动登录)
    var resp chat.RegisterUserResp
    if req.AutoLogin {
        chatToken, err := o.Admin.CreateToken(ctx, req.User.UserID, constant.NormalUser)
        if err == nil {
            resp.ChatToken = chatToken.Token
        } else {
            log.ZError(ctx, "Admin CreateToken Failed", err, "userID", req.User.UserID, "platform", req.Platform)
        }
    }
    
    resp.UserID = req.User.UserID
    return &resp, nil
}

数据库事务操作 - database/chat.go

go 复制代码
// chat/pkg/common/db/database/chat.go:214
func (o *ChatDatabase) RegisterUser(ctx context.Context, register *chatdb.Register, account *chatdb.Account, attribute *chatdb.Attribute, credentials []*chatdb.Credential) error {
    // 使用MongoDB事务确保数据一致性
    return o.tx.Transaction(ctx, func(ctx context.Context) error {
        // 1. 创建注册记录
        if err := o.register.Create(ctx, register); err != nil {
            return err
        }
        
        // 2. 创建用户账户(密码)
        if err := o.account.Create(ctx, account); err != nil {
            return err
        }
        
        // 3. 创建用户属性
        if err := o.attribute.Create(ctx, attribute); err != nil {
            return err
        }
        
        // 4. 创建登录凭证
        if err := o.credential.Create(ctx, credentials...); err != nil {
            return err
        }
        
        return nil
    })
}

4.3 第三阶段:OpenIM Server集成

4.3.1 Chat系统调用OpenIM - caller.go
go 复制代码
// chat/pkg/common/imapi/caller.go:175
func (c *Caller) RegisterUser(ctx context.Context, users []*sdkws.UserInfo) error {
    // 调用OpenIM Server用户注册接口
    _, err := registerUser.Call(ctx, c.imApi, &user.UserRegisterReq{
        Users: users,
    })
    return err
}

获取UserToken

go 复制代码
// chat/pkg/common/imapi/caller.go:126
func (c *Caller) GetUserToken(ctx context.Context, userID string, platformID int32) (string, error) {
    // 为用户生成IM Token用于WebSocket连接
    resp, err := getuserToken.Call(ctx, c.imApi, &auth.GetUserTokenReq{
        PlatformID: platformID,
        UserID:     userID,
    })
    if err != nil {
        return "", err
    }
    return resp.Token, nil
}

获取AdminToken

go 复制代码
// open-im-server/internal/rpc/auth/auth.go:142
func (s *authServer) GetAdminToken(ctx context.Context, req *pbauth.GetAdminTokenReq) (*pbauth.GetAdminTokenResp, error) {
    // 1. 验证系统密钥
    if req.Secret != s.config.Share.Secret {
        return nil, errs.ErrNoPermission.WrapMsg("secret invalid")
    }
    
    // 2. 验证用户ID是否在管理员列表中
    if !datautil.Contain(req.UserID, s.config.Share.IMAdminUserID...) {
        return nil, errs.ErrArgs.WrapMsg("userID is error")
    }
    
    // 3. 验证管理员用户是否存在
    if err := s.userClient.CheckUser(ctx, []string{req.UserID}); err != nil {
        return nil, err
    }
    
    // 4. 生成JWT Token
    token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(constant.AdminPlatformID))
    if err != nil {
        return nil, err
    }
    
    return &pbauth.GetAdminTokenResp{
        Token: token,
        ExpireTimeSeconds: s.config.RpcConfig.TokenPolicy.Expire * 24 * 60 * 60,
    }, nil
}
4.3.2 OpenIM Server用户注册 - user.go
go 复制代码
// open-im-server/internal/rpc/user/user.go:453
func (s *userServer) UserRegister(ctx context.Context, req *pbuser.UserRegisterReq) (resp *pbuser.UserRegisterResp, err error) {
    resp = &pbuser.UserRegisterResp{}
    
    // 1. 参数验证
    if len(req.Users) == 0 {
        return nil, errs.ErrArgs.WrapMsg("users is empty")
    }

    // 2. 管理员权限检查
    if err = authverify.CheckAdmin(ctx, s.config.Share.IMAdminUserID); err != nil {
        return nil, err
    }

    // 3. 检查用户ID重复
    if datautil.DuplicateAny(req.Users, func(e *sdkws.UserInfo) string { return e.UserID }) {
        return nil, errs.ErrArgs.WrapMsg("userID repeated")
    }
    
    // 4. 用户ID格式验证
    userIDs := make([]string, 0)
    for _, user := range req.Users {
        if user.UserID == "" {
            return nil, errs.ErrArgs.WrapMsg("userID is empty")
        }
        if strings.Contains(user.UserID, ":") {
            return nil, errs.ErrArgs.WrapMsg("userID contains ':' is invalid userID")
        }
        userIDs = append(userIDs, user.UserID)
    }
    
    // 5. 检查用户是否已存在
    exist, err := s.db.IsExist(ctx, userIDs)
    if err != nil {
        return nil, err
    }
    if exist {
        return nil, servererrs.ErrRegisteredAlready.WrapMsg("userID registered already")
    }
    
    // 6. 注册前webhook
    if err := s.webhookBeforeUserRegister(ctx, &s.config.WebhooksConfig.BeforeUserRegister, req); err != nil {
        return nil, err
    }
    
    // 7. 构建用户数据
    now := time.Now()
    users := make([]*tablerelation.User, 0, len(req.Users))
    for _, user := range req.Users {
        users = append(users, &tablerelation.User{
            UserID:           user.UserID,
            Nickname:         user.Nickname,
            FaceURL:          user.FaceURL,
            Ex:               user.Ex,
            CreateTime:       now,
            AppMangerLevel:   user.AppMangerLevel,
            GlobalRecvMsgOpt: user.GlobalRecvMsgOpt,
        })
    }
    
    // 8. 保存到OpenIM数据库
    if err := s.db.Create(ctx, users); err != nil {
        return nil, err
    }
    
    // 9. 更新监控指标
    prommetrics.UserRegisterCounter.Add(float64(len(users)))
    
    // 10. 注册后webhook
    s.webhookAfterUserRegister(ctx, &s.config.WebhooksConfig.AfterUserRegister, req)
    
    return resp, nil
}

4.4 注册的系统交互时序图

以下时序图展示了OpenIM设备注册的完整流程,包括验证码发送和用户注册两个主要阶段:

sequenceDiagram participant Client as Android Demo
RegisterActivity participant SDK as open-im-sdk-android
LoginVM participant ChatAPI as Chat System
HTTP API participant ChatRPC as Chat System
RPC Service participant Redis as Redis Cache
验证码/Token存储 participant ChatDB as Chat Database
MongoDB participant OpenIMAPI as OpenIM Server
RPC API participant OpenIMDB as OpenIM Database
MongoDB Note over Client,OpenIMDB: === 阶段1: 验证码发送流程 === Client->>SDK: 1.1 用户点击发送验证码
vm.getVerificationCode(1) SDK->>ChatAPI: 1.2 POST /account/code/send
{"account":"手机号","usedFor":1} ChatAPI->>ChatRPC: 1.3 SendVerifyCode RPC调用
检查IP频率限制 ChatRPC->>ChatDB: 1.4 验证账户状态
检查是否已注册 ChatDB-->>ChatRPC: 1.5 返回查询结果 ChatRPC->>ChatRPC: 1.6 生成6位验证码
fmt.Sprintf("%06d", rand.Intn(1000000)) ChatRPC->>Redis: 1.7 存储验证码
Key: verify_code:{account}:1
TTL: 5分钟 Redis-->>ChatRPC: 1.8 存储成功 ChatRPC->>ChatRPC: 1.9 发送短信/邮件
调用第三方服务 ChatRPC-->>ChatAPI: 1.10 发送成功响应 ChatAPI-->>SDK: 1.11 HTTP 200 OK SDK-->>Client: 1.12 UI显示发送成功
启动倒计时 Note over Client,OpenIMDB: === 阶段2: 用户注册流程 === Client->>SDK: 2.1 用户输入信息并注册
vm.register() SDK->>SDK: 2.2 客户端验证
密码格式、昵称等 SDK->>ChatAPI: 2.3 POST /account/register
{"user":{...},"verifyCode":"123456","platform":2} ChatAPI->>ChatAPI: 2.4 获取管理员Token
ImAdminTokenWithDefaultAdmin() ChatAPI->>OpenIMAPI: 2.5 获取Admin Token
GetAdminToken(secret, adminUserID) OpenIMAPI->>OpenIMAPI: 2.6 验证密钥和管理员身份 OpenIMAPI->>OpenIMDB: 2.7 检查管理员用户存在性 OpenIMDB-->>OpenIMAPI: 2.8 用户存在确认 OpenIMAPI->>Redis: 2.9 缓存Admin Token
Key: CHAT:IM_TOKEN:{adminUserID}
TTL: 4分钟 Redis-->>OpenIMAPI: 2.10 缓存成功 OpenIMAPI-->>ChatAPI: 2.11 返回Admin Token
JWT格式 ChatAPI->>ChatRPC: 2.12 RegisterUser RPC调用
携带所有注册信息 ChatRPC->>Redis: 2.13 验证验证码
Key: verify_code:{account}:1 Redis-->>ChatRPC: 2.14 返回存储的验证码 ChatRPC->>ChatRPC: 2.15 验证码比对
验证成功后删除 ChatRPC->>Redis: 2.16 删除已使用的验证码
确保一次性使用 ChatRPC->>ChatRPC: 2.17 生成用户ID
10位数字ID ChatRPC->>ChatDB: 2.18 事务性保存用户数据
Register+Account+Attribute+Credential Note over ChatDB: MongoDB事务操作
register: 注册记录
account: 密码信息
attribute: 用户属性
credential: 登录凭证 ChatDB-->>ChatRPC: 2.19 保存成功确认 ChatRPC->>ChatRPC: 2.20 生成Chat Token
JWT: {userID, userType:1, platformID:0} ChatRPC->>Redis: 2.21 缓存Chat Token状态
Key: CHAT_UID_TOKEN_STATUS:{userID}
Value: {token: 1} Redis-->>ChatRPC: 2.22 缓存成功 ChatRPC-->>ChatAPI: 2.23 返回注册结果
{userID, chatToken} ChatAPI->>OpenIMAPI: 2.24 在OpenIM Server注册用户
RegisterUser([UserInfo]) OpenIMAPI->>OpenIMAPI: 2.25 管理员权限检查
验证Admin Token OpenIMAPI->>OpenIMAPI: 2.26 用户数据验证
UserID格式、重复检查 OpenIMAPI->>OpenIMDB: 2.27 检查用户是否已存在 OpenIMDB-->>OpenIMAPI: 2.28 用户不存在确认 OpenIMAPI->>OpenIMDB: 2.29 保存IM用户信息
User{UserID, Nickname, FaceURL...} OpenIMDB-->>OpenIMAPI: 2.30 保存成功 OpenIMAPI-->>ChatAPI: 2.31 IM用户注册成功 ChatAPI->>OpenIMAPI: 2.32 获取用户IM Token
GetUserToken(userID, platformID) OpenIMAPI->>OpenIMAPI: 2.33 验证请求权限
只有管理员可调用 OpenIMAPI->>OpenIMAPI: 2.34 生成用户IM Token
JWT: {userID, platformID:2} OpenIMAPI->>Redis: 2.35 缓存IM Token状态
Key: UID_PID_TOKEN_STATUS:{userID}:Android
Value: {token: 1} Redis-->>OpenIMAPI: 2.36 缓存成功 OpenIMAPI-->>ChatAPI: 2.37 返回IM Token ChatAPI-->>SDK: 2.38 返回完整注册结果
{userID, chatToken, imToken} SDK->>SDK: 2.39 缓存登录凭证
LoginCertificate.cache() SDK-->>Client: 2.40 注册成功,跳转主界面 Note over Client,OpenIMDB: === Token缓存结构说明 === Note over Redis: Chat Token: CHAT_UID_TOKEN_STATUS:{userID}
Admin Token: CHAT:IM_TOKEN:{adminUserID}
IM Token: UID_PID_TOKEN_STATUS:{userID}:{platform}
验证码: verify_code:{account}:{usedFor}

5. 登录流程源码分析

5.1 第一阶段:Android客户端用户交互

5.1.1 用户界面 - LoginActivity.java
java 复制代码
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/ui/login/LoginActivity.java:177-253
view.submit.setOnClickListener(v -> {
    vm.areaCode.setValue("+"+view.loginContent.countryCode.getSelectedCountryCode());
    waitDialog.show();
    vm.login(isVCLogin ? vm.pwd.getValue() : null, 3);
});

关键点分析:

  • 设置区号:从国家代码选择器获取区号
  • 显示等待对话框:提供用户反馈
  • 调用ViewModel的login方法:传入验证码和用途标识(3表示登录)
5.1.2 登录业务逻辑 - LoginVM.java

LoginViewModel负责处理登录的核心业务逻辑:

java 复制代码
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:51-85
public void login(String verificationCode, int usedFor) {
    // 1. 保存登录类型到本地缓存
    SharedPreferencesUtil.get(BaseApp.inst())
        .setCache(Constants.K_LOGIN_TYPE, isPhone.val() ? 0 : 1);
    
    // 2. 构建登录参数
    Parameter parameter = getParameter(verificationCode, usedFor);
    
    // 3. 调用Chat系统登录API
    N.API(OpenIMService.class)
        .login(parameter.buildJsonBody())
        .compose(N.IOMain())
        .map(OpenIMService.turn(LoginCertificate.class))
        .subscribe(new NetObserver<LoginCertificate>(getContext()) {
            @Override
            public void onSuccess(LoginCertificate loginCertificate) {
                // 4. 获取到ChatToken和ImToken后,连接OpenIM SDK
                try {
                    OpenIMClient.getInstance().login(new OnBase<String>() {
                        @Override
                        public void onError(int code, String error) {
                            getIView().err(error);
                        }
                        
                        @Override
                        public void onSuccess(String data) {
                            // 5. SDK连接成功,缓存登录信息
                            loginCertificate.cache(getContext());
                            BaseApp.inst().loginCertificate = loginCertificate;
                            getIView().jump();
                        }
                    }, loginCertificate.userID, loginCertificate.imToken);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            
            @Override
            protected void onFailure(Throwable e) {
                getIView().err(e.getMessage());
            }
        });
}

参数构建详解:

java 复制代码
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:94-110
@NonNull
private Parameter getParameter(String verificationCode, int usedFor) {
    Parameter parameter = new Parameter().add("password",
            TextUtils.isEmpty(verificationCode) ? md5(pwd.val()) : null)
        .add("platform", 2).add("usedFor", usedFor)
        .add("operationID", System.currentTimeMillis() + "")
        .add("verifyCode", verificationCode);
    if (isPhone.val()) {
        parameter.add("phoneNumber", account.getValue());
        parameter.add("areaCode", areaCode.val());
    } else
        parameter.add("email", account.getValue());
    return parameter;
}

关键参数说明:

  • platform: 平台ID,Android固定为2
  • usedFor: 用途标识,登录为3
  • password: 密码MD5哈希(验证码登录时为null)
  • verifyCode: 验证码(密码登录时为null)
  • phoneNumber/email: 登录账号
  • operationID: 操作ID,用于链路追踪
5.1.3 API接口定义 - OpenIMService.java
java 复制代码
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/repository/OpenIMService.java:18-19
@POST("account/login")
Observable<ResponseBody> login(@Body RequestBody requestBody);

使用Retrofit定义RESTful API接口,支持RxJava响应式编程。

5.2 第二阶段:Chat API网关处理

5.2.1 HTTP请求路由 - chat.go

Chat API网关接收到登录请求后进行处理:

go 复制代码
// chat/internal/api/chat/chat.go:197-247
func (o *Api) Login(c *gin.Context) {
    // 1. 解析登录请求参数(账号/手机/邮箱 + 密码/验证码)
    req, err := a2r.ParseRequest[chatpb.LoginReq](c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    // 2. 获取客户端IP地址,用于安全检查和登录记录
    ip, err := o.GetClientIP(c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }
    req.Ip = ip

    // 3. 调用Chat RPC服务进行用户认证,获取ChatToken
    resp, err := o.chatClient.Login(c, req)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    // 4. 获取OpenIM管理员Token,用于调用OpenIM Server的管理接口
    adminToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }
    apiCtx := mctx.WithApiToken(c, adminToken)

    // 5. 使用管理员Token调用OpenIM Server,为用户生成ImToken
    imToken, err := o.imApiCaller.GetUserToken(apiCtx, resp.UserID, req.Platform)
    if err != nil {
        apiresp.GinError(c, err)
        return
    }

    // 6. 返回登录结果,包含两个Token
    apiresp.GinSuccess(c, &apistruct.LoginResp{
        ImToken:   imToken,     // OpenIM Server WebSocket连接Token
        UserID:    resp.UserID,
        ChatToken: resp.ChatToken, // Chat系统API认证Token
    })
}

双Token机制详解:

  • ChatToken: 用于Chat系统REST API认证,访问用户管理、配置等功能
  • ImToken: 用于OpenIM Server WebSocket连接认证,进行实时消息通信

5.3 第三阶段:Chat RPC服务认证

5.3.1 用户认证核心逻辑 - login.go

Chat RPC服务负责核心的用户认证逻辑,这是登录流程的关键环节:

go 复制代码
// chat/internal/rpc/chat/login.go:434-511
func (o *chatSvr) Login(ctx context.Context, req *chat.LoginReq) (*chat.LoginResp, error) {
    resp := &chat.LoginResp{}
    
    // 1. 参数验证:密码和验证码至少要有一个
    if req.Password == "" && req.VerifyCode == "" {
        return nil, errs.ErrArgs.WrapMsg("password or code must be set")
    }
    
    var (
        err        error
        credential *chatdb.Credential  // 用户凭证信息
        acc        string              // 登录账号
    )

    // 2. 根据不同的登录方式构建账号字符串
    switch {
    case req.Account != "":
        // 用户名登录
        acc = req.Account
    case req.PhoneNumber != "":
        // 手机号登录
        if req.AreaCode == "" {
            return nil, errs.ErrArgs.WrapMsg("area code must")
        }
        if !strings.HasPrefix(req.AreaCode, "+") {
            req.AreaCode = "+" + req.AreaCode
        }
        if _, err := strconv.ParseUint(req.AreaCode[1:], 10, 64); err != nil {
            return nil, errs.ErrArgs.WrapMsg("area code must be number")
        }
        // 构建手机号凭证格式:+86 13800000000
        acc = BuildCredentialPhone(req.AreaCode, req.PhoneNumber)
    case req.Email != "":
        // 邮箱登录
        acc = req.Email
    default:
        return nil, errs.ErrArgs.WrapMsg("account or phone number or email must be set")
    }
    
    // 3. 根据账号查询用户凭证信息
    credential, err = o.Database.TakeCredentialByAccount(ctx, acc)
    if err != nil {
        if dbutil.IsDBNotFound(err) {
            return nil, eerrs.ErrAccountNotFound.WrapMsg("user unregistered")
        }
        return nil, err
    }
    
    // 4. IP和用户登录频率检查
    if err := o.Admin.CheckLogin(ctx, credential.UserID, req.Ip); err != nil {
        return nil, err
    }
    
    var verifyCodeID *string
    
    // 5. 根据登录方式进行认证
    if req.Password == "" {
        // 验证码登录方式
        var id string
        
        if req.Email == "" {
            // 手机验证码登录
            account := o.verifyCodeJoin(req.AreaCode, req.PhoneNumber)
            id, err = o.verifyCode(ctx, account, req.VerifyCode, phone)
            if err != nil {
                return nil, err
            }
        } else {
            // 邮箱验证码登录
            account := req.Email
            id, err = o.verifyCode(ctx, account, req.VerifyCode, mail)
            if err != nil {
                return nil, err
            }
        }
        
        if id != "" {
            verifyCodeID = &id
        }
    } else {
        // 密码登录方式
        account, err := o.Database.TakeAccount(ctx, credential.UserID)
        if err != nil {
            return nil, err
        }
        
        // 密码比对(MD5哈希值比较)
        if account.Password != req.Password {
            return nil, eerrs.ErrPassword.Wrap()
        }
    }
    
    // 6. 生成ChatToken
    chatToken, err := o.Admin.CreateToken(ctx, credential.UserID, constant.NormalUser)
    if err != nil {
        return nil, err
    }
    
    // 7. 记录登录日志
    record := &chatdb.UserLoginRecord{
        UserID:    credential.UserID,
        LoginTime: time.Now(),
        IP:        req.Ip,
        DeviceID:  req.DeviceID,
        Platform:  constantpb.PlatformIDToName(int(req.Platform)),
    }
    if err := o.Database.LoginRecord(ctx, record, verifyCodeID); err != nil {
        return nil, err
    }
    
    // 8. 清理已使用的验证码
    if verifyCodeID != nil {
        if err := o.Database.DelVerifyCode(ctx, *verifyCodeID); err != nil {
            return nil, err
        }
    }
    
    // 9. 返回登录成功响应
    resp.UserID = credential.UserID
    resp.ChatToken = chatToken.Token
    return resp, nil
}
5.3.2 关键认证机制详解

账号凭证构建策略:

  • 用户名登录: 直接使用账号字符串
  • 手机号登录 : 格式化为+{区号} {手机号}(如:+86 13800000000)
  • 邮箱登录: 直接使用邮箱地址

双重认证支持:

  • 密码认证: MD5哈希值比对,适用于常规登录
  • 验证码认证: 动态验证码验证,适用于快捷登录或找回密码

安全防护机制:

  • IP频率限制: 防止暴力破解攻击
  • 验证码一次性使用: 防止重放攻击
  • 登录日志记录: 支持安全审计和异常检测

5.4 第四阶段:OpenIM Admin Token生成

5.4.1 管理员Token请求 - auth.go
go 复制代码
// open-im-server/internal/rpc/auth/auth.go:200-236
func (s *authServer) GetAdminToken(ctx context.Context, req *pbauth.GetAdminTokenReq) (*pbauth.GetAdminTokenResp, error) {
    resp := pbauth.GetAdminTokenResp{}

    // 验证系统密钥
    if req.Secret != s.config.Share.Secret {
        return nil, errs.ErrNoPermission.WrapMsg("secret invalid")
    }

    // 验证用户ID是否在管理员列表中
    if !datautil.Contain(req.UserID, s.config.Share.IMAdminUserID...) {
        return nil, errs.ErrArgs.WrapMsg("userID is error.", "userID", req.UserID, "adminUserID", s.config.Share.IMAdminUserID)
    }

    // 验证管理员用户是否存在
    if err := s.userClient.CheckUser(ctx, []string{req.UserID}); err != nil {
        return nil, err
    }

    // 生成管理员Token
    token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(constant.AdminPlatformID))
    if err != nil {
        return nil, err
    }

    // 记录管理员登录指标
    prommetrics.UserLoginCounter.Inc()

    // 构建响应
    resp.Token = token
    resp.ExpireTimeSeconds = s.config.RpcConfig.TokenPolicy.Expire * 24 * 60 * 60
    return &resp, nil
}

安全验证层级:

  1. 系统密钥验证: 确保请求来自可信系统
  2. 管理员身份验证: 确认用户ID在管理员列表中
  3. 用户存在性验证: 确认管理员用户存在于系统中

5.5 第五阶段:用户ImToken生成

5.5.1 用户Token生成 - auth.go
go 复制代码
// open-im-server/internal/rpc/auth/auth.go:267-303
func (s *authServer) GetUserToken(ctx context.Context, req *pbauth.GetUserTokenReq) (*pbauth.GetUserTokenResp, error) {
    // 验证调用者是否为管理员
    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
}

权限控制层级:

  1. 管理员权限验证: 只有管理员才能为用户生成Token
  2. 平台限制: 不能为管理员平台生成普通用户Token
  3. 用户类型检查: 防止权限提升攻击
  4. 应用账号保护: 特殊账号的额外保护
5.5.2 Token生成和缓存机制

Token的生成涉及复杂的多端登录策略和缓存机制(具体实现在controller.AuthDatabase中):

Token状态管理:

  • NormalToken(1): 正常有效状态
  • KickedToken(2): 被踢下线状态
  • 缓存键格式 : UID_PID_TOKEN_STATUS:{userID}:{platformName}

多端登录策略:

  • DefaultNotKick: 允许多端同时在线
  • AllLoginButSameTermKick: 同终端互踢
  • AllLoginButSameClassKick: 同类别互踢

5.6 第六阶段:设备端Token保存

5.6.1 登录凭证缓存 - LoginCertificate.java
java 复制代码
// open-im-android-demo2/OUIKit/OUICore/src/main/java/io/openim/android/ouicore/entity/LoginCertificate.java:32-40
public void cache(Context context) {
    Log.d(TAG, "LoginCertificate cache context:" + context);
    SharedPreferencesUtil.get(context).setCache("user.LoginCertificate",
        GsonHel.toJson(this));
}

public static LoginCertificate getCache(Context context) {
    String u = SharedPreferencesUtil.get(context).getString("user.LoginCertificate");
    if (u.isEmpty()) {
        Log.d(TAG, "LoginCertificate getCache null, context:" + context);
        return null;
    }
    Log.d(TAG, "LoginCertificate getCache ok. context:" + context);
    return GsonHel.fromJson(u, LoginCertificate.class);
}

LoginCertificate结构:

java 复制代码
// open-im-android-demo2/OUIKit/OUICore/src/main/java/io/openim/android/ouicore/entity/LoginCertificate.java:13-26
public class LoginCertificate {
    public String nickname;      // 用户昵称
    public String faceURL;       // 头像URL
    public String userID;        // 用户ID
    public String imToken;       // IM连接Token
    public String chatToken;     // Chat API Token
    public boolean allowAddFriend;    // 允许添加好友
    public boolean allowBeep;         // 允许提示音
    public boolean allowVibration;    // 允许振动
    public int globalRecvMsgOpt;      // 全局消息接收选项
}
5.6.2 OpenIM SDK完整登录流程 - LoginVM.java

Chat系统认证成功后,Android应用需要调用OpenIM SDK的login接口建立IM连接:

java 复制代码
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:51-85
public void login(String verificationCode, int usedFor) {
    // 1. 保存登录类型到本地缓存
    SharedPreferencesUtil.get(BaseApp.inst())
        .setCache(Constants.K_LOGIN_TYPE, isPhone.val() ? 0 : 1);
    
    // 2. 构建登录参数并调用Chat系统API
    Parameter parameter = getParameter(verificationCode, usedFor);
    
    N.API(OpenIMService.class)
        .login(parameter.buildJsonBody())
        .compose(N.IOMain())
        .map(OpenIMService.turn(LoginCertificate.class))
        .subscribe(new NetObserver<LoginCertificate>(getContext()) {
            @Override
            public void onSuccess(LoginCertificate loginCertificate) {
                // 3. Chat登录成功后,立即调用OpenIM SDK登录
                try {
                    /**
                     * OpenIM SDK登录核心调用
                     * 参数说明:
                     * - callback: 登录结果回调接口
                     * - userID: 用户唯一标识,与Chat系统保持一致
                     * - imToken: IM连接Token,用于WebSocket认证
                     * 
                     * 功能作用:
                     * 1. 建立与OpenIM Server的WebSocket长连接
                     * 2. 初始化SDK内部各种管理器和服务
                     * 3. 启动消息同步、会话管理等后台服务
                     * 4. 验证Token并处理多端登录策略
                     */
                    OpenIMClient.getInstance().login(new OnBase<String>() {
                        @Override
                        public void onError(int code, String error) {
                            // SDK登录失败处理
                            log.ZError("SDK login failed", "code", code, "error", error);
                            getIView().err(error);
                        }
                        
                        @Override
                        public void onSuccess(String data) {
                            // 4. SDK登录成功,缓存完整登录信息
                            log.ZInfo("SDK login success", "userID", loginCertificate.userID);
                            
                            // 缓存登录凭证到本地存储
                            loginCertificate.cache(getContext());
                            
                            // 设置全局登录状态
                            BaseApp.inst().loginCertificate = loginCertificate;
                            
                            // 通知UI层登录成功
                            getIView().jump();
                        }
                    }, loginCertificate.userID, loginCertificate.imToken);
                    
                } catch (Exception e) {
                    log.ZError("SDK login exception", e);
                    getIView().err("SDK登录异常: " + e.getMessage());
                }
            }
            
            @Override
            protected void onFailure(Throwable e) {
                // Chat系统登录失败
                getIView().err(e.getMessage());
            }
        });
}

OpenIM SDK登录核心机制:

  1. 双层认证设计

    • Chat Token: 用于Chat系统REST API调用
    • IM Token: 用于OpenIM Server WebSocket连接
  2. SDK初始化流程

    java 复制代码
    // SDK内部初始化关键步骤
    OpenIMClient.getInstance().login() -> {
        // 1. 验证登录状态,防止重复登录
        // 2. 保存用户认证信息(userID + imToken)
        // 3. 初始化各种管理器:消息、会话、好友、群组等
        // 4. 启动后台服务:长连接、消息同步、事件处理
        // 5. 建立WebSocket连接到OpenIM Server
        // 6. 处理多端登录冲突和Token验证
    }
  3. 连接建立顺序

    rust 复制代码
    Android应用 -> Chat系统认证 -> 获取双Token -> SDK初始化 -> WebSocket连接 -> 消息服务启动
  4. 错误处理机制

    • Chat认证失败: 直接返回错误,不进行SDK初始化
    • SDK初始化失败: 回滚登录状态,清理临时数据
    • WebSocket连接失败: 自动重连机制,支持断网恢复

5.7 第七阶段:openim-sdk-core登录

5.7.1 SDK初始化检查 - userRelated.go
go 复制代码
// openim-sdk-core/open_im_sdk/userRelated.go:474-500
func (u *UserContext) login(ctx context.Context, userID, token string) error {
    // 1. 检查是否已经登录,避免重复登录
    if u.getLoginStatus(ctx) == Logged {
        return sdkerrs.ErrLoginRepeat
    }

    // 2. 设置登录状态为登录中
    u.setLoginStatus(Logging)
    log.ZDebug(ctx, "login start... ", "userID", userID, "token", token)
    t1 := time.Now() // 记录登录开始时间,用于统计登录耗时

    // 3. 保存用户认证信息
    u.info.UserID = userID
    u.info.Token = token

    // 4. 初始化各种模块和服务
    if err := u.initialize(ctx, userID); err != nil {
        return err
    }

    // 5. 启动各种后台服务和协程
    u.run(ctx)

    // 6. 设置登录状态为已登录
    u.setLoginStatus(Logged)
    log.ZDebug(ctx, "login success...", "login cost time: ", time.Since(t1))
    return nil
}
5.7.2 启动后台服务 - userRelated.go
go 复制代码
// openim-sdk-core/open_im_sdk/userRelated.go:584-600
func (u *UserContext) run(ctx context.Context) {
    // 1. 启动长连接管理器(包含读写pump和心跳)
    u.longConnMgr.Run(ctx, u.fgCtx)

    // 2. 启动消息同步器监听协程
    go u.msgSyncer.DoListener(ctx)

    // 3. 启动会话事件处理协程
    go u.conversation.ConsumeConversationEventLoop(ctx)

    // 4. 启动登出监听协程
    go u.logoutListener(ctx)
}
5.7.3 长连接管理器启动 - long_conn_mgr.go
go 复制代码
// openim-sdk-core/internal/interaction/long_conn_mgr.go:163-168
func (c *LongConnMgr) Run(ctx, fgCtx context.Context) {
    go c.readPump(ctx, fgCtx)  // 启动读消息协程
    go c.writePump(ctx)        // 启动写消息协程
    go c.heartbeat(ctx, fgCtx) // 启动心跳协程
}

关键协程说明:

  • readPump: 负责从WebSocket连接读取消息,处理重连逻辑
  • writePump: 负责向WebSocket连接发送消息,处理发送队列
  • heartbeat: 负责心跳检测,保持连接活跃状态
5.7.4 读消息协程启动与重连机制 - long_conn_mgr.go

readPump是长连接的核心协程,负责维护WebSocket连接和处理重连:

go 复制代码
// openim-sdk-core/internal/interaction/long_conn_mgr.go:178-242
func (c *LongConnMgr) readPump(ctx context.Context, fgCtx context.Context) {
    // panic恢复机制,确保程序不会因为panic而崩溃
    defer func() {
        if r := recover(); r != nil {
            err := fmt.Sprintf("panic: %+v\n%s", r, debug.Stack())
            log.ZWarn(ctx, "readPump panic", nil, "panic info", err)
        }
    }()

    // 退出时清理资源
    defer func() {
        _ = c.close() // 关闭连接
        log.ZWarn(c.ctx, "readPump closed", c.closedErr)
    }()

    connNum := 0 // 连接计数器,用于统计重连次数

    // 主循环:持续读取消息
    for {
        // 检查上下文状态,支持优雅退出
        select {
        case <-ctx.Done():
            // 主上下文取消,通常是SDK退出登录
            c.closedErr = ctx.Err()
            log.ZInfo(c.ctx, "readPump done, sdk logout.....")
            return
        case <-fgCtx.Done():
            // 前台上下文取消,应用切换到后台
            c.closedErr = context.Cause(fgCtx)
            log.ZInfo(c.ctx, "SDK transitioning from foreground to background, read message goroutine ended.")
            return
        default:
            // 继续执行读取逻辑
        }

        // 为每次操作生成新的操作ID,便于日志追踪
        ctx = ccontext.WithOperationID(ctx, utils.OperationIDGenerator())

        // 尝试重连或确保连接可用 - 关键步骤
        needRecon, err := c.reConn(ctx, &connNum)
        if !needRecon {
            // 不需要重连,但有错误,说明是致命错误,需要退出
            c.closedErr = err
            return
        }
        if err != nil {
            // 重连失败,等待一段时间后重试
            log.ZWarn(c.ctx, "reConn", err)
            time.Sleep(c.reconnectStrategy.GetSleepInterval()) // 使用重连策略的等待间隔
            continue
        }

        // 连接建立成功,开始读取消息
        // 设置连接参数
        c.conn.SetReadLimit(maxMessageSize)  // 设置最大消息大小限制
        _ = c.conn.SetReadDeadline(pongWait) // 设置读取超时时间

        // 读取WebSocket消息并处理...
    }
}
5.7.5 WebSocket连接建立与Token携带 - long_conn_mgr.go

reConn方法是建立WebSocket连接的核心,这里展示了token如何通过URL参数传递给服务端:

go 复制代码
// openim-sdk-core/internal/interaction/long_conn_mgr.go:938-970
func (c *LongConnMgr) reConn(ctx context.Context, num *int) (needRecon bool, err error) {
    // 检查连接状态,如果已连接则直接返回
    if c.IsConnected() {
        return true, nil
    }
    
    c.connWrite.Lock()         // 获取写锁,确保连接建立的原子性
    defer c.connWrite.Unlock() // 释放写锁
    
    // 通知监听器开始连接
    c.listener().OnConnecting()
    c.SetConnectionStatus(Connecting)
    
    // 构建WebSocket连接URL,关键:token通过URL参数传递
    url := fmt.Sprintf("%s?sendID=%s&token=%s&platformID=%d&operationID=%s&isBackground=%t",
        ccontext.Info(ctx).WsAddr(),      // WebSocket服务器地址
        ccontext.Info(ctx).UserID(),      // 用户ID
        ccontext.Info(ctx).Token(),       // 关键:ImToken通过URL参数传递
        ccontext.Info(ctx).PlatformID(),  // 平台ID
        ccontext.Info(ctx).OperationID(), // 操作ID,用于链路追踪
        c.GetBackground())                // 是否后台状态
    
    // 如果启用压缩,添加压缩参数
    if c.IsCompression {
        url += fmt.Sprintf("&compression=%s", "gzip")
    }
    
    log.ZDebug(ctx, "conn start", "url", url)
    
    // 发起WebSocket连接
    resp, err := c.conn.Dial(url, nil)
    if err != nil {
        c.SetConnectionStatus(Closed)
        
        // 处理连接失败响应
        if resp != nil {
            body, err := io.ReadAll(resp.Body)
            if err != nil {
                return true, err
            }
            log.ZInfo(ctx, "reConn resp", "body", string(body))
            
            // 解析错误响应
            var apiResp struct {
                ErrCode int    `json:"errCode"`
                ErrMsg  string `json:"errMsg"`
                ErrDlt  string `json:"errDlt"`
            }
            if err := json.Unmarshal(body, &apiResp); err != nil {
                return true, err
            }
            
            err = errs.NewCodeError(apiResp.ErrCode, apiResp.ErrMsg).WithDetail(apiResp.ErrDlt).Wrap()
            ccontext.GetApiErrCodeCallback(ctx).OnError(ctx, err)
            
            // 检查Token相关错误,这些错误不需要重连
            switch apiResp.ErrCode {
            case
                errs.TokenExpiredError,      // Token过期
                errs.TokenInvalidError,      // Token无效
                errs.TokenMalformedError,    // Token格式错误
                errs.TokenNotValidYetError,  // Token尚未生效
                errs.TokenUnknownError,      // Token未知错误
                errs.TokenNotExistError,     // Token不存在
                errs.TokenKickedError:       // Token被踢下线
                return false, err // 不需要重连,直接返回错误
            default:
                return true, err // 其他错误,可以重连
            }
        }
        
        c.listener().OnConnectFailed(sdkerrs.NetworkError, err.Error())
        return true, err
    }
    
    // 连接建立成功后的处理
    if err := c.writeConnFirstSubMsg(ctx); err != nil {
        log.ZError(ctx, "first write user online sub info error", err)
        ccontext.GetApiErrCodeCallback(ctx).OnError(ctx, err)
        c.listener().OnConnectFailed(sdkerrs.NetworkError, err.Error())
        c.conn.Close()
        return true, err
    }
    
    // 通知连接成功
    c.listener().OnConnectSuccess()
    c.sub.onConnSuccess()
    c.ctx = newContext(c.conn.LocalAddr())
    c.ctx = context.WithValue(ctx, "ConnContext", c.ctx)
    c.SetConnectionStatus(Connected)
    
    // 设置心跳处理器
    c.conn.SetPongHandler(c.pongHandler)
    c.conn.SetPingHandler(c.pingHandler)
    
    *num++
    log.ZInfo(c.ctx, "long conn establish success", "localAddr", c.conn.LocalAddr(), "connNum", *num)
    
    // 重置重连策略
    c.reconnectStrategy.Reset()
    
    // 通知消息同步器连接已建立
    _ = common.DispatchConnected(ctx, c.pushMsgAndMaxSeqCh)
    return true, nil
}

WebSocket URL构建关键点:

  1. Token传递方式 : 通过URL查询参数token字段携带ImToken

  2. 必要参数:

    • sendID: 用户ID,标识连接的所有者
    • token: ImToken,用于身份验证(阶段5生成的)
    • platformID: 平台ID,区分不同设备类型
    • operationID: 操作ID,用于链路追踪
    • isBackground: 后台状态标识
    • compression: 压缩类型(可选)
  3. 错误处理: 详细的Token相关错误处理,区分可重连和不可重连的错误

连接URL示例:

ini 复制代码
ws://localhost:10003?sendID=user123&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&platformID=2&operationID=1640123456789&isBackground=false&compression=gzip

5.8 第八阶段:SDK HTTP请求Token认证与API网关验证

登录成功后,SDK在进行HTTP API调用时需要携带Token进行身份认证,OpenIM Server的API网关会对这些请求进行统一的Token验证。

5.8.1 SDK HTTP客户端Token携带 - http_client.go

SDK的HTTP客户端在发送API请求时会自动携带Token:

go 复制代码
// openim-sdk-core/pkg/network/http_client.go:90-110
func ApiPost(ctx context.Context, api string, req, resp any) (err error) {
    // 提取operationID并验证
    operationID, _ := ctx.Value("operationID").(string)
    if operationID == "" {
        err := sdkerrs.ErrArgs.WrapMsg("call api operationID is empty")
        log.ZError(ctx, "ApiRequest", err, "type", "ctx not set operationID")
        return err
    }

    // 序列化请求对象为JSON
    reqBody, err := json.Marshal(req)
    if err != nil {
        log.ZError(ctx, "ApiRequest", err, "type", "json.Marshal(req) failed")
        return sdkerrs.ErrSdkInternal.WrapMsg("json.Marshal(req) failed " + err.Error())
    }

    // 构建完整的API URL并创建HTTP请求
    ctxInfo := ccontext.Info(ctx)
    reqUrl := ctxInfo.ApiAddr() + api
    request, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewReader(reqBody))
    if err != nil {
        log.ZError(ctx, "ApiRequest", err, "type", "http.NewRequestWithContext failed")
        return sdkerrs.ErrSdkInternal.WrapMsg("sdk http.NewRequestWithContext failed " + err.Error())
    }

    // 设置关键的请求头信息
    log.ZDebug(ctx, "ApiRequest", "url", reqUrl, "token", ctxInfo.Token(), "body", string(reqBody))
    request.ContentLength = int64(len(reqBody))
    request.Header.Set("Content-Type", "application/json")
    request.Header.Set("operationID", operationID)
    request.Header.Set("token", ctxInfo.Token())           // 关键:携带认证Token
    request.Header.Set("Accept-Encoding", "gzip")

    // 发送请求并处理响应...
}

关键点分析:

  • Token来源: 从上下文信息中获取登录时缓存的ImToken
  • 请求头设置 : 通过token请求头携带认证信息
  • 链路追踪 : 通过operationID实现请求的全链路追踪
  • 内容协商: 支持gzip压缩以优化传输效率
5.8.2 API网关统一Token验证 - router.go

OpenIM Server的API网关对所有API请求进行统一的Token验证:

go 复制代码
// open-im-server/internal/api/router.go:130-153
func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, config *Config) (*gin.Engine, error) {
    // 建立各种RPC连接...
    authConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Auth)
    if err != nil {
        return nil, err
    }
    
    // 初始化Gin路由器
    gin.SetMode(gin.ReleaseMode)
    r := gin.New()
    
    // 注册全局中间件,包括Token解析中间件
    r.Use(prommetricsGin(), gin.RecoveryWithWriter(gin.DefaultErrorWriter, mw.GinPanicErr), 
          mw.CorsHandler(), mw.GinParseOperationID(), 
          GinParseToken(rpcli.NewAuthClient(authConn))) // 核心:Token解析中间件
    
    // 注册各种业务路由...
    return r, nil
}

// GinParseToken Token解析中间件实现
func GinParseToken(authClient *rpcli.AuthClient) gin.HandlerFunc {
    return func(c *gin.Context) {
        switch c.Request.Method {
        case http.MethodPost:
            // 1. 检查API白名单,部分接口无需Token验证
            for _, wApi := range Whitelist {
                if strings.HasPrefix(c.Request.URL.Path, wApi) {
                    c.Next()  // 白名单接口直接放行
                    return
                }
            }

            // 2. 从请求头中提取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
            }

            // 3. 调用认证服务解析Token
            resp, err := authClient.ParseToken(c, token)
            if err != nil {
                apiresp.GinError(c, err)
                c.Abort()  // Token验证失败,终止请求
                return
            }

            // 4. 将用户信息设置到请求上下文中
            c.Set(constant.OpUserPlatform, constant.PlatformIDToName(int(resp.PlatformID)))
            c.Set(constant.OpUserID, resp.UserID)
            c.Next()  // 继续处理请求
        }
        kickTokenFunc(kickClients)
    }
}
5.8.3 认证服务Token解析实现 - auth.go

认证服务提供Token解析的具体实现:

go 复制代码
// open-im-server/internal/rpc/auth/auth.go:354-369
func (s *authServer) ParseToken(ctx context.Context, req *pbauth.ParseTokenReq) (resp *pbauth.ParseTokenResp, error) {
    resp = &pbauth.ParseTokenResp{}

    // 调用内部Token解析方法
    claims, err := s.parseToken(ctx, req.Token)
    if err != nil {
        return nil, err
    }

    // 构建响应,返回Token中的用户信息
    resp.UserID = claims.UserID                    // 用户ID
    resp.PlatformID = int32(claims.PlatformID)     // 平台ID
    resp.ExpireTimeSeconds = claims.ExpiresAt.Unix() // Token过期时间
    return resp, nil
}

// parseToken Token解析的核心逻辑
func (s *authServer) parseToken(ctx context.Context, tokensString string) (claims *tokenverify.Claims, err error) {
    // 1. 解析JWT Token,验证签名和格式
    claims, err = tokenverify.GetClaimFromToken(tokensString, authverify.Secret(s.config.Share.Secret))
    if err != nil {
        return nil, err
    }

    // 2. 检查是否为管理员Token(管理员Token享有特殊权限)
    isAdmin := authverify.IsManagerUserID(claims.UserID, s.config.Share.IMAdminUserID)
    if isAdmin {
        return claims, nil // 管理员Token直接通过,跳过Redis状态检查
    }

    // 3. 从Redis获取普通用户Token的状态映射
    m, err := s.authDatabase.GetTokensWithoutError(ctx, claims.UserID, claims.PlatformID)
    if err != nil {
        return nil, err
    }

    // 4. Token不存在于Redis中(可能已过期或被删除)
    if len(m) == 0 {
        return nil, servererrs.ErrTokenNotExist.Wrap()
    }

    // 5. 检查特定Token的状态
    if v, ok := m[tokensString]; ok {
        switch v {
        case constant.NormalToken:
            return claims, nil // Token状态正常
        case constant.KickedToken:
            return nil, servererrs.ErrTokenKicked.Wrap() // Token已被踢下线
        default:
            return nil, errs.Wrap(errs.ErrTokenUnknown) // 未知Token状态
        }
    }

    return nil, servererrs.ErrTokenNotExist.Wrap()
}

Token验证安全机制:

  1. JWT签名验证: 使用系统密钥验证Token的完整性和真实性
  2. 管理员特权: 系统管理员Token跳过Redis状态检查,提高性能
  3. 状态实时检查: 从Redis查询Token的实时状态,支持踢人等操作
  4. 多状态支持: 区分正常Token、被踢Token和未知状态
  5. 过期自动清理: 过期Token从Redis中自动清理
5.8.4 认证客户端RPC调用 - auth.go

API网关通过RPC客户端调用认证服务:

go 复制代码
// open-im-server/pkg/rpcli/auth.go:25-27
func (x *AuthClient) ParseToken(ctx context.Context, token string) (*auth.ParseTokenResp, error) {
    return x.AuthClient.ParseToken(ctx, &auth.ParseTokenReq{Token: token})
}

RPC调用特点:

  • 简化接口: 封装了gRPC调用的复杂性
  • 类型安全: 强类型的请求和响应结构
  • 错误传播: 透明的错误传播机制
  • 性能优化: 连接复用和负载均衡

5.9 第九阶段:WebSocket连接建立与多端登录处理

5.9.1 WebSocket连接处理 - ws_server.go
go 复制代码
// open-im-server/internal/msggateway/ws_server.go:768-834
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
    }

    // 调用认证服务解析令牌
    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返回错误
        httpError(connContext, err)
        return
    }

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

    log.ZDebug(connContext, "new conn", "token", connContext.GetToken())

    // 创建WebSocket长连接
    wsLongConn := newGWebSocket(WebSocket, ws.handshakeTimeout, ws.writeBufferSize)
    if err := wsLongConn.GenerateLongConn(w, r); err != nil {
        log.ZWarn(connContext, "long connection fails", err)
        return
    } else {
        // 检查是否需要发送成功响应
        shouldSendSuccessResp := connContext.ShouldSendResp()
        if shouldSendSuccessResp {
            if err := wsLongConn.RespondWithSuccess(); err != nil {
                return
            }
        }
    }

    // 从对象池获取客户端对象并重置状态
    client := ws.clientPool.Get().(*Client)
    client.ResetClient(connContext, wsLongConn, ws)

    // 注册客户端并启动消息处理循环
    ws.registerChan <- client
    go client.readMessage()
}
5.9.2 Token验证逻辑 - auth.go
go 复制代码
// open-im-server/internal/rpc/auth/auth.go:312-346
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
    isAdmin := authverify.IsManagerUserID(claims.UserID, s.config.Share.IMAdminUserID)
    if isAdmin {
        return claims, nil // 管理员Token直接通过,跳过Redis状态检查
    }

    // 从Redis获取Token状态映射
    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:
            return claims, nil
        case constant.KickedToken:
            return nil, servererrs.ErrTokenKicked.Wrap()
        default:
            return nil, errs.Wrap(errs.ErrTokenUnknown)
        }
    }

    return nil, servererrs.ErrTokenNotExist.Wrap()
}

Token验证流程:

  1. JWT解析: 验证Token格式和签名
  2. 管理员特权: 管理员Token跳过Redis检查
  3. 状态查询: 从Redis查询Token状态
  4. 状态验证: 检查Token是否正常、被踢或过期
5.9.3 多端登录策略处理核心机制 - ws_server.go
go 复制代码
// open-im-server/internal/msggateway/ws_server.go:635-688
func (ws *WsServer) multiTerminalLoginChecker(clientOK bool, oldClients []*Client, newClient *Client) {
    // 根据多端登录策略执行相应逻辑
    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)
    }
}
5.9.4 多端登录剔除机制深度解析

剔除策略详细说明:

  1. DefalutNotKick(默认不踢)

    • 允许所有平台同时在线
    • 适用于宽松的多端使用场景
    • 不进行任何连接冲突处理
  2. PCAndOther(PC和其他)

    • PC端享有特殊权限,不被踢下线
    • 其他平台按照同终端踢下线策略处理
    • 体现PC端的重要性和稳定性需求
  3. AllLoginButSameTermKick(同终端踢下线)

    go 复制代码
    // 核心踢下线流程
    func handleSameTerminalKick(oldClients []*Client, newClient *Client) {
        // 1. 从用户映射中删除旧连接
        ws.clients.DeleteClients(newClient.UserID, oldClients)
        
        // 2. 向旧连接发送踢下线消息
        for _, c := range oldClients {
            err := c.KickOnlineMessage()
            if err != nil {
                log.ZWarn(c.ctx, "KickOnlineMessage", err)
            }
        }
        
        // 3. 调用认证服务失效旧Token,保留新Token
        ctx := mcontext.WithMustInfoCtx(...)
        req := &pbAuth.InvalidateTokenReq{
            PreservedToken: newClient.token,  // 保留新连接的Token
            UserID:         newClient.UserID,
            PlatformID:     int32(newClient.PlatformID),
        }
        ws.authClient.InvalidateToken(ctx, req)
    }

5.10. OpenIM登录流程完整时序图

以下时序图展示了OpenIM设备登录的完整流程,包括Chat系统认证、Token生成、SDK初始化和WebSocket连接建立:

sequenceDiagram participant Client as Android Demo
LoginActivity participant SDK as open-im-sdk-android
LoginVM participant ChatAPI as Chat System
HTTP API participant ChatRPC as Chat System
RPC Service participant Redis as Redis Cache
Token/Session存储 participant ChatDB as Chat Database
MongoDB participant OpenIMAPI as OpenIM Server
RPC API participant OpenIMDB as OpenIM Database
MongoDB participant IMSDK as openim-sdk-core
UserContext participant WSGateway as OpenIM Gateway
WebSocket Server participant UserMap as Connection Map
用户连接管理 Note over Client,UserMap: === 阶段1: 用户登录界面交互 === Client->>SDK: 1.1 用户输入登录信息
账号/手机/邮箱 + 密码/验证码 SDK->>SDK: 1.2 客户端参数验证
格式检查、必填项验证 SDK->>SDK: 1.3 构建登录参数
密码MD5加密、平台标识等 Note over Client,UserMap: === 阶段2: Chat系统认证 === SDK->>ChatAPI: 2.1 POST /account/login
{"phoneNumber":"...", "password":"...", "platform":2} ChatAPI->>ChatAPI: 2.2 获取客户端IP
用于安全检查和登录记录 ChatAPI->>ChatRPC: 2.3 Login RPC调用
传递完整登录信息 ChatRPC->>ChatRPC: 2.4 参数验证
密码和验证码至少一个 ChatRPC->>ChatRPC: 2.5 构建账号凭证
手机号: +86 13800000000 ChatRPC->>ChatDB: 2.6 查询用户凭证
根据账号查找用户信息 ChatDB-->>ChatRPC: 2.7 返回用户凭证信息 ChatRPC->>ChatRPC: 2.8 IP和用户登录频率检查
防止暴力破解 alt 密码登录 ChatRPC->>ChatDB: 2.9a 查询账户密码 ChatDB-->>ChatRPC: 2.10a 返回密码哈希 ChatRPC->>ChatRPC: 2.11a 密码MD5比对验证 else 验证码登录 ChatRPC->>Redis: 2.9b 验证验证码
Key: verify_code:{account}:3 Redis-->>ChatRPC: 2.10b 返回存储的验证码 ChatRPC->>ChatRPC: 2.11b 验证码比对验证 ChatRPC->>Redis: 2.12b 删除已使用验证码 end ChatRPC->>ChatRPC: 2.13 生成ChatToken
JWT: {userID, userType:1, platformID:0} ChatRPC->>Redis: 2.14 缓存ChatToken状态
Key: CHAT_UID_TOKEN_STATUS:{userID} ChatRPC->>ChatDB: 2.15 记录登录日志
IP、设备ID、平台信息 ChatRPC-->>ChatAPI: 2.16 返回认证结果
{userID, chatToken} Note over Client,UserMap: === 阶段3: 获取OpenIM管理员Token === ChatAPI->>ChatAPI: 3.1 获取管理员Token
ImAdminTokenWithDefaultAdmin() ChatAPI->>OpenIMAPI: 3.2 GetAdminToken RPC
{secret, adminUserID} OpenIMAPI->>OpenIMAPI: 3.3 验证系统密钥
config.Share.Secret OpenIMAPI->>OpenIMAPI: 3.4 验证管理员身份
IMAdminUserID列表检查 OpenIMAPI->>OpenIMDB: 3.5 检查管理员用户存在性 OpenIMDB-->>OpenIMAPI: 3.6 确认用户存在 OpenIMAPI->>OpenIMAPI: 3.7 生成管理员Token
JWT: {adminUserID, AdminPlatformID} OpenIMAPI->>Redis: 3.8 缓存管理员Token
Key: CHAT:IM_TOKEN:{adminUserID} OpenIMAPI-->>ChatAPI: 3.9 返回管理员Token Note over Client,UserMap: === 阶段4: 生成用户ImToken === ChatAPI->>OpenIMAPI: 4.1 GetUserToken RPC
{userID, platformID} + AdminToken OpenIMAPI->>OpenIMAPI: 4.2 验证管理员权限
CheckAdmin(adminUserID) OpenIMAPI->>OpenIMAPI: 4.3 平台ID合法性检查
不能为AdminPlatformID OpenIMAPI->>OpenIMDB: 4.4 获取用户信息验证
检查用户存在和权限 OpenIMDB-->>OpenIMAPI: 4.5 返回用户信息 OpenIMAPI->>OpenIMAPI: 4.6 生成用户ImToken
JWT: {userID, platformID} OpenIMAPI->>Redis: 4.7 缓存ImToken状态
Key: UID_PID_TOKEN_STATUS:{userID}:Android OpenIMAPI-->>ChatAPI: 4.8 返回用户ImToken ChatAPI-->>SDK: 4.9 返回登录结果
{userID, chatToken, imToken} Note over Client,UserMap: === 阶段5: Android SDK初始化 === SDK->>SDK: 5.1 缓存登录凭证
LoginCertificate.cache() SDK->>IMSDK: 5.2 调用OpenIM SDK登录
OpenIMClient.getInstance().login() IMSDK->>IMSDK: 5.3 检查登录状态
避免重复登录 IMSDK->>IMSDK: 5.4 设置登录状态为Logging
保存userID和token IMSDK->>IMSDK: 5.5 初始化各种管理器
消息、会话、好友、群组等 IMSDK->>IMSDK: 5.6 启动后台服务
长连接、消息同步、事件处理 Note over Client,UserMap: === 阶段6: WebSocket连接建立 === IMSDK->>IMSDK: 6.1 启动长连接管理器
readPump, writePump, heartbeat IMSDK->>IMSDK: 6.2 构建连接URL
携带token等参数 IMSDK->>WSGateway: 6.3 WebSocket连接请求
ws://server?sendID={userID}&token={imToken}&platformID=2 WSGateway->>WSGateway: 6.4 检查连接数限制
防止过载 WSGateway->>WSGateway: 6.5 解析连接参数
userID, token, platformID等 WSGateway->>OpenIMAPI: 6.6 ParseToken RPC调用
验证ImToken OpenIMAPI->>OpenIMAPI: 6.7 JWT Token解析
验证签名和格式 alt 管理员Token OpenIMAPI->>OpenIMAPI: 6.8a 管理员特权处理
跳过Redis状态检查 else 普通用户Token OpenIMAPI->>Redis: 6.8b 查询Token状态
Key: UID_PID_TOKEN_STATUS:{userID}:Android Redis-->>OpenIMAPI: 6.9b 返回Token状态 OpenIMAPI->>OpenIMAPI: 6.10b 检查Token状态
Normal/Kicked/Unknown end OpenIMAPI-->>WSGateway: 6.11 返回解析结果
{userID, platformID, expireTime} WSGateway->>WSGateway: 6.12 验证请求匹配性
URL参数与Token一致性 WSGateway->>WSGateway: 6.13 建立WebSocket连接
协议升级 WSGateway->>WSGateway: 6.14 创建Client对象
从对象pool获取并重置 Note over Client,UserMap: === 阶段7: 多端登录处理 === WSGateway->>UserMap: 7.1 检查用户连接状态
Get(userID, platformID) UserMap-->>WSGateway: 7.2 返回连接状态
userOK, clientOK, oldClients alt 用户首次登录 WSGateway->>UserMap: 7.3a 注册新用户连接
Set(userID, client) WSGateway->>WSGateway: 7.4a 更新监控指标
onlineUserNum++ else 用户已存在连接 WSGateway->>WSGateway: 7.3b 多端登录策略检查
根据配置处理冲突 alt 同终端踢下线策略 WSGateway->>UserMap: 7.4b1 删除旧连接
DeleteClients(userID, oldClients) WSGateway->>WSGateway: 7.5b1 发送踢下线消息
KickOnlineMessage() WSGateway->>OpenIMAPI: 7.6b1 失效旧Token
InvalidateToken RPC OpenIMAPI->>Redis: 7.7b1 更新Token状态
设置为KickedToken else 允许多端登录 WSGateway->>UserMap: 7.4b2 正常添加连接
Set(userID, client) end end Note over Client,UserMap: === 阶段8: 连接成功与服务启动 === WSGateway->>WSGateway: 8.1 启动消息处理协程
client.readMessage() WSGateway->>IMSDK: 8.2 连接成功通知
OnConnectSuccess回调 IMSDK->>IMSDK: 8.3 设置登录状态为Logged
初始化完成 IMSDK->>IMSDK: 8.4 启动消息同步服务
msgSyncer.DoListener() IMSDK->>IMSDK: 8.5 启动会话事件处理
ConsumeConversationEventLoop() IMSDK->>SDK: 8.6 SDK登录成功回调
onSuccess(data) SDK->>SDK: 8.7 设置全局登录状态
BaseApp.loginCertificate SDK->>Client: 8.8 通知UI登录成功
jump()跳转主界面 Note over Client,UserMap: === 数据存储结构说明 === Note over Redis: ChatToken: CHAT_UID_TOKEN_STATUS:{userID}
AdminToken: CHAT:IM_TOKEN:{adminUserID}
ImToken: UID_PID_TOKEN_STATUS:{userID}:{platform}
验证码: verify_code:{account}:{usedFor} Note over UserMap: 用户连接映射: {userID -> UserPlatform}
连接状态通知: UserState Channel
多端登录冲突处理: 实时剔除机制
相关推荐
excel34 分钟前
Nginx 与 Node.js(PM2)的对比优势及 HTTPS 自动续签配置详解
后端
bobz9652 小时前
vxlan 为什么一定要封装在 udp 报文里?
后端
bobz9652 小时前
vxlan 直接使用 ip 层封装是否可以?
后端
郑道4 小时前
Docker 在 macOS 下的安装与 Gitea 部署经验总结
后端
3Katrina4 小时前
妈妈再也不用担心我的课设了---Vibe Coding帮你实现期末课设!
前端·后端·设计
汪子熙4 小时前
HSQLDB 数据库锁获取失败深度解析
数据库·后端
高松燈4 小时前
若伊项目学习 后端分页源码分析
后端·架构
没逻辑5 小时前
主流消息队列模型与选型对比(RabbitMQ / Kafka / RocketMQ)
后端·消息队列
倚栏听风雨5 小时前
SwingUtilities.invokeLater 详解
后端
Java中文社群5 小时前
AI实战:一键生成数字人视频!
java·人工智能·后端