CommIM 第三方接入文档
CommIM 是一套完整的即时通讯微服务系统,支持单聊、群聊、好友管理、文件传输等核心 IM 能力。本文档面向第三方开发者,提供从零接入 CommIM 服务的完整指南。
文档版本:v1.0 | 更新日期:2026-05-24
即时通讯
单聊/群聊,支持文本、图片、语音、视频、文件、自定义消息
用户认证
注册、登录、验证码、密码管理,支持自有账号体系映射
好友系统
好友申请/审批、好友列表、备注、黑名单
群组系统
创建群、邀请入群、群管理、群历史消息
文件上传
服务端签名直传 OSS / STS 临时凭证上传
多端同步
支持多端同时在线,消息实时同步,离线消息自动推送
音视频通话
一对一语音/视频通话,WebRTC P2P + TURN 中继
核心能力
| 能力 | 说明 |
|---|---|
| 用户认证 | 注册、登录、验证码、密码管理 |
| 即时消息 | 单聊/群聊,支持文本、图片、语音、视频、文件、自定义消息 |
| 好友系统 | 好友申请/审批、好友列表、备注、黑名单 |
| 群组系统 | 创建群、邀请入群、申请入群、群管理、群历史消息 |
| 文件上传 | 服务端签名直传 OSS / STS 临时凭证上传 |
| 离线消息 | 自动推送离线消息,支持消息回执和已读状态 |
| 多端同步 | 支持多端同时在线,消息实时同步 |
| 音视频通话 | 一对一语音/视频通话,WebRTC P2P + TURN 中继,自建 TURN 服务器 |
架构与通信协议
整体架构
┌─────────────┐ HTTP/HTTPS ┌──────────────────┐ gRPC ┌──────────────┐
│ 第三方客户端 │ ──────────────────→ │ service_commim_gate│ ───────────→ │ 后端 gRPC 服务 │
│ (Web/App/PC) │ │ (API 网关) │ │ (UAA/Friend/ │
│ │ ←────────────────── │ │ ←─────────── │ Group/...) │
└──────┬───────┘ HTTP Response └──────────────────┘ gRPC Rsp └──────────────┘
│
│ WebSocket (Protobuf 二进制)
▼
┌──────────────┐
│ service_gate │ 长连接网关,负责消息实时推送、心跳、认证
└──────────────┘
双通道通信模型
CommIM 采用 HTTP + WebSocket 双通道 通信模型:
| 通道 | 协议 | 用途 | 方向 |
|---|---|---|---|
| HTTP | HTTP JSON over POST /rpc/:service/:method |
请求-响应式操作(登录、注册、查询好友列表等) | 客户端主动调用 |
| WS | Protobuf 二进制 | 实时消息推送、好友申请通知、群变更通知等 | 服务端主动推送 + 客户端发送消息 |
HTTP 通用请求格式
POST http://{gate_host}:11260/rpc/{service}/{method}
Content-Type: application/json
token: {user_token}
x-pcd: {base64_encoded_pbcommdata}
x-app-secret: {app_secret}
通用响应格式
{
"code": 0,
"message": "success",
"data": { ... }
}
| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 0 表示成功,非 0 表示错误 |
message | string | 错误描述或 "success" |
data | object | 业务响应数据 |
WebSocket Protobuf 协议
WebSocket 通道使用 Protobuf 二进制编码,消息封装在 PBMessage 结构中:
message PBMessage {
uint32 version = 1;
uint32 checkCode = 2;
uint32 errCode = 3;
string service = 4; // 目标服务名
string hashKey = 5; // 路由键(通常为用户 IM ID)
PBCommData pbCommData = 6;
map<string, string> opts = 7;
string pbName = 8; // 具体消息的 protobuf 消息名
bytes pbData = 9; // 具体消息的 protobuf 编码数据
string errDesc = 10;
}
- 从服务端获取
client_pb.json(Protobuf 定义文件) - 使用
protobufjs库加载定义,编解码消息 - 连接 WebSocket,设置
binaryType = 'arraybuffer' - 收到二进制数据后,先用
PBMessage解码外层,再根据pbName解码内层消息
快速开始
1. 申请应用凭证
接入 CommIM 前需向平台方申请以下凭证:
| 凭证 | 说明 | 示例 |
|---|---|---|
appId | 应用唯一标识,由平台分配 | 10001 |
secret | 应用密钥,用于接口签名校验 | dAEmUyk6pouC... |
2. 获取服务地址
POST http://{gate_host}:11260/rpc/pb_grpc_commim_uaa.UAA/FetchEndPoint
Content-Type: application/json
x-pcd: {base64_pcd}
3. 用户登录获取 Token
POST /rpc/pb_grpc_commim_uaa.UAA/Login
Content-Type: application/json
{
"phone": "13800138000",
"password": "mypassword123"
}
响应中关键返回值:
| 字段 | 说明 |
|---|---|
token | UAA JWT Token,用于后续 HTTP API 调用的鉴权 |
imToken | IM Token,用于 WebSocket 长连接认证 |
imId | IM 用户 ID,系统内唯一标识,后续所有操作均使用此 ID |
4. 获取 WebSocket 连接地址
GET http://{allocator_host}:11000/Allocater/GetWsGate?userId={imId}
5. 建立 WebSocket 连接并登录
const ws = new WebSocket('ws://xxx.xxx.xxx.xxx:11030/ws');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
sendLoginReq(ws, imToken, imId, appId);
};
ws.onmessage = (event) => {
const head = PBMessage.decode(new Uint8Array(event.data));
handleMessage(head);
};
UAA 注册/登录
适用于客户端直接使用 CommIM 内置账号体系的场景。
发送验证码
POST /rpc/pb_grpc_commim_uaa.UAA/SendPhoneCode
Content-Type: application/json
{
"PhoneNo": "13800138000"
}
约束:同一手机号 60 秒内只能发送一次,验证码有效期 20 分钟。
POST /rpc/pb_grpc_commim_uaa.UAA/SendEmailCode
Content-Type: application/json
{
"EmailAddr": "user@example.com"
}
验证码有效期 30 分钟。
用户注册
POST /rpc/pb_grpc_commim_uaa.UAA/Signup
Content-Type: application/json
{
"username": "zhangsan",
"phone": "13800138000",
"password": "mypassword123",
"code": 456789,
"nickname": "张三"
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
username | string | 推荐 | 用户名,6-30 个字符 |
phone | string | 三选一 | 手机号码 |
email | string | 三选一 | 邮箱地址 |
password | string | 是 | 密码,至少 6 字符 |
code | int32 | 是 | 验证码(开发环境可用万能码 918666) |
nickname | string | 否 | 昵称 |
avatar | string | 否 | 头像 URL |
用户登录
POST /rpc/pb_grpc_commim_uaa.UAA/Login
{
"phone": "13800138000",
"password": "mypassword123"
}
POST /rpc/pb_grpc_commim_uaa.UAA/Login
{
"phone": "13800138000",
"code": 456789
}
登录/注册响应
{
"code": 0,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"imToken": "im_token_xxxxxxxx",
"info": {
"userId": "10086",
"username": "zhangsan",
"imId": 10086,
"phone": "13800138000",
"nickname": "张三",
"avatar": "https://default.example.com/avatar_m.png",
"gender": 1
}
}
}
获取/更新用户信息
POST /rpc/pb_grpc_commim_uaa.UAA/UserInfo
Content-Type: application/json
token: {user_token}
{}
POST /rpc/pb_grpc_commim_uaa.UAA/UpdateUserInfo
{
"userId": "10086",
"info": {
"nickname": "新昵称",
"sign": "这是我的新签名"
},
"keys": ["nickname", "sign"]
}
| key | 说明 | 额外要求 |
|---|---|---|
nickname | 修改昵称 | — |
avatar | 修改头像 | — |
gender | 修改性别 | — |
sign | 修改签名 | — |
freeAddMeFriend | 自由加好友开关 | — |
phone | 修改手机号 | 需要 info.code + info.passWord |
email | 修改邮箱 | 需要 info.code + info.passWord |
passWord | 修改密码 | 需要 info.code 验证码,新密码 6-32 字符 |
第三方应用集成
适用于第三方已有自己的用户体系,希望将自有用户映射到 CommIM 的场景。
接入流程
用户在第三方应用完成身份认证
获取该用户对应的 IM ID 和 Token,首次接入系统自动分配 IM ID
无需用户重新注册,无缝接入 IM 能力
完整的 IM 功能即刻可用
获取用户 IM ID 和 Token
GET http://{inter_api_host}:11190/GetIMID?appId={appId}&secret={secret}&userId={userId}
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
appId | int64 | 是 | 应用 ID(平台分配) |
secret | string | 是 | 应用密钥 |
userId | string | 是 | 第三方系统的用户 ID |
{
"result": 0,
"msg": "成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"userIds": {
"appId": 10001,
"appUserId": "user_238",
"imUserId": 50001,
"lastLoginTime": "2026-05-24T10:00:00Z"
}
}
}
同步用户信息
PUT http://{inter_api_host}:11190/userInfo/{imId}
Content-Type: application/json
{
"nickname": "张三",
"avatar": "https://your-cdn.com/avatar/238.jpg",
"sign": "这是我的签名"
}
完整接入示例
async function integrateCommIM(appId, secret, userId) {
const imidResp = await fetch(
`http://inter-api-host:11190/GetIMID?appId=${appId}&secret=${secret}&userId=${userId}`
);
const imidData = await imidResp.json();
const { token, userIds } = imidData.data;
const imId = userIds.imUserId;
const wsResp = await fetch(
`http://allocator-host:11000/Allocater/GetWsGate?userId=${imId}`
);
const wsData = await wsResp.json();
const wsUrl = wsData.data;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
sendLoginReq(ws, token, imId, appId);
};
ws.onmessage = (event) => {
handleMessage(new Uint8Array(event.data));
};
}
x-pcd 请求头
x-pcd 是 CommIM 的上下文传递机制,值为 PBCommData JSON 经 Base64 编码后的字符串。
| 字段 | 类型 | 说明 |
|---|---|---|
srcId | int64 | 发起者 IM ID |
aimId | int64 | 目标 IM ID |
groupId | int64 | 群 ID(群操作时使用) |
appId | int64 | 应用 ID |
appUserId | string | 应用层用户 ID |
srcClientType | int32 | 客户端类型:0=未知, 1=手机, 2=H5, 3=PC |
time | int64 | 当前时间戳(秒) |
needReadReceipt | bool | 是否需要已读回执 |
msgSn | int64 | 消息序列号 |
生成 x-pcd 示例
function makeXPcd(appId, appUserId, imId, aimId, srcClientType = 2) {
const pcd = {
srcId: imId || 0,
aimId: aimId || 0,
srcClientType,
appId,
appUserId: appUserId || '',
time: Math.floor(Date.now() / 1000),
};
return btoa(JSON.stringify(pcd));
}
连接管理
连接流程
token + imToken + imId发送登录请求
// pbName: pb_msg_gate.LoginReq
{
token: imToken,
token_type: 2, // UNI_USER = 2(推荐)
clientInfo: {
modelType: 0,
packageName: 'com.yourapp.name',
systemVersion: '1.0.0',
phoneModels: 'Web',
appVersion: '1.0.0',
systemName: 'Browser',
identifier: 'web-xxxxxx',
xChannel: 'web'
},
deviceToken: '',
forceLogin: true, // 首次登录填 true,静默重连填 false
version: 1
}
| Token Type | 值 | 说明 |
|---|---|---|
| JAVA | 0 | 旧版 Java 下发的 token |
| IM | 1 | IM 下发的重连 token |
| UNI_USER | 2 | 统一认证 token(推荐) |
登录响应
// pbName: pb_msg_gate.LoginRsp
{
result: 0, // 0=成功,非0=失败
reconnectToken: 'xxxx', // 重连 token
msg_sn: 12345,
app_id: 10001,
app_user_id: '10086',
deviceToken: 'device_xxx'
}
心跳保活
连接建立后,客户端需每 10 秒发送一次心跳:
// pbName: pb_pub.HeartBeat
// 发送: { type: 0, state: 0 } PING
// 回复: { type: 0, state: 1 } PANG
断线重连
reconnectToken,token_type 设为 IM(1),forceLogin 设为 false被踢下线
// pbName: pb_msg_gate.KickOffUser
{
optUId: 10001,
aimUId: 10086,
reason: 21,
desc: '账号在其他设备登录'
}
客户端收到后应断开连接,提示用户重新登录。
消息收发
消息类型
| 值 | 类型 | 说明 | data 字段 |
|---|---|---|---|
| 0 | TEXT | 文本消息 | 空 |
| 1 | PIC | 图片消息 | 图片 URL |
| 2 | VIDEO | 视频消息 | 视频 URL |
| 3 | AUDIO | 语音消息 | 语音 URL |
| 7 | CUSTOMIZE | 自定义消息 | 自定义 JSON |
| 8 | FILE | 文件消息 | 文件 URL |
| 9 | RECALL | 撤回消息 | 原消息信息 |
| 10 | RED_PACKET | 红包消息 | 红包 ID |
发送单聊消息
// PBMessage 封装:
{
service: '',
hashKey: String(targetImId),
pbName: 'pb_msg_gate.ChatText',
pbCommData: {
srcId: myImId,
aimId: targetImId,
appId: appId,
appUserId: String(myImId),
time: 0,
srcClientType: 2,
needReadReceipt: true
}
}
// ChatText 内容:
{
aim_user_id: targetImId,
chat_type: 0,
data: '',
text: '你好,这是一条测试消息',
exp: {}
}
发送群聊消息
// PBMessage 封装:
{
service: 'group',
hashKey: String(groupId),
pbName: 'pb_msg_group.GroupChat',
pbCommData: {
srcId: myImId,
groupId: groupId,
appId: appId,
time: 0,
srcClientType: 2,
atList: [10002, 10003]
}
}
// GroupChat 内容:
{
aim_user_id: 0,
chat_type: 0,
data: '',
text: '大家好!',
exp: {}
}
接收消息
WebSocket 收到消息后,根据 pbName 区分消息类型:
| pbName | 说明 |
|---|---|
pb_msg_gate.ChatText | 单聊消息 |
pb_msg_group.GroupChat | 群聊消息 |
pb_pub.MsgReceipt | 消息回执 |
pb_msg_gate.KickOffUser | 被踢下线 |
pb_pub.HeartBeat | 心跳回复 |
pb_msg_friend.ApplyReq | 好友申请通知 |
pb_msg_friend.ApplyAnswerRsp | 好友申请应答通知 |
pb_msg_offlineMsg.ReadOfflineMsgRsp | 离线消息 |
接收消息示例
const head = PBMessage.decode(new Uint8Array(event.data));
const pbName = head.pbName;
if (pbName === 'pb_msg_gate.ChatText') {
const ChatText = pbRoot.lookupType('pb_msg_gate.ChatText');
const msg = ChatText.decode(head.pbData);
const pcd = head.pbCommData;
console.log('收到消息:', {
from: pcd.srcId,
text: msg.text,
chatType: msg.chat_type,
data: msg.data,
time: pcd.time,
msgSn: pcd.msgSn
});
}
自定义消息与撤回
{
"aim_user_id": 10002,
"chat_type": 7,
"data": "{\"type\":\"card\",\"title\":\"名片分享\",\"userId\":10003}",
"text": "",
"exp": {}
}
// pbName: pb_pub.MsgRecallReq
{
msgSn: 12345,
msgOwnerId: 10086
}
消息回执与状态
消息状态流转
INIT(0) → SEND(2) → RECEIVED(3) → READ(4) → END(5) │ └→ FAULT(1) 发送失败 └→ IN_BLACK_LIST(6) 被拉黑 └→ LIMITED(7) 被限制
发送已读回执
// pbName: 'pb_pub.MsgReceipt'
{
isAtMe: false,
state: 4, // MSG_STATE_READ = 4
time: 0
}
// pbCommData.aimId = 消息发送者 IM ID
// pbCommData.msgSn = 原消息的 msgSn
离线消息
用户上线(WebSocket 登录成功)后,服务端自动推送离线消息。
// pbName: 'pb_msg_offlineMsg.ReadOfflineMsgRsp'
{
msgNum: 5,
msgList: [
{
srcUserid: 10002,
aimUserid: 10086,
chatType: 0,
text: '你好',
data: '',
sn: 12345,
time: 1700000000
}
]
}
好友管理
好友相关操作通过 WebSocket 发送请求并等待响应(请求-响应模式)。
申请添加好友
// service: 'friend', hashKey: String(targetImId)
// pbName: 'pb_msg_friend.ApplyReq'
{
msg: '你好,我是张三'
}
// pbCommData.aimId = 目标用户 IM ID
应答好友申请
// pbName: 'pb_msg_friend.ApplyAnswerReq'
{
agree: true // true=同意, false=拒绝
}
其他好友操作
| 操作 | pbName | 说明 |
|---|---|---|
| 获取好友列表 | pb_msg_friend.FriendsReq | 空请求,返回好友列表 |
| 删除好友 | pb_msg_friend.DeleteFriendReq | aimId 设为要删除的好友 IM ID |
| 拉黑用户 | pb_msg_friend.AddBlackListReq | aimId 设为目标 IM ID |
| 取消拉黑 | pb_msg_friend.RemoveBlackListReq | aimId 设为目标 IM ID |
| 判断黑名单 | pb_msg_friend.IsInBlackListReq | 返回 0=不在, 1=在我黑名单, 2=在对方黑名单 |
群组管理
创建群聊
// service: 'group', hashKey: String(myImId)
// pbName: 'pb_msg_group.CreateGroupReq'
{
name: '项目讨论组',
memberIds: [10086, 10087, 10088],
memberCountLimit: 200,
groupType: 0 // 0=普通群, 1=聊天室, 2=频道, 3=系统通知
}
群操作一览
| 操作 | pbName | 关键参数 |
|---|---|---|
| 邀请入群 | pb_msg_group.InviteReq | inviteeIds, shareMsgCount: -1 |
| 应答入群邀请 | pb_msg_group.InviteAnswerReq | agree: true/false |
| 申请入群 | pb_msg_group.ApplyReq | groupId |
| 审批入群申请 | pb_msg_group.ApplyAnswerReq | agree |
| 获取群列表 | pb_msg_group.GroupsReq | 空请求 |
| 获取群详情 | pb_msg_group.GroupDetailReq | groupId |
| 获取群成员 | pb_msg_group.MembersReq | page, pageSize(最大 100) |
| 退出群聊 | pb_msg_group.QuitReq | groupId |
| 修改群名称 | pb_msg_group.EditNameReq | name |
| 修改群公告 | pb_msg_group.EditNoticeReq | notice |
| 设置群禁言 | pb_msg_group.SetMemberChatBannedStatusReq | bannedStatus: 0/1/2 |
| 添加群管理员 | pb_msg_group.AddAdminsReq | memberIds |
| 踢出群成员 | pb_msg_group.KickoutReq | aimId |
| 解散群组 | pb_msg_group.DisbandGroupsReq | groupIds |
| 群历史消息 | pb_msg_group.GroupHistoryMsgReq | page, pageSize, filterNew |
文件上传
CommIM 支持两种文件上传方式,上传后获取 URL,将 URL 放入消息的 data 字段发送。
方式一:服务端签名直传(推荐)
GET http://{oss_host}:11070/uploadToken
Authorization: {token}
UserID: {imId}
响应返回阿里云 OSS PostObject 签名(含 policy、signature、OSSAccessKeyId、key、host),客户端使用签名直传 OSS。
方式二:STS 临时凭证上传
GET http://{oss_host}:11070/getStsToken?bucketName=commim
Authorization: {token}
UserID: {imId}
返回 STS 临时凭证(AccessKeyId、AccessKeySecret、SecurityToken、EndPoint、Bucket),使用 STS 凭证初始化 OSS Client SDK 上传。
发送图片/文件消息
{
"aim_user_id": 10002,
"chat_type": 1,
"data": "https://your-bucket.oss-cn-hangzhou.aliyuncs.com/commim/image_123.jpg",
"text": "",
"exp": {"width": "800", "height": "600"}
}
音视频通话
CommIM 提供一对一语音/视频通话能力,基于 WebRTC 实现端到端音视频通信,通过 IM 消息通道传输信令,自建 TURN 服务器实现 NAT 穿透。
浏览器要求:Chrome 72+、Firefox 68+、Safari 14.1+、Edge 79+(需 HTTPS 环境)
通话架构
┌──────────────────────────────────────────────────────────────┐
│ 第三方客户端 (Web/App) │
│ │
│ ┌────────────┐ ┌───────────────┐ ┌────────────────┐ │
│ │ 通话 UI │───▶│ 通话状态管理 │───▶│ WebRTC Handler │ │
│ └────────────┘ └───────┬───────┘ └───────┬────────┘ │
│ │ │ │
│ ┌───────┴───────┐ │ │
│ │ 信令收发模块 │ │ │
│ └───────┬───────┘ │ │
└────────────────────────────┼─────────────────────┼───────────┘
│ │
IM 消息通道 (WebSocket) WebRTC 媒体通道
ChatType.CUSTOMIZE=7 STUN/TURN + P2P
│ │
┌────────────────────────────┼─────────────────────┼───────────┐
│ CommIM 服务端 │ │
│ │ │ │
│ ┌──────────────┐ ┌───────┴──────────┐ ┌──────┴──────┐ │
│ │ service_gate │ │ service_out_api │ │ coturn │ │
│ │ (消息路由) │ │ (TURN凭证签发) │ │ (TURN服务) │ │
│ └──────────────┘ └──────────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
接入流程总览
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 获取 TURN 凭证 | 调用 /Turn/GetCredentials 获取 ICE 服务器配置 |
| 2 | 初始化 WebRTC | 使用凭证创建 RTCPeerConnection |
| 3 | 发送通话邀请 | 通过 IM 自定义消息发送 call_invite 信令 |
| 4 | SDP 协商 | 交换 SDP Offer/Answer |
| 5 | ICE 交换 | 双向交换 ICE Candidate |
| 6 | 建立 P2P 连接 | 直连或通过 TURN 中继 |
| 7 | 通话结束 | 发送 call_hangup 信令,发送通话记录消息 |
TURN 凭证签发 API
第三方客户端在发起通话前,需先获取 TURN 临时凭证。凭证基于 HMAC-SHA1 签名,与用户绑定,有效期 24 小时。
/Turn/GetCredentials
需登录
获取 TURN 服务器临时凭证,用于 WebRTC ICE 配置。凭证与当前登录用户绑定,有效期由服务端配置(默认 24 小时)。
请求头
| Header | 类型 | 必填 | 说明 |
|---|---|---|---|
Authorization | string | 是 | 用户登录 Token |
UserID | string | 是 | 用户 IM ID |
响应体
{
"result": 0,
"msg": "success",
"data": {
"urls": [
"turn:121.196.153.186:3478?transport=udp",
"turn:121.196.153.186:3478?transport=tcp",
"turns:121.196.153.186:5349?transport=tcp",
"turns:121.196.153.186:443?transport=tcp"
],
"username": "1719583640:10086",
"credential": "a3N5V2xhRmFqZTg4Nw==",
"ttl": 86400
}
}
响应字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
urls | string[] | TURN/STUN 服务器 URL 列表,包含 UDP/TCP/TLS 多种传输方式 |
username | string | 临时用户名,格式 {expiry_timestamp}:{userId} |
credential | string | HMAC-SHA1 签名凭证(Base64 编码) |
ttl | number | 凭证有效时长(秒),默认 86400(24 小时) |
凭证签发算法
expiry = current_unix_timestamp + ttl
username = "{expiry}:{userId}"
credential = base64(hmac_sha1(secret, username))
/Turn/GetConfig
无需登录
获取 TURN 服务器配置信息(不含凭证),用于展示或诊断。
响应体
{
"result": 0,
"msg": "success",
"data": {
"host": "121.196.153.186",
"port": 3478,
"tlsPort": 5349,
"altPort": 443,
"realm": "commim.turn.fuhui-zhilian.com"
}
}
信令协议
通话信令通过 IM 自定义消息通道传输,使用 chat_type = 7(CUSTOMIZE),data 字段携带 commim_call: 前缀的 JSON 数据。
信令类型
| 信令类型 | 方向 | 说明 |
|---|---|---|
call_invite | 主叫 → 被叫 | 发起通话邀请 |
call_answer | 被叫 → 主叫 | 接听通话 |
call_reject | 被叫 → 主叫 | 拒绝通话 |
call_cancel | 主叫 → 被叫 | 取消呼叫 |
call_hangup | 双向 | 结束通话 |
call_sdp_offer | 主叫 → 被叫 | WebRTC SDP Offer |
call_sdp_answer | 被叫 → 主叫 | WebRTC SDP Answer |
call_ice_candidate | 双向 | ICE 候选交换 |
信令数据结构
interface CallSignal {
type: CallSignalType // 信令类型,见上表
callId: string // 通话唯一标识,格式: call_{timestamp}_{random6}
mediaType: CallMediaType // 'audio' | 'video'
sdp?: string // SDP 内容(仅 sdp_offer/sdp_answer 携带)
candidate?: RTCIceCandidateInit // ICE 候选(仅 ice_candidate 携带)
timestamp: number // 毫秒时间戳
}
信令编码格式
data 字段 = "commim_call:" + JSON.stringify(CallSignal)
// 示例:发起视频通话邀请
data = 'commim_call:{"type":"call_invite","callId":"call_1719500000_a3f2k1","mediaType":"video","timestamp":1719500000000}'
// 发送时 chat_type = 7 (CUSTOMIZE)
// exp 扩展字段: { "_callSignal": "1", "callType": "video", "callId": "call_1719500000_a3f2k1" }
信令消息识别
第三方客户端收到 chat_type = 7 的消息时,检查 data 字段是否以 commim_call: 开头:
function isCallSignal(data) {
return data && data.startsWith('commim_call:');
}
function decodeCallSignal(data) {
if (!isCallSignal(data)) return null;
try {
return JSON.parse(data.slice('commim_call:'.length));
} catch {
return null;
}
}
commim_call: 前缀的消息后,应拦截并分发给通话模块处理,不进入聊天消息列表。
完整通话流程
正常通话(被叫接听)
主叫方 (Caller) 被叫方 (Callee)
│ │
│ 1. 获取 TURN 凭证 │
│ 2. 初始化 RTCPeerConnection │
│ 3. 获取本地媒体流 │
│ │
│────── call_invite ────────────────────────▶│ 4. 显示来电弹窗
│ │ 5. 播放振铃音
│ │
│◀────── call_answer ───────────────────────│ 6. 用户点击接听
│ │ 7. 获取 TURN 凭证
│ │ 8. 初始化 RTCPeerConnection
│ 9. 创建 SDP Offer │
│────── call_sdp_offer ─────────────────────▶│ 10. 设置远端 Offer
│ │ 11. 创建 SDP Answer
│◀────── call_sdp_answer ───────────────────│
│ 12. 设置远端 Answer │
│ │
│◀────── call_ice_candidate ────────────────│ ICE 交换
│────── call_ice_candidate ─────────────────▶│ (双向持续)
│ │
│═══════════ P2P 音视频流 ═══════════════════│ 通话中
│ │
│────── call_hangup ────────────────────────▶│ 挂断
│ 发送 VIDEO_INVITE 消息 │ 发送 VIDEO_INVITE 消息
被叫拒绝
主叫方 被叫方
│────── call_invite ──────────────────────▶│
│◀────── call_reject ──────────────────────│ 用户点击拒绝
│ 通话结束 │ 通话结束
主叫取消
主叫方 被叫方
│────── call_invite ──────────────────────▶│
│ 用户点击取消 │
│────── call_cancel ──────────────────────▶│
│ 通话结束 │ 通话结束
WebRTC 集成指南
1. 获取 TURN 凭证并创建 PeerConnection
// 1. 获取 TURN 凭证
const credResp = await fetch('/api/Turn/GetCredentials', {
method: 'GET',
headers: {
'Authorization': userToken,
'UserID': String(imId),
},
});
const credData = await credResp.json();
// 2. 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: [{
urls: credData.data.urls,
username: credData.data.username,
credential: credData.data.credential,
}],
});
// 3. 获取本地媒体流
const localStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: mediaType === 'video', // 'audio' | 'video'
});
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
2. 监听事件
// 远端媒体流
pc.ontrack = (event) => {
if (event.streams && event.streams[0]) {
remoteVideoElement.srcObject = event.streams[0];
}
};
// ICE 候选 → 通过信令发送给对方
pc.onicecandidate = (event) => {
if (event.candidate) {
sendSignal(peerImId, {
type: 'call_ice_candidate',
callId: currentCallId,
mediaType: 'video',
candidate: event.candidate.toJSON(),
timestamp: Date.now(),
});
}
};
// 连接状态
pc.oniceconnectionstatechange = () => {
switch (pc.iceConnectionState) {
case 'connected':
case 'completed':
// 通话已建立
break;
case 'disconnected':
// 连接断开,结束通话
break;
case 'failed':
// ICE 连接失败
break;
}
};
3. 主叫方:创建 Offer
// 收到 call_answer 后执行
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await pc.setLocalDescription(offer);
// 通过信令发送 SDP Offer
sendSignal(peerImId, {
type: 'call_sdp_offer',
callId: currentCallId,
mediaType: 'video',
sdp: offer.sdp,
timestamp: Date.now(),
});
4. 被叫方:创建 Answer
// 收到 call_sdp_offer 后执行
await pc.setRemoteDescription(new RTCSessionDescription({
type: 'offer',
sdp: signal.sdp,
}));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 通过信令发送 SDP Answer
sendSignal(peerImId, {
type: 'call_sdp_answer',
callId: currentCallId,
mediaType: 'video',
sdp: answer.sdp,
timestamp: Date.now(),
});
5. 通话控制
// 静音/取消静音
function toggleAudio(enabled) {
localStream.getAudioTracks().forEach(track => {
track.enabled = enabled;
});
}
// 开关摄像头
function toggleVideo(enabled) {
localStream.getVideoTracks().forEach(track => {
track.enabled = enabled;
});
}
// 挂断
function hangup() {
sendSignal(peerImId, {
type: 'call_hangup',
callId: currentCallId,
mediaType: 'video',
timestamp: Date.now(),
});
// 释放资源
localStream.getTracks().forEach(track => track.stop());
pc.close();
}
通话记录消息
通话结束后,双方应向聊天流发送 VIDEO_INVITE(chat_type=6)类型的消息,作为通话记录落库。
{
"aim_user_id": 10002,
"chat_type": 6,
"text": "",
"data": "{\"callType\":\"video\",\"duration\":332,\"result\":\"completed\"}"
}
通话记录 data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
callType | string | 通话类型:audio 语音 / video 视频 |
duration | number | 通话时长(秒),未接通为 0 |
result | string | 通话结果:completed 已完成 / missed 未接 / rejected 已拒绝 / cancelled 已取消 |
通话状态机
┌──────────┐
│ idle │ 初始状态
└────┬─────┘
│
┌─────────┴──────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ outgoing │ │ incoming │
│ (呼出中) │ │ (来电中) │
└────┬─────┘ └────┬─────┘
│ │
│ call_answer │ 用户接听
│ │
└────────┬─────────┘
│
▼
┌──────────┐
│connecting│ SDP/ICE 协商中
└────┬─────┘
│
│ ICE connected
│
▼
┌──────────┐
│ connected│ 通话中
└────┬─────┘
│
│ hangup / disconnect / error
│
▼
┌──────────┐
│ ended │ 已结束(1.5秒后回到 idle)
└──────────┘
超时与异常处理
| 场景 | 检测方式 | 处理策略 |
|---|---|---|
| 振铃超时 | 30 秒无应答 | 主叫方:call_cancel + 结束通话;被叫方:自动拒绝 |
| 连接超时 | connecting 阶段 15 秒 | 结束通话,提示"连接超时" |
| 媒体设备不可用 | getUserMedia 抛出异常 | 结束通话,提示"无法获取媒体设备" |
| ICE 连接失败 | iceConnectionState = 'failed' | 结束通话,提示"连接失败" |
| 网络断开 | iceConnectionState = 'disconnected' | 结束通话,提示"连接断开" |
| 对方正在通话 | 收到 call_reject | 结束通话,提示"对方已拒绝" |
| TURN 服务不可用 | 凭证 API 返回非 200 | 降级到公共 STUN 服务器 |
TURN 服务器信息
CommIM 自建 TURN 服务器(coturn),提供 STUN/TURN/TLS 全协议支持。
| 端口 | 协议 | 用途 |
|---|---|---|
| 3478 | UDP/TCP | STUN/TURN 主端口 |
| 5349 | TCP | TURN over TLS |
| 443 | TCP | TURN over TLS(备用,穿透企业防火墙) |
| 49152-65535 | UDP | TURN Relay 媒体中继端口范围 |
- TURN 凭证基于 HMAC-SHA1 签名,24 小时过期,与用户绑定不可跨用户使用
- WebRTC 内置 DTLS-SRTP 加密,媒体流端到端加密
- TURN over TLS(端口 5349/443)防止信令窃听
- 生产环境客户端必须运行在 HTTPS 下(
getUserMedia要求安全上下文)
完整接入示例
class CommIMCall {
constructor(imSdk, gateUrl) {
this.imSdk = imSdk;
this.gateUrl = gateUrl;
this.pc = null;
this.localStream = null;
this.callId = null;
this.peerImId = null;
this.mediaType = 'audio';
this.iceConfig = null;
this.iceConfigExpiry = 0;
}
// 获取 TURN 凭证(带缓存)
async getIceConfig() {
const now = Date.now();
if (this.iceConfig && now < this.iceConfigExpiry) return this.iceConfig;
const token = localStorage.getItem('im_token') || '';
const userId = localStorage.getItem('im_user_id') || '';
const resp = await fetch(`${this.gateUrl}/Turn/GetCredentials`, {
headers: { 'Authorization': token, 'UserID': userId },
});
const json = await resp.json();
if (json.result !== 0) throw new Error('TURN 凭证获取失败');
this.iceConfig = {
iceServers: [{
urls: json.data.urls,
username: json.data.username,
credential: json.data.credential,
}],
};
this.iceConfigExpiry = now + (json.data.ttl - 300) * 1000;
return this.iceConfig;
}
// 发送信令
sendSignal(signal) {
const data = 'commim_call:' + JSON.stringify(signal);
this.imSdk.sendChatText(this.peerImId, '', 7, data, {
_callSignal: '1',
callType: signal.mediaType,
callId: signal.callId,
});
}
// 发起通话
async startCall(peerImId, mediaType) {
this.peerImId = peerImId;
this.mediaType = mediaType;
this.callId = `call_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
// 获取 TURN 凭证
const iceConfig = await this.getIceConfig();
// 获取媒体流
this.localStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: mediaType === 'video',
});
// 创建 PeerConnection
this.pc = new RTCPeerConnection(iceConfig);
this.localStream.getTracks().forEach(t => this.pc.addTrack(t, this.localStream));
// 监听事件
this.pc.ontrack = (e) => {
if (e.streams[0]) this.onRemoteStream?.(e.streams[0]);
};
this.pc.onicecandidate = (e) => {
if (e.candidate) {
this.sendSignal({
type: 'call_ice_candidate',
callId: this.callId,
mediaType: this.mediaType,
candidate: e.candidate.toJSON(),
timestamp: Date.now(),
});
}
};
// 发送邀请
this.sendSignal({
type: 'call_invite',
callId: this.callId,
mediaType: this.mediaType,
timestamp: Date.now(),
});
}
// 处理来电信令(在 IM 消息回调中调用)
async handleSignal(signal, fromImId) {
if (signal.type === 'call_answer') {
// 对方接听,创建 Offer
const offer = await this.pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await this.pc.setLocalDescription(offer);
this.sendSignal({
type: 'call_sdp_offer', callId: this.callId,
mediaType: this.mediaType, sdp: offer.sdp, timestamp: Date.now(),
});
} else if (signal.type === 'call_sdp_offer') {
await this.pc.setRemoteDescription(new RTCSessionDescription({
type: 'offer', sdp: signal.sdp,
}));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.sendSignal({
type: 'call_sdp_answer', callId: this.callId,
mediaType: this.mediaType, sdp: answer.sdp, timestamp: Date.now(),
});
} else if (signal.type === 'call_sdp_answer') {
await this.pc.setRemoteDescription(new RTCSessionDescription({
type: 'answer', sdp: signal.sdp,
}));
} else if (signal.type === 'call_ice_candidate') {
await this.pc.addIceCandidate(new RTCIceCandidate(signal.candidate));
} else if (signal.type === 'call_hangup') {
this.endCall();
}
}
// 挂断
hangup() {
this.sendSignal({
type: 'call_hangup', callId: this.callId,
mediaType: this.mediaType, timestamp: Date.now(),
});
this.endCall();
}
// 结束通话(内部)
endCall() {
this.localStream?.getTracks().forEach(t => t.stop());
this.pc?.close();
this.pc = null;
this.localStream = null;
this.callId = null;
}
}
错误码参考
gRPC 状态码 → HTTP 状态码映射
| gRPC Code | HTTP Status | 含义 |
|---|---|---|
| OK | 200 | 成功 |
| Canceled | 408 | 请求取消 |
| Unknown | 500 | 未知错误 |
| InvalidArgument | 400 | 参数错误 |
| DeadlineExceeded | 504 | 超时 |
| NotFound | 404 | 未找到 |
| AlreadyExists | 409 | 资源冲突 |
| PermissionDenied | 403 | 权限不足 |
| Unauthenticated | 401 | 未认证 |
| ResourceExhausted | 429 | 请求过于频繁 |
| Internal | 500 | 内部错误 |
| Unavailable | 503 | 服务不可用 |
常见业务错误信息
| 错误信息 | 触发场景 |
|---|---|
| "操作太频繁,请稍后再试" | 验证码发送被限流(60 秒 CD) |
| "手机号码格式不正确" | 手机号校验未通过 |
| "验证码不正确" | 验证码与 Redis 值不匹配 |
| "用户名被其他人占用" | 注册时用户名重复 |
| "该手机号码已有关联账号" | 手机号已被注册 |
| "密码错误" | bcrypt 比对不通过 |
| "该用户不存在" | 登录时账号未找到 |
| "用户限制登录" | 用户被封禁或手机在黑名单 |
| "请求过于频繁" | IP 限流(每 IP 每秒 10 次) |
服务端口清单
| 服务 | HTTP 端口 | gRPC 端口 | 说明 |
|---|---|---|---|
| service_commim_gate | 11260 | 12260 | API 网关,HTTP 反代 gRPC |
| service_commim_uaa | 11250 | 12250 | 用户认证与授权 |
| service_allocator | 11000 | 12000 | 连接地址分配 |
| service_gate | 11030 | 12030 | WebSocket/TCP 长连接网关 |
| service_friend | 11220 | 12220 | 好友关系 |
| service_group | 11230 | 12230 | 群组管理 |
| service_oss | 11070 | — | 文件上传签名 |
| service_inter_api | 11190 | — | 第三方应用接口 |
| service_out_api | 11120 | — | 对外 API(TURN 凭证签发等) |
| coturn (TURN) | 3478/5349/443 | — | STUN/TURN/TLS 音视频 NAT 穿透 |
常见问题
请联系 CommIM 技术团队获取 client_pb.json 文件。该文件包含所有 Protobuf 消息定义,前端使用 protobufjs 库加载即可进行编解码。
请确认:1) 心跳间隔为 10 秒;2) 超过 30 秒未收到消息触发重连;3) 重连时使用 reconnectToken 和 token_type=IM(1);4) 检查网络环境是否稳定。
通过 service_inter_api 的 GetIMID 接口,传入 appId、secret 和第三方 userId,系统自动映射并返回 IM ID 和 Token。首次调用时自动创建映射关系。
有。开发环境可使用万能验证码 918666,可跳过短信/邮箱验证。生产环境请务必关闭此功能。
网关层 IP 限流为每 IP 每秒最多 10 次请求,超限返回 "请求过于频繁"。验证码发送另有 60 秒冷却时间限制。
将 chat_type 设为 7(CUSTOMIZE),data 字段携带自定义 JSON 字符串即可。接收端根据 chat_type 判断为自定义消息后,自行解析 data 字段。
CommIM 支持多端同时在线,消息实时同步。如果同一设备类型重复登录,先登录的设备会收到 KickOffUser 通知被踢下线。不同设备类型(如 Web 和手机)可同时在线。
技术支持
如有接入问题,请联系 CommIM 技术团队获取协助: