如何在 Laravel API 驱动的移动应用中处理 offsite payment?
结论
这个流程可行,但不能通过 WebView 跳转或移动端轮询来最终确认支付结果。建议采用以下流程:
- Laravel 创建支付订单,返回第三方支付 URL。
- Flutter 使用系统浏览器、Custom Tab/Safari View Controller,或支付商明确支持的 WebView 打开该 URL。
- 第三方通过服务端 webhook 通知 Laravel。Laravel 验签、查询支付状态并创建 invoice。
- 支付完成页通过 Universal Link/App Link 唤醒移动应用。
- 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 可能无法正确处理这些场景。
通常可以按照以下顺序选择:
- 支付网关提供的 Flutter SDK。
- 系统浏览器或安全浏览器组件,例如 Android Custom Tabs、iOS
ASWebAuthenticationSession或SFSafariViewController。 - 仅在支付网关明确支持时使用嵌入式 WebView。
不要在 WebView 中注入脚本、截获银行卡信息,也不要把网关密钥交给 Flutter。密钥管理、签名和状态确认都应放在 Laravel 服务端。
需要特别注意的事项
- 全程使用 HTTPS。
- 不要根据客户端传入的 success 参数创建 invoice。
- 不要仅凭回调中的 payment ID 确认支付,必须验签或向网关查询。
- 保存本地订单 ID 与网关交易 ID 的对应关系。
- 核对金额、币种、商户号和订单号,避免交易串单。
- 为支付状态更新、invoice 创建和后续履约设计幂等机制。
- 为 pending 订单设置合理的过期时间。
- 对状态查询接口进行身份验证、所有权校验和频率限制。
- 不要在日志中记录银行卡数据、完整签名、访问令牌等敏感信息。
原方案不必推倒重来。可以保留”打开站外支付页”和”返回后查询状态”,但要把最终确认交给 Laravel,由它接收 webhook 或主动查询支付网关。同时,把固定每 2 秒轮询改为次数有限、带退避的状态刷新。
备注:内容仅供参考。
