IM 即时通讯云 · 接入文档

多租户 SaaS 版即时通讯服务:每个开发者创建多个「应用(App)」,应用之间数据完全隔离;支持单聊、群聊、好友关系、已读回执、输入状态、上下线通知、离线消息、消息历史与用量统计。

多租户隔离 单聊 · 群聊 · 好友 已读回执 · 输入状态 消息撤回 离线消息 · 历史消息 WebSocket 长连接 服务端 REST API
功能一览:
  • 多租户隔离:按 AppID 隔离用户、消息、群、好友,不同应用互不可见
  • 单聊:文字 / 图片 / 语音 / 视频 / 自定义消息,离线消息上线自动推送、ACK 去重、已读回执、正在输入
  • 群聊:建群 / 邀请 / 踢人 / 退群 / 解散 / 禁言 / 群资料修改,群主与管理员角色
  • 好友:申请 / 同意 / 拒绝 / 删除,双向好友关系,申请实时推送
  • 套餐配额:IM 用户数、好友人数、可加入群数、群成员上限、峰值群组数、历史消息存储时长、同平台多设备在线,按应用生效
  • 服务端 API:业务后端可用 HTTP 直接发消息、拉历史、查未读、查在线

一、产品与架构

接入只涉及三个地址:开发者控制台(注册账号、创建应用)、服务端接口(HTTPS,用 AppID + AppSecret 换取 IM Token)与 WebSocket 网关(长连接收发消息):

组成地址示例说明
开发者控制台https://im.linglankj.cn/console/注册开发者、创建应用、查看 AppID 与 AppSecret、重置密钥、看用量
服务端接口(HTTPS)https://im.linglankj.cn/core/*你的业务服务器对接(签发 IM Token,与控制台同域名)
WebSocket 网关wss://swoole.linglankj.cn/ws长连接收发消息;另提供 /api/* REST 接口、/health、文档页 /docs 与测试页 /test
⚠️ 密钥安全:AppSecret 只能保存在你的业务服务器上,绝对不能下发到小程序 / App 客户端。 客户端只持有短期 IM Token(默认 7 天有效)。

二、接入流程

三端(小程序 / 网页 / App)通用接入链路:
  1. 开发者在控制台注册账号、登录,创建应用,获得 AppID 与 AppSecret(如 im1a2b3c...);
  2. 用户登录你的业务系统后,你的业务服务器直接调用 POST /core/open/token(传 IM_APPID + IM_APP_SECRET + 业务用户 uid + 昵称/头像),换回 IM Token 与 ws_url;
  3. 客户端(小程序 / 网页 / App)用 Token 建立 WebSocket:wss://swoole.linglankj.cn/ws?token=xxx,各端代码见第十三章;
  4. 连接成功后服务端推送 auth_ok(含未读汇总)与 offline_messages(离线消息);
  5. 收发消息:发 {"type":"message",...},服务端回 ack,对方实时收到 message;
  6. 客户端每 30 秒发一次 {"type":"ping"} 保活;断线自动重连。服务端每 30 秒主动巡检,连接超过 90 秒无任何上行数据将被强制断开并广播 user_offline。
微信小程序生产环境要求 wss://,域名需在「微信公众平台 → 开发管理 → 开发设置 → 服务器域名」配置 socket 合法域名,且域名已备案。

三、套餐与配额

每个应用在开通/续费套餐后,配额快照保存在应用上,网关实时拦截。配额字段:

配额字段含义超限表现
quota_users应用 IM 用户总数(单聊用户规模)换取 Token 时新用户被拒:「应用用户数已达套餐上限」
quota_groups应用峰值群组数(可创建的群总数)group_create 返回错误码 4112
quota_join_groups每个用户可加入的群组数建群/受邀入群返回 4114(已达上限的受邀成员自动跳过)
quota_group_members每个群的人数上限建群/邀请返回 4113 / 4134
quota_friends每个用户的好友数上限同意好友申请时返回 4212 / 4213
quota_history_days历史消息存储时长(天)超期历史消息不再返回(0=永久保存)
quota_devices同平台同时在线设备数超出上限时最早登录的设备被顶下线(0=不限)

配额值为 0 或 -1 表示不限。应用到期或被禁用后,WebSocket 握手会被拒绝(auth_fail)。

四、服务端接口:换取 IM Token(核心)

服务端接口基础地址为业务站域名(如 https://im.linglankj.cn),统一前缀 /core。统一响应格式:HTTP 状态码始终 200,业务结果看 JSON:

{
  "code": 200,            // 200=成功,其他=失败
  "message": "ok",
  "data": { ... }         // 业务数据
}

POST/core/open/token

由你的业务服务器在用户登录小程序后调用。业务服务器只需在配置中保存 IM_APPID 与 IM_APP_SECRET(控制台「我的应用」中获取),无需开发者账号登录态。为应用下的业务用户(uid)签发 IM Token;用户不存在时自动创建(受 quota_users 限制)。

// 请求头
Content-Type: application/json

// 请求体
{
  "appid": "im1a2b3c4d5e6f708",    // 应用 ID(IM_APPID),控制台创建应用获得
  "app_secret": "********",        // 应用密钥(IM_APP_SECRET),仅服务端使用
  "uid": 10001,                    // ★ 你的业务系统用户 ID,正整数,全局唯一对应该用户
  "nickname": "小明",              // 可选,会同步到 IM 用户资料
  "avatar": "https://..."          // 可选,头像 URL
}

// 成功返回
{
  "code": 200,
  "data": {
    "token": "eyJhcHBpZCI6...a1b2c3",   // ★ IM Token,小程序拿它连 WebSocket
    "expires_in": 604800,                // 有效期(秒),默认 7 天
    "ws_url": "wss://swoole.linglankj.cn/ws", // WebSocket 连接地址(平台配置)
    "appid": "im1a2b3c4d5e6f708",
    "uid": 10001
  }
}
可能返回的错误(HTTP 200,业务 code 非 200;前端按 message 提示):
codemessage 示例原因与处理
400参数 appid、app_secret、uid 必填请求缺参数,检查请求体
401AppID 或 AppSecret 不正确密钥错误或在控制台重置过,到「我的应用」核对
403应用已被平台封禁,请联系平台方应用违规被封禁
403应用已停用,请在控制台启用后再接入控制台启用应用
403应用套餐已到期,请续费后再接入续费套餐
403应用用户数已达套餐上限(N 人)升级套餐后才能接入新用户
服务端对接示例(PHP,完整可复制):第一步把通用函数复制进你的项目;第二步在业务后端写一个「换 Token」接口给自己的前端调用——前端永远只调你自己的接口,AppSecret 只存在业务服务器上:
<?php
// ========= 第一步:通用请求函数(原样复制到你的业务项目里) =========
function httpPost(string $url, array $data): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS     => json_encode($data, JSON_UNESCAPED_UNICODE),
    ]);
    $body = curl_exec($ch);
    curl_close($ch);
    return json_decode((string) $body, true) ?: [];
}

// ========= 第二步:给你的前端提供「换 Token」接口(如 /api/im-token) =========
// 前置:业务服务器 .env 配置(控制台「我的应用」中获取)
//   IM_APPID=im1a2b3c4d5e6f708
//   IM_APP_SECRET=********(64 位密钥)

// 1. 只有已登录的业务用户才能拿到 IM Token(换成你自己的登录态判断)
$uid = $_SESSION['user_id'] ?? 0;
if ($uid <= 0) {
    header('Content-Type: application/json; charset=utf-8');
    http_response_code(401);
    exit(json_encode(['code' => 401, 'message' => '未登录']));
}

// 2. 用 IM_APPID + IM_APP_SECRET 为该用户换取 IM Token(无需开发者账号登录态)
$res = httpPost('https://im.linglankj.cn/core/open/token', [
    'appid'      => getenv('IM_APPID'),
    'app_secret' => getenv('IM_APP_SECRET'),
    'uid'        => (int) $uid,                          // 业务用户 ID
    'nickname'   => $_SESSION['nickname'] ?? '',         // 可选
    'avatar'     => $_SESSION['avatar'] ?? '',           // 可选,头像 URL
]);

// 3. 把 token 和 ws_url 返回给前端(小程序/网页/App 拿去连 WebSocket)
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 0, 'data' => [
    'token'  => $res['data']['token']  ?? '',
    'ws_url' => $res['data']['ws_url'] ?? '',
]]);

IM Token 是不透明字符串:由平台业务服务端签发、网关验签,内部包含 appid、uid 与有效期等信息。开发者无需解析、也不要自行构造,拿到后原样用于 WebSocket 连接与 HTTP API 调用即可。

五、WebSocket 连接与鉴权

WS

// 推荐:Token 放在 URL 上
wss://swoole.linglankj.cn/ws?token=<IM_TOKEN>

// 也可以先连接再鉴权(二选一)
new WebSocket('wss://swoole.linglankj.cn/ws')
// 连接建立后立即发送:
{"type":"auth","token":"<IM_TOKEN>"}

鉴权成功服务端推送:

{
  "type": "auth_ok",
  "app_id": 12,
  "appid": "im1a2b3c4d5e6f708",
  "uid": 10001,
  "server_time": 1693500000000,      // 毫秒
  "unread": {
    "total": 5,
    "private": { "total": 2, "conversations": [ {"peer_id": 10002, "count": 2} ] },
    "groups":  [ { "group_id": 5, "count": 3, "last_read_id": 880 } ]
  }
}

若有离线期间的单聊消息,紧接着推送 offline_messages:

{ "type": "offline_messages", "count": 2, "messages": [ /* 消息结构数组,见下节 */ ] }

鉴权失败推送 {"type":"auth_fail","reason":"token 无效或已过期 / 应用已被禁用 / 应用套餐已到期"} 后断开。同账号在别处登录会收到 {"type":"kick","reason":"账号已在其他设备登录"}。

心跳:客户端每 30 秒发送 {"type":"ping"},服务端回 {"type":"pong","server_time":...}。 服务端每 30 秒主动巡检一次,连接超过 90 秒没有任何上行数据(ping、消息等)将被强制断开并广播 user_offline——断网、杀进程等「未发关闭帧」的异常离线,最迟约 2 分钟内全网可见其离线。 建议客户端实现断线重连(重连后用新 Token 或原 Token 重新握手);小程序请在 onHide / onUnload 时主动 socketTask.close(),离线更即时。

六、消息统一结构(Envelope)

所有聊天消息(单聊/群聊、实时/离线/历史)都使用同一结构:

{
  "msg_id": 12345,               // 服务端消息 ID(全局递增)
  "client_msg_id": "a1b2c3...",  // 客户端去重 ID(发送时可自带,服务端兜底生成)
  "conv_type": 1,                // 1=单聊  2=群聊
  "from_id": 10001,              // 发送者 uid
  "to_id": 10002,                // 单聊=接收者 uid;群聊=group_id
  "msg_type": "text",            // text | image | voice | video | custom | system
  "content": "你好",             // 文本内容;图片/语音/视频传【文件 URL】字符串;撤回后为空字符串
  "is_read": 0,                  // 0=未读 1=已读
  "recalled": 0,                 // 0=正常 1=已被撤回(撤回消息 content 不再下发,显示"XX撤回了一条消息")
  "created_at": 1693500000000    // 毫秒时间戳
}
多媒体消息约定:图片/语音/视频不走 WebSocket 传二进制。客户端先把文件上传到自己的文件存储(或业务后端), 把访问 URL 作为 content 发送,msg_type 分别为 image / voice / video; custom 类型的 content 可放自定义 JSON 字符串(点赞、礼物、订单卡片等)。

七、单聊协议

7.1 发送消息 客户端→服务端

{
  "type": "message",
  "to_id": 10002,              // 接收方 uid(不能是自己)
  "msg_type": "text",          // 可选,默认 text
  "content": "你好",
  "client_msg_id": "uuid-xxx"  // 可选,建议传,用于 ACK 去重
}

服务端应答:

{ "type": "ack", "client_msg_id": "uuid-xxx", "msg_id": 12345, "to_id": 10002, "created_at": 1693500000000 }

对方在线时实时收到(服务端→客户端):

{ "type": "message", "msg_id": 12345, "conv_type": 1, "from_id": 10001, "to_id": 10002, "msg_type": "text", "content": "你好", ... }

对方不在线:消息存为离线消息,下次上线通过 offline_messages 推送。

7.2 标记已读 →

{ "type": "read", "peer_id": 10002 }
// 应答:{ "type": "read_ack", "peer_id": 10002, "affected": 3 }
// 对方在线则收到已读回执:
{ "type": "read_receipt", "from_id": 10001, "to_id": 10002, "created_at": 1693500000000 }

7.3 正在输入 →

{ "type": "typing", "to_id": 10002 }
// 对方实时收到(不入库):{ "type": "typing", "from_id": 10001 }

7.4 历史消息 →

{ "type": "history", "peer_id": 10002, "before_id": 0, "limit": 20 }
// before_id=0 表示拉最新一页;翻页时传本页最小 msg_id
// 应答:
{
  "type": "history",
  "peer_id": 10002,
  "messages": [ /* Envelope 数组,按时间倒序 */ ],
  "has_more": true
}

7.5 撤回消息 →

单聊、群聊消息撤回使用同一指令 recall:仅消息发送者本人可撤回,需在发送后 2 分钟内操作;重复撤回幂等。

撤回成功后,单聊双方(含自己的其他设备)、群聊全体成员都会实时收到 message_recalled 推送;历史消息 / 离线消息中该条消息 recalled=1 且 content 为空字符串,客户端统一展示「XX 撤回了一条消息」。

// 撤回消息(单聊 / 群聊通用;msg_id 来自发送 ACK 或消息体)
{ "type": "recall", "msg_id": 12345 }
// 应答:{ "type": "recall_ack", "msg_id": 12345, "conv_type": 1 }
// 单聊:对方与自己的其他在线设备收到:
{ "type": "message_recalled", "conv_type": 1, "msg_id": 12345, "peer_id": 10001, "from_id": 10001 }
// 群聊:全体群成员(含自己其他设备)收到:
{ "type": "message_recalled", "conv_type": 2, "msg_id": 12399, "group_id": 5, "from_id": 10001 }
// 客户端按 msg_id 定位本地消息,替换为"XX撤回了一条消息"

八、群聊协议

群角色 role:1=群主,2=管理员,3=普通成员;非成员为 0。群消息结构中单聊 Envelope 的 conv_type=2、to_id=group_id。

8.1 群消息

// 发送(仅群成员;被禁言成员发送会被拒)
{ "type": "group_message", "group_id": 5, "msg_type": "text", "content": "大家好", "client_msg_id": "uuid" }
// 应答:{ "type": "group_ack", "client_msg_id": "uuid", "msg_id": 12399, "group_id": 5, "created_at": ... }
// 其他在线成员收到:
{ "type": "group_message", "group_id": 5, "msg_id": 12399, "conv_type": 2, "from_id": 10001, "to_id": 5, ... }

8.2 群操作指令一览 客户端→服务端

type参数权限 / 说明应答 / 推送
group_create{name, avatar?, notice?, member_uids?:[uid...]}任何人可建群;创建者为群主;受群数/每群人数配额限制应答 group_created;被拉成员收 group_event(invited)
group_update{group_id, name?, avatar?, notice?}群主/管理员应答 group_updated;全员收 group_event(updated)
group_invite{group_id, uids:[10004,...]}群成员可邀请;受每群人数配额限制应答 group_invite_result(added/skipped);新成员收 group_event(invited)
group_kick{group_id, uid}群主/管理员;管理员不能踢管理员和群主应答 group_kick_result;被踢者收 group_event(kicked)
group_quit{group_id}成员主动退群;群主需先解散应答 group_quit_result;管理员收 group_event(quit)
group_dismiss{group_id}仅群主应答 group_dismiss_result;全员收 group_event(dismissed)
group_mute{group_id, uid, mute:1|0}群主/管理员;不能禁言群主应答 group_mute_result;被操作者收 group_event(muted/unmuted)
group_read{group_id}群成员;把群未读清零{type:"group_read_ack", group_id, last_read_id}
group_typing{group_id}群成员;实时透传不入库其他成员收 {type:"group_typing", group_id, from_id}
group_history{group_id, before_id?, limit?}群成员{type:"group_history", group_id, messages:[...], has_more}
group_list无我加入的群列表{type:"group_list", groups:[...]}
group_members{group_id}群成员{type:"group_members", group_id, members:[...]}

群对象结构

{
  "group_id": 5,
  "name": "技术交流群",
  "avatar": "https://.../g.png",
  "notice": "群公告内容",
  "owner_id": 10001,
  "member_count": 8,
  "role": 1,          // 当前请求者在该群的角色(group_list 中返回)
  "mute": 0,          // 当前请求者是否被禁言(group_list 中返回)
  "created_at": 1693500000000
}

群成员对象

{ "uid": 10002, "nickname": "小红", "avatar": "https://...", "role": 3, "mute": 0, "join_time": 1693500000 }

群事件推送 服务端→客户端

{
  "type": "group_event",
  "event": "invited",      // invited | updated | kicked | quit | dismissed | muted | unmuted
  "group_id": 5,
  "inviter": 10001,        // 或 operator / uid,视事件而定
  "group": { /* 完整群对象,部分事件携带 */ }
}

九、好友协议

方向type参数 / 说明
→friend_apply{to_uid, message?} 发起好友申请;已是好友/已有待处理申请会返回错误
→friend_handle{request_id, action:"agree"|"refuse"} 处理收到的申请;同意时校验双方好友配额
→friend_list无参数,返回我的好友列表
→friend_requests无参数,返回收到的(inbox)与发出的(outbox)申请
→friend_delete{friend_uid} 删除好友(双向解除)

