PAYATHON 2026

如何在 Laravel API 驱动的移动应用中处理 offsite payment?

支付阿杰

结论

这个流程可行,但不能通过 WebView 跳转或移动端轮询来最终确认支付结果。建议采用以下流程:

  1. Laravel 创建支付订单,返回第三方支付 URL。
  2. Flutter 使用系统浏览器、Custom Tab/Safari View Controller,或支付商明确支持的 WebView 打开该 URL。
  3. 第三方通过服务端 webhook 通知 Laravel。Laravel 验签、查询支付状态并创建 invoice。
  4. 支付完成页通过 Universal Link/App Link 唤醒移动应用。
  5. App 恢复到前台后查询一次订单状态,必要时再进行次数有限、带退避的轮询。

只有支付网关不支持 webhook 时,才由 Laravel 在收到回调后主动调用网关接口查询结果。每 2 秒轮询可以用来兜底,但不能作为唯一的支付确认机制。

推荐流程

Flutter -> Laravel: 创建支付订单
Laravel -> Payment Gateway: 创建支付会话
Laravel -> Flutter: 返回 payment_url 和本地 payment_id

Flutter -> Payment Gateway: 打开支付页面
Payment Gateway -> Laravel: webhook / return callback
Laravel -> Payment Gateway: 查询并核实真实状态
Laravel: 更新订单、幂等创建 invoice

Payment Gateway -> Laravel return URL
Laravel -> Flutter deep link: 唤醒应用
Flutter -> Laravel: 查询 payment_id 状态

这里要区分两类回调:

  • return_url:供用户浏览器访问,用于展示结果或唤醒 App。用户可能直接关闭页面,所以它并不可靠。
  • webhook:由支付平台服务器直接请求 Laravel,应作为判断支付结果的主要依据。

即使 return_url 中带有 payment id 或 status=success,也不能直接将订单标记为已支付。这些参数可能被伪造,也可能出现页面已经跳转成功,但支付平台尚未完成最终结算的情况。

Laravel 创建支付订单

先在自己的数据库中创建订单,再向支付网关申请支付链接。返回给 Flutter 的内容应包括本地订单标识和支付 URL,不要暴露数据库自增 ID。

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

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/payments', [PaymentController::class, 'store']);
    Route::get('/payments/{payment:uuid}', [PaymentController::class, 'show']);
});

Route::get('/payments/return', [PaymentController::class, 'handleReturn'])
    ->name('payments.return');

Route::post('/webhooks/payment-provider', [PaymentController::class, 'webhook'])
    ->name('payments.webhook');

创建订单时,生成不可预测的 uuid 和一次性 state:

use App\Models\Payment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

public function store(Request $request): JsonResponse
{
    $data = $request->validate([
        'amount' => ['required', 'integer', 'min:1'],
        'currency' => ['required', 'string', 'size:3'],
    ]);

    $payment = Payment::create([
        'uuid' => (string) Str::uuid(),
        'user_id' => $request->user()->id,
        'amount' => $data['amount'],
        'currency' => strtoupper($data['currency']),
        'status' => 'pending',
        'return_state' => Str::random(64),
    ]);

    // 以下方法名仅为示例,需要替换为实际支付网关 SDK/API。
    $gatewayPayment = $this->gateway->createPayment([
        'merchant_reference' => $payment->uuid,
        'amount' => $payment->amount,
        'currency' => $payment->currency,
        'return_url' => route('payments.return', [
            'payment' => $payment->uuid,
            'state' => $payment->return_state,
        ]),
        'webhook_url' => route('payments.webhook'),
    ]);

    $payment->update([
        'gateway_payment_id' => $gatewayPayment->id,
    ]);

    return response()->json([
        'payment_id' => $payment->uuid,
        'payment_url' => $gatewayPayment->payment_url,
        'status' => $payment->status,
    ], 201);
}

金额应由 Laravel 根据商品、优惠和运费重新计算,不能直接采用 App 提交的金额。上例接收 amount 只是为了演示流程。

使用 webhook 确认付款

处理 webhook 前必须验证签名。具体方式由支付网关决定,可能使用 HMAC、非对称签名,也可能需要再次调用网关 API 查询交易。

use App\Models\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

public function webhook(Request $request)
{
    if (! $this->gateway->verifyWebhook(
        $request->getContent(),
        $request->headers->all()
    )) {
        abort(401);
    }

    $event = $this->gateway->parseWebhook($request->getContent());

    // 不直接相信 webhook 中的前端状态,向网关查询最终交易信息。
    $transaction = $this->gateway->getPayment($event->paymentId);

    DB::transaction(function () use ($transaction) {
        $payment = Payment::where(
            'gateway_payment_id',
            $transaction->id
        )->lockForUpdate()->firstOrFail();

        if ($payment->status === 'paid') {
            return;
        }

        if (
            $transaction->status !== 'paid'
            || $transaction->amount !== $payment->amount
            || strtoupper($transaction->currency) !== $payment->currency
        ) {
            return;
        }

        $payment->update([
            'status' => 'paid',
            'paid_at' => now(),
        ]);

        $payment->invoice()->firstOrCreate(
            ['payment_id' => $payment->id],
            [
                'number' => $this->invoiceNumberGenerator->next(),
                'amount' => $payment->amount,
            ]
        );
    });

    return response()->noContent();
}

