📝 两个 enabled 的 webhook 只有一个在收:UseePay 多环境回调隔离实录

背景

最近在做一个跨境支付接入:后端是 Spring Cloud 微服务,测试环境分 dev / sit / uat 三套,支付渠道是 UseePay。为了让每个环境都能收到支付结果通知,我们通过 OpenAPI 给 dev 和 sit 各注册了一条 webhook,指向各自环境的回调地址。

架构上有个”图省事”的决定:三个测试环境共用同一个商户号下的同一个 app_id。

然后 dev 环境开始出现怪事:支付成功了,webhook 却收不到通知。

排查:从”没收到”到”被截胡”

第一步,用 OpenAPI 查 webhook 列表(GET /api/v1/webhooks,请求头带 x-merchant-no、x-app-id 和 api-key):

webhook指向创建时间状态
wek_139A…dev 回调地址2026-07-06enabled
wek_14G0…sit 回调地址2026-11-03 18:08enabled

两条都 enabled,配置层面看起来没问题。但 dev 单就是收不到。

第二步,翻 sit 环境的日志,发现一类反复出现的报错:转发UseePay支付通知失败。诡异的是,这条报错前面的验签是通过的——UseePay 确实把事件投过来了,sit 也正确验了签,只是按通知里的单号查库时查无此单。

第三步,拿一笔 dev 测试订单对时间线:

  • 14:26:51 dev 创建订单
  • 14:27:17 渠道侧支付成功
  • 同一分钟,sit 日志出现”转发UseePay支付通知失败”(验签通过、查无此单)

完全吻合。真相是:dev 的支付事件被投给了 sit 的回调地址,然后死在了那里。

这里还有个小插曲:更早排查时我们一度以为 dev 的事件”两边都没收到”,后来才发现是日志关键词没选对——那些事件其实同样被投到了 sit,报的是同一个错。

至此实锤:同一 app_id 下可以创建多条 webhook(API 也都显示 enabled),但投递只认最新创建/更新的那一条。谁最后注册,谁收事件;其余的 enabled 状态是”假活”。

根因:不是 bug,是设计模型

带着证据去翻官方文档(docs-v2.useepay.com → Webhooks),原文写得很清楚:

UseePay matches the sending addresses of webhooks through x-merchant-no and x-app-id. If you have multiple websites, you need to create webhooks for each website.

也就是说,webhook 的投递匹配域是 (merchant_no, app_id) 这一对键。同一个 app_id 下注册多条,等于多个”网站”在抢同一个匹配域——最新注册的赢。这不算 bug,是它的匹配模型;多环境共用 app_id 本身就不在它的支持范围内。

一个耐人寻味的旁证:官方 FAQ《Unable to receive Webhook notifications》只列了两个收不到通知的原因——RSA 秘钥未启用、没调 API 注册 webhook——从头到尾没提”多条 webhook 互顶”。因为在它的设计假设里,你根本不该一个 app 配多条。

顺带从老版文档确认了一个细节:回调收不到 HTTP 200 会重试,最多 18 次。这解释了为什么 sit 转发失败后渠道不会无限重发——它的重试预算是有限的。

决策:三条路怎么选

方案做法评价
A. 报障,要求渠道做 fan-out拿实测证据要求同一 app 的多 webhook 全部投递等不起;大概率被”请按文档使用”挡回
B. 每环境独立 app_id ✅dev/sit/uat 各申请一个测试应用,各自注册 webhook官方设计内用法,天然隔离
C. 统一回调分发一个 webhook 入口按单号路由回各环境需要全局单号路由层,架构改动大

我们选了 B。理由很直接:改的是配置层(每个环境多一组 app-id/api-key),不动架构;而且这正是官方文档”每个 website 一个 webhook”的标准形态。C 方案留作未来做多渠道统一网关时的备选。

过渡期策略:sit 收实时通知,dev 暂时靠对账兜底——事实上 dev 的支付并没有因为 webhook 失联而卡住。

兜底为什么必须:webhook 是优化,不是正确性保证

排查中的那笔 dev 订单,支付成功后不到一分钟就完成了落账——不是靠 webhook,而是靠我们自己的对账兜底:定时任务批扫 CREATED/PAYING 状态超过宽限期的支付单,调渠道查单接口核对真实状态,补落账并事后通知业务侧。

这件事反过来印证了一个原则:webhook 只能当优化路径,对账才是正确性底线。渠道回调会丢、会延迟、会像这次一样被”路由走”,任何支付系统都不能把终态确认单点压在 webhook 上。

落地四步

第一步:向渠道申请。 一句话说清问题和诉求(三环境共用 app_id + 实测投递互顶),申请为每个测试环境发放独立测试应用。顺带问清四件事:merchant_no 是否沿用;RSA 验签公钥是否每个 app 独立;测试卡/支付方式是否一致;旧 app 保留多久(回滚窗口)。

第二步:配置替换。 各环境配置文件(application-{dev,sit,uat}.yml)换成自己的 app-id / api-key。密钥用 jasypt 加密入库,明文不进 git。

第三步:部署 + 各环境注册。 每个环境部署后,在该环境调一次 createPaymentWebhook——新 app 请求头会自动带上,注册到各自 app 的匹配域下。再用 GET /api/v1/webhooks 确认各自域下只有自己那一条。

第四步:验证闭环。 每个环境付一笔,并做交叉检查:

验证预期
dev 付一笔dev 通知日志 +1;sit 不再出现”转发失败”
sit 付一笔sit 通知日志 +1;dev 无感知
交叉检查任一环境支付,另一环境日志零动静 = 隔离完成

回滚方案:配置换回旧 app_id + 重新注册旧 webhook,十分钟内回到现状——所以过渡期旧 app 千万别删。

复盘四条

  1. enabled ≠ 在收。 渠道配置的状态字段描述的是”这条配置存在”,不是”投递正在发生”。多条 webhook 时的实际投递规则,文档没明说就实测一笔,别只信状态字段。
  2. 多环境隔离的正确粒度是应用凭据,不是 webhook 条数。 想隔离投递域,就申请独立的 app_id/api-key,而不是在同一凭据下堆回调地址。
  3. webhook 必须配对账兜底。 渠道回调会丢、会延迟、会被路由走;支付终态确认永远要有第二条路。
  4. 排查回调问题,先看渠道的匹配模型。 它用什么键定位投递地址(merchant+app?单号?网站?),决定了你能怎么隔离。这一步应该排在查网络、查验签之前。

快速自检清单

  • webhook 的投递匹配键是什么?
  • 同键多条 webhook 时,投递规则是什么?(实测过吗?)
  • 各测试环境的应用凭据是否独立?
  • 对账/补单任务是否覆盖了 webhook 丢失的场景?