应答与推送示例

// 申请发出:{ "type": "friend_apply_sent", "request_id": 3, "to_uid": 10002 }
// 被申请方实时收到:
{
  "type": "friend_event",
  "event": "request_new",       // request_new | request_agreed | request_refused | deleted
  "request": {
    "id": 3, "from_uid": 10001, "message": "我是小明",
    "nickname": "小明", "avatar": "https://...", "created_at": 1693500000000
  }
}
// 同意/拒绝后申请人收到:friend_event(event=request_agreed/request_refused, request_id, friend_uid, ...)
// 好友列表:
{ "type": "friend_list", "friends": [ {"uid":10002,"nickname":"小红","avatar":"...","remark":"","created_at":...} ] }
// 申请列表:
{ "type": "friend_requests",
  "inbox":  [ {"id":3,"from_uid":10003,"nickname":"小刚","avatar":"...","message":"...","status":0,"created_at":...} ],
  "outbox": [ {"id":2,"to_uid":10004,"nickname":"小丽","avatar":"...","message":"...","status":1,"created_at":...} ] }
// status: 0=待处理 1=已同意 2=已拒绝
// 删除好友:应答 { "type":"friend_deleted", "friend_uid":10002 };对方收 friend_event(event=deleted)

十、在线状态与用户资料

// 批量查在线状态(限同应用)
{ "type": "presence_query", "uids": [10002, 10003] }
// 应答:{ "type": "presence", "presence": { "10002": 1, "10003": 0 } }   // 1=在线 0=离线

// 批量查用户资料
{ "type": "user_profile", "uids": [10002, 10003] }
// 应答:{ "type": "user_profile", "users": [ {"uid":10002,"nickname":"小红","avatar":"https://..."} ] }

好友/有过会话的用户上下线时会自动推送(限同应用):

{ "type": "user_online", "uid": 10002 }
{ "type": "user_offline", "uid": 10002 }

十一、服务端推送事件汇总

type触发时机关键字段
pong心跳应答server_time
auth_ok / auth_fail鉴权结果unread / reason
kick同账号在别处登录reason
offline_messages上线时推送离线单聊消息count, messages[]
message / group_message收到单聊/群聊消息Envelope
ack / group_ack消息发送成功应答client_msg_id, msg_id
read_ack / read_receipt已读应答 / 对方已读回执peer_id / from_id
typing / group_typing对方正在输入from_id, group_id?
history / group_history历史消息应答messages[], has_more
recall_ack撤回消息成功应答msg_id, conv_type
message_recalled消息被撤回(单聊双方 / 群聊全员实时推送)msg_id, conv_type, from_id, peer_id?/group_id?
group_created / group_updated / group_invite_result / group_kick_result / group_quit_result / group_dismiss_result / group_mute_result群操作应答group / added / skipped 等
group_event群成员被动接收群变更event: invited/updated/kicked/quit/dismissed/muted/unmuted
group_list / group_members / group_read_ack群列表/成员/已读应答groups[] / members[] / last_read_id
friend_apply_sent / friend_handle_result / friend_list / friend_requests / friend_deleted好友操作应答—
friend_event好友申请/同意/拒绝/被删推送event: request_new/request_agreed/request_refused/deleted
presence / user_profile / user_online / user_offline状态与资料—
error任何错误{code, message}

十二、网关 HTTP API(业务服务器调用)

网关同时提供 REST 接口,用于业务服务器主动发通知、拉历史等。鉴权使用 IM Token(三种方式任选:Authorization: Bearer <token> 请求头 / ?token= / body 中 token 字段)。响应格式为 {"code":0,"msg":"ok","data":{...}}(code=0 成功)。

方法路径说明
GET/health健康检查(无需鉴权),返回在线连接数
POST/api/messages/sendHTTP 发单聊消息 {to_id, msg_type?, content, client_msg_id?},返回 {message, delivered}(delivered=false 表示已存离线)
GET/api/messages/history?peer_id=&before_id=&limit=单聊历史消息
GET/api/messages/unread未读汇总(单聊 + 群聊)
POST/api/messages/read标记单聊已读 {peer_id},并给对方推 read_receipt
GET/api/users?uids=10001,10002批量查用户资料(限同应用,最多 200 个)
GET/api/users/online?uids=10001,10002批量查在线状态(POST 传 JSON 数组也可)
// 例:业务服务器给用户推送系统通知
curl -X POST https://swoole.linglankj.cn/api/messages/send \
  -H "Authorization: Bearer <某用户的 IM_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"to_id":10002,"msg_type":"system","content":"您的订单已发货"}'

