PAYATHON 2026

如何在 Ctools modal 中显示 Drupal Commerce checkout

支付阿杰

结论

可以把 Drupal Commerce checkout 放进 CTools modal,但不能像登录表单那样只把 form ID 交给 ctools_modal_form_wrapper()。

Commerce checkout form 依赖两个构建参数:

  • 当前订单 $order
  • 当前 checkout page 配置 $checkout_page

这两个参数通常由 Commerce 的 checkout 路由传入。Modal callback 绕过默认路由后,必须通过 $form_state['build_info']['args'] 补回,否则常见结果就是空表单、缺少 panes、Ajax loader 不结束,或者请求返回 HTTP 200 但前端仍报告 Ajax error。

以下方案针对问题中这些 API 所对应的 Drupal 7、Drupal Commerce 1.x 和 CTools 1.x。若实际使用的是 Drupal Commerce 2.x,则不应套用这些代码,而应使用 Drupal Core Dialog、Ajax API 和 Commerce checkout flow。

正确的处理流程

完整流程应当是:

  1. 先通过 Commerce Cart API 完成 add-to-cart,取得已经保存的 cart order。
  2. 检查当前用户是否有权访问该订单。
  3. 找到订单当前应显示的 checkout page。
  4. 使用动态 form ID,例如 commerce_checkout_form_checkout。
  5. 将 $order 和 $checkout_page放进 $form_state['build_info']['args']。
  6. 交给 ctools_modal_form_wrapper()构建和处理表单。
  7. 成功提交后,按照 Commerce 写入 $form_state['redirect'] 的结果跳转。
  8. 如果表单验证失败,继续返回重建后的 modal form。

一般不应在 modal callback 中重新创建订单。用户点击 add-to-cart 后,Commerce 已经创建或更新了购物车订单;modal 应继续处理这张订单,否则容易产生重复订单、订单所有权错误或 checkout 状态不一致。

下面的代码展示核心结构。菜单路由、权限回调及第一个 checkout page 的 ID,需要按照站点实际配置调整。

/**
 * Ajax callback used by a CTools modal link.
 *
 * @param object $order
 *   A fully loaded Commerce order.
 * @param string $page_id
 *   Checkout page machine name, for example "checkout".
 */
function mymodule_checkout_modal($order, $page_id = 'checkout') {
  ctools_include('ajax');
  ctools_include('modal');

  // Load the Commerce checkout form implementation when it has not already
  // been loaded by the regular checkout route.
  module_load_include(
    'inc',
    'commerce_checkout',
    'commerce_checkout.pages'
  );

  // commerce_checkout_pages() returns checkout page definitions, not rendered
  // page content.
  $checkout_pages = commerce_checkout_pages();

  if (empty($checkout_pages[$page_id])) {
    $commands = array(
      ctools_modal_command_dismiss(),
      ctools_ajax_command_alert(t('The requested checkout page is unavailable.')),
    );

    print ajax_render($commands);
    drupal_exit();
  }

  $checkout_page = $checkout_pages[$page_id];

  // Commerce uses a page-specific form ID. hook_forms() maps this form ID
  // to the common Commerce checkout form builder.
  $form_id = 'commerce_checkout_form_' . $checkout_page['page_id'];

  $form_state = array(
    'ajax' => TRUE,
    'build_info' => array(
      'args' => array(
        $order,
        $checkout_page,
      ),
    ),
  );

  $commands = ctools_modal_form_wrapper($form_id, $form_state);

  /*
   * On a successful checkout submission, Commerce may request a redirect to
   * the next checkout page, the completion page, or a payment gateway.
   */
  if (!empty($form_state['executed']) && !empty($form_state['redirect'])) {
    $redirect_url = mymodule_checkout_redirect_url(
      $form_state['redirect']
    );

    if ($redirect_url) {
      $commands = array(
        ctools_ajax_command_redirect($redirect_url),
      );
    }
  }

  print ajax_render($commands);
  drupal_exit();
}

$form_state['redirect'] 在 Drupal 7 中既可能是字符串,也可能是包含路径和 URL options 的数组,因此可以用一个小函数统一处理:

