自定义 RTC 凭证接入

大约 10 分钟

自定义 RTC 凭证接入

概述

CallKit 默认通过传入的 IM SDK 客户端获取 RTC 进房信息和 RTC UID 映射:

  • chatClient.getRTCTokenInfo({ channelName }):获取声网 App ID、RTC Token 和本端 RTC UID;
  • chatClient.getUserIdsWithRTCUids(rtcUids):将远端 RTC UID 映射为 IM userId,用于显示用户昵称和头像等业务信息。

如果应用使用自有的声网项目,或希望由业务服务端签发 RTC Token、维护 RTC UID 与 IM 用户 ID 的关系,可以为 CallKit 传入 rtcProviderrtcProvider 的类型为公开导出的 CallKitRTCProvider。源码详见 CallKitRTCProvider Interfaceopen in new window

import type { CallKitRTCProvider, RTCTokenInfo, RTCUidUserIdMap } from 'easemob-chat-uikit';

CallKit 以传入的 chatClient 为基础工作。未配置 rtcProvider 时,UIKit 会通过该 IM SDK 客户端自动获取 RTC 数据;需要使用自有声网项目或业务服务端 Token 时,再按需覆写相应方法。

配置项默认 UIKit / IM SDK 模式自定义 CallKitRTCProvider 模式
适用场景使用 IM SDK 提供的 RTC 凭证与 UID 映射。使用自有声网项目,或由业务服务端签发 RTC Token、维护 UID 映射。
组件配置<CallKit chatClient={client} /><CallKit chatClient={client} rtcProvider={rtcProvider} />
入会凭证UIKit 调用 chatClient.getRTCTokenInfo({ channelName })rtcProvider.getRTCTokenInfo({ channelName }) 返回 { appId, rtcToken, rtcUid }
UID 与 IM 用户 ID 映射UIKit 调用 chatClient.getUserIdsWithRTCUids(rtcUids)rtcProvider.getUserIdsWithRTCUids(rtcUids) 返回 { [rtcUid]: userId }
需要自行维护的数据无。声网 App ID、RTC Token、RTC UID,以及 RTC UID 与 IM userId 的映射。

CallKitRTCProvider 的两个方法可独立配置。例如,只提供 getRTCTokenInfo 时,Token 由业务服务端获取,而 UID 映射仍由 IM SDK 获取;未提供的方法始终按默认模式回退到 chatClient

API

CallKitRTCProvider 用于接管 CallKit 从 IM SDK 获取的两类 RTC 数据:加入频道凭证和 RTC UID 映射。CallKit 会在发起通话、接听通话以及远端用户加入频道等实际时机按需调用这些方法。业务侧可以只实现需要自定义的部分,未实现的方法会继续使用 chatClient 的对应接口。下面是该 provider 及其返回值的公开类型定义:

interface RTCTokenInfo {
  appId: string;
  rtcToken: string;
  rtcUid: number;
}

type RTCUidUserIdMap = Record<string, string>;

interface CallKitRTCProvider {
  getRTCTokenInfo?: (params: { channelName: string }) => Promise<RTCTokenInfo>;
  getUserIdsWithRTCUids?: (rtcUids: number[]) => Promise<RTCUidUserIdMap>;
}

两个方法均为可选,可以只替换其中一个数据来源:

方法用途未提供时的行为
getRTCTokenInfo为当前通话频道返回 RTC 入会凭证。调用 chatClient.getRTCTokenInfo({ channelName })
getUserIdsWithRTCUids将远端 RTC UID 批量映射为 IM userId调用 chatClient.getUserIdsWithRTCUids(rtcUids)

当某个 provider 方法已提供时,CallKit 会优先使用它,而不会再调用该方法对应的 IM SDK 接口。getRTCTokenInfo 抛出异常、返回空值或返回不合法数据时,通话无法发起或加入频道;getUserIdsWithRTCUids 失败时,远端 UID 无法通过 IM SDK 再次查询(仅一对一通话会尝试使用邀请中的对端用户 ID 兜底)。因此请在服务端响应和前端转换处完成必要的校验与错误处理。

