回调排障

回调是 IM → 应用服务器的 Webhook(当前契约 22 类事件)。它至少一次投递、失败重试、用 eventId 去重;排查的关键是分清“没发”“发了但你没接住”“接住了但你处理失败”。

投递契约​

项值
方法 / 内容类型POST / application/json; charset=utf-8
超时5000ms
成功判定HTTP 2xx
重试次数3 次
退避1000ms / 5000ms / 30000ms
触发重试non-2xx / timeout / network-error
投递语义至少一次(应用按 eventId 幂等)
顺序同事件类型内近似有序,不保证全局顺序;eventId 幂等去重由应用服务器负责
请求体上限262144 字节
每应用并发8
签名hex(hmac_sha256(secret, timestamp + '.' + nonce + '.' + rawBody)) 小写
时间戳窗口300000ms
生产 HTTPS要求
JavaScript
// 回调接收端最小实现:先验签,再按 eventId 幂等,再快速 ACK
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.IM_CALLBACK_SECRET;
const handled = new Set(); // 生产用数据库唯一键替代

createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const raw = Buffer.concat(chunks).toString('utf8');
  const ts = req.headers['x-im-timestamp'];
  const nonce = req.headers['x-im-nonce'];
  const sig = req.headers['x-im-signature'];
  const expected = createHmac('sha256', SECRET).update(`${ts}.${nonce}.${raw}`).digest('hex');
  if (!sig || sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    res.writeHead(401).end('bad signature');
    return;
  }
  if (Math.abs(Date.now() - Number(ts)) > 300000) { res.writeHead(401).end('stale'); return; }

  const event = JSON.parse(raw);
  if (handled.has(event.eventId)) { res.writeHead(200).end('duplicate'); return; }
  handled.add(event.eventId);

  // 业务处理要快:重活丢队列,避免超过 5 秒超时
  await enqueue(event.eventType, event.data);
  res.writeHead(200, { 'content-type': 'application/json' }).end('{"code":0}');
}).listen(9090);
两个最常见的坑

把重活(拉大量数据、调第三方)放在回调里:超过 5 秒即判失败并重试,最终变成重复处理。

不验签或验签用解析后的 JSON 重新序列化:签名基于原始 body,必须用原始字节计算 HMAC。

事件清单​

事件触发时机业务用途
message.routed全量消息路由:每一条成功入库的消息(单聊/群/超级群/聊天室/系统通知)都会触发,供应用服务器归档/审核/业务路由。since 1.0.0
message.offline接收方无任何在线会话时触发(配合离线推送通道抽象)。since 1.0.0
message.recalled消息撤回成功后触发。since 1.0.0
read.receipt已读回执(按读者聚合;群聊含累计已读人数)。since 1.0.0
user.registered用户注册成功。since 1.0.0
user.banned用户封禁/解封。since 1.0.0
group.created群/超级群创建。since 1.0.0
group.updated群信息/成员/管理员/禁言变更。since 1.0.0
group.destroyed群解散。since 1.0.0
chatroom.created聊天室创建。since 1.0.0
chatroom.destroyed聊天室销毁。since 1.0.0
chatroom.member_changed聊天室成员变化(join/leave/kick)。since 1.0.0
user.online_status_changed用户在线状态变更:WS 建连/断连时触发(等价于融云用户在线状态回调)。since 1.1.0
user.deactivated用户注销(deactivate)成功后触发,既有 token 立即失效。since 1.1.0
user.reactivated用户恢复激活后触发。since 1.1.0
user.profile_changed用户资料变更(name 等)后触发。since 1.1.0
friend.changed好友关系变更:申请/接受/拒绝/删除。since 1.1.0
chatroom.kv_changed聊天室属性(KV)变更:设置/删除后触发,便于应用服务器同步。since 1.1.0
message.modified消息内容被修改(message.modify)后触发。since 1.1.0
message.deleted历史消息被批量清理(message.history.clean)后触发。since 1.1.0
message.audit_result消息审核结果回推:管理端对内容审核任务做出裁决(approve/reject)后触发,供应用服务器同步处置结果。since 1.1.0
message.publish.before发送前同步回调:客户端消息投递前阻塞询问应用服务器(超时 syncTimeoutMs,默认 500ms)。应用服务器返回 {code:0,data:{action:"pass"|"replace"|"block",content?,reason?}},超时/故障按 syncDefaultAction 降级(默认放行)。该事件不会异步重试,每次发送最多一次同步调用。since 1.2.0

排查流程​

  1. 确认事件是否发生

    例如 message.routed 只在消息成功入库后触发;发送失败(1008/1010/1035)不会有该事件。

  2. 查配置是否订阅

    回调配置里的 events 必须包含该事件;修改后确认已保存并启用。

  3. 查投递日志

    用 GET /api/admin/apps/{appkey}/callbacks/deliveries 按 eventType/status/from/to 过滤;看 attempts/responseCode/error。

  4. 区分网络失败与业务拒绝
    failed + 5xx/超时 = 接收端问题;4xx = 你的验签/路由/方法不允许。
  5. 手动重试一次
    POST …/callbacks/deliveries/{deliveryId}/retry;重试仍是同一 eventId,应用侧必须幂等。
  6. 发测试事件
    POST /api/admin/apps/{appkey}/callbacks/test 触发 test.ping,验证 URL/验签/ACK 链路。
JavaScript
// 管理端:查失败投递并重试
const failed = await admin.request('GET', '/apps/demo/callbacks/deliveries?status=failed&limit=20');
const delivery = failed.items[0];
console.log(delivery.eventType, delivery.responseCode, delivery.error);

await admin.request('POST', `/apps/demo/callbacks/deliveries/${delivery.deliveryId}/retry`);

// 发送测试事件(eventType=test.ping,不计入契约事件)
await admin.request('POST', '/apps/demo/callbacks/test');

发送前同步回调(特殊链路)​

订阅 message.publish.before 后,消息入库前会阻塞询问应用服务器,默认超时 500ms(可配 100–2000ms),超时/故障按 syncDefaultAction 降级(默认放行)。它不进入异步重试,每次发送最多一次调用。

响应结果客户端表现
{code:0,data:{action:"pass"}}放行正常发送
{code:0,data:{action:"replace",content:{…}}}替换后放行发送成功,内容被替换
{code:0,data:{action:"block",reason:"…"}}拒绝1035
超时 / 非 2xx / 无法解析按 syncDefaultAction默认放行;配置为 block 则 1035
同步回调与异步回调的日志不同

同步回调也会写投递记录(eventType=message.publish.before),但它没有重试;排查时不要拿异步重试策略套用。

相关页面​