多通道支付回调框架实现:统一入口、验签适配器与补偿机制
接第一个通道时,写一个回调接口是最省事的:验签、更新订单、返回成功,三十行代码搞定。接第二个通道时复制一份改改。接到第三个通道时问题开始暴露——三个接口三套验签、三套应答格式、三套幂等逻辑,其中一套忘了做幂等,另一套验签失败时返回了 200,还有一套在日志里没记录原始报文,出问题完全无法追溯。
这篇给一套可复用的框架结构,思路是把变化的部分(验签与应答)隔离出来,把不变的部分(幂等、状态机、补偿)收拢到公共层。单通道细节见 微信支付 APIv3 接入实操 与 支付宝开放平台接入实操,幂等表设计见 支付订单号设计与幂等表实现。
一、失控的三个信号
如果你的代码出现下面三种情况,就该重构了:
信号一:验签逻辑散落。 微信的 RSA 验签、支付宝的 RSA2 验签、银联的证书验签分别写在三个 controller 里,每处实现细节都不一样,其中一处用错了公钥,三个月后才被对账发现。
信号二:应答格式各自为政。 微信要返回 JSON,支付宝要返回纯文本 success,银联要返回特定报文。任何一个返错,对应通道会持续重推通知,日志被刷满。
信号三:丢通知后无从追溯。 没保存原始报文,通道说推了、你说没收到,双方扯不清。
二、统一入口设计
所有通道的回调都指向同一个路由,用通道标识区分:
POST /api/payment/notify/{channel}
路由层只做三件事:识别通道、取出原始报文、交给对应处理器。原始报文必须原样保存,因为验签要对原始字节计算,任何反序列化再序列化都可能破坏签名。
Route::post('/api/payment/notify/{channel}', function (Request $request, string $channel) {
$raw = $request->getContent(); // 原始字节,不可加工
$handler = app(PaymentNotifyFactory::class)->make($channel);
return $handler->handle($raw, $request->headers->all());
})->withoutMiddleware(['auth', 'csrf']);
两个容易踩的坑:一是回调路由必须排除鉴权与 CSRF 中间件,否则通道拿不到你的业务响应;二是不能加 IP 白名单了事,通道出口 IP 会变,正确做法是验签。
三、验签适配器
用接口把差异隔离掉,每个通道实现自己的验签与解析:
interface NotifyAdapter
{
public function verify(string $raw, array $headers): bool;
public function parse(string $raw): NotifyPayload;
public function successResponse(): mixed;
public function failResponse(string $reason): mixed;
}
final class NotifyPayload
{
public function __construct(
public readonly string $orderNo,
public readonly int $amount, // 最小货币单位整数
public readonly string $channelTxnId,
public readonly string $eventType, // paid / refunded / closed
public readonly bool $isSuccess,
) {}
}
这样每个通道只需要关心三件事:怎么验签、怎么解析成统一结构、怎么应答。业务层完全不知道通道的存在。
NotifyPayload 里 amount 强制为整数最小单位,是刻意的设计——微信给分、支付宝给元,如果在适配器里不转成同一口径,业务层迟早写出 100 到底代表一元还是一百元的歧义代码。
四、公共处理层
适配器之后走统一流程,这一步是所有通道共享的:
public function handle(string $raw, array $headers): mixed
{
$adapter = $this->adapter();
// 1. 存档原始报文,无论验签成功与否
$log = $this->archive($raw, $headers);
// 2. 验签
if (!$adapter->verify($raw, $headers)) {
$this->alert('notify_verify_failed', $log->id);
return $adapter->failResponse('signature error');
}
$payload = $adapter->parse($raw);
// 3. 幂等:唯一索引裁决
if (!$this->idempotent($payload)) {
return $adapter->successResponse(); // 重复通知,直接应答成功
}
// 4. 金额与订单号双向校验
$order = $this->orderRepo->findByNo($payload->orderNo);
if (!$order || $order->amount !== $payload->amount) {
$this->alert('notify_amount_mismatch', $log->id);
return $adapter->failResponse('amount mismatch');
}
// 5. 状态机流转
$this->orderService->transit($order, $payload);
return $adapter->successResponse();
}
注意第 3 步的顺序:幂等判断必须在业务处理之前,但必须在验签之后。验签失败不能写入幂等记录,否则伪造请求会把合法的后续通知挡掉。
五、各通道应答格式差异
这一张表是踩坑最集中的地方:
| 通道 | 成功应答 | 失败应答 | 重试行为 |
|---|---|---|---|
| 微信支付 | JSON,含 code: SUCCESS |
JSON,含错误码 | 按策略多次重推 |
| 支付宝 | 纯文本 success |
任意其他内容均视为失败 | 按策略多次重推 |
| 银联 | 特定报文格式 | 特定错误报文 | 按策略重推 |
三个必须注意的点:支付宝的应答必须是纯文本,任何框架的 JSON 包装都会导致它视为失败;HTTP 状态码不等于业务应答,返回 200 但报文内容不对,通道仍然会重推;应答要在业务处理完成后才发出,不能先应答再异步处理。
六、回调丢失的补偿机制
回调一定会丢。网络抖动、服务重启、部署窗口,任何一个环节都可能让通知没到达。补偿只有一个可靠方式:定时查单。
// 每 5 分钟执行一次
Order::query()
->where('status', PaymentState::PENDING)
->where('created_at', '>=', now()->subHours(2))
->where('created_at', '<=', now()->subMinutes(5))
->chunk(200, function ($orders) {
foreach ($orders as $order) {
$result = $this->channelQuery($order); // 主动查询通道状态
if ($result->isPaid()) {
$this->orderService->transit($order, $result);
}
}
});
三个参数是经验值:只查 5 分钟到 2 小时之间的订单(太新的可能还在支付中,太老的基本不会变了);每批 200 条(避免一次查太多把通道接口打限流);查单结果同样走幂等与状态机,不能绕过。
对超过 2 小时仍未支付的订单,走另一条路径:主动关单,避免用户在旧页面完成支付造成重复入账。
七、监控指标与告警
框架跑起来后,需要盯四个指标:
- 验签失败率:突然升高说明配置错了或有人在伪造请求;
- 重复通知比例:正常会有少量重推,比例过高说明你的应答有问题;
- 补偿查单命中数:命中越多说明回调丢失越严重;
- 回调处理耗时 P99:超过通道超时时间会导致它判定失败并重推,形成恶性循环。
告警阈值建议设在"验签失败连续 10 次"与"补偿命中率突增 3 倍"这两个条件上,前者是配置事故,后者是链路事故。
八、六个上线后必然遇到的问题
一是框架吞掉了异常,业务抛错但外层捕获后仍返回成功,导致通道不再重推,订单永久挂起。二是原始报文被中间件改写,验签永远不过。三是幂等键没有包含事件类型,支付成功与退款成功被当成同一事件。四是日志记录了完整密钥或用户身份证号,属于数据安全事故。五是回调接口被 CDN 或 WAF 拦截,通道侧看到 403 后停止重推。六是多实例部署下幂等只做了单机内存缓存,重启即失效。
统一的实践建议:幂等必须落库走唯一索引,不能只依赖缓存;原始报文单独存档表并定期归档;回调路由单独排除鉴权与防护规则;异常必须向上抛出并触发告警。
对账兜底见 对账自动化脚本实现,交易链路全景见 支付交易流程基础,多通道聚合方案见 聚合支付与服务商模式。更多支付方案可参见 支付 分类。
来源:16IDC 原创,文中涉及的产品能力、费率与准入要求以相关服务商官网文档及合同为准。