getRTCTokenInfo

  • 说明: 异步返回当前参与者加入指定 channelName 所需的声网 appIdrtcTokenrtcUid。业务服务端应根据当前 IM 用户和频道签发这些数据。
  • 调用时机:
    • 主叫发起通话、发送邀请前调用。
    • 被叫接受邀请、加入频道前调用;RTC 进房数据无效时会重新获取。
  • 回退逻辑(返回 null 时): rtcProvider 未提供 getRTCTokenInfo 方法时,CallKit 才会调用 chatClient.getRTCTokenInfo({ channelName })。如果已提供该方法,但运行时返回 nullundefined、不合法对象或抛出异常,CallKit 会将 加入 RTC 频道所需的数据 视为不可用,不会再回退到 IM SDK;主叫无法发起通话,被叫无法加入频道,并触发通话错误处理。虽然 TypeScript 返回类型是 Promise<RTCTokenInfo>,业务实现仍应避免返回空值。
  • 返回值约束:
    • 必须返回 RTCTokenInfo 对象。appId 必须是非空字符串,并且必须与签发 rtcToken 的声网项目一致。
    • rtcUid 必须是有限数字,并且必须与 rtcToken 中的 UID 一致;同一 IM 用户建议保持稳定的 UID 映射。channelName 和 Token 中的频道也必须一致。
    • useRTCToken(默认 true)开启时,rtcToken 必须是非空字符串;设置 useRTCToken={false} 时可以返回空字符串,CallKit 会以 null Token 加入频道。仅在声网项目允许不校验 Token 时使用此配置。
    • 返回值不包含 expiration 字段,CallKit 也不会依据该字段自动续期。请保证服务端 Token 在预期通话时长内有效;如需续期,应结合 RTC SDK 的过期回调和业务侧 Token 接口自行实现。

getUserIdsWithRTCUids

  • 说明: 异步批量返回 RTC UID 与 IM userId 的映射,用于获取远端用户的昵称、头像等资料。返回值的键是 RTC UID 的字符串形式,值是对应的 IM userId
{
  '10001': 'alice',
  '10002': 'bob',
}
  • 调用时机: 远端用户进入频道后,CallKit 在需要将远端 UID 转换为 IM 用户资料时按需调用。CallService 会先查询本地缓存,未命中且 UID 有效时才请求 provider。
  • 回退逻辑(返回 null 时): rtcProvider 未提供 getUserIdsWithRTCUids 方法时,CallKit 才会调用 chatClient.getUserIdsWithRTCUids(rtcUids)。如果已提供该方法,但运行时返回 nullundefined、非对象、缺少请求 UID 的映射或抛出异常,CallKit 不会再调用 IM SDK 查询;一对一通话会尽力使用邀请中的对端 IM userId 兜底,多人通话没有该兜底,对应远端用户可能只能显示 RTC UID 或无法关联用户资料。
  • 返回值约束: 必须返回 RTCUidUserIdMap 对象。每个请求的 RTC UID 都应使用其规范的字符串形式作为键(例如 10001 对应 '10001'),值必须是非空的 IM userId。多人通话应返回所有请求 UID 的映射;不要返回无法确认归属的用户 ID,也不要把 UID 映射到 Token 中的其他用户。

接入流程

步骤 1:在服务端提供 RTC 数据接口

不要在浏览器中生成声网 Token,也不要将声网 App Certificate 暴露给前端。建议让前端只请求业务服务端:

POST /api/rtc/token
{ "userId": "alice", "channelName": "call_channel" }

=> { "appId": "your-agora-app-id", "rtcToken": "...", "rtcUid": 10001 }

POST /api/rtc/user-ids
{ "rtcUids": [10001, 10002] }

=> { "10001": "alice", "10002": "bob" }

服务端需要验证当前请求者的身份和通话权限,并确保 Token 中的频道、UID 与返回的 channelNamertcUid 一致;UID 映射只返回调用方有权获知的用户数据。

步骤 2:创建 CallKitRTCProvider

下面示例假定应用已拥有可信的业务登录态,currentUserId 是当前已登录的 IM 用户 ID。接口字段名可按服务端实际响应调整,但最终必须转换为 RTCTokenInfoRTCUidUserIdMap

import React from 'react';
import type { CallKitRTCProvider, RTCTokenInfo, RTCUidUserIdMap } from 'easemob-chat-uikit';

function useCallKitRTCProvider(
  currentUserId: string,
  useRTCToken = true,
): CallKitRTCProvider {
  return React.useMemo(
    () => ({
      async getRTCTokenInfo({ channelName }): Promise<RTCTokenInfo> {
        const response = await fetch('/api/rtc/token', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',
          body: JSON.stringify({ userId: currentUserId, channelName }),
        });

        if (!response.ok) {
          throw new Error(`获取 RTC Token 失败:${response.status}`);
        }

        const data = (await response.json()) as RTCTokenInfo;
        if (
          !data.appId ||
          !Number.isFinite(data.rtcUid) ||
          (useRTCToken && !data.rtcToken)
        ) {
          throw new Error('RTC Token 接口返回的数据不完整');
        }

        return {
          appId: data.appId,
          rtcToken: data.rtcToken || '',
          rtcUid: data.rtcUid,
        };
      },

      async getUserIdsWithRTCUids(rtcUids): Promise<RTCUidUserIdMap> {
        const response = await fetch('/api/rtc/user-ids', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',
          body: JSON.stringify({ rtcUids }),
        });

        if (!response.ok) {
          throw new Error(`查询 RTC UID 映射失败:${response.status}`);
        }

        return (await response.json()) as RTCUidUserIdMap;
      },
    }),
    [currentUserId, useRTCToken],
  );
}