/**
 * Converts a Drupal 7 form redirect value to an absolute URL.
 */
function mymodule_checkout_redirect_url($redirect) {
  if (is_string($redirect)) {
    return url($redirect, array('absolute' => TRUE));
  }

  if (!is_array($redirect) || empty($redirect[0])) {
    return FALSE;
  }

  $path = $redirect[0];
  $options = !empty($redirect[1]) && is_array($redirect[1])
    ? $redirect[1]
    : array();

  $options['absolute'] = TRUE;

  return url($path, $options);
}

不同 CTools 版本提供的 redirect command 名称可能略有差异。如果当前版本没有 ctools_ajax_command_redirect(),可以返回一个自定义 Ajax command,在浏览器端执行 window.location.href = response.url。关键点是:不要在 Ajax callback 中直接依赖普通的 drupal_goto() 响应,因为前端期待的是 Ajax command JSON,而不是完整 HTML 页面或普通 302 页面。

菜单路由和访问控制

Modal callback 应通过一个独立的 Ajax 路由访问,并加载订单实体。例如:

function mymodule_menu() {
  $items = array();

  $items['checkout-modal/%commerce_order/%'] = array(
    'page callback' => 'mymodule_checkout_modal',
    'page arguments' => array(1, 2),
    'access callback' => 'mymodule_checkout_modal_access',
    'access arguments' => array(1),
    'type' => MENU_CALLBACK,
  );

  return $items;
}

访问检查至少应确认:

  • 订单存在并已保存;
  • 订单是当前用户的购物车订单,或者当前用户拥有管理权限;
  • 订单仍处于允许 checkout 的状态;
  • 当前 checkout page 对该订单有效;
  • 匿名用户的订单归属和 session/cart association 正常。

示意实现如下:

function mymodule_checkout_modal_access($order) {
  global $user;

  if (empty($order->order_id)) {
    return FALSE;
  }

  if (user_access('administer commerce_order entities')) {
    return TRUE;
  }

  return isset($order->uid) && (int) $order->uid === (int) $user->uid;
}

这只是最低限度的示例。匿名购物车不能简单依靠 uid === 0 判断所有权,否则任何匿名订单都可能通过该检查。匿名订单应继续使用 Commerce Cart 原有的 session/cart 归属机制,或复用当前版本 Commerce 提供的订单访问检查。

打开 modal 的链接

页面必须加载 CTools modal 所需的 JavaScript,并给链接添加 ctools-use-modal 类:

ctools_include('modal');
ctools_modal_add_js();

$link = l(
  t('Checkout'),
  'checkout-modal/' . $order->order_id . '/checkout',
  array(
    'attributes' => array(
      'class' => array('ctools-use-modal'),
    ),
  )
);

这里的 checkout 必须是实际 checkout page 的 machine name,不能只根据页面标题猜测。若站点配置了多个 checkout pages,应根据订单当前 checkout 状态决定 page ID,而不是始终写死第一个页面。

如果 add-to-cart 本身也是 Ajax 操作,建议在 add-to-cart 成功、订单保存完成后再打开 modal。不要让 modal 请求与订单保存同时发生,否则 modal 可能拿到尚未更新的订单。

为什么只传 form ID 不够

普通登录表单可以这样构建:

ctools_modal_form_wrapper('user_login', $form_state);

因为 user_login 的 form builder 不要求额外业务对象。Commerce checkout 则类似于下面的调用:

drupal_get_form(
  'commerce_checkout_form_' . $checkout_page['page_id'],
  $order,
  $checkout_page
);

ctools_modal_form_wrapper() 内部仍然走 Drupal Form API,但它不会自行知道当前订单和 checkout page。因此必须显式提供:

$form_state['build_info']['args'] = array(
  $order,
  $checkout_page,
);

直接调用 commerce_checkout_form() 或复制 commerce_checkout.pages.inc 中的内部调用,只解决了“构建出一个数组”的问题,并没有自动解决 Ajax rebuild、验证、提交、redirect 和 checkout 状态流转。

不建议直接渲染 checkout panes

