PAYATHON 2026

如何在 Ubercart 中集成第三方 merchant payment gateway?

支付阿杰

结论

不要把支付地址当作 checkout 完成后的普通跳转地址。应为 Ubercart 编写一个“站外支付”模块,把该 merchant 注册为支付方式,并在订单创建后生成一个 POST 表单,提交到 merchant 的支付页面。

完整流程如下:

  1. Ubercart 创建订单,并将状态设为待付款。

  2. 重定向回调生成表单,其中包含订单号、金额、币种、返回地址和签名等字段。

  3. 浏览器把表单提交到:

    https://test.MyMerchantGateway.com/ECN/eng/payment/payForm.jsp

  4. merchant 完成支付后,将浏览器带回网站。

  5. 网站通过服务端通知或查询接口核实支付结果。

  6. 确认支付成功后,再把订单更新为已付款。

如果只使用 drupal_goto() 或修改 checkout 完成页,merchant 需要的 POST 参数会丢失,网站也无法可靠判断订单是否真的支付成功。

先确认 merchant 的接口要求

由于示例代码为空,目前无法确定该网关使用的字段名、签名算法和通知协议。动手实现前,至少要从 merchant 的接口文档中确认以下内容:

  • 支付地址,以及测试和生产环境各自的地址
  • 商户编号字段
  • 订单号字段
  • 金额单位,例如元或分
  • 币种格式
  • 签名字段和签名算法
  • 浏览器返回地址
  • 服务端异步通知地址
  • 支付成功状态码
  • 订单查询接口
  • 是否必须使用 POST
  • 是否要求指定字符集或时间戳

下面的代码只展示整体结构。merchant_id、order_id、amount、signature 等名称都要换成 merchant 文档规定的真实参数。

创建自定义支付模块

下面以 Drupal 7 / Ubercart 3 的写法为例。其他 Ubercart 版本使用的 hook、路由和订单 API 可能不同,应以项目里已有的站外支付模块为准,也可以参考 PayPal、Authorize.Net 等模块的实现。

模块目录可以命名为:

sites/all/modules/custom/uc_mymerchant/
├── uc_mymerchant.info
└── uc_mymerchant.module

uc_mymerchant.info:

name = My Merchant Gateway
description = Provides an off-site payment method for Ubercart.
core = 7.x
package = Ubercart - payment
dependencies[] = uc_payment
dependencies[] = uc_order

注册支付方式

在 uc_mymerchant.module 中注册支付方式,并指定 checkout 后使用的重定向表单:

<?php

/**
 * Implements hook_uc_payment_method().
 */
function uc_mymerchant_uc_payment_method() {
  return array(
    'mymerchant' => array(
      'name' => t('My Merchant Gateway'),
      'title' => t('Credit card / Online payment'),
      'review' => t('You will be redirected to the secure payment page.'),
      'desc' => t('Pay through My Merchant Gateway.'),
      'callback' => 'uc_payment_method_mymerchant',
      'redirect' => 'uc_mymerchant_redirect_form',
      'checkout' => TRUE,
      'weight' => 0,
    ),
  );
}

/**
 * Payment method settings callback.
 */
function uc_payment_method_mymerchant($op, &$order, $form = NULL, &$form_state = NULL) {
  switch ($op) {
    case 'cart-details':
      return array(
        '#markup' => t('After placing the order, you will be redirected to the payment provider.'),
      );
  }
}

不同 Ubercart 小版本对支付方式字段的定义可能不一样。如果后台没有出现这个支付方式,应检查当前版本的 hook_uc_payment_method() 定义和已经安装的 PayPal 站外支付模块,不要直接照搬其他版本的代码。

生成并提交重定向表单

如果 merchant 要求使用 POST,应通过 Drupal Form API 生成表单:

/**
 * Builds the off-site payment form.
 */