如果你的 IM 服务仍负责 UID 映射,只需提供 getRTCTokenInfo;省略 getUserIdsWithRTCUids 后,CallKit 会继续使用 IM SDK 的映射接口。

步骤 3:将 provider 传给 CallKit

CallKit 必须使用 Provider 创建的 IM 客户端。下面通过公开导出的 RootContext 获取该客户端,避免依赖仓库内部模块路径。

import React from 'react';
import { CallKit, Provider, RootContext } from 'easemob-chat-uikit';
import type { CallKitRef } from 'easemob-chat-uikit';
import 'easemob-chat-uikit/style.css';

function CallKitLayer({ currentUserId }: { currentUserId: string }) {
  const { client } = React.useContext(RootContext);
  const callKitRef = React.useRef<CallKitRef>(null);
  const rtcProvider = useCallKitRTCProvider(currentUserId);

  return (
    <CallKit
      ref={callKitRef}
      chatClient={client}
      rtcProvider={rtcProvider}
      onCallError={error => {
        console.error('CallKit 通话失败', error);
      }}
    />
  );
}

export default function App() {
  const currentUserId = 'alice';

  return (
    <Provider
      initConfig={{
        appKey: 'your-org#your-app',
        userId: currentUserId,
        token: 'your-im-token',
      }}
    >
      <CallKitLayer currentUserId={currentUserId} />
    </Provider>
  );
}

Provider 会使用 initConfig.userIdinitConfig.token 登录 IM;这份 IM Token 与 RTC Token 是两套独立凭证。CallKit 在需要加入 RTC 频道时才会调用 rtcProvider,无需在登录成功后手动预取或初始化 RTC 服务。

步骤 4:发起通话

自定义 RTC provider 不改变 CallKit 的通话 API。通过组件 ref 发起一对一通话即可:

await callKitRef.current?.startSingleCall({
  to: 'bob',
  callType: 'video',
  msg: '邀请你进行视频通话',
});

发起方会使用新建的频道名调用 getRTCTokenInfo;被叫方接受邀请并加入同一频道前也会调用该方法。因此,服务端必须能够针对任意有效频道为每位参与者返回相匹配的 App ID、Token 和 UID。

完整示例代码

下面的 App.tsx 将服务端 RTC 接口、CallKitRTCProvider、UIKit Provider 和一对一通话按钮组合在一起。将接口地址、App Key、IM Token 和用户 ID 替换为实际值即可使用;IM Token 与 RTC Token 仍由各自的服务端流程签发。

import React from 'react';
import {
  CallKit,
  Provider,
  RootContext,
} from 'easemob-chat-uikit';
import type {
  CallKitRef,
  CallKitRTCProvider,
  RTCTokenInfo,
  RTCUidUserIdMap,
} from 'easemob-chat-uikit';
import 'easemob-chat-uikit/style.css';

const appKey = 'your-org#your-app';
const currentUserId = 'alice';
const imToken = 'your-im-token';

type TokenResponse = {
  appId?: unknown;
  rtcToken?: unknown;
  rtcUid?: unknown;
};

async function postJson<T>(url: string, body: unknown): Promise<T> {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    throw new Error(`${url} 请求失败:${response.status}`);
  }
  return (await response.json()) as T;
}

function useCallKitRTCProvider(
  userId: string,
  useRTCToken = true,
): CallKitRTCProvider {
  return React.useMemo(
    () => ({
      async getRTCTokenInfo({ channelName }): Promise<RTCTokenInfo> {
        const data = await postJson<TokenResponse>('/api/rtc/token', {
          userId,
          channelName,
        });

        if (
          typeof data.appId !== 'string' ||
          data.appId.trim() === '' ||
          typeof data.rtcUid !== 'number' ||
          !Number.isFinite(data.rtcUid) ||
          (useRTCToken &&
            (typeof data.rtcToken !== 'string' || data.rtcToken.trim() === ''))
        ) {
          throw new Error('RTC Token 接口返回的数据不完整或格式错误');
        }

        return {
          appId: data.appId,
          rtcToken: typeof data.rtcToken === 'string' ? data.rtcToken : '',
          rtcUid: data.rtcUid,
        };
      },

      async getUserIdsWithRTCUids(rtcUids: number[]): Promise<RTCUidUserIdMap> {
        const data = await postJson<unknown>('/api/rtc/user-ids', { rtcUids });
        if (!data || typeof data !== 'object' || Array.isArray(data)) {
          throw new Error('RTC UID 映射接口返回的数据格式错误');
        }

        const result: RTCUidUserIdMap = {};
        for (const [rtcUid, mappedUserId] of Object.entries(data)) {
          if (
            rtcUids.includes(Number(rtcUid)) &&
            typeof mappedUserId === 'string' &&
            mappedUserId.trim() !== ''
          ) {
            result[rtcUid] = mappedUserId;
          }
        }
        return result;
      },
    }),
    [userId, useRTCToken],
  );
}

