Compare commits

..

3 Commits

Author SHA1 Message Date
tikkhun 4727881e05 Merge branch 'main' of https://git.yoone.ca/zksu/yoone-moneris-payments 2025-11-05 15:53:45 +08:00
tikkhun 73dc9ca3c3 feat(moneris): 添加唯一订单ID生成和自动重试逻辑
实现唯一订单ID生成函数以避免Moneris主机拒绝重复订单
在API请求失败时自动重试一次并生成新订单ID
增加请求/响应日志记录和敏感信息脱敏处理
2025-11-05 15:53:20 +08:00
tikkhun a9acdacf80 feat(moneris): 添加详细日志记录以增强支付流程调试能力
在支付流程关键节点添加错误日志记录,包括卡片字段验证、API请求响应、令牌创建和支付处理等环节
优化IDE类型提示支持,新增支付方法类型占位文件
改进支付数据处理逻辑,优先使用Blocks/Store API提供的数据
2025-11-05 14:10:42 +08:00
4 changed files with 164 additions and 28 deletions

View File

@ -19,6 +19,7 @@ function yoone_moneris_store_payment_data($order, $result, $request) {
} elseif (is_array($result) && isset($result['payment_method'])) {
$pm = (string) $result['payment_method'];
}
error_log('【Yoone Moneris Blocks】订单处理 hookpayment_method=' . ($pm ?: '未提供'));
if ('yoone_moneris' !== $pm) {
return;
}
@ -34,9 +35,13 @@ function yoone_moneris_store_payment_data($order, $result, $request) {
} else {
$payload = is_array($data) ? $data : array();
}
error_log('【Yoone Moneris Blocks】解析 payment_method_data' . print_r($payload, true));
if (! empty($payload)) {
$order->update_meta_data('_yoone_moneris_pm_data', $payload);
$order->save();
error_log('【Yoone Moneris Blocks】已保存 Blocks 支付数据到订单 _yoone_moneris_pm_data');
} else {
error_log('【Yoone Moneris Blocks】未收到有效的 payment_method_data');
}
}

View File

