NEW

免费试用已开放

立即开始

API · SDK · 文档与调试

Node.js 接入企业微信 API

更新于 2026-08-168 分钟

Node 侧接企业微信 API,写出来能跑很容易,跑稳很难,难点几乎全在异步:一个没等的 Promise、一次没设超时的请求、一个 Promise.all 打满的并发,在演示环境全都看不出来,上线第一周会一起出现。这篇按「先发出去、再收回来、再让它稳住」的顺序走一遍,示例按 wecomapi 的接入方式写。

先把闭环跑通,再谈工程化

Node 侧的第一步和语言无关:发出去一条、收回来一条。发送是一次普通的 HTTPS POST;接收是让你的服务挂一个公网可达的 HTTPS 路由,用 wecomapi 的事件回调把消息推回来。这条链路一通,后面所有工作都是在它上面加逻辑;它不通,写多少业务代码都是在盲写。

示意:Node 18+ 用全局 fetch 发出第一条javascript
// 示意逻辑,精确字段与端点以线上接口文档为准
const res = await fetch("https://manager.wecomapi.com/message/sendText", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ guid, toId, content: "Hello WeCom" }),
  signal: AbortSignal.timeout(5_000),  // fetch 没有默认超时,必须自己带
});

// fetch 只在网络层失败时 reject,4xx / 5xx 在它眼里都是「成功返回」
if (!res.ok) throw classify(res);
const raw = await res.json();          // 业务层的成败还要再判一次

这十几行里已经埋了两个决定,而它们正是 Node 侧最常被跳过的两个:超时,以及「什么算失败」。全局 fetch 不带 AbortSignal 就可以一直挂着;它也只在网络层出错时才 reject,HTTP 状态码和业务层结果都要你自己判。这两件事在本地永远不会暴露,因为本地永远是快的、永远是通的。

调通之前不要引入框架和分层。这条链路一旦通了就很少再坏,而它没通的时候,多一层抽象就多一处可疑点。

HTTP 客户端:fetch、undici 还是 axios

面对 wecomapi 这种 Bearer 鉴权加 JSON 收发的接口,客户端只需要做三件事:带上 header、设超时、复用连接。三个常见选择的差别不在 API 风格上,而在后两件的默认值和可调程度上。

  • 全局 fetch(Node 18+,底层就是 undici):零依赖,够用。代价是超时要每个请求自己带 AbortSignal,连接池参数得通过 undici 的 Agent 才能改。
  • undici:需要显式控制连接数、keep-alive 的时候用它。Agent 上的连接数就是你并发的物理上限,这个数和你代码里的并发闸必须对齐。
  • axios:拦截器和生态成熟,已经在用的项目继续用没问题。新项目为了拦截器专门引一个依赖不划算 —— 那层拦截器你自己写就是二十行,还能顺手把错误分类塞进去。

判断线很直接:单账号、每分钟几十次调用,全局 fetch 就是终点。多账号并行、有批量补发这类突发流量,用 undici 的 Agent 把连接数显式定下来。最差的形态是项目里两套并存 —— 一半走 fetch、一半走 axios,连接池各算各的,你限出来的速和实际发出去的速对不上,排查时先怀疑的还不会是这里。

回调侧:ACK 之后的代码才是坑

口径不变:收到事件先快速返回 2xx,再异步处理。这句话在 Node 上有两个具体到写法的陷阱,而且都不会在测试里出现。

陷阱一是原始报文。验签要用原始字节,而 express.json() 解析完就把它扔了;用 JSON.stringify(req.body) 重建出来的字符串和原始字节不保证一致 —— 键序、空格、非 ASCII 的转义都可能变。它的表现是「大部分报文验签通过,偶尔一条过不了」,最难查的那一类。

陷阱二是 ACK 之后的错误。响应已经发出去了,后面那段 await 抛错时没有任何人接,Node 默认会因为 unhandledRejection 让进程退出 —— 于是你看到的现象是「服务偶尔自己重启,日志里什么都没有」。ACK 之后的每一条异步链路都必须自己兜住错误,或者干脆让 ACK 之后只剩不会抛的动作。

