回调排障
回调是 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 |
排查流程
- 确认事件是否发生
例如
message.routed只在消息成功入库后触发;发送失败(1008/1010/1035)不会有该事件。 - 查配置是否订阅
回调配置里的
events必须包含该事件;修改后确认已保存并启用。 - 查投递日志
用
GET /api/admin/apps/{appkey}/callbacks/deliveries按 eventType/status/from/to 过滤;看attempts/responseCode/error。 - 区分网络失败与业务拒绝
failed+ 5xx/超时 = 接收端问题;4xx = 你的验签/路由/方法不允许。 - 手动重试一次
POST …/callbacks/deliveries/{deliveryId}/retry;重试仍是同一 eventId,应用侧必须幂等。 - 发测试事件
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),但它没有重试;排查时不要拿异步重试策略套用。