function uc_mymerchant_redirect_form($form, &$form_state, $order) {
  $payment_url = 'https://test.MyMerchantGateway.com/ECN/eng/payment/payForm.jsp';

  $merchant_id = variable_get('uc_mymerchant_merchant_id', '');
  $secret_key = variable_get('uc_mymerchant_secret_key', '');

  // 确认 merchant 要求的金额单位。
  // 如果接口要求“分”,通常需要乘以 100,并转换为整数。
  $amount = number_format($order->order_total, 2, '.', '');

  $return_url = url(
    'uc_mymerchant/return',
    array('absolute' => TRUE, 'https' => TRUE)
  );

  $notify_url = url(
    'uc_mymerchant/notify',
    array('absolute' => TRUE, 'https' => TRUE)
  );

  $parameters = array(
    'merchant_id' => $merchant_id,
    'order_id' => $order->order_id,
    'amount' => $amount,
    'currency' => 'USD',
    'return_url' => $return_url,
    'notify_url' => $notify_url,
  );

  // 这里只是演示调用位置,不代表 merchant 的真实签名算法。
  $parameters['signature'] = uc_mymerchant_create_signature(
    $parameters,
    $secret_key
  );

  $form['#action'] = $payment_url;
  $form['#method'] = 'post';

  foreach ($parameters as $name => $value) {
    $form[$name] = array(
      '#type' => 'hidden',
      '#value' => $value,
    );
  }

  $form['submit'] = array(
    '#type' => 'submit',
    '#value' => t('Continue to payment'),
  );

  return $form;
}

如果需要在页面加载后自动提交,可以增加一个小型 JavaScript 行为。不过,最好保留可见的“继续支付”按钮,这样即使自动提交失败,用户仍能手动继续。

不要通过查询字符串拼接敏感参数:

// 不推荐。
drupal_goto($payment_url . '?order_id=' . $order->order_id);

除非 merchant 文档明确要求使用 GET,否则应按文档示例提交 POST 表单。

按 merchant 文档实现签名

签名算法不能靠经验推测。下面只展示一种常见结构,并不表示该 merchant 实际使用 HMAC-SHA256:

/**
 * Example signature helper.
 *
 * Replace this implementation with the merchant's documented algorithm.
 */
function uc_mymerchant_create_signature(array $parameters, $secret_key) {
  ksort($parameters);

  $signing_data = http_build_query(
    $parameters,
    '',
    '&',
    PHP_QUERY_RFC3986
  );

  return hash_hmac('sha256', $signing_data, $secret_key);
}

以下细节都要逐项核对:

  • 参数如何排序
  • 空字段是否参与签名
  • URL 编码是在签名前还是签名后执行
  • 金额保留几位小数
  • 使用哪种字符编码
  • 密钥是否要拼接在字符串的开头或结尾
  • 签名输出采用十六进制、Base64 还是其他格式
  • 参数名是否区分大小写

其中任何一项不一致,都可能让网关返回签名错误。

添加返回地址和异步通知地址

Drupal 7 可以通过 hook_menu() 注册回调地址:

/**
 * Implements hook_menu().
 */
function uc_mymerchant_menu() {
  $items = array();

  $items['uc_mymerchant/return'] = array(
    'page callback' => 'uc_mymerchant_return',
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );

  $items['uc_mymerchant/notify'] = array(
    'page callback' => 'uc_mymerchant_notify',
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );

  return $items;
}

浏览器返回地址主要负责向用户显示支付结果:

function uc_mymerchant_return() {
  $order_id = isset($_REQUEST['order_id'])
    ? $_REQUEST['order_id']
    : NULL;

  if (!$order_id) {
    return t('The payment response does not contain an order number.');
  }

  $order = uc_order_load($order_id);

  if (!$order) {
    return t('The requested order could not be found.');
  }

  // 不要因为用户回到了这个页面,就直接把订单标记为已付款。
  return t('Your payment is being verified. Order number: @order_id', array(
    '@order_id' => $order_id,
  ));
}

支付结果应主要在异步通知接口中处理:

