回调这段链路的返工,多半不在算法上。签名照文档实现一遍就能对,真正把人绊住的是它周围那一圈东西:地址挂在哪一层、订阅开多宽、原始报文在到达验签函数之前被谁动过、返回 200 究竟承诺了什么、对方重试的时候你的系统在干什么。这篇按配置、验签、响应、重试四段走一遍,每段只讲需要你做判断的那几个点,示例按 wecomapi 的事件回调接入方式来写。
配置阶段其实只在做三个决定
控制台那张表单看着简单 —— 一个地址、一组订阅项、一把密钥。但填下去的那一刻,你已经把三件事定了:这个入口挂在哪、听多宽、第一跳出问题时谁来兜。这三件事后面都不好改,因为改它们意味着一段有真实事件正在流动的切换窗口。
- 地址粒度:一个环境一个地址,测试、预发、生产各配各的。用同一个地址靠查询参数区分环境,是把一次配置手误直接兑现成给真实客户发消息。
- 订阅范围:只订当前真的会处理的事件类型。全开的代价不是流量,是噪声 —— 日志里九成是你不看的东西,之后每一次排查都要先做一遍筛选。
- 入口位置:地址后面挂一个薄接收端,不要直接挂业务服务。接收端要能独立扩容和重启,而业务进程每重启一次就丢一批事件,这两件事不该绑在一起。
有一个坑几乎每个团队都会撞一次:回调路径被自己的鉴权中间件挡了。接收端通常长在既有服务里,全局登录校验、CSRF 防护、租户解析一层层套着,而回调请求既没有 Cookie 也没有你们签发的 Token,于是整整齐齐全被挡在门外,日志里只剩一片鉴权失败。这条路径必须显式排除在业务鉴权之外 —— 它的身份校验由验签负责,两套机制不要互相代劳,更不要叠着用。
还有一个配置阶段就该定、拖到后面会很贵的决定:多个下游都想要同一份事件的时候怎么办。这件事没有第二条路:一个账号实例只对应一个回调地址,平台侧不存在「一个下游配一个地址」的形态,扇出只能做在自己这边 —— 一个入口收下、落盘、再分发给各个消费方。好处是验签和幂等只实现一次,新增一个下游是改自己的路由表,不用回控制台动配置,也就不用制造一段有真实事件在流动的切换窗口。用 wecomapi 的事件回调时,扇出这层做在你自己的接收端里,平台侧始终只认这一个入口。
域名和证书也算配置的一部分。证书过期、中间证书链不全这类问题在浏览器里可能只是一个警告,在服务端的 HTTP 客户端那边通常直接是连接失败,而且失败得非常安静 —— 没有人会收到通知,事件就是不来了。
握手成功,只证明这个地址是你的
保存配置时会有一次验证请求,你的服务按约定回应,配置才生效。不少人做完这步就当验签做完了。这两件事验的根本不是同一个问题:握手回答「这个地址是不是你的」,一辈子只发生几次;常态验签回答「这一次推送是不是从平台来的」,每一个请求都要做一遍。
把两段逻辑写进同一个函数是常见的第二个错。握手分支通常有自己的返回格式要求,混在业务分支里,下一次重构很容易被顺手清掉,而它失效的表现是「改配置的时候保存不上」,跟当时改的代码看不出任何关联,排查成本极高。给它单独一个分支,上面写一行注释说明它什么时候会被调用。
第三个错更隐蔽:验签函数写了,但异常走 catch 分支返回放行。这等于没写。验签只有通过和拒绝两种结果,解析失败、取不到报文、密钥读不出来,全部归到拒绝那一边。想在上线前先摸清失败率,只能在预发环境用观察模式跑,生产上线第一天就必须是拒绝模式 —— 观察模式留在生产上,这道门就只是个装饰。
失败日志要能分辨是哪一步挂的:取原始字节、算摘要、比对、检查时间窗,四步各自打点。只打一句「签名错误」,等于把排查交给运气,而这类问题往往是某个中间件升级之后才冒出来的,谁也想不到去看它。
验签对不上,八成不是算法的问题
用 wecomapi 的事件回调接入时,签名算法照文档实现基本不会错,会错的是报文在到达验签函数之前被谁动过、密钥怎么管。
- 1拿原始字节,不要拿解析后的对象重新序列化。反序列化再序列化会改键顺序、改空格、改数字格式,摘要必然对不上。多数 Web 框架需要显式开启原始报文缓存,还要保证解析器不会抢在前面把请求体消费掉。
- 2查一遍链路上有没有人改写请求。网关补头、日志中间件读完 body 又塞回去、压缩层解压后再交给你 —— 任何一处都可能让字节不同于对方签名时的那份。本地能过、线上过不了,八成就在这一段。
- 3比对摘要用常量时间比较函数,别用字符串等号。成本几乎为零,不做就是白留一条可以反复试探的侧信道。
- 4校验时间戳窗口。签名正确只说明报文没被改过,不说明它不是几小时前被录下来重放的。窗口给到分钟级,宽度要覆盖得住对方的重试间隔,比重试间隔还窄会把正常重试拒成重放。
// 示意逻辑,签名算法与参与计算的字段以线上接口文档为准
app.post("/wecom/callback", raw({ type: "*/*", limit: "256kb" }), (req, res) => {
// req.body 是原始字节,不能用解析后的对象重新序列化再算摘要
if (!verify(req.body, req.headers)) return res.sendStatus(401);
persist(req.body); // ACK 之前只做这一件事
res.sendStatus(200); // 快速 ACK,之后的代码都不在对方的耐心范围内了
// 扇出给各个消费方;回写动作走 https://manager.wecomapi.com/message/sendText,
// 由消费者在队列里执行,不放进这个请求的生命周期
fanout(req.body);
});这段的顺序是有讲究的:验签在最前,落盘在 ACK 之前,其余一律在 ACK 之后。把落盘挪到 ACK 后面看着响应更快,实际是在承诺一件没做到的事 —— 你已经告诉对方收下了,事件却只活在内存里,进程一重启就没了。
2xx 承诺的是收下了,不是处理成功了
这两件事必须分开,否则你会在两个错误方向之间来回摆:要么把业务处理塞进请求生命周期,换一个「真实」的成功语义,代价是响应变慢、重试变多;要么业务失败了照样返 2xx,事件就此蒸发。
分法很简单,判据是这次失败重投一遍会不会好。会好的属于投递层 —— 网络抖动、你这边正在滚动重启,交给对方重试合适;重投一百次结果都一样的属于业务层 —— 下游库挂了、数据不合法,交给自己的重试队列和死信兜底,让平台重试只是在浪费双方资源。
- 验签不通过:拒绝,返鉴权类错误。不要返 200 假装收下 —— 那会把一次密钥配错变成一场静默的数据丢失,而且没有任何指标会亮。
- 能验签但解析不出来:收下、落盘、告警。这通常意味着对端发了新版本或者你的解析有 bug,丢掉就再也查不回来。
- 自己过载:这是唯一适合主动返非 2xx 的场景。让对方的退避机制帮你分担压力,比硬扛到超时体面,也比默默丢事件诚实。
响应体里放什么多数时候不重要,重要的是别把业务结果塞进去。见过在响应里返「已受理,工单号 xxx」的写法,看着贴心,实际是把业务处理拽回了请求生命周期:想拿到工单号就得先建单,建单要落库,库一慢响应就慢,响应一慢重试就来。
重试是对方的策略,你能动的只有三个变量
重试节奏、次数、退避曲线由投递方决定,你改不了,也别指望靠「把重试关掉」解决问题 —— 重复投递是 HTTP 事件投递的固有语义,不是缺陷。你能动的只有三样:把响应做快,让超时这个触发条件尽量不成立;把幂等做对,让重复投递不产生第二次副作用;把原始报文留下来,让你自己也具备重放能力。
第三样最常被跳过。平台的重试有次数上限,过了窗口就不再送;你自己攒的那份原始报文是最后一道保险 —— 下游修好之后按顺序重放一遍,通常比让业务方手工补数据快一个数量级。接 wecomapi 的事件回调时,这层留痕做在自己的接收端,和上游的重试策略互不干扰,也不需要对方配合。幂等键怎么取、去重窗口按什么定,站内有专门一篇,这里只到「必须做」为止。
还有一件该提前想的事:接收端自己也要限流。事件洪峰 —— 一个大群突然活跃、一次批量操作引发大量变更 —— 会把接收端打满,而这时候你越慢重试越多,重试又让你更慢,很容易滚成雪崩。给接收端设一个并发上限,超过上限就只落盘、跳过一切额外处理,保住 ACK 这条最关键的路径。
配置生效之后,先把三件事演练掉
保存成功不等于链路可用。趁着还没有真实流量,在预发环境把下面三件事验掉,比上线之后靠客户投诉发现便宜得多。
- 1造一次真实事件,确认从触发到你的服务收到的整条路径是通的。握手走的是一条特殊路径,它成功只说明地址可达,不说明事件推得过来。
- 2看响应时延的分布,不要看平均值。平均值会被大量快速返回拉平,真正触发重试的是尾部那几个百分点。给自己定一个远小于对方超时的预算,把超出预算的请求单独挑出来看。
- 3故意返一次 5xx、再故意超时一次,观察重试的实际表现,并确认重试到达时幂等真的生效了。这件事必须在预发做,线上没有第二次机会。
换地址、换域名、换证书都要按有流量的变更来做:新入口先并行接一段时间,确认真的收得到事件,再摘掉旧的。直接切换的那几分钟里丢掉的事件,事后大概率补不回来。签名涉及的具体字段、时间窗与错误返回以 wecomapi 线上接口文档为准,示意代码不要照抄上生产。
常见问题
- 握手验证过了,为什么线上还会验签失败?
- 两段用的输入不一样。常态验签必须基于原始报文字节,而请求经过网关、日志中间件或者 body 解析器之后,字节可能已经和对方签名时的那份不同了。把收到的原始 Buffer 打出来,和签名一起比对,多数情况能立刻定位。
- 业务处理失败了,该返回非 2xx 让平台重试吗?
- 不该。投递层重试解决的是「没送到」,业务失败重投多少次结果都一样。照常返 2xx,把失败交给自己的重试队列和死信队列;只有服务自身过载时才用非 2xx 请求对方退避。
- 验签密钥怎么轮换才不断线?
- 留一个双密钥并行的窗口:校验端先同时接受新旧两把密钥,再去切换配置,观察确认流量全部走新密钥之后才下线旧的。没有并存期,就等于计划一次必然的中断。轮换的具体操作与生效时机以 wecomapi 文档为准。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
