PAYATHON 2026

如何在 Laravel 中实现 Realtime Payment Notification?

支付老李

明确结论

Response URL 是 Laravel 提供给银行服务器调用的 Webhook 接口。一般可以把路由定义在 routes/api.php,再用独立的 Controller 处理通知,例如:

https://merchant.example.com/api/payments/bank/notify

银行通过 HTTP POST 发送 JSON 后,Laravel 可以使用 $request->json()、$request->input() 或 $request->all() 读取数据。

这个接口不应依赖用户登录状态,也不能看到通知中的“支付成功”字段就直接修改订单。处理通知前,必须验证银行提供的签名或消息认证信息,同时防止重复通知被多次处理。

定义 Response URL 路由

如果项目使用 routes/api.php,可以添加以下路由:

<?php

use App\Http\Controllers\BankPaymentNotificationController;
use Illuminate\Support\Facades\Route;

Route::post(
    '/payments/bank/notify',
    [BankPaymentNotificationController::class, 'handle']
)->name('payments.bank.notify');

最终的 Response URL 通常是:

https://merchant.example.com/api/payments/bank/notify

URL 是否带有 /api 前缀,取决于项目的路由配置。可以用下面的命令检查实际注册结果:

php artisan route:list

如果当前 Laravel 项目不会自动加载 routes/api.php,需要根据项目版本和现有结构启用 API 路由,也可以把接口定义在 routes/web.php。

创建通知处理 Controller

可以创建一个专门处理银行回调的 Controller:

php artisan make:controller BankPaymentNotificationController

示例实现如下:

<?php

namespace App\Http\Controllers;

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

class BankPaymentNotificationController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        // 确认请求确实是 JSON。
        if (! $request->isJson()) {
            return response()->json([
                'message' => 'Content-Type must be application/json',
            ], 415);
        }

        $payload = $request->json()->all();

        $validated = validator($payload, [
            'transaction_id' => ['required', 'string'],
            'merchant_order_id' => ['required', 'string'],
            'status' => ['required', 'string'],
            'amount' => ['required', 'numeric'],
            'currency' => ['required', 'string'],
        ])->validate();

        // 必须根据银行文档验证签名。
        if (! $this->hasValidSignature($request, $payload)) {
            Log::warning('Invalid bank payment notification signature', [
                'transaction_id' => $payload['transaction_id'] ?? null,
                'ip' => $request->ip(),
            ]);

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

        DB::transaction(function () use ($validated, $payload) {
            $order = Order::query()
                ->where('order_number', $validated['merchant_order_id'])
                ->lockForUpdate()
                ->firstOrFail();

            // 防止同一笔交易被重复处理。
            if ($order->gateway_transaction_id === $validated['transaction_id']
                && $order->payment_status === 'paid') {
                return;
            }

            // 金额和币种必须与本地订单一致。
            if ((string) $order->amount !== (string) $validated['amount']
                || $order->currency !== $validated['currency']) {
                throw new \RuntimeException('Payment amount or currency mismatch.');
            }

            if ($this->isSuccessfulStatus($validated['status'])) {
                $order->update([
                    'payment_status' => 'paid',
                    'gateway_transaction_id' => $validated['transaction_id'],
                    'paid_at' => now(),
                    'payment_notification' => $payload,
                ]);
            }
        });

        // 返回格式和状态码应以银行的接入文档为准。
        return response()->json([
            'message' => 'Notification accepted',
        ], 200);
    }

    private function hasValidSignature(Request $request, array $payload): bool
    {
        /*
         * 此处只是接口位置示例,不能直接返回 true 后用于生产环境。
         *
         * 应严格按照银行文档实现,例如:
         * - 从指定 Header 或 JSON 字段读取签名;
         * - 使用约定的字段顺序构造待签名字符串;
         * - 使用 HMAC、RSA 或银行指定的算法进行验证;
         * - 必要时校验 timestamp 和 nonce。
         */
        return true;
    }

    private function isSuccessfulStatus(string $status): bool
    {
        // 成功状态值必须以银行接口文档为准。
        return $status === 'SUCCESS';
    }
}

示例中的字段名、成功状态和响应内容仅用于说明结构。实际开发时,必须换成银行文档规定的字段。例如,交易编号可能叫 transactionId、payment_id 或其他名称。