function uc_mymerchant_notify() {
  $response = $_POST;

  if (!uc_mymerchant_verify_response($response)) {
    watchdog(
      'uc_mymerchant',
      'Rejected an invalid payment notification.',
      array(),
      WATCHDOG_WARNING
    );

    drupal_add_http_header('Status', '400 Bad Request');
    print 'INVALID';
    drupal_exit();
  }

  $order_id = isset($response['order_id'])
    ? $response['order_id']
    : NULL;

  $order = $order_id ? uc_order_load($order_id) : FALSE;

  if (!$order) {
    drupal_add_http_header('Status', '404 Not Found');
    print 'ORDER_NOT_FOUND';
    drupal_exit();
  }

  // 字段名和成功状态值必须以 merchant 文档为准。
  if ($response['status'] === 'SUCCESS') {
    // 还必须核对金额、币种、merchant ID 和交易号。
    // 通过当前 Ubercart 版本提供的订单及付款 API 记录付款,
    // 然后将订单推进到适当状态。
  }

  // 返回内容必须符合 merchant 的通知确认协议。
  print 'OK';
  drupal_exit();
}

验证函数也必须遵循 merchant 规定的规则:

function uc_mymerchant_verify_response(array $response) {
  if (empty($response['signature'])) {
    return FALSE;
  }

  $received_signature = $response['signature'];
  unset($response['signature']);

  $secret_key = variable_get('uc_mymerchant_secret_key', '');
  $expected_signature = uc_mymerchant_create_signature(
    $response,
    $secret_key
  );

  return hash_equals($expected_signature, $received_signature);
}

如果项目使用的 PHP 版本不支持 hash_equals(),需要自行实现可防止时序攻击的安全比较函数。普通字符串比较不能直接视为同等安全。

订单状态的处理原则

用户点击“提交订单”,只能说明订单已经创建,不能证明款项已经到账。建议按以下顺序变更状态:

checkout 完成
    ↓
订单创建并设为 Pending payment
    ↓
提交表单到 merchant
    ↓
merchant 返回异步通知
    ↓
验证签名、订单号、金额、币种和交易状态
    ↓
记录交易并将订单更新为已付款

不要仅凭以下信息更新订单:

  • 浏览器跳回了成功页面
  • 请求中存在 status=SUCCESS
  • 用户看到了 merchant 的支付成功提示
  • HTTP 请求来源看起来是 merchant 域名
  • 前端 JavaScript 报告支付成功

这些信息可能被伪造,也可能因网络中断而不完整。

处理重复通知

支付平台通常会重试异步通知,因此需要保存 merchant 返回的唯一交易号,避免同一笔交易被重复入账:

$transaction_id = $response['transaction_id'];

处理通知前,应先检查该交易号是否已经记录。如果已经处理过,仍应向 merchant 返回成功确认,但不要再次增加付款记录或变更订单状态。

后台配置与上线检查

模块完成后,需要清理 Drupal 缓存,再到 Ubercart 的支付方式配置页面启用该支付方式。具体后台路径会因 Ubercart 版本和管理菜单配置而异。

上线前至少检查以下事项:

  • 测试环境和生产环境的 URL 分开配置
  • 密钥没有写死在公开代码仓库中
  • 所有返回地址和通知地址都使用 HTTPS
  • merchant 可以从公网访问异步通知地址
  • 通知接口不依赖用户登录状态或浏览器 Session
  • 金额和币种与原订单完全一致
  • 客户端不能任意覆盖订单号
  • 支付失败、取消或超时不会把订单标记为已付款
  • 重复通知不会造成重复记账
  • 已付款订单不会被之后到达的失败通知降级
  • 日志不会记录银行卡号、CVV、完整密钥等敏感数据

需要实现的不是“checkout 后跳转到某个 URL”,而是一套完整的站外支付流程,包括注册支付方式、提交 merchant 要求的表单、验证服务端支付通知,以及安全地更新 Ubercart 订单。

备注:内容仅供参考。