4.3 支付接入:回调验签与幂等
场景:一笔订单被回调两次,系统多扣了一台手机
第三场活动上线两小时,客服就涌进几十个投诉:用户只买了一台手机,App 却显示两笔待发货。查数据库发现订单被支付回调执行了两次。原来支付渠道那边网络抖动,回调失败后自动重试,而我们的回调接口没有任何幂等处理——每次回调都执行一遍「更新订单状态、扣减库存、触发履约」的流程,两笔回调就产生了两台手机的库存扣减。多扣的那台库存只能人工调账追回,整个大促期间库存数据都对不齐。
方案:验签与幂等两道闸门
支付回调是外部请求进入系统的最深处,请求的合法性、重复性、顺序性都必须由我们自己把关。两道闸门缺一不可:
| 闸门 | 防的是什么 | 实现手段 |
|---|---|---|
| 验签 | 伪造回调 | 校验渠道签名 |
| 幂等 | 重复回调 | 订单状态前置检查 |
| 顺序 | 乱序回调 | 回调序号比对 |
| 超时 | 回调丢失 | 主动查单补偿 |
先说验签。渠道都会在回调里附上签名,签名用商户密钥对请求体做摘要。验签不通过一律返回失败,绝不进入业务逻辑。验签通过=请求确实来自渠道,但不保证这笔订单真的支付成功,所以还要以主动查单的结果为准:收到回调后先调一次渠道的查单接口,拿到「支付成功」的状态才处理订单。这一步把渠道回调与渠道状态彻底分开,回调只当通知用,不当事实用。有同行问过:主动查单会不会让响应变慢?实测量级在百毫秒,相比资损风险完全可以接受,而且查单失败时直接返回失败等下次重试,不会拖垮回调接口本身。
幂等依赖订单状态机(4.1 节):回调处理前先读订单状态,已经是「已支付」就说明之前的回调处理过了,直接返回成功让渠道停止重试。这一步看似多余,却是治理重复回调成本最低的办法。除此之外我们还做了回调记录表,每次回调 raw 落盘,即使状态机出了纰漏,记录表也能在对账时把重复回调揪出来,相当于给系统留了一张底牌。
| 回调形态 | 判定依据 | 处理结果 |
|---|---|---|
| 首次回调 | 订单为待支付 | 正常推进 |
| 重复回调 | 订单为已支付 | 直接返回成功 |
| 乱序回调 | 回调序号旧于记录 | 丢弃并记录 |
| 缺失回调 | 查单任务发现 | 由查单补偿 |
渠道的重试策略也值得摸清:我们对接的渠道重试四次,间隔按指数退避,所以回调处理逻辑必须把「同一条回调最多见四次」当作常态来设计。
配置:回调处理与补偿任务
回调入口的完整处理链路:
class PayCallbackController
{
public function handle(Request $request): string
{
if (! $this->verify($request)) {
ClsLog::warning('pay_callback_bad_sign', $request->all());
return 'FAIL';
}
$orderNo = $request->input('out_trade_no');
$result = PayChannel::query($orderNo); // 主动查单
if (! $result->isPaid()) {
return 'FAIL'; // 渠道尚未成功,等下次回调
}
$order = Order::where('no', $orderNo)->lockForUpdate()->first();
if (! $order || $order->status === 'paid') {
return 'SUCCESS'; // 已处理,幂等返回
}
app(OrderStateMachine::class)->transition($order, 'paid');
ClsLog::info('order_paid', ['order' => $order->id]);
return 'SUCCESS';
}
private function verify(Request $request): bool
{
$sign = $request->input('sign');
$expect = strtoupper(md5(
$request->input('out_trade_no') . $request->input('total_fee') . config('pay.key')
));
return hash_equals($expect, $sign);
}
}
几个细节值得说明。其一,lockForUpdate 配合外层事务锁住订单行,防止两个回调并发同时通过状态检查。其二,hash_equals 做签名比较而不是 ==,避免时序攻击。其三,已支付的订单返回 SUCCESS 而非 FAIL——返回失败渠道会一直重试,既浪费流量又增加对账噪音。
回调还有可能丢失:渠道网络故障、我方服务重启都会造成回调断档。只靠回调是不可靠的,所以还要有补偿任务,每两分钟扫描待支付订单,对超过五分钟未支付的主动查一次渠道:
class PayPollTask
{
public function __invoke(): void
{
$orders = Order::where('status', 'pending')
->where('created_at', '<', now()->subMinutes(5))
->limit(200)->get();
foreach ($orders as $order) {
$result = PayChannel::query($order->no);
if ($result->isPaid()) {
app(OrderStateMachine::class)->transition($order, 'paid');
}
}
}
}
这条任务同时是掉单的最后一道防线。超时未支付的任务由 4.4 节的回滚链路处理,两条任务职责分明:查单任务只管把已付款订单接回来,回滚任务只管把过期订单的钱与库存还回去。两条任务用不同的 Redis 锁键,互不干扰,同一订单在任一时刻只可能被其中一条处理,边界清晰是这套设计最舒服的地方。
踩坑:支付接入的四个坑
坑一:验签密钥写在代码仓库里。 早期为了上线快,密钥直接写在配置文件提交到仓库,安全评审时被点名。后来所有支付密钥统一走环境变量注入,仓库里只留 key 名称,密钥不上 Git,换密钥也不用发版。
坑二:金额校验缺失。 有笔订单实付一分钱,商品价值两千元。查发现渠道传回的金额单位是分而我方按元解析,滞留了用户几分钟。回调处理必须比对实付金额与订单金额,不一致直接告警并拦截,宁可让用户重付也不能让系统少收钱。
坑三:回调返回状态不规范。 一开始所有失败都返回 HTTP 500,渠道把失败理解为「重试」,疯狂重试加重试指数退避也不管用,日志被刷爆。成功与失败要分开返回:已处理返回成功让渠道闭嘴,临时异常返回失败等渠道重试,永久错误返回成功并记人工处理单。
坑四:测试环境没有真回调。 联调时全靠渠道沙箱,上线后才发现沙箱的重试频率与生产完全不同。后来我们搭了一个内部的模拟回调器,按生产的重试节奏(立即、十秒、一分钟、五分钟)压自己的接口,把并发回调与乱序回调都纳入常规测试。
坑五:只验签不验金额方向。 渠道某个版本的回调里金额字段有负数的边界情况,我们只判断了金额相等没判断正负,测试环境差点放过。金额校验的规则越朴实越好:相等、为正、单位正确,三条缺一不可。
小结
支付接入的核心是别相信任何一侧的单一信息:回调验签确认请求来自渠道,主动查单确认渠道确实收到钱,订单状态机确认只处理一次,补偿任务确保回调丢失也能自愈。四层确认环环相扣,掉单与重复支付这两个电商最常见的资损问题,在这个组合下基本绝迹。涉及到钱的地方,多一层确认永远不嫌多。最后补一句选型经验:对接渠道时一定把「回调与查单文档是否清晰」当作硬指标,对接过的渠道里,文档清晰的渠道接入只需两天,文档含糊的每次大促都要派专人盯着解读字段含义,这笔人力账算进选型里绝对不少,渠道支持力度应该成为续约谈判时的核心筹码,这一点我们深有体会。
