PAYATHON 2026

如何设置 callback url 接收 payment gateway 支付状态

支付小周

结论

这个思路基本正确:为支付网关提供一个公网可访问的 callback endpoint,由后端接收支付结果并更新订单。

callback 应使用 POST 还是 GET,要以支付网关的接口文档为准,不能自行选择。大多数支付网关通过 POST 发送异步支付通知,因为通知中包含交易状态、金额、签名等数据,适合放在请求体中。只有网关明确要求使用 GET 时,才应注册 GET 路由。

支付回调也不能在读取 paymentStatus 后直接修改订单。签名验证、金额校验、重复通知处理和状态流转检查都是必要环节。

Callback 与 Return URL 的区别

支付系统通常会提供两种地址:

  • Callback URL / Webhook URL:支付网关的服务器直接请求后端,用于通知支付结果。
  • Return URL / Redirect URL:支付完成后,把用户的浏览器或 App 跳转到指定页面,用于展示结果。

用户可能关闭页面,也可能遇到网络中断,导致 Return URL 没有被访问。因此,不能把浏览器的跳转结果当作支付成功的最终依据。订单状态应以验证通过的服务器 callback 为准。如果支付网关支持主动查询交易状态,必要时也可以调用查询接口复核。

路由设置

如果支付网关文档规定使用 POST,可以在 routes/api.php 中定义路由:

Route::post('order/payment/status', 'PaymentController@getStatus');

假设 API 前缀采用 Laravel 的默认配置,实际地址通常类似:

https://example.com/api/order/payment/status

具体地址由项目的路由配置决定。把地址填写到支付网关后台前,应确认 URL 与实际路由完全一致,并且 HTTPS 证书有效。

如果路由写在 routes/web.php 中,还需要处理 Laravel 的 CSRF 验证。支付网关无法提供 Laravel 会话中的 CSRF token,所以 webhook 通常应放在 api.php 中,也可以把对应路径加入 CSRF 例外列表。不要因为一个回调接口而关闭整个站点的 CSRF 保护。

Controller 示例

字段名称和签名算法需要根据支付网关文档调整。下面的示例只展示通用处理结构,其中 verifyGatewaySignature() 需要按照网关规则实现:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;

public function getStatus(Request $request)
{
    $payload = $request->all();

    $validator = Validator::make($payload, [
        'amount'               => 'required|numeric',
        'paymentStatus'        => 'required|string',
        'transactionReference' => 'required|string',
    ]);

    if ($validator->fails()) {
        return response()->json([
            'message' => 'Invalid callback payload',
        ], 422);
    }

    // 必须按照支付网关文档验证签名。
    // 某些网关要求使用未经解析的原始请求体计算签名:
    // $rawBody = $request->getContent();
    if (!$this->verifyGatewaySignature($request)) {
        Log::warning('Invalid payment callback signature', [
            'transactionReference' => $request->input('transactionReference'),
        ]);

        return response()->json([
            'message' => 'Invalid signature',
        ], 401);
    }

    $transactionReference = $request->input('transactionReference');
    $paymentStatus = $request->input('paymentStatus');
    $amount = $request->input('amount');

    DB::transaction(function () use (
        $transactionReference,
        $paymentStatus,
        $amount
    ) {
        $order = Order::where(
            'transaction_reference',
            $transactionReference
        )->lockForUpdate()->first();

        if (!$order) {
            throw new \RuntimeException('Order not found');
        }

        // 金额应与创建交易时保存在本地的金额一致。
        // 货币代码也应一并校验,前提是网关会返回该字段。
        if ((string) $order->amount !== (string) $amount) {
            throw new \RuntimeException('Payment amount mismatch');
        }

        // 网关可能重复发送同一个通知,因此处理必须具有幂等性。
        if ($order->payment_status === 'paid') {
            return;
        }

        switch ($paymentStatus) {
            case 'SUCCESS':
                $order->payment_status = 'paid';
                $order->paid_at = now();
                break;

            case 'FAILED':
                $order->payment_status = 'failed';
                break;

            case 'PENDING':
                $order->payment_status = 'pending';
                break;

            default:
                throw new \RuntimeException('Unknown payment status');
        }

        $order->save();
    });

    // 返回值和状态码也应符合支付网关的回调协议。
    return response()->json([
        'message' => 'Callback processed',
    ], 200);
}

SUCCESS、FAILED 和 PENDING 只是示例,不能假设支付网关一定使用这些状态值。实际开发时,应根据网关返回的状态码建立映射。

原代码还有一个拼写错误:

$paymentStatus = $request->paaymentStatus;

如果真实字段名是 paymentStatus,应改成:

$paymentStatus = $request->input('paymentStatus');

使用 input() 比直接读取动态属性更清楚,也方便统一处理 JSON、表单字段和查询参数。

必须验证回调真实性

Callback URL 是一个公网接口,任何人都可能向它发送请求。因此,不能只根据下面几个字段判断付款是否成功:

$request->input('paymentStatus');
$request->input('amount');
$request->input('transactionReference');

需要按照支付网关提供的机制验证请求,例如:

  • 校验 HMAC、RSA 等数字签名。
  • 校验 webhook secret。
  • 校验时间戳,防止旧请求被重放。
  • 比对商户号、订单号、金额和货币。
  • 必要时调用支付网关的交易查询接口再次确认。
  • 如果网关提供固定 IP,可以把 IP 白名单作为辅助措施,但不能用它代替签名验证。

各家网关对签名字段、字段顺序、编码方式和原始请求体的要求不同,具体实现必须以对应网关的文档为准。

处理重复通知

如果发生超时、网络异常,或者支付网关没有收到预期响应,它通常会重试通知。因此,同一笔交易的 callback 可能被调用多次。

处理逻辑必须具备幂等性:

  • 为 transactionReference 或网关事件 ID 建立唯一约束。
  • 已支付订单再次收到成功通知时,直接返回成功响应。
  • 不要重复发货、增加余额或发送优惠券。
  • 使用数据库事务和行锁,避免两个回调同时处理同一笔订单。
  • 已经成功的订单不能随意改回失败状态。

如果支付网关提供唯一的 webhook event ID,最好单独保存已经处理过的 event ID。

Callback 应快速响应

回调接口应尽快完成必要的校验,并返回支付网关要求的成功响应。发送邮件、生成报表、通知第三方等耗时操作可以交给 Laravel 队列处理。

处理时间过长时,支付网关可能把通知判定为失败并反复重试。成功响应也不一定是 JSON。有些网关要求返回 OK 或 success 等固定文本,必须严格遵循其文档。

最终建议

Route::post() 很可能是正确的选择,但仍要以支付网关规定的 HTTP method、请求格式和响应格式为准。完整的处理流程包括:

  1. 创建独立的 HTTPS callback endpoint。
  2. 按照网关文档接收 POST、JSON 或表单数据。
  3. 验证签名和时间戳。
  4. 根据本地订单号查找交易。
  5. 校验金额、货币和商户信息。
  6. 以幂等方式更新订单状态。
  7. 返回网关要求的成功响应。
  8. 保留必要的回调日志,但不要记录密钥、银行卡信息或完整的敏感数据。

React app 和 mobile app 返回的”支付成功”只能用于改善用户体验,不能直接作为后端发货或确认收款的依据。订单的最终状态应由验证通过的服务器回调或支付网关的交易查询结果决定。

备注:内容仅供参考。