Laravel API 如何使用 Zarinpal payment gateway
结论
Laravel 并非”只能作为 API”。出现问题,是因为支付接口通过 XMLHttpRequest 或 fetch 调用。后端执行 $zarinpal->redirect() 后,浏览器会继续把重定向当作 AJAX 请求处理。一旦请求跳转到 Zarinpal 域名,就会受到浏览器同源策略限制。支付网关通常不会为你的前端域名返回 Access-Control-Allow-Origin,所以浏览器最终会报告 CORS 错误。
正确的支付流程如下:
- React 调用 Laravel 的”创建支付”接口。
- Laravel 向 Zarinpal 发起支付请求并取得
Authority。 - Laravel 返回支付页面 URL,不在 AJAX 请求中执行重定向。
- React 使用
window.location.assign()或window.location.href跳转整个页面。 - 支付完成后,Zarinpal 跳回 Laravel 的回调地址。
- Laravel 在服务端验证支付结果,然后跳转到 React 的结果页面。
页面级导航不受 AJAX 的 CORS 规则限制。
Laravel 创建支付接口
下面的示例沿用问题中的 SDK 调用方式。支付地址和 SDK 方法名可能会因 Zarinpal PHP 包的版本而异,请以当前所用包的文档为准。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
public function create(Request $request): JsonResponse
{
$amount = 1000;
// 生产环境中应先创建待支付订单,并从服务端读取实际金额
$order = auth()->user()->orders()->create([
'amount' => $amount,
'status' => 'pending',
]);
$callbackUrl = route('payments.zarinpal.callback', [
'order' => $order->id,
]);
$zarinpal = new \Zarinpal(
config('services.zarinpal.merchant_id')
);
$zarinpal->isZarinGate();
$results = $zarinpal->request(
$callbackUrl,
$amount,
'Order #' . $order->id,
auth()->user()->email ?? null,
auth()->user()->mobile ?? null
);
if (!isset($results['Authority'])) {
return response()->json([
'message' => 'Unable to create payment request.',
'gateway_response' => $results,
], 502);
}
$authority = $results['Authority'];
$order->update([
'authority' => $authority,
]);
/*
* 此处不要调用 $zarinpal->redirect()。
* 请按照当前环境和 SDK 文档构造支付 URL。
*/
$paymentUrl = 'https://www.zarinpal.com/pg/StartPay/' .
rawurlencode($authority);
return response()->json([
'payment_url' => $paymentUrl,
]);
}
}
API 路由可以这样定义:
use App\Http\Controllers\PaymentController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->post(
'/deposit/agent',
[PaymentController::class, 'create']
);
回调地址必须是 Zarinpal 可以访问的后端 URL,不能使用只能在本机访问的 localhost。本地开发时,可以使用公开可访问的 HTTPS 隧道或测试环境地址。
React 发起支付并跳转
React 先通过 AJAX 获取支付地址,再执行顶层页面导航:
async function startPayment() {
const response = await fetch('http://localhost:8000/api/deposit/agent', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || 'Failed to create payment');
}
const data = await response.json();
if (!data.payment_url) {
throw new Error('Payment URL was not returned');
}
window.location.assign(data.payment_url);
}
如果使用 Axios:
async function startPayment() {
const response = await axios.post(
'http://localhost:8000/api/deposit/agent',
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
window.location.assign(response.data.payment_url);
}
不要再用 Axios 或 fetch 请求 payment_url,而要把它交给浏览器导航:
// 错误:仍然属于跨域 AJAX 请求
await axios.get(paymentUrl);
// 正确:整个页面跳转
window.location.assign(paymentUrl);
Laravel 处理支付回调
支付回调必须由服务端处理。不能只看前端参数、Status=OK,或用户是否回到了成功页面,就认定付款成功。
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PaymentCallbackController extends Controller
{
public function __invoke(Request $request, Order $order): RedirectResponse
{
if ($order->status === 'paid') {
return redirect(
config('app.frontend_url') .
'/payment/result?order=' . $order->id
);
}
$authority = $request->query('Authority');
$status = $request->query('Status');
if ($status !== 'OK' || !$authority) {
$order->update(['status' => 'cancelled']);
return redirect(
config('app.frontend_url') .
'/payment/result?order=' . $order->id
);
}
/*
* 调用所用 SDK 的 verify 方法。
* 验证时必须使用数据库中保存的订单金额,
* 不要接受前端传入的金额。
*/
$zarinpal = new \Zarinpal(
config('services.zarinpal.merchant_id')
);
$result = $zarinpal->verify(
$authority,
$order->amount
);
/*
* 不同 SDK 返回的数据结构不同。
* 应根据当前 SDK 文档判断验证是否成功,并取得 RefID。
*/
$verified = /* 根据 $result 判断 */ false;
if ($verified) {
$order->update([
'status' => 'paid',
'reference_id' => $result['RefID'] ?? null,
]);
} else {
$order->update(['status' => 'failed']);
}
return redirect(
config('app.frontend_url') .
'/payment/result?order=' . $order->id
);
}
}
回调可以定义在 web.php 中,因为它不是由 React 发起的认证 API 请求:
use App\Http\Controllers\PaymentCallbackController;
use Illuminate\Support\Facades\Route;
Route::get(
'/payments/zarinpal/callback/{order}',
PaymentCallbackController::class
)->name('payments.zarinpal.callback');
示例中的 verify() 参数和成功响应结构,需要根据实际安装的 Zarinpal SDK 调整。不能保留示例里的占位判断并直接上线。
正确配置本地 CORS
当前错误很可能是 AJAX 跟随支付重定向引起的,但 Laravel 的创建支付接口仍然需要允许 React 开发服务器访问。
config/cors.php 可以配置为:
<?php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
'http://localhost:3000',
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
如果使用 Bearer Token 而非 Cookie,可以根据项目的认证方案把 supports_credentials 设为 false。
还需要检查以下几点:
- 不要随意在
allowed_origins的域名末尾添加/。 - React 的实际访问地址必须与配置完全一致,包括协议和端口。
- 创建支付接口的异常响应也必须经过 Laravel 的 CORS 中间件。
- 不要试图通过配置 CORS,让浏览器用 AJAX 访问 Zarinpal。第三方网关的响应头不受 Laravel 项目控制。
- 修改配置后,如果项目启用了配置缓存,需要清除缓存再测试。
为什么只有支付接口出现 CORS 错误
普通 API 接口直接返回 Laravel 的 JSON 响应,因此可以带上项目配置的 CORS 响应头。
支付接口调用 $zarinpal->redirect() 后,请求链变成:
React AJAX
→ Laravel API
→ HTTP redirect
→ Zarinpal payment page
最后一个响应来自 Zarinpal,而非 Laravel。这个页面是供浏览器直接打开的支付页面,不是跨域 AJAX API,所以通常不会包含允许 http://localhost:3000 读取响应的 CORS 头。浏览器因此会把整次 AJAX 调用报告为 CORS 失败。
将流程改为下面这样即可:
React AJAX
→ Laravel 返回 payment_url
→ React 执行 window.location.assign(payment_url)
→ 浏览器打开 Zarinpal
→ Zarinpal 回调 Laravel
→ Laravel 验证后跳回 React
安全注意事项
- Merchant ID 应保存在服务端环境变量中,不能暴露给 React。
- 金额、订单号和支付状态应以数据库记录为准,不能信任前端提交的数据。
Authority应与本地订单绑定,回调时需要检查是否匹配。- 是否支付成功,必须以服务端的
verify结果为准。 - 回调处理应具有幂等性,避免刷新页面或重复回调造成重复入账。
- 前端结果页只能展示状态。最终支付状态应通过受保护的 API 从后端查询。
- 生产环境应使用 HTTPS,并确保回调地址可以从公网访问。
备注:内容仅供参考。
