REST 这个词在接口说明里通常只承诺了两件事:资源加动词的 URL,和 JSON 的请求响应体。真正决定你代码怎么写的,是它没写在名字里的那部分 —— 凭证怎么带、响应怎么判、重试安不安全、出事怎么把那一次调用捞回来。这四条约定全站通用,读一次就覆盖之后的每个接口,不像字段定义那样得一个个查。下面按 wecomapi 的接口形态把这四件事一次说清。
无状态是这四条约定的共同来源
无状态不是一句架构口号,它有非常具体的后果:服务端不记得你上一次做了什么。每个请求必须自带身份,于是有了鉴权约定;必须自带完整意图,于是有了数据格式约定;服务端记不住「这个动作你刚才已经做过」,于是重复提交的问题被推回给调用方,成了幂等约定;请求之间没有会话可以串起来,于是需要一个显式的标识把单次调用捞回来。
把企业微信REST API 的这四条当成同一个设计决定的四个断面来看,比当成四个知识点有用。因为任何一条你偷懒不做,缺口都会从另一条冒出来:不带业务幂等键,重试就变成重复;不留请求标识,两层状态码分得再清也追不到具体那一次失败。
- 鉴权:解决「你是谁」。一套密钥打通全站,它的作用范围有多大,接入第一天就要弄清
- 数据格式:解决「怎么说」。JSON 收发,真正的坑在两层状态码
- 幂等:解决「重复了怎么办」。它保证的东西比多数人以为的少
- 请求标识:解决「出事了找谁、找哪一次」。给你和平台方共用
鉴权:一把密钥的爆炸半径
一套 Bearer 密钥打通全站是最省事的形态,调用侧只要在每个请求上挂一个 Authorization 头,不用为每个能力单独申请。省事的代价是粒度:一把密钥能做的事,等于它被授予的那部分能力的全部。所以接入第一天就要弄清手上这把的作用范围有多大 —— 范围越大,泄露一次要换掉的东西越多,换的时候被牵连的在跑服务也越多。
平台侧给到什么粒度,你这边通常还要再切一刀:至少按环境分,再按业务线或调用方分,出事时能只停一把而不是全线停摆 —— 分环境具体要隔离哪几条轴、误发生产怎么防,站内讲多环境管理那篇有完整清单。还有一条从第一天就该做:密钥只从一个函数读,不要在十个地方各读一次环境变量,否则轮换那天你要改十处,而且一定会漏掉一处。
- 反向代理和网关的访问日志也在暴露面里,别只盯应用日志
- CI 的构建日志会长期留存,密钥别通过命令行参数传进去
日志脱敏、前端不直连这类泄露路径的完整清单,以及凭证的存放位置、刷新时机与多实例竞态,站内讲 Token 管理那篇已经展开过。这里补一条容易漏的:在 wecomapi 这类按账号订阅的形态下,密钥和账号实例是两个维度 —— 一把密钥可能覆盖多个实例,所以每次调用还要显式指定这次操作的是哪个实例。把实例标识当业务参数传,别绑在密钥上,扩容加账号时才不用重新发一轮密钥。
数据格式:真正会咬人的是两层状态码
请求体和响应体都是 JSON,这句话本身没什么可讲的。要讲的是响应包封:HTTP 状态码说的是「这次通信怎么样」,包封里的业务码说的是「这件事办得怎么样」。两者不是一回事,也不总是同向。拿到 200 不等于消息发出去了。
只判 HTTP 状态的代码在测试环境永远是对的,因为测试环境很少产生业务失败。上线之后的典型表现是:监控面板上成功率 100%,客户在群里说没收到。判断顺序要固定成三步 —— 有没有拿到响应(没拿到是传输层的事)、HTTP 状态是几(4xx 是你请求的问题,5xx 是对端的问题)、业务码是几(这才是业务结果)。三步对应三套完全不同的处置。
// 示意逻辑:包封结构、业务码取值与标识字段以线上文档为准
const res = await fetch("https://manager.wecomapi.com/message/sendText", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ guid, toId, content }),
});
// 请求标识成功失败都要取,别只写在成功分支里
const traceId = pickTraceId(res);
// 第二步:HTTP 层 —— 4xx 回去改代码,5xx 是对端的问题
if (!res.ok) throw new TransportError(res.status, traceId);
// 第三步:业务层 —— 这一步最常被整段跳过
const env = parseEnvelope(await res.json()); // 包封解析只写一处
if (!env.ok) throw new BizError(env.code, env.message, traceId);
return env.data;- 定义一个统一的包封类型解一次,别在业务代码里到处点原始 JSON。结构一变,报错的位置离真正的原因隔着十几个文件
- 所有标识一律当字符串处理。数字形态的长 ID 在 JavaScript 里超过 2 的 53 次方会静默丢精度,这类 bug 的表现是「偶尔发给了另一个人」
- 区分「字段缺失」和「字段为空」。用可选类型接住,别让语言的零值替你决定语义 —— Go 的空字符串和 Java 的 null 在这里含义并不相同
包封里业务数据的形状按接口而定,可能是对象也可能是数组。写解析层时别假设成同一种,也别为了「统一」强行再包一层,以文档给出的响应结构为准。
幂等:「重试是安全的」到底保证了什么
接口按幂等语义设计,这句话的实际含义是:同样的一次请求重复到达,不会产生第二次不可撤销的后果。它保证的是「你重发同一个请求不会出事」。它不保证「你的两次业务动作不会被当成一次」,也不保证「在你不知道发没发出去的时候可以随便再来一次」。这三层混着理解的结果是两个极端:要么不敢重试,要么重试出重复。
- 1拿到了明确的失败响应。你知道对面没做,按错误类型决定重不重试,这种情况最简单也最安全。
- 2拿到了成功响应,但你自己后续处理失败了。对面已经做过了,再发就是第二次 —— 这时该查的是你自己的发送记录,不是接口的幂等语义。
- 3没拿到响应。最麻烦的一种,你不知道对面做没做。必须带一个由业务动作决定、重试期间保持不变的键;没有键就只能事后对账,不能靠再发一次碰运气。
还有一个边界经常被忽略:幂等语义通常按单次调用定义,批量提交不等于原子提交。一次提交十条,其中三条失败,不会把成功的七条撤回来。所以批量接口的返回要逐条判、重试要按条重试。整批重发是重复外发的头号来源,而且它一次就能把上百个客户得罪掉。
按 wecomapi 的约定重试同一个请求本身是安全的,但「同一个请求」的判定标准在你这边,不在网络那边。幂等键怎么拼、保存多久、放缓存还是落库,站内讲频控与重试那篇写得更细;这里只强调一件事 —— 它必须在第一次提交之前就生成并落下来,事后补一个键等于没有键。
请求标识:它是给两个人用的
响应头里回传的请求标识(站上示例里是 x-request-id)有两个用途,对应两个不同的人。对你自己,它是把一次调用从海量日志里捞出来的钥匙;对平台方,它是你和对方在说同一次调用的唯一凭据。两个用途对「存在哪」的要求不一样,而多数团队只满足了第一个。
满足第一个只要打进日志。满足第二个的门槛高一些:失败的那次调用也得有。而失败路径恰恰是代码里最不认真的一条 —— 抛个异常往上扔,响应体丢了,标识跟着一起没了。所以取标识这个动作要放在判断成败之前,不管这次是 200 还是 500。
- 取标识的动作写在成败判断之前:异常往上抛的那一刻,wecomapi 回传的那个标识必须已经在手里,否则它跟着响应体一起没了
- 真正用得上的是失败那一次的标识 —— 成功的调用你几乎不会回头去查
完整的排障方法站内讲接口调试那篇更全,这里只补一个判断:如果日志里只有你自己的链路 ID、没有平台标识,那么所有需要对方配合才能定位的问题,你都只能靠描述沟通。这个差距在联调期完全看不出来,在线上偶发问题面前是决定性的。
这四条约定是全站通用的部分,读一遍就够。单个接口的字段名、枚举值、错误码与端点路径一律以 wecomapi 线上文档为准,本文示意代码不要照抄上生产。
常见问题
- HTTP 200 是不是就代表消息发成功了?
- 不是。HTTP 状态码描述这次通信,包封里的业务码描述这件事的结果,两者可以不同向。判断顺序固定成三步:有没有拿到响应、HTTP 状态是几、业务码是几,三步各自对应完全不同的处置。只判 HTTP 状态的监控会长期显示成功率 100%,而客户那边在说没收到。
- 接口说重试是安全的,那超时了直接重发行不行?
- 看你处于哪种情形。拿到明确失败响应时重发是安全的;没拿到响应时你并不知道对面做没做,必须带一个业务侧生成、重试期间不变的幂等键,而且这个键要在第一次提交之前就落库。批量提交也别整批重发 —— 幂等语义按单次调用定义,批量不等于原子,要按条重试。
- 企业微信API文档该怎么配合这四条约定看?
- 约定部分读一次就够,它覆盖之后的每个接口;文档里真正要反复查的是单个接口的字段、枚举与错误码。查的顺序建议先按能力域定位再深挖,而不是从目录第一章顺着读。wecomapi 的线上文档是这些精确定义的唯一可信来源,任何二手描述都可能已经过期。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
