如何在 Ubercart 中集成第三方 merchant payment gateway?
结论
不要把支付地址当作 checkout 完成后的普通跳转地址。应为 Ubercart 编写一个“站外支付”模块,把该 merchant 注册为支付方式,并在订单创建后生成一个 POST 表单,提交到 merchant 的支付页面。
完整流程如下:
-
Ubercart 创建订单,并将状态设为待付款。
-
重定向回调生成表单,其中包含订单号、金额、币种、返回地址和签名等字段。
-
浏览器把表单提交到:
https://test.MyMerchantGateway.com/ECN/eng/payment/payForm.jsp -
merchant 完成支付后,将浏览器带回网站。
-
网站通过服务端通知或查询接口核实支付结果。
-
确认支付成功后,再把订单更新为已付款。
如果只使用 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 订单。
备注:内容仅供参考。