示意:Express 收回调的最小骨架javascript
// 原始报文要在解析时留住,验签用它
app.use("/wecom/callback", express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

app.post("/wecom/callback", async (req, res) => {
  if (!verifySignature(req.rawBody, req.headers)) return res.sendStatus(401);

  // 持久化要在 ACK 之前完成:ACK 之后再落库,进程在中间被杀就凭空少一条
  await store.append(req.rawBody);
  res.sendStatus(200);

  // ACK 之后没人接错误,必须自己 catch
  queue.enqueue(req.rawBody).catch((err) => log.error({ err }));
});

回调进程里不要做同步 CPU 活 —— 大对象的反复序列化、加解密、图片处理都算。单线程的事件循环被占住时,卡住的不是这一个请求,是后面所有还没 ACK 的请求;平台侧看到的是超时,紧接着就是重试,然后你的服务被自己的重试压垮。

并发:Promise.all 是最常见的事故源

补发一万条消息,写成 await Promise.all(list.map(send)),就是一瞬间打出一万个请求。这行代码在几十条的测试数据上完美运行,在真实批量上会同时撞上连接池、对端节奏和你自己的内存 —— 一万个 Promise 连同它们各自持有的请求体,都在堆上等着。

示意:二十行的出向并发闸javascript
// 示意逻辑:并发数从连接池上限倒推,不要拍脑袋定
async function mapLimit(items, limit, fn) {
  const it = items[Symbol.iterator]();
  const workers = Array.from({ length: limit }, async () => {
    // 单线程下迭代器天然互斥,不需要额外的锁
    for (const item of it) {
      try { await fn(item); }
      catch (err) { log.error({ item, err }); }  // 单条失败不该拖垮整批
    }
  });
  await Promise.all(workers);
}

并发数怎么定:从连接池上限倒推,再留出余量给重试。比连接池大就是在排队,症状是延迟整体抬高而不是报错;比连接池小很多就是白占资源。另外,队列的消费并发和出向的发送并发是两个数,别共用一个信号量 —— 前者决定你消化积压事件的速度,后者受对端节奏约束,绑在一起时调任何一边都会误伤另一边。

Node 里最容易被吞掉的三类错误

  1. 1HTTP 层成功、业务层失败。这一类要判两次:先判传输层和状态码,再判响应体里的业务结果。只判一次的代码会把失败当成功记进日志,然后你在对账时才发现少了几百条。
  2. 2请求永远挂着。没有超时的请求不会失败,它会一直占着一个连接和一个 Promise。批量场景下这类请求会慢慢把连接池吃干净,表现是「跑了两小时之后整个服务不动了」。超时必须逐请求带上,不能指望默认值。
  3. 3被吞的 rejection。then 后面没接 catch、事件回调里的 async handler 抛出去没人管、for 循环里 await 忘了包。挂一个 process.on('unhandledRejection') 至少让它出现在日志里,但那是兜底,不是方案。

这三类的共同解法是同一个:错误分类函数只写一处,返回可重试、不可重试、需要人工三档,所有调用点都用它的结果做决定。分散在各处的 if (err.message.includes(...)) 是这块最脏的代码,也是最容易在对端改一句文案时集体失效的代码。

上生产前的四件事

  • 用 AsyncLocalStorage 串起 trace。回调进来时生成一个标识,全链路自动带着走,不用在每个函数签名里传。这是 Node 里少数几个真正省事的内置能力。
  • 处理 SIGTERM。收到信号后先停止从队列取新任务,等在途的处理完再退出。没有这一步,每次发布都会丢掉正在处理的那一批。
  • 把进程内状态外置。用 PM2 或 cluster 起多进程时,进程内的凭证缓存和令牌桶都会乘以进程数 —— 缓存变成各刷各的,限速变成实际速率翻倍。
  • 日志记完整的请求与响应,不要只记 err.message。排查线上问题时,能不能还原「当时发的是什么」决定了这件事是十分钟还是一整天。

示意代码只演示写法。精确的字段名、错误分类与端点以 wecomapi 线上接口文档为准,不要照抄上生产。

常见问题

有官方的企业微信 Nodejs SDK 吗?
接入以 REST 为主,Node 18 之后用全局 fetch 就能直接调,不依赖特定语言包;wecomapi 这侧提供统一的 REST 接口与示例。要不要再封一层,看你有几个调用点、横切逻辑写在几个地方,而不是看有没有现成的包。
回调服务和主动调用要放在同一个进程里吗?
量小时可以,长期建议拆开。回调进程只做验签、持久化、ACK 三件事,重活交给消费进程,这样发布重启不会丢事件,回调延迟也不会被业务处理拖累。两侧对 wecomapi 的调用各自限速,别共用进程内的计数器 —— 多进程下它本来就不准。
TypeScript 有必要吗?
有,但价值不在给响应体建类型。真正值钱的是把错误分成可重试 / 不可重试 / 需人工的联合类型,让漏掉分支的代码编译不过。响应体反而要宽松处理:只取你用到的字段,对端多加一个字段不该让你的服务报错。

准备好动手了?

精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。

相关文章