// 返回(HTTP 200,code=0 成功):
{
  "code": 0,
  "msg": "ok",
  "data": {
    "message":   { "msg_id": 12400, "conv_type": 1, "from_id": 10001, "to_id": 10002, "msg_type": "system", "content": "您的订单已发货", ... },
    "delivered": true        // true=对方在线已实时投递;false=已存离线,上线后自动推送
  }
}
HTTP API 以 Token 中的 uid 作为发送方;服务端通知场景可给系统账号(如 uid=99999)换 Token 后调用。

十三、多端接入代码(小程序 / 网页 / App)

三端使用完全相同的协议:Token 由业务服务器调 /core/open/token 换取,客户端建立 WebSocket 后收发的 JSON 指令、事件名、消息结构全部一致。区别只在各平台的 WebSocket API 写法与域名配置方式。

13.1 微信小程序

新建 utils/im.js:

// utils/im.js —— 微信小程序 IM 接入封装(单聊 + 群聊 + 好友 + 自动重连心跳)
let socket = null, connected = false, pingTimer = null, reconnectTimer = null;
let listeners = {};   // 事件监听
const HEARTBEAT = 30000;

function on(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }
function emit(type, data) { (listeners[type] || []).forEach(fn => fn(data)); }

function connect(wsUrl, token) {
  disconnect();
  socket = wx.connectSocket({
    url: wsUrl + '?token=' + token,
    success() {}
  });

  socket.onOpen(() => {
    connected = true;
    emit('open');
    clearInterval(pingTimer);
    pingTimer = setInterval(() => send({ type: 'ping' }), HEARTBEAT);
  });

  socket.onMessage((res) => {
    const msg = JSON.parse(res.data);
    emit(msg.type, msg);      // auth_ok / message / group_message / group_event / friend_event ...
    emit('message', msg);     // 兜底:所有消息都监听
  });

  socket.onClose(() => {
    connected = false;
    clearInterval(pingTimer);
    emit('close');
    // 断线自动重连(Token 未过期可直接重连,生产环境建议向业务后端重新取 Token)
    reconnectTimer = setTimeout(() => connect(wsUrl, token), 3000);
  });

  socket.onError(() => { emit('error'); });
}

function send(data) {
  if (!connected) return false;
  socket.send({ data: JSON.stringify(data) });
  return true;
}

function disconnect() {
  clearInterval(pingTimer);
  clearTimeout(reconnectTimer);
  connected = false;
  if (socket) { try { socket.close({}); } catch (e) {} socket = null; }
}

// ===== 业务快捷方法 =====
function sendPrivate(toUid, content, msgType = 'text') {
  return send({
    type: 'message', to_id: toUid, msg_type: msgType, content,
    client_msg_id: Date.now() + '' + Math.floor(Math.random() * 1000)
  });
}
function sendGroup(groupId, content, msgType = 'text') {
  return send({
    type: 'group_message', group_id: groupId, msg_type: msgType, content,
    client_msg_id: Date.now() + '' + Math.floor(Math.random() * 1000)
  });
}

module.exports = { connect, disconnect, send, sendPrivate, sendGroup, on };

页面中使用:

// pages/chat/chat.js
const im = require('../../utils/im');

Page({
  onLoad() {
    // 1. 先向你的业务后端换取 IM Token(服务端调 /core/open/token)
    wx.request({
      url: 'https://你的业务域名/api/im-token',  // 你自己的接口
      method: 'POST',
      data: {},
      success: (res) => {
        const { token, ws_url } = res.data.data;
        im.connect(ws_url, token);
      }
    });

    // 2. 监听事件
    im.on('auth_ok', (d) => console.log('登录成功,未读', d.unread));
    im.on('message', (d) => {
      if (d.type === 'message') console.log('收到单聊', d);
    });
    im.on('group_message', (d) => console.log('收到群消息', d));
    im.on('group_event', (d) => console.log('群事件', d.event, d));
    im.on('friend_event', (d) => console.log('好友事件', d.event, d));
    im.on('kick', () => wx.showModal({ title: '提示', content: '账号在其他设备登录' }));
  },

  // 发送文本
  onSend() {
    im.sendPrivate(10002, '你好,这是一条小程序消息');
    // 群聊:im.sendGroup(5, '大家好');
  },

  onUnload() { /* 全局单例建议在 App 级别管理,不在页面卸载时断开 */ }
});
小程序生产环境要求 wss://,需在「微信公众平台 → 开发管理 → 服务器域名」添加 socket 合法域名(网关域名);上传/下载文件还要配置 uploadFile / downloadFile 域名。

13.2 网页端(Web / H5 / PC 网站)

浏览器原生 WebSocket 即可接入,无需任何 SDK。新建 utils/im.js:

// utils/im.js —— 网页端 IM 接入封装(浏览器原生 WebSocket,零依赖)
let socket = null, connected = false, pingTimer = null, reconnectTimer = null;
let listeners = {};
const HEARTBEAT = 30000;

export function on(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }
function emit(type, data) { (listeners[type] || []).forEach(fn => fn(data)); }

export function connect(wsUrl, token) {
  disconnect();
  socket = new WebSocket(wsUrl + '?token=' + token);

  socket.onopen = () => {
    connected = true;
    emit('open');
    clearInterval(pingTimer);
    pingTimer = setInterval(() => send({ type: 'ping' }), HEARTBEAT);
  };

  socket.onmessage = (ev) => {
    const msg = JSON.parse(ev.data);
    emit(msg.type, msg);
    emit('message', msg);
  };

  socket.onclose = () => {
    connected = false;
    clearInterval(pingTimer);
    emit('close');
    // 断线自动重连(生产环境建议先向业务后端重新取 Token)
    reconnectTimer = setTimeout(() => connect(wsUrl, token), 3000);
  };

  socket.onerror = () => emit('error');
}

export function send(data) {
  if (!connected || socket.readyState !== WebSocket.OPEN) return false;
  socket.send(JSON.stringify(data));
  return true;
}

export function disconnect() {
  clearInterval(pingTimer);
  clearTimeout(reconnectTimer);
  connected = false;
  if (socket) { try { socket.close(); } catch (e) {} socket = null; }
}

// 业务快捷方法(与小程序版完全一致)
export function sendPrivate(toUid, content, msgType = 'text') {
  return send({ type: 'message', to_id: toUid, msg_type: msgType, content,
    client_msg_id: Date.now() + '' + Math.floor(Math.random() * 1000) });
}
export function sendGroup(groupId, content, msgType = 'text') {
  return send({ type: 'group_message', group_id: groupId, msg_type: msgType, content,
    client_msg_id: Date.now() + '' + Math.floor(Math.random() * 1000) });
}

Vue / React 页面中使用:

// Chat.vue(Vue3 示例,React 同理)
import { onMounted, onUnmounted } from 'vue'
import { connect, disconnect, on, sendPrivate } from '@/utils/im'

onMounted(async () => {
  // 1. 向你的业务后端换取 IM Token(服务端调 /core/open/token)
  const res = await fetch('/api/im-token', { method: 'POST' }).then(r => r.json())
  const { token, ws_url } = res.data
  connect(ws_url, token)

  // 2. 监听事件(事件名与小程序完全一致)
  on('auth_ok', (d) => console.log('登录成功,未读', d.unread))
  on('message', (d) => { if (d.type === 'message') console.log('收到单聊', d) })
  on('group_message', (d) => console.log('收到群消息', d))
  on('kick', () => alert('账号在其他设备登录'))
})

onUnmounted(() => { /* 建议在全局状态/Pinia 中管理连接,组件卸载不断开 */ })

function sendText() {
  sendPrivate(10002, '你好,这是一条网页消息')
}
网页端注意:① 页面所在域名与网关域名不同源时,网关已开启 CORS,WebSocket 本身不受同源策略限制; ② Token 务必由你的业务后端换取后再下发给页面,不要把 AppSecret 写进前端代码; ③ 移动端 H5 切后台后连接可能被系统挂起,回到前台时检测 readyState 并自动重连。

13.3 移动 App(iOS / Android / uni-app)

推荐使用 uni-app(一套代码同时出小程序 / H5 / iOS / Android)。下面是完整可用的封装,与网页版的协议、事件名、快捷方法完全一致,直接复制到项目即可:

// utils/im.js —— uni-app 版(一套代码发布 iOS / Android / 小程序 / H5)
// 与小程序版唯一区别:wx.* 换成 uni.*,其余协议、事件名、快捷方法全部一致
let socketTask = null, connected = false, pingTimer = null;
let listeners = {};
const HEARTBEAT = 30000;

export function on(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }
function emit(type, data) { (listeners[type] || []).forEach(fn => fn(data)); }