commerce_checkout_panes() 和 commerce_checkout_pages()主要返回插件或页面定义,并不是已经完成构建的表单。

单独加载 panes 会遗漏 Commerce checkout form 负责的内容,例如:

  • checkout page 上下文;
  • pane 的 checkout_form、验证和提交回调;
  • 表单缓存及 rebuild 信息;
  • checkout page 前进、后退和完成逻辑;
  • 订单状态变化;
  • payment pane 与 payment transaction 的处理;
  • $form_state['redirect']。

因此,不应把 pane 定义逐个 render() 后拼成一个看起来相同的表单。表面上可能显示出 checkbox,但提交时很容易绕开 Commerce 的验证和状态机。

是否应该创建自定义 checkbox 表单

如果这些 checkbox 确实属于订单 checkout 数据,优先保留 Commerce checkout pane,让 Commerce 负责写入订单、验证和提交。

自定义表单只有在以下情况下更合适:

  • 该操作本质上不是 checkout;
  • checkbox 只是进入 checkout 前的筛选条件;
  • 提交后仅更新购物车,再跳转到标准 checkout;
  • 能够明确实现订单保存、pane validation、checkout transition 和 payment handling。

不要仅因为 modal 渲染失败就复制一套 checkbox 表单。这样很容易出现字段值已经保存,但 checkout pane 的 validation 或 submit handler 没有执行的情况。

支付步骤需要特别处理

如果提交后进入第三方支付网关,建议结束 modal 流程并跳转到正常页面:

$commands = array(
  ctools_ajax_command_redirect($payment_url),
);

支付页面通常不适合继续嵌在 modal 中,原因包括:

  • 网关可能要求顶层导航;
  • 网关可能禁止 iframe;
  • 返回地址、取消地址和异步通知依赖完整 URL;
  • 3-D Secure、OAuth 或银行验证可能打开新页面;
  • 跨域 cookie 和浏览器安全策略可能阻止嵌入。

站内支付且没有跨域流程时可以继续使用 Ajax,但仍要确保 Commerce 的 payment method submit callback 和订单状态更新完整执行。

更简单的替代方案

如果 checkout 最终必然发生页面跳转,最稳妥的实现通常是:

  1. modal 只显示两个 checkbox;
  2. checkbox 值通过 Commerce Cart 或订单字段 API 保存到当前订单;
  3. 保存成功后跳转到标准 Commerce checkout URL;
  4. 让标准 checkout 路由负责 payment 和 completion。

如果必须在当前页面完成整个 checkout,则可以使用 Drupal 7 Core Ajax API,把真正的 checkout form 放入一个 wrapper,并通过 #ajax 替换该 wrapper。其核心要求仍然相同:每次 rebuild 都必须保留 $order 和 $checkout_page 参数。换成 Core Ajax 并不会消除 Commerce checkout 所需的上下文,只是不用 CTools modal 的命令格式。

iframe 打开标准 checkout URL 能最大程度保留原始流程,但会引入完整页面 DOM、主题嵌套、跨域支付和浏览器限制,通常只适合作为临时方案。

排查 HTTP 200 Ajax error

CTools 显示“HTTP 200 Ajax error”时,通常不是 HTTP 请求失败,而是返回内容不是合法的 Ajax command JSON。应检查响应正文是否混入了:

  • PHP warning 或 notice;
  • dpm()、var_dump()、print_r() 等调试输出;
  • Drupal status messages 之外的直接输出;
  • 完整 HTML 页面;
  • 普通 access denied 页面;
  • payment callback 的重定向页面;
  • callback 末尾继续执行后产生的额外内容。

Ajax callback 输出 ajax_render() 后应立即调用 drupal_exit(),并确保之前没有任何调试输出。

最终的关键不是绕过 Commerce checkout,而是让 CTools 继续通过 Drupal Form API 调用它,并完整传入 Commerce 默认路由原本提供的 $order 和 $checkout_page。只要这两个构建参数、访问控制以及成功提交后的 redirect 处理正确,CTools 本身并不要求 checkout form 是普通静态表单。

备注:内容仅供参考。