Swoole IM 即时消息服务
基于 PHP Swoole 的 WebSocket 即时通讯服务端,一个端口同时提供 WebSocket 长连接与 HTTP API,适用于微信小程序 / App / 网页的单聊即时消息场景。
- WebSocket 长连接实时收发消息,支持文字、图片、语音、视频、自定义消息
- Token 鉴权(HMAC-SHA256 签名),与你现有的业务用户体系无缝对接
- 消息持久化(MySQL)、离线消息上线自动推送、消息 ACK 去重
- 未读消息汇总、已读回执、"正在输入"状态、上下线状态通知
- HTTP API 兜底:业务后端可直接发消息、拉历史、查未读
- 心跳保活 + 断线自动重连支持、同账号多端登录顶号
一、服务简介
服务启动后监听一个端口(默认 9501),同时提供三类服务:
| 服务 | 地址 | 说明 |
|---|---|---|
| WebSocket | ws://服务器IP:9501/?token=xxx | 小程序长连接(生产环境经 Nginx 反代为 wss://你的域名/ws?token=xxx) |
| HTTP API | http://服务器IP:9501/api/... | 业务后端调用:签发 token、发消息、拉历史等 |
| 健康检查 | GET /health | 返回在线连接数,可用于负载均衡探活 |
二、接入流程
- 小程序端调用 你的业务后端 登录接口(wx.login + 后端换 openid 等,你已有的逻辑);
- 你的业务后端调用 IM 服务的
POST /api/auth/token(带 app_key / app_secret + uid),换回 IM token; - 小程序拿到 token 后,建立 WebSocket 连接:
wss://你的域名/ws?token=xxx; - 连接成功后服务端推送
auth_ok(含未读数)和offline_messages(离线消息); - 发消息:WS 发送
{"type":"message",...},服务端回ack,对方在线则实时收到message; - 客户端每 30 秒发一次
{"type":"ping"}保活;断线自动重连。
⚠️ 微信小程序生产环境要求 wss://(HTTPS),且域名必须在「微信公众平台 → 开发管理 → 开发设置 → 服务器域名 → socket 合法域名」中配置,域名需已备案。
三、WebSocket 连接
WS
// 生产环境(Nginx 反向代理 + HTTPS) wss://im.yourdomain.com/ws?token=TOKEN // 本地/测试环境(直连 IP + 端口,需放行端口) ws://127.0.0.1:9501/?token=TOKEN
token也可以在连接建立后通过{"type":"auth","token":"xxx"}消息发送(但推荐握手时携带,失败会直接 401);- token 无效或过期:握手阶段返回 HTTP 401;auth 消息方式则收到
auth_fail后被断开; - 客户端需每 30 秒发送
{"type":"ping"},服务端超过 180 秒无消息会断开连接。
四、WS 消息协议
所有消息均为 JSON 文本帧,统一带 type 字段。
4.1 客户端 → 服务端
| type | 字段 | 说明 |
|---|---|---|
| ping | — | 心跳,服务端回 pong |
| auth | token | 连接后鉴权(握手已带 token 时无需再发) |
| message | to_id 接收者UIDmsg_type text/image/voice/video/custom,默认 textcontent 文本内容,或图片/语音URL(自定义类型可传JSON字符串)client_msg_id 客户端生成的唯一ID(建议用随机UUID,用于去重和ACK匹配) | 发送私聊消息 |
| read | peer_id 对方UID | 标记与某人的会话全部已读 |
| typing | to_id 对方UID | "正在输入",实时透传给对方,不入库 |
| history | peer_id 对方UIDbefore_id 传消息ID拉取更早的消息,首次传0limit 每页条数,默认20,最大100 | 分页拉取历史消息 |
| presence_query | uids UID数组,如 [10002,10003] | 批量查询用户在线状态 |
4.2 服务端 → 客户端
| type | 字段 | 说明 |
|---|---|---|
| pong | server_time | 心跳响应 |
| auth_ok | uid 你的UIDserver_timeunread 未读汇总 {total, conversations:[{peer_id,count}]} | 鉴权成功 |
| auth_fail | reason | 鉴权失败(随后断开) |
| kick | reason | 被踢下线(同账号在其他设备登录) |
| ack | client_msg_id 对应你发送时的IDmsg_id 服务端消息IDto_id created_at | 消息已送达服务端(用 client_msg_id 匹配本地消息) |
| message | msg_id client_msg_id from_id to_idmsg_type content is_read created_at | 收到一条新消息(对方发来的) |
| offline_messages | count messages 消息数组 | 上线时推送离线未读消息(最多100条,更多用 history 拉) |
| read_receipt | from_id 已读的人to_id 你自己 | 对方已读你发的消息 |
| typing | from_id | 对方正在输入 |
| user_online | uid | 有过会话的用户上线了 |
| user_offline | uid | 有过会话的用户下线了 |
| history | peer_id messages has_more | 历史消息返回(时间正序) |
| presence | presence {uid: true/false} | 在线状态查询结果 |
| error | code message | 错误(见错误码表) |
4.3 消息示例
// 发送一条文本消息(客户端 → 服务端) { "type": "message", "to_id": 10002, "msg_type": "text", "content": "你好,在吗?", "client_msg_id": "a3f8c1e2-9b7d-4e5f-8a2b-1c6d9e0f2a4b" } // 服务端 ACK(服务端 → 发送方) {"type":"ack","client_msg_id":"a3f8c1e2-...","msg_id":8521,"to_id":10002,"created_at":1693555200123} // 实时投递(服务端 → 接收方) { "type": "message", "msg_id": 8521, "client_msg_id": "a3f8c1e2-9b7d-4e5f-8a2b-1c6d9e0f2a4b", "from_id": 10001, "to_id": 10002, "msg_type": "text", "content": "你好,在吗?", "is_read": 0, "created_at": 1693555200123 } // 图片消息建议:小程序先把图片上传到你的文件服务器/OSS,content 传图片URL {"type":"message","to_id":10002,"msg_type":"image","content":"https://cdn.yourdomain.com/chat/2026/xxx.jpg","client_msg_id":"img-8f21..."}
五、HTTP API 总览
统一响应格式:{"code":0,"msg":"ok","data":{...}},code=0 表示成功。鉴权方式(除签发 token / 同步用户外):
- Header:
Authorization: Bearer TOKEN(推荐) - 或 Query:
?token=TOKEN;或 Body 中带token字段
| 方法 | 路径 | 说明 | 鉴权 |
|---|---|---|---|
| POST | /api/auth/token | 业务后端为用户签发 IM token | app_key / app_secret |
| POST | /api/messages/send | HTTP 方式发送消息 | token 或 app 凭证 |
| GET | /api/messages/history | 分页拉取历史消息 | token |
| GET | /api/messages/unread | 未读消息汇总 | token |
| POST | /api/messages/read | 标记会话已读 | token |
| POST | /api/users/sync | 同步用户昵称/头像 | app_key / app_secret |
| GET | /api/users?uids=1,2 | 批量查用户资料 | token |
| GET | /api/users/online | 批量查在线状态 | token |
| GET | /health | 健康检查(在线人数) | 无 |
5.1 签发 Token
POST /api/auth/token —— 由你的业务后端调用,为登录用户换取 IM token。
请求参数(JSON Body):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| app_key | string | 是 | 应用 Key(config.php 中配置) |
| app_secret | string | 是 | 应用密钥(config.php 中配置,只能在服务端使用) |
| uid | int | 是 | 用户ID,与你业务系统用户ID一致 |
| nickname | string | 否 | 昵称,传入则同步更新 |
| avatar | string | 否 | 头像URL,传入则同步更新 |
curl -X POST https://im.yourdomain.com/api/auth/token \ -H "Content-Type: application/json" \ -d '{"app_key":"im_demo","app_secret":"你的app_secret","uid":10001,"nickname":"小明","avatar":"https://cdn/1.png"}' // 响应 { "code": 0, "msg": "ok", "data": { "token": "eyJ1aWQiOjEwMDAxLCJpYXQiOjE2OTM1...(很长)", "uid": 10001, "expires_in": 604800, "ws_url": "wss://你的域名/ws?token=..." } }
5.2 发送消息(HTTP)
POST /api/messages/send —— 业务后端主动发消息(如系统通知、客服消息),对方在线会实时投递,不在线存为离线消息。
鉴权二选一:① Header 带用户 token(以该用户身份发送);② Body 带 app_key/app_secret/from_id(服务端对服务端)。
curl -X POST https://im.yourdomain.com/api/messages/send \ -H "Content-Type: application/json" \ -H "Authorization: Bearer 用户TOKEN" \ -d '{"to_id":10002,"msg_type":"text","content":"你好","client_msg_id":"srv-20260831-001"}' // 响应(delivered=true 表示已实时推送到对方) {"code":0,"msg":"ok","data":{"message":{"msg_id":8522,...},"delivered":true}}
5.3 历史消息
GET /api/messages/history
GET /api/messages/history?peer_id=10002&before_id=0&limit=20 Header: Authorization: Bearer TOKEN // 响应(messages 为时间正序;翻页时把本页最早一条的 msg_id 作为 before_id) {"code":0,"msg":"ok","data":{ "peer_id":10002, "messages":[{"msg_id":8501,"from_id":10001,"to_id":10002,"msg_type":"text","content":"...","is_read":1,"created_at":1693555000000}], "has_more":true }}
5.4 未读消息汇总
GET /api/messages/unread —— 小程序会话列表页用,显示每个会话的未读数红点。
GET /api/messages/unread Header: Authorization: Bearer TOKEN // 响应 {"code":0,"msg":"ok","data":{ "total":5, "conversations":[ {"peer_id":10002,"count":3}, {"peer_id":10005,"count":2} ] }}
5.5 标记已读
POST /api/messages/read —— 用户打开某个聊天会话时调用,对方会收到 read_receipt。
POST /api/messages/read
Header: Authorization: Bearer TOKEN
Body: {"peer_id":10002}
// 响应
{"code":0,"msg":"ok","data":{"peer_id":10002,"affected":3}}
5.6 用户资料同步 / 查询
POST /api/users/sync —— 业务后端同步昵称/头像(app 凭证鉴权),支持批量:
POST /api/users/sync
Body:
{
"app_key":"im_demo",
"app_secret":"xxx",
"users":[
{"uid":10001,"nickname":"小明","avatar":"https://cdn/1.png"},
{"uid":10002,"nickname":"小红","avatar":"https://cdn/2.png"}
]
}
// 也支持单用户写法:{"app_key":"...","app_secret":"...","uid":10001,"nickname":"小明","avatar":"..."}
GET /api/users?uids=10001,10002 —— 批量查资料(token 鉴权),返回 {"users":[{"uid","nickname","avatar"}]}。
5.7 在线状态
GET /api/users/online?uids=10002,10003(也可 POST JSON {"uids":[10002,10003]},token 鉴权)
{"code":0,"msg":"ok","data":{"presence":{"10002":true,"10003":false}}}
此外 WS 连接中,有过会话的用户上/下线时会主动推送 user_online / user_offline。
六、小程序接入示例代码
新建 utils/im.js,封装连接、心跳、断线重连与消息分发:
// utils/im.js —— 小程序 WebSocket IM 客户端 const WS_URL = 'wss://im.yourdomain.com/ws' let socketTask = null let isConnected = false let token = '' let heartbeatTimer = null let reconnectTimer = null let reconnectCount = 0 let messageHandlers = [] // 业务层注册的消息回调 function connect(t) { token = t || token if (!token) { console.error('IM: 缺少 token'); return } if (socketTask) { try { socketTask.close({}) } catch (e) {} } socketTask = wx.connectSocket({ url: WS_URL + '?token=' + token }) socketTask.onOpen(() => { isConnected = true reconnectCount = 0 startHeartbeat() }) socketTask.onMessage((res) => { let msg try { msg = JSON.parse(res.data) } catch (e) { return } // 分发给业务页面 messageHandlers.forEach(fn => fn(msg)) handleSystemMessage(msg) }) socketTask.onClose(() => scheduleReconnect(false)) socketTask.onError(() => scheduleReconnect(false)) } function handleSystemMessage(msg) { switch (msg.type) { case 'auth_ok': // msg.unread.total 未读总数;可在这里刷新会话列表红点 break case 'offline_messages': // 离线消息,入库/展示后可对每个会话发 read 标记已读 break case 'kick': // 被踢下线,提示并跳登录页 wx.showToast({ title: '账号在其他设备登录', icon: 'none' }) break case 'auth_fail': // token 过期:调你的后端重新拿 token 再 connect(newToken) scheduleReconnect(true) break } } function startHeartbeat() { stopHeartbeat() heartbeatTimer = setInterval(() => send({ type: 'ping' }), 30000) } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null } } // 指数退避重连;token 失效时先刷新 token function scheduleReconnect(refreshToken) { isConnected = false stopHeartbeat() if (reconnectTimer) clearTimeout(reconnectTimer) if (reconnectCount >= 10) return const delay = Math.min(1000 * Math.pow(2, reconnectCount), 30000) reconnectCount++ reconnectTimer = setTimeout(async () => { if (refreshToken) { // TODO: 调用你自己的后端接口重新获取 IM token // const res = await wx.request({ url: 'https://api.yourdomain.com/im/token' }) // token = res.data.data.token } connect() }, delay) } // 发送消息(自动重试由业务层结合 ack 处理) function send(data) { if (!isConnected || !socketTask) return false socketTask.send({ data: JSON.stringify(data) }) return true } // 发送聊天消息:clientMsgId 建议本地生成并保存,收到 ack 后更新状态 function sendChatMessage(toId, msgType, content) { const clientMsgId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10) send({ type: 'message', to_id: toId, msg_type: msgType, content, client_msg_id: clientMsgId }) return clientMsgId } function markRead(peerId) { send({ type: 'read', peer_id: peerId }) } function sendTyping(toId) { send({ type: 'typing', to_id: toId }) } function pullHistory(peerId, beforeId = 0, limit = 20) { send({ type: 'history', peer_id: peerId, before_id: beforeId, limit }) } function onMessage(fn) { messageHandlers.push(fn) } module.exports = { connect, send, sendChatMessage, markRead, sendTyping, pullHistory, onMessage, isConnected: () => isConnected }
页面中使用:
const im = require('../../utils/im') Page({ onLoad() { // 1. 先从你的后端拿到 IM token(后端调 /api/auth/token) im.connect('服务端返回的token') // 2. 监听服务端消息 im.onMessage((msg) => { if (msg.type === 'message') { // 渲染到聊天界面;进入会话时发已读: im.markRead(msg.from_id) } if (msg.type === 'ack') { // 用 msg.client_msg_id 找到本地消息,标记为"已发送" } }) }, onSend() { im.sendChatMessage(10002, 'text', this.data.input) } })
wx.uploadFile 把文件上传到你自己的文件服务/OSS,再把 URL 作为 content、msg_type 设为 image/voice 发送,减少 IM 服务器带宽压力。七、错误码
| code | 含义 |
|---|---|
| 401 | HTTP:token / app 凭证无效或过期;WS 握手失败返回 HTTP 401 |
| 404 | 接口或消息类型不存在 |
| 4001 | to_id 不合法(不能给自己发消息) |
| 4002 | 消息内容不能为空 |
| 4003 | 消息内容过长(超过 50000 字符) |
| 4004 / 4005 | read / history 缺少 peer_id |
| 4006 | presence_query 的 uids 必须为数组 |
| 500 | 服务端内部错误(查看 runtime/logs/swoole.log) |
八、在线测试
服务启动后,浏览器打开 http://服务器IP:9501/test.html(经 Nginx 反代后为 https://你的域名/test),可以用两个不同 UID 登录两个窗口互发消息,验证实时投递、离线消息、已读回执等功能。
部署步骤请参阅项目根目录的 DEPLOY.md(宝塔面板图文教程)。