function CallKitLayer({ userId }: { userId: string }) {
  const { client } = React.useContext(RootContext);
  const callKitRef = React.useRef<CallKitRef>(null);
  const useRTCToken = true;
  const rtcProvider = useCallKitRTCProvider(userId, useRTCToken);
  const [peerUserId, setPeerUserId] = React.useState('bob');

  const startCall = async (callType: 'video' | 'audio') => {
    const to = peerUserId.trim();
    if (!to) {
      window.alert('请输入对方用户 ID');
      return;
    }

    try {
      await callKitRef.current?.startSingleCall({
        to,
        callType,
        msg: callType === 'video' ? '邀请你进行视频通话' : '邀请你进行语音通话',
      });
    } catch (error) {
      console.error('发起通话失败', error);
    }
  };

  const userInfoProvider = React.useCallback(async (userIds: string[]) => {
    // 生产环境中请从业务服务端或本地缓存返回真实昵称和头像。
    return userIds.map(id => ({
      userId: id,
      nickname: id,
      avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(id)}`,
    }));
  }, []);

  return (
    <>
      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <input
          value={peerUserId}
          onChange={event => setPeerUserId(event.target.value)}
          placeholder="对方用户 ID"
        />
        <button onClick={() => void startCall('video')}>视频通话</button>
        <button onClick={() => void startCall('audio')}>语音通话</button>
      </div>

      <CallKit
        ref={callKitRef}
        chatClient={client}
        rtcProvider={rtcProvider}
        useRTCToken={useRTCToken}
        userInfoProvider={userInfoProvider}
        enableRingtone
        onCallError={error => console.error('CallKit 通话失败', error)}
        onEndCallWithReason={(reason, callInfo) => {
          console.log('通话结束', { reason, callInfo });
        }}
      />
    </>
  );
}

export default function App() {
  return (
    <Provider
      initConfig={{
        appKey,
        userId: currentUserId,
        token: imToken,
      }}
    >
      <CallKitLayer userId={currentUserId} />
    </Provider>
  );
}

示例中的服务端接口应按以下约定返回数据:

POST /api/rtc/token
{ "userId": "alice", "channelName": "由 CallKit 传入的频道名" }
=> { "appId": "your-agora-app-id", "rtcToken": "...", "rtcUid": 10001 }

POST /api/rtc/user-ids
{ "rtcUids": [10001, 10002] }
=> { "10001": "alice", "10002": "bob" }

如果只需要自定义 Token 获取而继续使用 IM SDK 的 UID 映射,可以从 provider 中删除 getUserIdsWithRTCUids;如果声网项目允许不校验 Token,请将示例中的 useRTCToken 常量改为 false(该常量同时传给 useCallKitRTCProviderCallKit),此时仍需返回正确的 appIdrtcUidrtcToken 可以为空。

运行页面后,请先等待 Provider 使用 initConfig 完成 IM 登录,再点击通话按钮。示例中的 App Key、IM Token 和服务端接口地址均为占位值,生产环境不要将真实凭证硬编码在前端代码中。

常见问题

现象排查方向
收到 Valid RTC token info is unavailable 错误检查 getRTCTokenInfo 是否成功返回非空 appId、有限数字 rtcUid,以及在 useRTCTokentrue 时返回非空 rtcToken
能发送通话邀请但无法进入频道确认 Token 的 App ID、频道名和 UID 与返回数据一致,并检查 Token 是否已过期。
多人通话中远端用户只有 UID,昵称或头像没有正确显示实现 getUserIdsWithRTCUids,并确保返回对象以 UID 的字符串作为键、IM userId 作为值。
配置了 provider 后仍然请求 IM SDK 的 RTC 接口确认传给 CallKitrtcProvider 中包含对应方法;只配置一个方法时,另一个方法仍会按设计回退到 IM SDK。
上次编辑于: