PAYATHON 2026

Commerce 2 中 onReturn 后如何保持订单当前状态?

支付阿杰

结论

不能通过 onReturn() 的返回值阻止 Commerce 2 继续结账。onReturn() 是 void 方法。只要它正常结束,Commerce 就会认为返回处理成功,并进入下一个 checkout step。如果当前已经是最后一步,订单可能随即进入 completed 状态。

对于浏览器返回不等于支付成功、只能信任服务端通知的网关,通常有两种处理方式:

  1. 尚未收到有效通知时,让 onReturn() 抛出 PaymentGatewayException,中断结账流程。
  2. 对于异步支付,使用自定义 return route 通常更合适。客户返回后先进入“支付结果确认中”页面,payment 仍由 onNotify() 创建或更新。

不要让一个没有实际处理逻辑的 onReturn() 正常结束,否则 Commerce 会继续结账。

为什么订单会自动进入下一状态

Commerce 的标准站外支付返回地址不只会调用支付网关插件的 onReturn(),还会恢复并继续 checkout flow。

因此,下面的实现虽然没有修改订单,Commerce 仍会认为返回处理成功:

public function onReturn(OrderInterface $order, Request $request) {
  // Only write a log entry.
}

onReturn() 没有类似下面这样的控制接口:

return FALSE;

它实际上只有两种执行结果:

  • 正常结束:继续 checkout flow。
  • 抛出异常:返回处理失败,不进入下一个 checkout step。

问题并不是 onReturn() 显式修改了订单状态,而是它正常结束后,外层 checkout controller 会继续执行结账流程。

方案一:未确认时抛出异常

如果仍要使用 Commerce 提供的标准 return URL,可以在 onReturn() 中查询该订单是否已经收到可信的服务端通知。尚未确认时,抛出 PaymentGatewayException:

<?php

namespace Drupal\my_module\Plugin\Commerce\PaymentGateway;

use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_payment\Exception\PaymentGatewayException;
use Symfony\Component\HttpFoundation\Request;

public function onReturn(OrderInterface $order, Request $request) {
  // 浏览器携带的参数只能用于关联请求,不能作为支付成功的依据。
  $transaction_id = $request->query->get('transaction_id');

  // 此处应查询由 onNotify() 写入的 payment 或本地交易记录。
  $payment = $this->loadPaymentForTransaction($order, $transaction_id);

  if (!$payment || !$this->isPaymentConfirmed($payment)) {
    throw new PaymentGatewayException(
      'The payment result has not yet been confirmed.'
    );
  }

  // 已经由服务端通知确认,可以正常返回,让 Commerce 继续 checkout。
}

这里的 loadPaymentForTransaction() 和 isPaymentConfirmed() 只是示意方法,需要根据网关交易号、payment remote ID 和实际支付状态自行实现。

这种方式可以让结账流程停留在当前 checkout step,但 Commerce 通常会把异常作为支付错误展示给客户。异步通知出现正常延迟时,类似“支付失败”的提示可能不够友好。因此,这种方式更适合通知很快就能到达,或者允许客户稍后重试的场景。

方案二:使用自定义等待页面

如果支付平台允许单独配置浏览器返回地址,可以采用下面的流程:

浏览器返回
    ↓
自定义 return route
    ↓
显示“正在确认支付结果”
    ↓
轮询本地支付状态或提供刷新按钮

支付平台服务器
    ↓
Commerce notify URL
    ↓
onNotify()
    ↓
验证签名并创建或更新 payment

自定义 return controller 不应调用 Commerce 的标准 payment return controller,也不应执行订单状态转换。它只需要:

  • 验证用于关联订单的签名或一次性 token。
  • 记录客户已经返回。
  • 查询 payment 是否已经由 onNotify() 确认。
  • 显示“处理中”“成功”或“失败”页面。
  • 确认成功后,将客户安全地引导回合适的 checkout 页面。

示意路由:

my_module.payment_return:
  path: '/payment/my-gateway/return/{commerce_order}'
  defaults:
    _controller: '\Drupal\my_module\Controller\PaymentReturnController::returnPage'
    _title: 'Payment status'
  requirements:
    _custom_access: '\Drupal\my_module\Controller\PaymentReturnController::access'

示意控制器:

<?php

namespace Drupal\my_module\Controller;

use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Request;

final class PaymentReturnController extends ControllerBase {

  public function returnPage(
    OrderInterface $commerce_order,
    Request $request,
  ): array {
    // 必须验证签名或一次性 token,不能仅凭订单 ID 授权访问。
    $this->validateReturnRequest($commerce_order, $request);

    $confirmed = $this->hasConfirmedPayment($commerce_order);

    if (!$confirmed) {
      return [
        '#markup' => $this->t(
          'Your payment is being confirmed. Please refresh this page shortly.'
        ),
        '#cache' => ['max-age' => 0],
      ];
    }

    return [
      '#markup' => $this->t('Your payment has been confirmed.'),
      '#cache' => ['max-age' => 0],
    ];
  }

}

如何把自定义 URL 传给支付平台,要根据网关的请求参数确定。有些平台分别使用 return_url、cancel_url 和 notify_url,也有平台使用其他名称。

onNotify() 应负责最终支付状态

支付平台的服务端通知才是可信来源,因此应由 onNotify() 负责创建或确认 payment:

use Drupal\commerce_payment\Exception\PaymentGatewayException;
use Symfony\Component\HttpFoundation\Request;

public function onNotify(Request $request) {
  $payload = $request->getContent();

  if (!$this->verifyNotification($payload, $request->headers->all())) {
    throw new PaymentGatewayException('Invalid payment notification.');
  }

  $notification = $this->decodeNotification($payload);
  $order = $this->loadOrderFromNotification($notification);

  if (!$order) {
    throw new PaymentGatewayException('The referenced order was not found.');
  }

  // 按网关交易号查找,避免重复通知创建多个 payment。
  $payment = $this->loadPaymentByRemoteId(
    $notification['transaction_id']
  );

  if (!$payment) {
    $payment = $this->createPaymentForNotification(
      $order,
      $notification
    );
  }

  $this->applyRemoteStatus($payment, $notification['status']);
}

实际代码需要根据所用 Commerce 版本提供的 payment storage、payment state 和 transition API 调整。示例中的辅助方法并不是 Commerce 核心 API,不能直接照搬。

需要特别注意的事项

通知可能早于浏览器返回

onNotify() 和浏览器 return 的先后顺序无法保证。实现时要兼容以下情况:

  • notify 先到,return 后到。
  • return 先到,notify 后到。
  • 客户关闭浏览器,只收到 notify。
  • notify 被重复发送。
  • 客户多次刷新返回页面。

因此,onNotify() 必须是幂等的,通常可以把网关的 transaction ID 或 remote payment ID 作为唯一标识。

不要信任浏览器返回的支付状态

查询字符串、表单字段和订单 ID 都可能被修改。浏览器返回的数据最多只能用于定位交易,不能单独触发以下操作:

  • 创建成功 payment。
  • 将 payment 标记为 completed。
  • 将订单推进到 completed。
  • 扣减余额或发货。

不建议直接修改 checkout controller

覆盖核心 controller、强行拦截订单 transition 或修改核心 route,都会增加后续升级成本,也可能影响其他支付网关。应在网关协议层明确区分三类操作:

  • 客户返回:展示状态。
  • 服务端通知:确认支付。
  • checkout 推进:只有确认结果满足业务条件后才能发生。

标准 onReturn() 的语义是返回处理成功后继续结账,它不是单纯的通知入口。如果客户返回时支付状态仍未确定,可以抛出异常来中断流程,也可以改用自定义等待页面。最终的 payment 状态应由 onNotify() 处理。

备注:内容仅供参考。