收到消息不做排重?回调里同步调 AI?6 秒超时不返回?——我在接入微信 API 的 Webhook 时踩过的坑,都写在这了。


前言:一个真实的需求

去年接了一个项目,需求很简单:把客户的个人微信变成 AI 客服,自动回复消息。

听起来不难是吧?API 发消息、收消息,两个接口的事。结果 Webhook 上线第一天,甲方的微信里同一个问题被 AI 回了五遍——因为断线重连触发了重复推送。

所以这篇文章不谈业务,就聊一件事:怎么做才算接对了 Webhook。


一、先理解:回调在整个系统里的位置

┌──────────┐   HTTP POST    ┌──────────┐   WebSocket    ┌────────┐
│ 你的服务  │ ←──────────── │ 开放平台  │ ←───────────  │  微信   │
│          │               │          │               │        │
│ 你的服务  │ ─────────────→│ 开放平台  │ ─────────────→│  微信   │
└──────────┘   HTTP POST    └──────────┘   HTTP API     └────────┘

看懂这张图就理解了全部:

  • 上行(发消息):你主动调 HTTP,跟一般 REST API 没啥区别,写完就忘。
  • 下行(收消息):微信来消息时,平台 HTTP POST 推给你。这是钩子,这篇文章只聊它。

关键认知:API 主动发的消息不会触发回调。 别指望调 sendText 之后回调里能拿到发送状态,办不到。回调只推两类东西:别人发给你的消息,和系统事件(掉线、好友申请等)。


二、回调的三个硬约束

平台给你的约束不多,就三条,但每一条都卡脖子:

约束 数值 你不遵守的后果
回调超时 6 秒 超时丢消息,10 分钟后平台重试 → 重复推送
重复推送 断线/重连/平台重启都可能触发 同一条消息被处理 N 次 → 重复回复、重复入库
公网可达 必须能从公网访问到你的回调地址 内网地址平台推不到

这三个约束决定了你回调服务的架构必须是:快速返回 + 幂等排重 + 异步处理。 缺一不可。


三、一条回调数据的结构

我用的 type=2(优化版),所有消息类型走统一结构:

{
  "messageType": "60001",
  "wcId": "wxid_myself",
  "data": {
    "content": "你好,在吗?",
    "fromUser": "wxid_sender",
    "fromGroup": "",
    "newMsgId": 3166120021925175285,
    "self": false,
    "timestamp": 1640594470
  }
}

四个字段是你写分发逻辑的命根子:

  • messageType → 你的 switch 语句就靠它,60001 是私聊文本,80001 是群聊文本
  • newMsgId → 唯一排重键,比 msgId 更可靠,必须用它
  • fromGroup → 空了就是私聊,非空是群聊(以 @chatroom 结尾)
  • self → true 表示这条是你自己发的,通常直接跳过

四、你的回调 Handler 应该长这样

不废话,贴核心骨架:

app.post('/webhook', async (req, res) => {
  // 第一件事:立刻返回,一秒都别拖
  res.json({ code: '1000', message: 'ok' });

  const { messageType, data } = req.body;

  // 第二件事:排重
  const locked = await redis.set(
    `msg:${data.newMsgId}`, '1', 'NX', 'EX', 3600
  );
  if (!locked) return;

  // 第三件事:跳过自己发的
  if (data.self) return;

  // 第四件事:扔进队列
  await mq.publish('wechat-message', { messageType, data });
});

// ============ 消费者(异步) ============
mq.consume('wechat-message', async ({ messageType, data }) => {
  switch (messageType) {
    case '60001': // 私聊文本
      const reply = await ai.chat(data.content);
      await api.sendText(data.fromUser, reply);
      break;

    case '30001': // 好友申请
      const { v1, v2, scene } = parseXML(data.content);
      await api.acceptFriend(v1, v2, scene);
      await api.sendText(data.fromUser, '你好,我是 AI 助理 😊');
      break;

    case '30000': // 离线
      alarm.send('微信掉线了!');
      await api.reconnect();
      break;
  }
});

核心思路四个字:快返异处。快速返回 + 异步处理。Handler 里不调 AI、不写库、不做任何耗时操作。


五、消息类型的规律:背不下来但能推出来

这套 API 的消息类型编码非常有规律,不需要背:

系列 范围 含义
0xxxx 00000 测试推送
3xxxx 30000~30003 系统通知(离线、好友申请、异步任务完成)
6xxxx 60001~60022 私聊消息(文本/图片/视频/语音/文件/链接/小程序/撤回……)
65xxx 65001~65004 好友关系变更(被删、被拉黑)
8xxxx 80001~80021 群聊消息(与 6xxxx 一一对应)
85xxx 85001~85015 群事件(进群/退群/改名/换群主/群公告)

跑通一个 60001(私聊文本),其他消息类型的处理逻辑几乎照搬。文件类消息唯一需要注意的是 60008(上传中)和 60009(上传完成)的区别——必须等 60009 才能下载。


六、我踩过的坑

1. 在回调里同步调 AI

大模型推理 3~10 秒是常态,超过 6 秒直接超时。平台重试 → 再超时 → 再重试,最后用户收到一串重复回复。

解法:消息入 Redis 队列 → 立即返回 200 → Worker 消费队列再调 AI。

2. 用了 msgId 而不是 newMsgId 排重

文档写得明白:newMsgId 才是排重专用字段。用 msgId 在断线重连时不管用。

3. 忘了 self 字段

AI 回复之后回调又推了一条到自己,又触发 AI 再回……死循环。data.self === true 直接跳过就行。

4. 多实例部署没用 Redis 排重

如果起了 3 个 Pod,同一条消息三个实例各处理一次。必须用 Redis 做分布式锁。


七、总结

用一段伪代码总结这篇文章:

收到回调 {
    立刻返回 200
    newMsgId 排重(Redis SETNX)
    跳过 self
    入队列
}

消费队列 {
    switch (messageType) {
        30001: 自动通过好友
        60001: AI 回复
        80001: 群聊处理
        30000: 告警 + 重连
    }
}

Webhook 的开发哲学其实就一句:在 HTTP 层你只管收发,所有业务逻辑都交给异步。 把这句刻进 DNA 里,回调就不会出大问题。

了解详情查看:Eyun官网

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