PAYATHON 2026

Laravel 使用 POST 接收支付回调并保存 Api 结果

支付阿杰

结论

支付完成后,浏览器返回页面和支付平台发送的服务器回调通常是两个独立请求:

  • 浏览器返回地址一般使用 GET,用于展示支付结果。
  • 支付通知地址由支付平台服务器通过 POST 调用,用于验签、更新订单和保存交易信息。

当前报错说明,支付平台或浏览器实际访问了 GET /gateway/confirm_payment/...,但该路由只接受 POST。不要为了消除报错而把路由改成 GET,也不要依赖 $_POST 读取数据。正确的做法是在支付平台后台分别配置 POST 回调地址和用户返回地址,再通过 Laravel 的 Request 获取回调数据。

路由配置

回调地址最好不要使用前端传入的 {order_id}。可以根据支付平台返回且参与签名的 merchant_oid 查询订单:

use App\Http\Controllers\Website\GatewayController;
use Illuminate\Support\Facades\Route;

Route::post('/gateway/confirm-payment', [GatewayController::class, 'confirmPayment'])
    ->name('gateway.confirm-payment');

Route::get('/gateway/payment-result', [GatewayController::class, 'paymentResult'])
    ->name('gateway.payment-result');

在支付平台中分别配置:

POST 通知地址:
https://example.com/gateway/confirm-payment

用户返回地址:
https://example.com/gateway/payment-result

具体字段名称、回调地址的配置方式以及成功响应内容,应以所用支付平台的接口文档为准。

处理 Laravel 的 CSRF 验证

第三方支付服务器不会携带 Laravel 生成的 CSRF Token,所以需要为回调路由排除 CSRF 验证。旧版 Laravel 可以在 VerifyCsrfToken 中配置:

protected $except = [
    'gateway/confirm-payment',
];

较新的 Laravel 项目可能需要在 bootstrap/app.php 中配置:

use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'gateway/confirm-payment',
    ]);
})

只排除明确的支付回调地址,不要关闭整个站点的 CSRF 防护。

回调也可以放在 routes/api.php 中,但默认 URL 通常带有 /api 前缀。支付平台中填写的地址必须和实际路由完全一致。

Controller 示例

下面的示例按照题目中的字段计算签名,并在数据库事务中更新订单。模型名和字段名需要按实际项目调整:

<?php

namespace App\Http\Controllers\Website;

use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class GatewayController extends Controller
{
    public function confirmPayment(Request $request)
    {
        $merchantKey  = config('services.payment.merchant_key');
        $merchantSalt = config('services.payment.merchant_salt');

        $merchantOid = (string) $request->input('merchant_oid', '');
        $status      = (string) $request->input('status', '');
        $totalAmount = (string) $request->input('total_amount', '');
        $receivedHash = (string) $request->input('hash', '');

        if (
            $merchantOid === '' ||
            $status === '' ||
            $totalAmount === '' ||
            $receivedHash === ''
        ) {
            Log::warning('Payment callback is missing required fields', [
                'payload' => $request->all(),
            ]);

            return response('Invalid callback', 400);
        }

        $expectedHash = base64_encode(
            hash_hmac(
                'sha256',
                $merchantOid . $merchantSalt . $status . $totalAmount,
                $merchantKey,
                true
            )
        );

        if (! hash_equals($expectedHash, $receivedHash)) {
            Log::warning('Payment callback signature mismatch', [
                'merchant_oid' => $merchantOid,
            ]);

            return response('Invalid signature', 400);
        }

        DB::transaction(function () use (
            $request,
            $merchantOid,
            $status,
            $totalAmount,
            $receivedHash
        ) {
            $order = Order::where('merchant_oid', $merchantOid)
                ->lockForUpdate()
                ->firstOrFail();

            // 必须核对数据库中的应付金额,不能只相信回调参数。
            if ((string) $order->total_amount !== $totalAmount) {
                throw new \RuntimeException('Payment amount mismatch.');
            }

            // 支付平台可能重复发送通知,因此处理必须具备幂等性。
            if ($order->payment_status === 'paid') {
                return;
            }

            $order->payment_status = $status === 'success'
                ? 'paid'
                : 'failed';

            $order->payment_hash = $receivedHash;
            $order->payment_payload = $request->all();
            $order->paid_at = $status === 'success' ? now() : null;
            $order->save();
        });

        // 替换成支付平台文档要求的确切响应,例如 OK、success 或 JSON。
        return response('OK', 200);
    }

    public function paymentResult(Request $request)
    {
        // 这里只展示结果,不以 GET 参数作为订单已付款的依据。
        return view('payment.result');
    }
}

如果 payment_payload 是 JSON 字段,可以在模型中添加类型转换:

protected $casts = [
    'payment_payload' => 'array',
    'paid_at' => 'datetime',
];

密钥不要直接写在 Controller 中,可以放入 .env:

PAYMENT_MERCHANT_KEY=xxxxxx
PAYMENT_MERCHANT_SALT=xxxxxx

再从 config/services.php 中读取:

'payment' => [
    'merchant_key' => env('PAYMENT_MERCHANT_KEY'),
    'merchant_salt' => env('PAYMENT_MERCHANT_SALT'),
],

为什么 GET 请求中没有 POST 数据

GET 和 POST 是两个独立的 HTTP 请求。即使支付平台先向回调地址发送了 POST,浏览器随后跳转到返回页面时,也不会自动携带前一次 POST 的请求体。因此,把路由改成 GET 后,下面这些字段为空属于正常情况:

$request->input('merchant_oid');
$request->input('status');
$request->all();

浏览器返回页面应根据订单编号重新查询数据库,展示服务器通过 POST 回调确认的支付状态。

如果平台发送 JSON

$request->input() 和 $request->all() 通常也能读取格式正确的 JSON 请求。需要查看原始请求体时,可以使用:

$rawBody = $request->getContent();
$json = $request->json()->all();

调试支付回调时不建议使用 dd()。它会返回调试页面,支付平台可能因此认为通知处理失败并反复重试。应改用日志记录必要信息,同时避免记录密钥、银行卡资料等敏感数据。

还需要检查的事项

  • 支付平台填写的通知 URL 是否和 Laravel POST 路由完全一致。
  • 回调 URL 能否从公网访问,HTTPS 证书是否有效。
  • 是否误将“支付成功跳转地址”填成了“服务器通知地址”。
  • 签名字段的顺序、编码和拼接规则是否与支付平台文档完全一致。
  • 数据库更新是否具备幂等性,避免重复通知造成重复入账。
  • 是否核对了订单号、金额、币种和商户身份。
  • 是否按照平台要求返回指定文本或 JSON,否则平台可能持续重试。

页面中的 iframe 和 iFrameResize 只负责支付页面的展示,不会把浏览器发出的 GET 返回请求转换成服务器端 POST 回调。

备注:内容仅供参考。