如何在 Drupal module 中集成 2c2p payment api?
结论
9002 : Payment authorization failed 通常表示 2C2P 无法验证商户身份或请求签名,不一定是银行卡授权失败。从这段代码来看,Drupal 不像是首要问题,建议先检查以下几项:
merchant_id、secret_key和支付 URL 是否属于同一环境。version = 6.7是否与当前 Redirect API 端点及商户配置一致。- 签名字段、字段顺序和空字段的处理是否完全符合该版本文档。
secret_key是否按文档指定的格式参与 HMAC 运算。- 请求是否包含不适用于当前交易的 recurring、promotion 等参数。
- 浏览器最终提交的值是否与计算签名时使用的值完全一致。
如果接口版本明确要求 HMAC-SHA1,并要求输出大写十六进制,那么 strtoupper(hash_hmac('sha1', ...)) 的写法没有问题。不过,不能根据旧示例断定 6.7 一定采用这套规则,仍需核对当前 endpoint 对应的参数表和签名规范。
首先检查环境配置
测试环境的 Merchant ID 和 Secret Key 必须提交到测试环境 URL,生产凭据则必须提交到生产环境。只要混用了环境,就可能返回 9002。
不要直接覆盖从后台读取的版本:
$version = $controller_data['msp_2c2p_version'];
$version = '6.7';
这样会让后台配置失效,还可能出现请求地址对应一个版本,签名却按另一个版本生成的情况。可以改为:
$version = trim($controller_data['msp_2c2p_version']);
$merchant_id = trim($controller_data['msp_2c2p_id']);
$secret_key = trim($controller_data['msp_2c2p_secret']);
$url = trim($controller_data['msp_2c2p_mode']);
还要确认 Secret Key 中没有复制时带入的空格或换行。
签名必须由最终请求数据生成
目前,签名字符串和 $data 分别由手工维护。以后修改参数时,如果只改了一处,签名就会与请求数据不一致。
更稳妥的做法是先确定最终提交的数据,再按文档规定的字段顺序生成签名:
$data = array(
'version' => $version,
'merchant_id' => $merchant_id,
'payment_description' => $payment_description,
'order_id' => $order_id,
'invoice_no' => $invoice_no,
'currency' => $currency,
'amount' => $amount,
'customer_email' => $customer_email,
'pay_category_id' => $pay_category_id,
'promotion' => $promotion,
'user_defined_1' => $user_defined_1,
'user_defined_2' => $user_defined_2,
'user_defined_3' => $user_defined_3,
'user_defined_4' => $user_defined_4,
'user_defined_5' => $user_defined_5,
'result_url_1' => $result_url_1,
'result_url_2' => $result_url_2,
'enable_store_card' => $enable_store_card,
'stored_card_unique_id' => $stored_card_unique_id,
'pan_masked' => $pan_masked,
'request_3ds' => $request_3ds,
'recurring' => $recurring,
'order_prefix' => $order_prefix,
'recurring_amount' => $recurring_amount,
'allow_accumulate' => $allow_accumulate,
'max_accumulate_amount' => $max_accumulate_amount,
'recurring_interval' => $recurring_interval,
'recurring_count' => $recurring_count,
'charge_next_date' => $charge_next_date,
'charge_on_date' => $charge_on_date,
'payment_option' => $payment_option,
'ipp_interest_type' => $ipp_interest_type,
'payment_expiry' => $payment_expiry,
'default_lang' => $default_lang,
'statement_descriptor' => $statement_descriptor,
);
然后明确列出签名字段的顺序:
$signature_fields = array(
'version',
'merchant_id',
'payment_description',
'order_id',
'invoice_no',
'currency',
'amount',
'customer_email',
'pay_category_id',
'promotion',
'user_defined_1',
'user_defined_2',
'user_defined_3',
'user_defined_4',
'user_defined_5',
'result_url_1',
'result_url_2',
'enable_store_card',
'stored_card_unique_id',
'pan_masked',
'request_3ds',
'recurring',
'order_prefix',
'recurring_amount',
'allow_accumulate',
'max_accumulate_amount',
'recurring_interval',
'recurring_count',
'charge_next_date',
'charge_on_date',
'payment_option',
'ipp_interest_type',
'payment_expiry',
'default_lang',
'statement_descriptor',
);
$signature_values = array();
foreach ($signature_fields as $field) {
$signature_values[] = isset($data[$field])
? (string) $data[$field]
: '';
}
$signature_string = implode('', $signature_values);
$data['hash_value'] = strtoupper(
hash_hmac('sha1', $signature_string, $secret_key, FALSE)
);
这里的字段清单只是根据问题中的 6.7 实现整理出来的。使用前还要与实际版本的官方文档逐项核对,不能混入其他版本的字段或字段顺序。
空参数不能随意过滤
当前表单使用了:
if (!empty($value)) {
empty() 会把下面这些值都视为空:
''
'0'
0
NULL
FALSE
这种处理不适合直接用于支付接口,因为 "0" 也可能是合法且必须提交的值。至少应改成严格判断:
if ($value !== '' && $value !== NULL) {
$form[$name] = array(
'#type' => 'hidden',
'#value' => (string) $value,
);
}
不过,还要先确认签名规范如何处理空参数。有些规范要求空参数保留签名位置,但允许 HTTP 请求省略空参数;另一些规范只签署实际发送的字段。这两种规则不能混用,具体做法要以对应版本的文档为准。
调试时可以暂时提交所有签名字段,包括空字符串,以确认问题是否由省略字段引起:
foreach ($data as $name => $value) {
$form[$name] = array(
'#type' => 'hidden',
'#value' => (string) $value,
);
}
如果接口文档明确规定不能发送空字段,再按参数规则移除。
不要发送与交易不匹配的参数
代码中设置了:
$recurring = 'N';
但请求仍然发送了:
$recurring_amount = '000000199900';
$allow_accumulate = 'N';
$max_accumulate_amount = '000000199900';
这些参数与普通的一次性支付无关,而且 recurring amount 与实际 amount 不一致。排查时可以先把请求缩减到最小可用参数集合。普通支付成功后,再逐项加入 promotion、recurring、stored card、IPP 等功能。
例如:
$promotion = '';
$recurring = 'N';
$order_prefix = '';
$recurring_amount = '';
$allow_accumulate = '';
$max_accumulate_amount = '';
$recurring_interval = '';
$recurring_count = '';
$charge_next_date = '';
$charge_on_date = '';
$stored_card_unique_id = '';
$pan_masked = '';
只有当该 Merchant ID 已配置并支持 PROMO_VC 时,才能发送这个 promotion code。排查 9002 时,先将它移除。
检查金额及业务字段格式
下面的金额:
$amount = '000000000100';
通常表示最小货币单位中的 100。对于 USD,一般相当于 1.00 USD。但具体长度、补零规则和小数位仍要以当前接口版本为准。
支付金额不要直接使用浮点数计算:
$amount += (1 + $line_item->tax_rate)
* $line_item->amount
* $line_item->quantity;
浮点运算可能产生精度误差。应该先将金额转换为最小货币单位,再格式化成接口要求的固定长度字符串。例如,在确认金额使用两位小数且接口要求 12 位数字后,可以这样处理:
$minor_amount = 100; // 1.00 USD
$amount = str_pad((string) $minor_amount, 12, '0', STR_PAD_LEFT);
还要逐项核对:
merchant_id的长度和字符集;order_id、invoice_no是否符合长度限制;invoice_no是否需要在商户范围内保持唯一;- 测试商户是否已开通
currency = 840; statement_descriptor的长度是否符合要求;result_url_1、result_url_2是否为公网可访问的绝对 URL。
Drupal form 的影响
Drupal Form API 可以把表单直接 POST 到外部地址,但需要明确设置提交方式:
$form['#method'] = 'post';
$form['#action'] = $url;
$form['#attributes']['accept-charset'] = 'UTF-8';
完整的构建方式可以写成:
$form['#method'] = 'post';
$form['#action'] = $url;
$form['#attributes']['accept-charset'] = 'UTF-8';
foreach ($data as $name => $value) {
$form[$name] = array(
'#type' => 'hidden',
'#value' => (string) $value,
);
}
$form['actions'] = array('#type' => 'actions');
$form['actions']['submit'] = array(
'#type' => 'submit',
'#value' => t('Pay with 2c2p'),
);
return $form;
Drupal 可能额外生成 form_id、form_build_id、form_token 和提交按钮字段。多数支付端点会忽略未知字段,但不能默认它一定如此。应在浏览器开发者工具的 Network 面板中查看最终的 POST payload,并确认:
- 字段名称没有变化;
- 字段值中没有多余空格;
- URL 与计算签名时使用的值完全一致;
amount的前导零没有丢失;hash_value是预期的大写十六进制字符串;- 数据没有被 Drupal submit handler 修改或重新构建。
推荐的排查顺序
先只提交文档规定的最小必填字段,使用测试商户的固定金额和全新的 invoice number。然后记录以下内容:
watchdog('msp_2c2p', 'Signature source: @source', array(
'@source' => $signature_string,
), WATCHDOG_DEBUG);
watchdog('msp_2c2p', 'Hash: @hash', array(
'@hash' => $data['hash_value'],
), WATCHDOG_DEBUG);
不要把 $secret_key 写入日志。
接下来,使用 2C2P 提供的签名示例或校验工具进行对比:
- 原始签名字符串是否逐字节一致;
- Secret Key 使用的是普通文本、十六进制值还是其他编码;
- HMAC 算法是否确实为 SHA-1;
- 输出格式是大写 hex、小写 hex 还是 Base64;
- 字段顺序是否与当前 API 版本一致。
如果最小请求仍返回 9002,而本地 hash 与 2C2P 官方示例完全一致,问题通常集中在 Merchant ID、Secret Key、环境 URL、接口版本或商户权限配置上。此时需要请 2C2P 支持方根据 Merchant ID 和请求时间检查服务端日志。
备注:内容仅供参考。
