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 回调。
备注:内容仅供参考。
