如何在 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 发送服务器间通知;
- 订单支付状态应以通过验签的服务器通知或银行交易查询结果为准,不能只依赖用户浏览器跳转。
备注:内容仅供参考。