支付平台通常会重复投递 webhook,因此处理逻辑必须具备幂等性:

  • 如果订单已经是 paid 状态,不要重复处理。
  • 为 gateway_payment_id 建立唯一索引。
  • 为 invoice 的 payment_id 建立唯一约束。
  • 在同一个数据库事务中更新支付状态并创建 invoice。
  • 只在状态确实发生变化时触发发货、积分或通知等操作。

还要根据业务需要处理 failed、cancelled、expired、refunded 等状态,不能只区分成功和失败。

return URL 与 App 唤醒

Laravel 的 return_url 可以核对 state,必要时查询一次网关,然后跳转到已配置的 Universal Link 或 App Link:

public function handleReturn(Request $request)
{
    $payment = Payment::where('uuid', $request->string('payment'))
        ->firstOrFail();

    abort_unless(
        hash_equals($payment->return_state, (string) $request->query('state')),
        403
    );

    // 可选:立即向支付网关查询一次,加快状态更新。
    $this->refreshPaymentStatus($payment);

    return redirect()->away(
        'https://app.example.com/payment-result?payment_id='
        . urlencode($payment->uuid)
    );
}

建议优先使用 HTTPS Universal Link/App Link,不要只依赖 myapp:// 这样的自定义 scheme。前者可以验证域名归属,也不容易被其他应用抢占。

深链接只需携带本地 payment_id。其中即使出现 status=success,也不能将它视为可信的支付结果。Flutter 被唤醒后,仍要向 Laravel 查询订单状态。

查询支付状态

查询接口必须确认当前用户拥有该订单:

public function show(Request $request, Payment $payment): JsonResponse
{
    abort_unless($payment->user_id === $request->user()->id, 403);

    return response()->json([
        'payment_id' => $payment->uuid,
        'status' => $payment->status,
        'paid_at' => $payment->paid_at?->toISOString(),
    ]);
}

Flutter 可以在以下时机发起查询:

  • 从浏览器或 WebView 返回 App 时。
  • App 收到 Universal Link/App Link 时。
  • App 从后台恢复到前台时。
  • 用户主动打开订单详情时。

webhook 和页面跳转之间可能存在时间差。这时可以短时间轮询,但要设置次数上限和退避间隔,例如:

const delays = [
  Duration(seconds: 1),
  Duration(seconds: 2),
  Duration(seconds: 3),
  Duration(seconds: 5),
  Duration(seconds: 8),
];

for (final delay in delays) {
  final payment = await api.getPayment(paymentId);

  if (payment.status == 'paid' ||
      payment.status == 'failed' ||
      payment.status == 'cancelled' ||
      payment.status == 'expired') {
    break;
  }

  await Future.delayed(delay);
}

轮询超时并不代表付款失败。此时更准确的提示是”支付结果确认中”,同时允许用户稍后刷新订单状态。

如果应用已经使用 WebSocket、Server-Sent Events 或推送通知,Laravel 收到 webhook 后也可以主动通知客户端。但没有必要只为短暂等待支付结果而单独引入长连接。采用”webhook + App 恢复时查询 + 有限轮询”即可满足可靠性要求。

WebView 还是系统浏览器

应优先采用支付网关官方推荐的打开方式。许多支付流程涉及 3-D Secure、银行 App 跳转、第三方 Cookie 或多窗口,普通 WebView 可能无法正确处理这些场景。

通常可以按照以下顺序选择:

  1. 支付网关提供的 Flutter SDK。
  2. 系统浏览器或安全浏览器组件,例如 Android Custom Tabs、iOS ASWebAuthenticationSession 或 SFSafariViewController。
  3. 仅在支付网关明确支持时使用嵌入式 WebView。

不要在 WebView 中注入脚本、截获银行卡信息,也不要把网关密钥交给 Flutter。密钥管理、签名和状态确认都应放在 Laravel 服务端。

需要特别注意的事项

  • 全程使用 HTTPS。
  • 不要根据客户端传入的 success 参数创建 invoice。
  • 不要仅凭回调中的 payment ID 确认支付,必须验签或向网关查询。
  • 保存本地订单 ID 与网关交易 ID 的对应关系。
  • 核对金额、币种、商户号和订单号,避免交易串单。
  • 为支付状态更新、invoice 创建和后续履约设计幂等机制。
  • 为 pending 订单设置合理的过期时间。
  • 对状态查询接口进行身份验证、所有权校验和频率限制。
  • 不要在日志中记录银行卡数据、完整签名、访问令牌等敏感信息。

原方案不必推倒重来。可以保留”打开站外支付页”和”返回后查询状态”,但要把最终确认交给 Laravel,由它接收 webhook 或主动查询支付网关。同时,把固定每 2 秒轮询改为次数有限、带退避的状态刷新。

备注:内容仅供参考。