@ -296,8 +296,10 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
// 新卡:需要确保卡片字段完整
$card = $this->get_posted_card();
error_log('【Yoone Moneris】validate_fields 读取到卡片字段:' . print_r($card, true));
if (empty($card['number']) || empty($card['exp_month']) || empty($card['exp_year']) || empty($card['cvc'])) {
wc_add_notice(__('请填写完整的银行卡信息。' . $card, 'yoone-moneris'), 'error');
wc_add_notice(__('请填写完整的银行卡信息。', 'yoone-moneris'), 'error');
error_log('【Yoone Moneris】validate_fields 缺少字段number=' . (empty($card['number']) ? '空' : '已填') . ' exp_month=' . (empty($card['exp_month']) ? '空' : $card['exp_month']) . ' exp_year=' . (empty($card['exp_year']) ? '空' : $card['exp_year']) . ' cvc=' . (empty($card['cvc']) ? '空' : '已填'));
return false;
}
return true;
@ -329,9 +331,13 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
*/
protected function get_posted_card()
{
// 记录原始支付方式选择
$raw_pm = isset($_POST['payment_method']) ? wc_clean(wp_unslash($_POST['payment_method'])) : '';
error_log('【Yoone Moneris】get_posted_card payment_method=' . ($raw_pm ?: '未提交') . ' 当前网关=' . $this->id);
// 如果当前提交并非选择我们网关,直接返回空卡数据,避免误读其他网关字段
$pm = isset($_POST['payment_method']) ? wc_clean(wp_unslash($_POST['payment_method'])) : '';
$pm = $raw_pm;
if ($pm && $pm !== $this->id) {
error_log('【Yoone Moneris】get_posted_card 非本网关提交,返回空卡数据');
return array('number' => '', 'exp_month' => '', 'exp_year' => '', 'cvc' => '');
}
@ -377,6 +383,8 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
'card_cvc',
));
error_log('【Yoone Moneris】get_posted_card 原始读取number=' . (isset($number) ? ('len=' . strlen((string)$number)) : '未读') . ' expiry_raw=' . ($expiry_raw ?: '空') . ' cvc=' . ($cvc ? ('len=' . strlen((string)$cvc)) : '空'));
// 如果没有组合有效期,尝试读取分拆的 月/年 字段
$exp_month = $read_post(array(
$this->id . '-exp-month',
@ -396,6 +404,7 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
'card-expiry-year',
'expiry_year',
));
error_log('【Yoone Moneris】get_posted_card 原始读取exp_month=' . ($exp_month ?: '空') . ' exp_year=' . ($exp_year ?: '空'));
// 规范化:仅保留数字
$number = preg_replace('/\D+/', '', (string) $number);
@ -420,6 +429,9 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
$exp_year = '20' . $exp_year;
}
$masked = $number ? str_repeat('*', max(strlen($number) - 4, 0)) . substr($number, -4) : '';
error_log('【Yoone Moneris】get_posted_card 规范化card=' . ($masked ?: '空') . ' len=' . strlen($number) . ' exp_month=' . ($exp_month ?: '空') . ' exp_year=' . ($exp_year ?: '空') . ' cvc_len=' . ($cvc ? strlen($cvc) : 0));
return array(
'number' => $number,
'exp_month' => $exp_month,
@ -441,10 +453,15 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
// 当用户选择“已保存的卡”,此字段会传递一个 token 的 ID当选择“使用新卡”该字段值为 'new'。
// 注意:此字段由 Woo 默认信用卡表单生成,适用于经典结账;如果使用 Blocks 结账,需要对应 Blocks 集成来产生等效数据。
$field = 'wc-' . $this->id . '-payment-token';
if (isset($_POST[$field])) {
$posted = wc_clean(wp_unslash($_POST[$field]));
error_log('【Yoone Moneris】get_selected_token_id 提交字段 ' . $field . ' = ' . $posted);
}
if (isset($_POST[$field]) && 'new' !== $_POST[$field]) {
// 将提交的令牌 ID 转为整数,后续用 WC_Payment_Tokens::get( $id ) 读取具体令牌对象
return absint($_POST[$field]);
}
error_log('【Yoone Moneris】get_selected_token_id 未选择已保存令牌或选择了新卡');
return 0;
}
@ -484,15 +501,29 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
*/
protected function create_wc_token($res, $user_id)
{
error_log('【yoone moneris 】创建 WC 支付令牌tokenize_card 返回结果' . print_r($res, true));
$token = new WC_Payment_Token_CC();
// 兼容 data_key 命名
if (empty($res['token']) && ! empty($res['data_key'])) {
$res['token'] = $res['data_key'];
error_log('【Yoone Moneris】create_wc_token 使用 data_key 作为 token');
}
$token->set_token($res['token']);
$token->set_gateway_id($this->id);
$token->set_user_id($user_id);
$token->set_last4(substr(preg_replace('/\D+/', '', (string) $res['last4'] ?? ''), -4) ?: '0000');
$token->set_expiry_month(preg_replace('/\D+/', '', (string) ($res['exp_month'] ?? '')) ?: '01');
$token->set_expiry_year(preg_replace('/\D+/', '', (string) ($res['exp_year'] ?? '')) ?: (date('Y') + 1));
// 设置卡类型(若有),避免 Woo 对必填字段校验失败
if (! empty($res['brand'])) {
$token->set_card_type((string) $res['brand']);
}
$token->save();
return $token->get_id();
$tid = $token->get_id();
if (! $tid) {
error_log('【Yoone Moneris】create_wc_token 保存失败WC_Token 未生成 ID检查必填字段。');
}
return $tid;
}
/**
@ -504,17 +535,22 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
*/
public function process_payment($order_id)
{
error_log('【Yoone Moneris】process_payment 开始order_id=' . $order_id);
$order = wc_get_order($order_id);
if (! $order) {
error_log('【Yoone Moneris】无法获取订单对象终止');
return array('result' => 'fail');
}
$user_id = $order->get_user_id();
// 支持 Blocks若从 Store API 捕获到支付数据,优先使用该数据
$blocks_data = is_object($order) && method_exists($order, 'get_meta') ? (array) $order->get_meta('_yoone_moneris_pm_data') : array();
$pm_meta = $order->get_meta('_yoone_moneris_pm_data', true);
if (! empty($pm_meta)) {
error_log('【Yoone Moneris】订单存在 Blocks/Store API 支付数据 _yoone_moneris_pm_data=' . print_r($pm_meta, true));
} else {
error_log('【Yoone Moneris】订单未找到 _yoone_moneris_pm_data 元数据');
}
// 选择已保存的令牌或创建新令牌
$selected_token_id = $this->get_selected_token_id();
if (! $selected_token_id && ! empty($blocks_data['saved_token_id']) && 'new' !== $blocks_data['saved_token_id']) {
$selected_token_id = absint($blocks_data['saved_token_id']);
}
$token_string = '';
if ($selected_token_id) {
@ -527,31 +563,40 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
}
} else {
// 创建新令牌(占位)
$card = $this->get_posted_card();
// 若 Blocks 提供了卡片数据,使用其值覆盖经典表单读取结果
if (! empty($blocks_data)) {
$normalize = function($v){ return preg_replace('/\D+/', '', (string) $v); };
$number = isset($blocks_data['number']) ? $normalize($blocks_data['number']) : '';
$cvc = isset($blocks_data['cvc']) ? $normalize($blocks_data['cvc']) : '';
$exp_month = isset($blocks_data['exp_month']) ? $normalize($blocks_data['exp_month']) : '';
$exp_year = isset($blocks_data['exp_year']) ? $normalize($blocks_data['exp_year']) : '';
if (strlen($exp_year) === 2) { $exp_year = '20' . $exp_year; }
// 优先使用 Blocks/Store API 保存到订单的支付数据
$card = array();
if (is_array($pm_meta) && ! empty($pm_meta)) {
$card = array(
'number' => $number ?: $card['number'],
'exp_month' => $exp_month ?: $card['exp_month'],
'exp_year' => $exp_year ?: $card['exp_year'],
'cvc' => $cvc ?: $card['cvc'],
'number' => preg_replace('/\D+/', '', (string) ($pm_meta['number'] ?? '')),
'exp_month' => preg_replace('/\D+/', '', (string) ($pm_meta['exp_month'] ?? '')),
'exp_year' => preg_replace('/\D+/', '', (string) ($pm_meta['exp_year'] ?? '')),
'cvc' => preg_replace('/\D+/', '', (string) ($pm_meta['cvc'] ?? '')),
);
error_log('【Yoone Moneris】使用 Blocks/Store API 数据作为卡片来源:' . print_r($card, true));
}
if (empty($card['number']) || empty($card['exp_month']) || empty($card['exp_year']) || empty($card['cvc'])) {
// 退回到经典结账表单的 POST 字段
$card = $this->get_posted_card();
error_log('【Yoone Moneris】使用经典结账 POST 字段作为卡片来源:' . print_r($card, true));
}
$res = $this->api()->tokenize_card($card);
error_log('【Yoone Moneris】tokenize_card 返回=' . print_r($res, true));
if (empty($res['success'])) {
wc_add_notice(__('令牌化失败:', 'yoone-moneris') . $res['error'], 'error');
error_log('【Yoone Moneris】令牌化失败' . ($res['error'] ?? '未知错误'));
return array('result' => 'fail');
}
// 兼容 data_key 命名
if (empty($res['token']) && ! empty($res['data_key'])) {
$res['token'] = $res['data_key'];
error_log('【Yoone Moneris】tokenize_card 返回使用 data_key 作为 token');
}
$token_string = $res['token'];
error_log('【Yoone Moneris】新令牌创建成功token_len=' . strlen($token_string) . ' last4=' . ($res['last4'] ?? ''));
// 保存到用户
if ($user_id) {
$wc_token_id = $this->create_wc_token($res + ['exp_month' => $card['exp_month'], 'exp_year' => $card['exp_year']], $user_id);
error_log('【Yoone Moneris】已保存令牌到用户wc_token_id=' . $wc_token_id . ' user_id=' . $user_id);
$order->add_payment_token($wc_token_id);
}
}
@ -559,15 +604,19 @@ class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
// 首笔扣款
$amount = $order->get_total();
$currency = $order->get_currency();
error_log('【Yoone Moneris】准备扣款amount=' . $amount . ' currency=' . $currency . ' order_id=' . $order_id);
$charge = $this->api()->charge_token($token_string, $amount, $currency, $order_id);
error_log('【Yoone Moneris】charge_token 返回=' . print_r($charge, true));
if (empty($charge['success'])) {
wc_add_notice(__('支付失败:', 'yoone-moneris') . ($charge['error'] ?? ''), 'error');
error_log('【Yoone Moneris】支付失败' . ($charge['error'] ?? '未知错误'));
return array('result' => 'fail');
}
// 标记订单已支付
$order->payment_complete($charge['transaction_id'] ?? '');
$order->add_order_note(sprintf('Moneris 首付成功,交易号:%s', $charge['transaction_id'] ?? 'N/A'));
error_log('【Yoone Moneris】支付成功transaction_id=' . ($charge['transaction_id'] ?? ''));
return array(
'result' => 'success',

View File

@ -49,7 +49,7 @@ class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
$this->http_method = isset( $config['http_method'] ) ? strtoupper( (string) $config['http_method'] ) : $this->http_method;
$this->timeout = isset( $config['timeout'] ) ? absint( $config['timeout'] ) : $this->timeout;
}
yoone_moneris_log_debug( 'Moneris API 配置', array(
error_log( 'Moneris API 配置' . print_r( array(
'store_id' => $this->store_id,
'sandbox' => $this->sandbox,
'country_code' => $this->country_code,
@ -61,7 +61,7 @@ class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
'path_override'=> $this->path_override,
'http_method' => $this->http_method,
'timeout' => $this->timeout,
) );
) ,true));
}
/**
@ -88,7 +88,11 @@ class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
'cvdValue' => preg_replace( '/\D+/', '', (string) $card['cvc'] ),
);
}
$num = isset($card['number']) ? preg_replace('/\D+/', '', (string) $card['number']) : '';
$masked = $num ? str_repeat('*', max(strlen($num) - 4, 0)) . substr($num, -4) : '';
error_log('【Yoone Moneris API】tokenize_card 请求last4=' . ($num ? substr($num, -4) : '') . ' len=' . strlen($num) . ' expdate=' . $payload['expdate'] . ' cryptType=' . $payload['cryptType']);
$res = $this->send_moneris_xml( 'res_add_cc', $payload );
error_log('$res11111111'. print_r($res, true));
if ( $res['ok'] ) {
return array(
'success' => true,
@ -117,21 +121,51 @@ class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
if ( empty( $this->store_id ) || empty( $this->api_token ) ) {
return array( 'success' => false, 'error' => 'Missing Moneris credentials' );
}
// 为避免重复 orderId 导致主机拒绝,生成唯一订单 ID
if ( ! function_exists( 'yoone_moneris_unique_order_id' ) ) {
require_once __DIR__ . '/utils/orderid.php';
}
$unique_order_id = yoone_moneris_unique_order_id( $order_id );
$type = $capture ? 'res_purchase_cc' : 'res_preauth_cc';
$payload = array(
'dataKey' => (string) $token,
'orderId' => (string) $order_id,
'orderId' => (string) $unique_order_id,
'amount' => $this->format_amount( $amount ),
'cryptType' => $this->crypt_type,
);
error_log('【Yoone Moneris API】charge_token 请求type=' . $type . ' token_len=' . strlen($token) . ' amount=' . $payload['amount'] . ' currency=' . $currency . ' orderId=' . $unique_order_id . ' (源Woo订单ID=' . $order_id . ')');
$res = $this->send_moneris_xml( $type, $payload );
if ( $res['ok'] ) {
return array(
'success' => true,
'transaction_id' => (string) ( $res['receipt']['txnNumber'] ?? '' ),
'order_id_used' => $unique_order_id,
'retry' => false,
);
}
return array( 'success' => false, 'error' => (string) $res['error'] );
// 如果出现系统错误,尝试一次自动重试(更换新的唯一 orderId
$err_msg = isset($res['error']) ? (string)$res['error'] : '';
$is_system_error = stripos($err_msg, 'SYSTEM ERROR') !== false;
if ( $is_system_error ) {
$retry_order_id = yoone_moneris_unique_order_id( $order_id );
$payload['orderId'] = (string) $retry_order_id;
error_log('【Yoone Moneris API】检测到 SYSTEM ERROR准备重试一次。新的 orderId=' . $retry_order_id);
// 短暂延时,避免立即重复错误
usleep(300 * 1000);
$res_retry = $this->send_moneris_xml( $type, $payload );
if ( $res_retry['ok'] ) {
return array(
'success' => true,
'transaction_id' => (string) ( $res_retry['receipt']['txnNumber'] ?? '' ),
'order_id_used' => $retry_order_id,
'retry' => true,
);
}
// 记录重试失败
error_log('【Yoone Moneris API】重试仍失败' . (string) ($res_retry['error'] ?? 'Unknown'));
return array( 'success' => false, 'error' => (string) ($res_retry['error'] ?? $err_msg) );
}
return array( 'success' => false, 'error' => (string) $err_msg );
}
/**
@ -176,6 +210,7 @@ class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
if ( ! $endpoint ) {
return array( 'ok' => false, 'error' => 'Endpoint not configured' );
}
error_log('【Yoone Moneris API】发送 XMLtype=' . $type . ' endpoint=' . $endpoint);
// 构建请求体
$body = array(
@ -189,6 +224,13 @@ class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
$xml = $this->array_to_moneris_xml( array( 'request' => $body ) );
// 日志:打印部分已脱敏的 XML 以便排查格式问题(掩码 pan、data_key、api_token
$xml_log = $xml;
$xml_log = preg_replace('/<pan>[^<]*<\/pan>/', '<pan>****</pan>', $xml_log);
$xml_log = preg_replace('/<data_key>[^<]*<\/data_key>/', '<data_key>****</data_key>', $xml_log);
$xml_log = preg_replace('/<api_token>[^<]*<\/api_token>/', '<api_token>****</api_token>', $xml_log);
error_log('【Yoone Moneris API】请求 XML脱敏前 400 字符:' . substr($xml_log, 0, 400));
$args = array(
'method' => $this->http_method ?: 'POST',
'timeout' => $this->timeout,
@ -203,26 +245,33 @@ class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
// 仅支持 POST
$resp = wp_remote_post( $endpoint, $args );
if ( is_wp_error( $resp ) ) {
error_log('【Yoone Moneris API】HTTP 错误:' . $resp->get_error_message());
return array( 'ok' => false, 'error' => $resp->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $resp );
$raw = wp_remote_retrieve_body( $resp );
if ( $code < 200 || $code >= 300 ) {
error_log('【Yoone Moneris API】HTTP 非 2xxcode=' . $code . ' body=' . substr($raw, 0, 200));
return array( 'ok' => false, 'error' => 'HTTP ' . $code . ' ' . $raw );
}
// 解析 XML
$parsed = $this->parse_moneris_xml( $raw );
// 打印部分响应原文,便于调试
error_log('【Yoone Moneris API】响应原文前 300 字符:' . substr($raw, 0, 300));
if ( ! $parsed['parsed'] ) {
error_log('【Yoone Moneris API】XML 解析失败:' . ($parsed['error'] ?? 'Unknown'));
return array( 'ok' => false, 'error' => 'Parse error: ' . ( $parsed['error'] ?? 'Unknown' ) );
}
$receipt = $parsed['receipt'];
$approved = $this->is_approved( $receipt );
if ( ! $approved ) {
$message = isset( $receipt['message'] ) ? $receipt['message'] : ( $receipt['Message'] ?? 'Declined' );
error_log('【Yoone Moneris API】交易未批准ResponseCode=' . ($receipt['responseCode'] ?? $receipt['response_code'] ?? '') . ' message=' . $message);
return array( 'ok' => false, 'error' => $message );
}
error_log('【Yoone Moneris API】交易批准ResponseCode=' . ($receipt['responseCode'] ?? $receipt['response_code'] ?? '') . ' txnNumber=' . ($receipt['txnNumber'] ?? $receipt['txn_number'] ?? ''));
error_log('receipt'. print_r($receipt,true));
return array( 'ok' => true, 'receipt' => $receipt );
}

View File

@ -0,0 +1,33 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* 生成用于 Moneris 的唯一订单 ID避免重复 orderId 导致主机拒绝)。
*
* 规则:<原始订单ID>-<毫秒时间戳>-<四位随机数>
* 示例1234-1730835123456-4821
*
* 注意Moneris orderId 的长度是有限制的(通常不超过 50 字符)。本实现控制在合理长度范围内。
* 如需自定义格式,可在此函数中调整。
*
* @param int|string $order_id WooCommerce 订单 ID
* @return string 唯一订单 ID 字符串
*/
function yoone_moneris_unique_order_id( $order_id ) {
// 毫秒级时间戳
$ms = (int) floor( microtime( true ) * 1000 );
// 四位随机数
$rand = mt_rand( 1000, 9999 );
// 仅保留数字和字母,避免特殊字符
$base = preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $order_id );
// 拼接唯一后缀
$unique = $base . '-' . $ms . '-' . $rand;
// 限长保护(最多 50 字符)
if ( strlen( $unique ) > 50 ) {
// 若过长,截断前半部分(保留后缀)
$unique = substr( $base, 0, max( 1, 50 - ( 1 + strlen( (string) $ms ) + 1 + 4 ) ) ) . '-' . $ms . '-' . $rand;
}
return $unique;
}