Laravel 如何读取 JSON

假设银行发送以下请求:

POST /api/payments/bank/notify HTTP/1.1
Host: merchant.example.com
Content-Type: application/json

{
  "transaction_id": "TXN-20260902-001",
  "merchant_order_id": "ORDER-10001",
  "status": "SUCCESS",
  "amount": 199.90,
  "currency": "MYR"
}

Laravel 可以这样读取数据:

$payload = $request->json()->all();

$transactionId = $request->input('transaction_id');
$status = $request->input('status');

如果需要查看银行发送的原始请求体,可以使用:

$rawBody = $request->getContent();

如果签名算法要求对原始 JSON 字符串验签,应使用 $request->getContent(),不要先解析 JSON 再重新编码。重新编码可能改变字段顺序、空格或数字格式,导致验签失败。

如果路由放在 web.php

定义在 routes/web.php 中的 POST 路由通常会经过 CSRF 检查。银行服务器拿不到 Laravel 页面生成的 CSRF Token,请求可能因此返回 419 Page Expired。

这类接口更适合放在不依赖 CSRF 和会话的 API 路由中。如果项目结构要求必须使用 Web 路由,应只对这个确定的通知路径排除 CSRF 校验。不同 Laravel 版本的配置位置不完全相同,需要按当前项目版本处理,不要关闭整个站点的 CSRF 防护。

Response URL 也不能放进只允许登录用户访问的中间件组,例如:

Route::middleware('auth')->post('/payments/bank/notify', ...);

银行发起的是独立的服务器请求,不会携带 Merchant 后台用户的登录 Session。

本地测试方法

可以先用 curl 检查 Laravel 能否接收 JSON:

curl -X POST 'https://merchant.example.com/api/payments/bank/notify' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -d '{
    "transaction_id": "TXN-TEST-001",
    "merchant_order_id": "ORDER-10001",
    "status": "SUCCESS",
    "amount": 199.90,
    "currency": "MYR"
  }'

如果 Laravel 只运行在 localhost,银行服务器无法直接访问。测试环境需要一个公网可访问的 HTTPS 地址。可以使用测试服务器或安全的临时隧道,再把公开地址配置到银行后台。

生产环境需要特别注意

验证通知真实性

不能因为请求来自某个 URL,或者 JSON 中包含 "status": "SUCCESS",就认定付款已经完成。至少要按照银行文档完成以下检查:

  • 验证签名、MAC 或证书;
  • 核对 Merchant ID;
  • 核对本地订单号;
  • 核对金额和币种;
  • 核对交易编号;
  • 必要时调用银行的交易查询接口进行二次确认;
  • 如果银行公布了固定出口 IP,可以把 IP 白名单作为辅助措施,但不能用它代替签名验证。

处理重复通知

银行可能因为网络超时、响应丢失或自身的重试机制,多次发送同一笔通知。因此,处理逻辑必须具备幂等性。

可以为银行交易编号建立唯一索引,也可以单独保存通知记录:

Schema::table('orders', function ($table) {
    $table->string('gateway_transaction_id')->nullable()->unique();
});

如果只在代码中先查询再写入,仍可能遇到并发请求。数据库唯一约束配合事务和行锁更可靠。

尽快返回确认响应

Webhook 接口应尽快完成验证和必要的状态落库,然后返回银行要求的状态码和内容。发送邮件、生成发票、同步第三方系统等耗时操作,可以放进队列。

如果响应太慢,银行可能把这次通知视为失败并再次发送。

不要记录敏感信息

日志可以保留交易编号、订单号和处理结果,但不要直接记录银行卡号、密钥、完整签名材料或其他敏感支付数据。原始 Payload 能否长期保存,也需要结合银行协议、安全规范和当地合规要求判断。

区分返回页面和通知地址

支付完成后的浏览器跳转地址,与 Realtime Payment Notification 的 Response URL 不是一回事:

  • 浏览器跳转地址面向付款用户,页面可能被关闭、刷新或伪造;
  • Response URL 用于银行向 Merchant 发送服务器间通知;
  • 订单支付状态应以通过验签的服务器通知或银行交易查询结果为准,不能只依赖用户浏览器跳转。

备注:内容仅供参考。