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,渠道把失败理解为「重试」,疯狂重试加重试指数退避也不管用,日志被刷爆。成功与失败要分开返回:已处理返回成功让渠道闭嘴,临时异常返回失败等渠道重试,永久错误返回成功并记人工处理单。

坑四:测试环境没有真回调。 联调时全靠渠道沙箱,上线后才发现沙箱的重试频率与生产完全不同。后来我们搭了一个内部的模拟回调器,按生产的重试节奏(立即、十秒、一分钟、五分钟)压自己的接口,把并发回调与乱序回调都纳入常规测试。

坑五:只验签不验金额方向。 渠道某个版本的回调里金额字段有负数的边界情况,我们只判断了金额相等没判断正负,测试环境差点放过。金额校验的规则越朴实越好:相等、为正、单位正确,三条缺一不可。

小结

支付接入的核心是别相信任何一侧的单一信息:回调验签确认请求来自渠道,主动查单确认渠道确实收到钱,订单状态机确认只处理一次,补偿任务确保回调丢失也能自愈。四层确认环环相扣,掉单与重复支付这两个电商最常见的资损问题,在这个组合下基本绝迹。涉及到钱的地方,多一层确认永远不嫌多。最后补一句选型经验:对接渠道时一定把「回调与查单文档是否清晰」当作硬指标,对接过的渠道里,文档清晰的渠道接入只需两天,文档含糊的每次大促都要派专人盯着解读字段含义,这笔人力账算进选型里绝对不少,渠道支持力度应该成为续约谈判时的核心筹码,这一点我们深有体会。

延伸阅读