export function connect(wsUrl, token) {
  socketTask = uni.connectSocket({ url: wsUrl + '?token=' + token, complete: () => {} });
  socketTask.onOpen(() => {
    connected = true; emit('open');
    clearInterval(pingTimer);
    pingTimer = setInterval(() => send({ type: 'ping' }), HEARTBEAT);
  });
  socketTask.onMessage((res) => {
    const msg = JSON.parse(res.data);
    emit(msg.type, msg);
    emit('message', msg);
  });
  socketTask.onClose(() => {
    connected = false;
    clearInterval(pingTimer);
    emit('close');
    setTimeout(() => connect(wsUrl, token), 3000);   // 断线重连
  });
}

export function send(data) {
  if (!connected) return false;
  socketTask.send({ data: JSON.stringify(data) });
  return true;
}

export function sendPrivate(toUid, content, msgType = 'text') {
  return send({ type: 'message', to_id: toUid, msg_type: msgType, content,
    client_msg_id: Date.now() + '' + Math.floor(Math.random() * 1000) });
}
export function sendGroup(groupId, content, msgType = 'text') {
  return send({ type: 'group_message', group_id: groupId, msg_type: msgType, content,
    client_msg_id: Date.now() + '' + Math.floor(Math.random() * 1000) });
}

// App.vue 中全局初始化:
//   uni.login / 自有账号体系登录后 → 调业务后端换 Token → connect(ws_url, token)
// 原生 iOS(Swift/OC)、Android(Kotlin/Java)、Flutter、React Native:
//   使用各自 WebSocket 库连接同样的 wss 地址,收发同样的 JSON 即可,无额外适配层。

原生 iOS / Android 或 Flutter / React Native 等跨端框架,接入要点相同:

  • 使用平台原生 WebSocket 库(iOS 可 Starscream / URLSessionWebSocketTask;Android 可 OkHttp WebSocket;Flutter 用 web_socket_channel;React Native 用 WebSocket 全局对象);
  • 连接地址 wss://网关域名/ws?token=IM_TOKEN,或先连接再发 {"type":"auth","token":"..."};
  • 收发消息按第六章至第十一章的 JSON 协议解析;每 30 秒发 {"type":"ping"} 心跳;
  • App 端建议在应用级单例管理连接,前后台切换时做断线重连;网络从 Wi-Fi 切到 4G/5G 后也要重连;
  • 图片/语音/视频先走 App 文件上传到业务后端/对象存储拿 URL,再以 image/voice/video 类型发 URL;
  • 业务服务器的通知类消息(如订单状态、系统通知)可直接调网关 REST API POST /api/messages/send,无需客户端在线。
发送图片/语音/视频(三端通用):先把文件传到你的业务后端(或对象存储)拿到 URL, 然后 im.sendPrivate(uid, url, 'image') / im.sendGroup(gid, url, 'video')。

十四、错误码

WebSocket 错误统一推送 {"type":"error","code":xxx,"message":"..."}:

code含义
400消息格式错误 / 参数不合法 / content 为空等
401未鉴权或 Token 无效
403应用不存在/被禁用/已到期
404未知消息类型
4004 / 4005 / 4006单聊:peer_id 不合法 / uids 为空
4040 ~ 4043撤回:msg_id 不合法 / 消息不存在或已过期 / 只能撤回自己的消息 / 超过 2 分钟不可撤回
4101 ~ 4181群聊:群不存在、非群成员、被禁言、非群主/管理员、群名非法、群数/群成员超配额等(message 有具体说明)
4201 ~ 4220好友:已是好友、申请已存在、申请不存在、好友数超配额、friend_uid 不合法等
4301查询资料 uids 为空
500服务端内部错误(可重试,同时请反馈平台方排查)

业务接口(/core/*)错误:HTTP 200,code 非 200,message 为中文原因;401 鉴权失败(AppID/AppSecret 不正确或 Token 无效)、403 无权限/配额上限、404 资源不存在。

十五、在线测试

控制台自带可视化测试台(无需写代码即可验证全部协议):登录 开发者控制台,左侧菜单「应用接入 → 在线测试」即可打开。

测试台已内置控制台登录态,无需再填 API 地址与开发者账号:选择应用、填写 UID,即自动换取 IM Token 并连接 WebSocket。支持:单聊互发、建群/群消息/群管理、好友申请全流程、在线状态查询。开两个浏览器窗口(或一个普通窗口 + 一个无痕窗口)用不同 UID 连接即可互测。