Swoole IM 即时消息服务

基于 PHP Swoole 的 WebSocket 即时通讯服务端,一个端口同时提供 WebSocket 长连接与 HTTP API,适用于微信小程序 / App / 网页的单聊即时消息场景。

功能特性:
  • WebSocket 长连接实时收发消息,支持文字、图片、语音、视频、自定义消息
  • Token 鉴权(HMAC-SHA256 签名),与你现有的业务用户体系无缝对接
  • 消息持久化(MySQL)、离线消息上线自动推送、消息 ACK 去重
  • 未读消息汇总、已读回执、"正在输入"状态、上下线状态通知
  • HTTP API 兜底:业务后端可直接发消息、拉历史、查未读
  • 心跳保活 + 断线自动重连支持、同账号多端登录顶号

一、服务简介

服务启动后监听一个端口(默认 9501),同时提供三类服务:

服务地址说明
WebSocketws://服务器IP:9501/?token=xxx小程序长连接(生产环境经 Nginx 反代为 wss://你的域名/ws?token=xxx
HTTP APIhttp://服务器IP:9501/api/...业务后端调用:签发 token、发消息、拉历史等
健康检查GET /health返回在线连接数,可用于负载均衡探活

二、接入流程

小程序单聊消息完整链路:
  1. 小程序端调用 你的业务后端 登录接口(wx.login + 后端换 openid 等,你已有的逻辑);
  2. 你的业务后端调用 IM 服务的 POST /api/auth/token(带 app_key / app_secret + uid),换回 IM token;
  3. 小程序拿到 token 后,建立 WebSocket 连接:wss://你的域名/ws?token=xxx
  4. 连接成功后服务端推送 auth_ok(含未读数)和 offline_messages(离线消息);
  5. 发消息:WS 发送 {"type":"message",...},服务端回 ack,对方在线则实时收到 message
  6. 客户端每 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

四、WS 消息协议

所有消息均为 JSON 文本帧,统一带 type 字段。

4.1 客户端 → 服务端

type字段说明
ping心跳,服务端回 pong
authtoken连接后鉴权(握手已带 token 时无需再发)
messageto_id 接收者UID
msg_type text/image/voice/video/custom,默认 text
content 文本内容,或图片/语音URL(自定义类型可传JSON字符串)
client_msg_id 客户端生成的唯一ID(建议用随机UUID,用于去重和ACK匹配)
发送私聊消息
readpeer_id 对方UID标记与某人的会话全部已读
typingto_id 对方UID"正在输入",实时透传给对方,不入库
historypeer_id 对方UID
before_id 传消息ID拉取更早的消息,首次传0
limit 每页条数,默认20,最大100
分页拉取历史消息
presence_queryuids UID数组,如 [10002,10003]批量查询用户在线状态

4.2 服务端 → 客户端

type字段说明
pongserver_time心跳响应
auth_okuid 你的UID
server_time
unread 未读汇总 {total, conversations:[{peer_id,count}]}
鉴权成功
auth_failreason鉴权失败(随后断开)
kickreason被踢下线(同账号在其他设备登录)
ackclient_msg_id 对应你发送时的ID
msg_id 服务端消息ID
to_id created_at
消息已送达服务端(用 client_msg_id 匹配本地消息)
messagemsg_id client_msg_id from_id to_id
msg_type content is_read created_at
收到一条新消息(对方发来的)
offline_messagescount messages 消息数组上线时推送离线未读消息(最多100条,更多用 history 拉)
read_receiptfrom_id 已读的人
to_id 你自己
对方已读你发的消息
typingfrom_id对方正在输入
user_onlineuid有过会话的用户上线了
user_offlineuid有过会话的用户下线了
historypeer_id messages has_more历史消息返回(时间正序)
presencepresence {uid: true/false}在线状态查询结果
errorcode 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 / 同步用户外):

方法路径说明鉴权
POST/api/auth/token业务后端为用户签发 IM tokenapp_key / app_secret
POST/api/messages/sendHTTP 方式发送消息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_keystring应用 Key(config.php 中配置)
app_secretstring应用密钥(config.php 中配置,只能在服务端使用
uidint用户ID,与你业务系统用户ID一致
nicknamestring昵称,传入则同步更新
avatarstring头像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 作为 contentmsg_type 设为 image/voice 发送,减少 IM 服务器带宽压力。

七、错误码

code含义
401HTTP:token / app 凭证无效或过期;WS 握手失败返回 HTTP 401
404接口或消息类型不存在
4001to_id 不合法(不能给自己发消息)
4002消息内容不能为空
4003消息内容过长(超过 50000 字符)
4004 / 4005read / history 缺少 peer_id
4006presence_query 的 uids 必须为数组
500服务端内部错误(查看 runtime/logs/swoole.log)

八、在线测试

服务启动后,浏览器打开 http://服务器IP:9501/test.html(经 Nginx 反代后为 https://你的域名/test),可以用两个不同 UID 登录两个窗口互发消息,验证实时投递、离线消息、已读回执等功能。

部署步骤请参阅项目根目录的 DEPLOY.md(宝塔面板图文教程)。