PAYATHON 2026

Laravel API 如何使用 Zarinpal payment gateway

支付老李

结论

Laravel 并非”只能作为 API”。出现问题,是因为支付接口通过 XMLHttpRequest 或 fetch 调用。后端执行 $zarinpal->redirect() 后,浏览器会继续把重定向当作 AJAX 请求处理。一旦请求跳转到 Zarinpal 域名,就会受到浏览器同源策略限制。支付网关通常不会为你的前端域名返回 Access-Control-Allow-Origin,所以浏览器最终会报告 CORS 错误。

正确的支付流程如下:

  1. React 调用 Laravel 的”创建支付”接口。
  2. Laravel 向 Zarinpal 发起支付请求并取得 Authority。
  3. Laravel 返回支付页面 URL,不在 AJAX 请求中执行重定向。
  4. React 使用 window.location.assign() 或 window.location.href 跳转整个页面。
  5. 支付完成后,Zarinpal 跳回 Laravel 的回调地址。
  6. 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,并确保回调地址可以从公网访问。

备注:内容仅供参考。