fengketrade/addons/shopro/library/ccblife/CcbPaymentService.php
2025-10-27 16:31:36 +08:00

965 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace addons\shopro\library\ccblife;
use app\admin\model\shopro\order\Order;
use think\Db;
use think\Log;
/**
* 建行生活支付服务类
* 处理支付串生成、支付回调、支付验证等业务
*/
class CcbPaymentService
{
/**
* 配置信息
*/
private $config;
/**
* 订单服务实例
*/
private $orderService;
/**
* 构造函数
*/
public function __construct()
{
// 加载插件配置文件
$configFile = __DIR__ . '/../../config/ccblife.php';
if (file_exists($configFile)) {
$this->config = include $configFile;
} else {
throw new \Exception('建行生活配置文件不存在');
}
// ✅ 修复: 删除processPemKeys()调用
// 密钥格式化统一由CcbRSA类处理避免重复格式化导致OpenSSL ASN1解析错误
// CcbRSA::formatPublicKey/formatPrivateKey 会在加密/解密时自动处理密钥格式
$this->orderService = new CcbOrderService();
}
/**
* 生成建行支付串
* 用于前端JSBridge调用建行收银台
*
* ⚠️ 注意必须包含所有必需参数签名前按ASCII排序
*
* @param int $orderId Shopro订单ID
* @param string $payFlowId 支付流水号(由控制器统一生成)
* @return array ['status' => bool, 'message' => string, 'data' => array]
*/
public function generatePaymentString($orderId, $payFlowId)
{
// ⚠️ 开启事务保护,确保数据一致性
Db::startTrans();
try {
// 获取订单信息
$order = Order::find($orderId);
if (!$order) {
throw new \Exception('订单不存在');
}
// 检查订单状态
if ($order['status'] != 'unpaid') {
throw new \Exception('订单状态不正确');
}
// 获取用户建行生活ID用于订单推送
$user = Db::name('user')->where('id', $order['user_id'])
->field('ccb_user_id')
->find();
if (empty($user['ccb_user_id'])) {
throw new \Exception('用户未绑定建行生活账号');
}
// ✅ 使用控制器传入的统一支付流水号(确保与订单推送使用同一流水号)
if (empty($payFlowId)) {
throw new \Exception('支付流水号不能为空');
}
// ⚠️ 关键建行要求参数按照文档表格定义的顺序拼接不是ASCII排序
// 根据建行文档4.1和4.2,必须严格按照参数表顺序构建签名字符串
// 1. 定义参与MAC签名的参数数组按文档表格顺序
$macParams = [];
// 1.1 商户信息(必填,二选一:建行商户号组合 或 外部平台商户号)
$usePlatMctId = !empty($this->config['plat_mct_id']);
if ($usePlatMctId) {
// 使用外部平台商户号
$macParams['PLATMCTID'] = $this->config['plat_mct_id'];
} else {
// 使用建行商户号组合
$macParams['MERCHANTID'] = $this->config['merchant_id'];
$macParams['POSID'] = $this->config['pos_id'];
$macParams['BRANCHID'] = $this->config['branch_id'];
}
// 1.2 订单信息(必填)
$macParams['ORDERID'] = $payFlowId; // 支付流水号
$macParams['USER_ORDERID'] = $order['order_sn']; // 用户订单号
$payment = number_format($order['pay_fee'], 2, '.', '');
$macParams['PAYMENT'] = $payment; // 支付金额
$macParams['CURCODE'] = '01'; // 币种01=人民币)
$macParams['TXCODE'] = '520100'; // 交易码
$macParams['REMARK1'] = ''; // 备注1空字符串也要传
$macParams['REMARK2'] = $this->config['service_id']; // 备注2服务方编号
$macParams['TYPE'] = '1'; // 接口类型1=防钓鱼)
$macParams['GATEWAY'] = '0'; // 网关类型
$macParams['CLIENTIP'] = ''; // 客户端IP建行生活环境送空
$macParams['REGINFO'] = ''; // 客户注册信息(空字符串)
// 商品信息escape编码
$proinfo = $this->buildProductInfo($order);
$macParams['PROINFO'] = $proinfo;
$macParams['REFERER'] = ''; // 商户URL空字符串
// ⚠️ 关键修复INSTALLNUM必须在THIRDAPPINFO之前严格按照文档4.1表格顺序)
// 1.3 可选参数按文档表格顺序有值才参与MAC
// 注意根据文档4.2橙色字段有值时才参与MAC空值不参与
// 分期期数在REFERER之后THIRDAPPINFO之前
if (!empty($this->config['install_num'])) {
$macParams['INSTALLNUM'] = $this->config['install_num'];
}
$macParams['THIRDAPPINFO'] = 'comccbpay1234567890cloudmerchant'; // 客户端标识(固定值)
// 记录关键参数
Log::info('[建行支付] 关键参数 order_id:' . $orderId . ' pay_flow_id:' . $payFlowId . ' user_orderid:' . $order['order_sn'] . ' payment:' . $payment);
Log::info('[建行支付] 商户信息 merchant_id:' . ($macParams['MERCHANTID'] ?? 'N/A') . ' pos_id:' . ($macParams['POSID'] ?? 'N/A') . ' branch_id:' . ($macParams['BRANCHID'] ?? 'N/A'));
Log::info('[建行支付] 商品信息 proinfo:' . $proinfo);
// 超时时间
if (!empty($this->config['timeout'])) {
$macParams['TIMEOUT'] = $this->config['timeout'];
} else {
// 默认30分钟超时
$macParams['TIMEOUT'] = date('YmdHis', strtotime('+30 minutes'));
}
// 中国建设银行App环境参数
if (!empty($this->config['user_id'])) {
$macParams['USERID'] = $this->config['user_id'];
}
if (!empty($this->config['token'])) {
$macParams['TOKEN'] = $this->config['token'];
}
if (!empty($this->config['pay_success_url'])) {
// ⚠️ 不要在这里urlencode会在构建最终支付串时统一编码
$macParams['PAYSUCCESSURL'] = $this->config['pay_success_url'];
}
// 支付位图和账户位图
if (!empty($this->config['pay_bitmap'])) {
$macParams['PAYBITMAP'] = $this->config['pay_bitmap'];
}
if (!empty($this->config['account_bitmap'])) {
$macParams['ACCOUNTBITMAP'] = $this->config['account_bitmap'];
}
// ✅ 严格按照文档4.1表格顺序添加参数第27-40位
// 积分活动编号第27位
if (!empty($this->config['point_avy_id'])) {
$macParams['POINTAVYID'] = $this->config['point_avy_id'];
}
// 数字人民币收款钱包编号第28位
if (!empty($this->config['dcep_dep_acc_no'])) {
$macParams['DCEPDEPACCNO'] = $this->config['dcep_dep_acc_no'];
}
// 有价券活动编号第29位
if (!empty($this->config['coupon_avy_id'])) {
$macParams['COUPONAVYID'] = $this->config['coupon_avy_id'];
}
// 限制信用卡支付标志第30位
if (!empty($this->config['only_credit_pay_flag'])) {
$macParams['ONLY_CREDIT_PAY_FLAG'] = $this->config['only_credit_pay_flag'];
}
// 固定抵扣积分值第31位
if (!empty($this->config['fixed_point_val'])) {
$macParams['FIXEDPOINTVAL'] = $this->config['fixed_point_val'];
}
// 最小使用积分抵扣限制第32位
if (!empty($this->config['min_point_limit'])) {
$macParams['MINPOINTLIMIT'] = $this->config['min_point_limit'];
}
// 特殊字段中石化专用第33-34位
if (!empty($this->config['identity_code'])) {
$macParams['IDENTITYCODE'] = $this->config['identity_code'];
}
if (!empty($this->config['notify_url'])) {
$macParams['NOTIFY_URL'] = $this->config['notify_url'];
}
// 数字人民币商户类型第35-38位
if (!empty($this->config['dcep_mct_type'])) {
$macParams['DCEP_MCT_TYPE'] = $this->config['dcep_mct_type'];
if ($this->config['dcep_mct_type'] == '2') {
// 非融合商户需要填写数币商户号
if (!empty($this->config['dcep_merchant_id'])) {
$macParams['DCEP_MERCHANTID'] = $this->config['dcep_merchant_id'];
}
if (!empty($this->config['dcep_pos_id'])) {
$macParams['DCEP_POSID'] = $this->config['dcep_pos_id'];
}
if (!empty($this->config['dcep_branch_id'])) {
$macParams['DCEP_BRANCHID'] = $this->config['dcep_branch_id'];
}
}
}
// 二级商户参数
if (!empty($this->config['sub_mct_id'])) {
$macParams['SUB_MCT_ID'] = $this->config['sub_mct_id'];
}
if (!empty($this->config['sub_mct_name'])) {
$macParams['SUB_MCT_NAME'] = $this->config['sub_mct_name'];
}
if (!empty($this->config['sub_mct_mcc'])) {
$macParams['SUB_MCT_MCC'] = $this->config['sub_mct_mcc'];
}
// 扩展域第43位
if (!empty($this->config['extend_params'])) {
// ⚠️ 不要在这里urlencode会在构建最终支付串时统一编码
$macParams['EXTENDPARAMS'] = $this->config['extend_params'];
}
// 2. 手动构建签名字符串不能用http_build_query避免URL编码破坏escape格式
// ⚠️ 关键按照建行文档4.2示例签名字符串不进行URL编码
$signParts = [];
foreach ($macParams as $key => $value) {
$signParts[] = $key . '=' . $value;
}
$signString = implode('&', $signParts);
// 3. 添加PLATFORMPUB参与MD5签名但不作为HTTP参数传递
$platformPubKey = $this->config['public_key']; // 服务方公钥
$macSignString = $signString . '&PLATFORMPUB=' . $platformPubKey;
// 4. 生成MAC签名32位小写MD5
$mac = strtolower(md5($macSignString));
Log::info('[建行支付] MAC签名字符串(前500字符): ' . mb_substr($macSignString, 0, 500));
Log::info('[建行支付] 生成MAC: ' . $mac);
// 5. 构建不参与MAC的参数
$nonMacParams = [];
// 微信支付19位终端号不参与MAC校验
if (!empty($this->config['pos_id_19'])) {
$nonMacParams['POSID19'] = $this->config['pos_id_19'];
}
// 场景编号埋点使用不参与MAC校验
if (!empty($this->config['scn_id'])) {
$nonMacParams['SCNID'] = $this->config['scn_id'];
}
if (!empty($this->config['scn_pltfrm_id'])) {
$nonMacParams['SCN_PLTFRM_ID'] = $this->config['scn_pltfrm_id'];
}
// 6. 生成ENCPUB商户公钥密文不参与MAC校验
$encpub = $this->encryptPublicKeyLast30();
// 7. 组装最终支付串传给建行收银台的URL参数
// ⚠️ 重要修复最终支付串不进行URL编码与MAC签名字符串保持一致
// 原因escape编码的字段PROINFO、REGINFO如果再URL编码会导致MAC验证失败
// 例如:%u6F2B 会被编码成 %25u6F2B与签名时的 %u6F2B 不一致
// 格式参与MAC的参数 + 不参与MAC的参数 + MAC + PLATFORMID + ENCPUB
// 7.1 构建参与MAC的参数部分不URL编码与签名字符串保持一致
$finalParts = [];
foreach ($macParams as $key => $value) {
// ✅ 不进行URL编码保持与MAC签名字符串一致
$finalParts[] = $key . '=' . $value;
}
// 7.2 添加不参与MAC的参数
if (!empty($nonMacParams)) {
foreach ($nonMacParams as $key => $value) {
// ✅ 不URL编码
$finalParts[] = $key . '=' . $value;
}
}
// 7.3 添加MAC、PLATFORMID、ENCPUB
$finalParts[] = 'MAC=' . $mac;
$finalParts[] = 'PLATFORMID=' . $this->config['service_id'];
$finalParts[] = 'ENCPUB=' . $encpub; // ✅ ENCPUB是BASE64不需要额外编码
// 7.4 拼接最终支付串
$finalPaymentString = implode('&', $finalParts);
Log::info('[建行支付] 最终支付串(前500字符): ' . mb_substr($finalPaymentString, 0, 500));
// 保存支付流水号到订单
Order::where('id', $orderId)->update([
'ccb_pay_flow_id' => $payFlowId,
'updatetime' => time()
]);
// 记录支付请求
$this->recordPaymentRequest($orderId, [
'payment_string' => $finalPaymentString,
'params' => $macParams,
'mac' => $mac,
'pay_flow_id' => $payFlowId
]);
// ✅ 提交事务
Db::commit();
return [
'status' => true,
'message' => '支付串生成成功',
'data' => [
'payment_string' => $finalPaymentString,
'mac' => $mac,
'order_sn' => $order['order_sn'],
'pay_flow_id' => $payFlowId,
'amount' => number_format($order['pay_fee'], 2, '.', ''),
'order_id' => $orderId // 补充order_id字段
]
];
} catch (\Exception $e) {
// ❌ 回滚事务
Db::rollback();
Log::error('建行支付串生成失败: ' . $e->getMessage());
return [
'status' => false,
'message' => $e->getMessage(),
'data' => null
];
}
}
/**
* 获取客户端IP
*
* @return string
*/
private function getClientIp()
{
try {
$ip = request()->ip();
return $ip ?: '127.0.0.1';
} catch (\Exception $e) {
// CLI模式或其他异常情况使用默认值
return '127.0.0.1';
}
}
/**
* 实现JavaScript的escape()编码
* 用于REGINFO和PROINFO字段的中文编码
*
* 根据建行文档4.3
* "使用js的escape()方法对REGINFO客户注册信息和PROINFO商品信息进行编码"
*
* @param string $str 要编码的字符串
* @return string 编码后的字符串
*/
private function jsEscape($str)
{
if (empty($str)) {
return '';
}
$result = '';
$length = mb_strlen($str, 'UTF-8');
for ($i = 0; $i < $length; $i++) {
$char = mb_substr($str, $i, 1, 'UTF-8');
// ASCII字符数字、字母、部分符号不编码
if (preg_match('/^[A-Za-z0-9@*_+\-.\\/]$/', $char)) {
$result .= $char;
} else {
// 非ASCII字符转为 %uXXXX 格式(如:小 -> %u5C0F
$unicode = mb_ord($char, 'UTF-8');
$result .= '%u' . strtoupper(str_pad(dechex($unicode), 4, '0', STR_PAD_LEFT));
}
}
return $result;
}
/**
* 构建商品信息字符串
* 根据建行文档4.3商品信息中文需使用escape()编码
*
* @param object $order 订单对象
* @return string escape编码后的商品信息
*/
private function buildProductInfo($order)
{
// 获取订单商品
$orderItems = Db::name('shopro_order_item')
->where('order_id', $order['id'])
->limit(3) // 最多取3个商品
->column('goods_title');
if (empty($orderItems)) {
$productInfo = '商城订单';
} else {
// 拼接商品名称建议不超过50字符编码前
$productInfo = mb_substr(implode(',', $orderItems), 0, 50, 'UTF-8');
}
// ✅ 使用JavaScript的escape()编码中文
return $this->jsEscape($productInfo);
}
/**
* 生成ENCPUB商户公钥密文
*
* 根据建行文档4.4
* "使用服务方公钥对商户公钥后30位进行RSA加密再进行base64后生成的密文串"
*
* 注意:这里的"商户公钥后30位"是指RSA公钥模数(modulus)的十六进制表示的后30位
* 不是BASE64字符串的后30位
*
* @return string BASE64编码的加密密文
* @throws \Exception
*/
private function encryptPublicKeyLast30()
{
// 获取商户公钥(被加密的公钥)
$merchantPublicKey = $this->config['merchant_public_key'] ?? '';
// 获取服务方公钥(用于加密的公钥)
$servicePublicKey = $this->config['public_key'] ?? '';
if (empty($merchantPublicKey)) {
throw new \Exception('商户公钥未配置');
}
if (empty($servicePublicKey)) {
throw new \Exception('服务方公钥未配置');
}
// ✅ 关键修复直接取商户公钥十六进制字符串的后30位
try {
// 商户公钥已经是十六进制格式直接取后30位
$last30Chars = substr($merchantPublicKey, -30);
if (strlen($last30Chars) < 30) {
throw new \Exception('商户公钥长度不足30位');
}
Log::info('[建行支付] 商户公钥后30位: ' . $last30Chars);
// 使用服务方公钥加密这30位十六进制字符串
// CcbRSA::encrypt 会自动进行base64编码
$encpub = CcbRSA::encrypt($last30Chars, $servicePublicKey);
// ⚠️ 关键修复转换为URL-safe BASE64格式
// 根据文档和示例:"若密文中带有"+"、"/"符号说明少了BASE64这一步骤"
// 实际上需要将标准BASE64的 + 和 / 替换为 - 和 _
// 这样在URL传输时不会被误解析也符合建行的格式要求
$encpub = str_replace(['+', '/'], ['-', '_'], $encpub);
return $encpub;
} catch (\Exception $e) {
Log::error('[建行支付] ENCPUB生成失败: ' . $e->getMessage());
throw $e;
}
}
/**
* 格式化公钥为PEM格式
*
* 支持三种输入格式:
* 1. PEM格式已包含BEGIN/END标记
* 2. BASE64格式不含PEM头尾
* 3. 十六进制格式DER编码的十六进制字符串
*
* @param string $publicKey 公钥PEM/BASE64/HEX格式
* @return string PEM格式的公钥
*/
private function formatPublicKeyToPem($publicKey)
{
// 移除可能存在的空格和换行
$publicKey = preg_replace('/\s+/', '', $publicKey);
// 如果已经是PEM格式直接返回
if (strpos($publicKey, '-----BEGIN PUBLIC KEY-----') !== false) {
return $publicKey;
}
// ✅ 判断是否为十六进制格式只包含0-9a-fA-F字符
if (ctype_xdigit($publicKey)) {
// 十六进制格式 → 解码为二进制 → 转BASE64
$binaryKey = hex2bin($publicKey);
if ($binaryKey === false) {
throw new \Exception('十六进制公钥解码失败');
}
$publicKey = base64_encode($binaryKey);
}
// 转换为PEM格式
$pem = "-----BEGIN PUBLIC KEY-----\n";
$pem .= rtrim(chunk_split($publicKey, 64, "\n"), "\n") . "\n";
$pem .= "-----END PUBLIC KEY-----\n";
return $pem;
}
/**
* 处理支付回调
* 建行支付完成后的同步回调
*
* @param array $params URL参数
* @return array
*/
public function handleCallback($params)
{
try {
// 解密ccbParamSJ参数使用服务方私钥
if (isset($params['ccbParamSJ'])) {
$decryptedParams = CcbUrlDecrypt::decrypt($params['ccbParamSJ'], $this->config['private_key']);
if ($decryptedParams) {
$params = array_merge($params, $decryptedParams);
}
}
// 获取关键参数
$payFlowId = $params['ORDERID'] ?? ''; // 支付流水号
$userOrderId = $params['USER_ORDERID'] ?? ''; // 商户订单号
$posId = $params['POSID'] ?? '';
$success = $params['SUCCESS'] ?? 'N';
// 验证参数
if (empty($payFlowId)) {
throw new \Exception('支付流水号不能为空');
}
// 验证POS号
if ($posId != $this->config['pos_id']) {
throw new \Exception('POS号验证失败');
}
// ⚠️ 重要ORDERID是支付流水号不是订单号
// 优先使用USER_ORDERID查询如果没有则用ccb_pay_flow_id查询
if (!empty($userOrderId)) {
$order = Order::where('order_sn', $userOrderId)->find();
} else {
$order = Order::where('ccb_pay_flow_id', $payFlowId)->find();
}
if (!$order) {
throw new \Exception('订单不存在');
}
// 处理支付结果
if ($success == 'Y') {
// 支付成功,更新订单状态
$this->updateOrderPaymentStatus($order, $params);
// ✅ 更新订单状态到建行订单已在createPayment时推送这里只需更新状态
$this->orderService->updateOrderStatus($order['id']);
return [
'status' => true,
'message' => '支付成功',
'data' => [
'order_id' => $order['id'],
'order_sn' => $order['order_sn'], // ✅ 返回真正的订单号
'pay_flow_id' => $payFlowId, // 支付流水号
'amount' => $params['PAYMENT'] ?? ''
]
];
} else {
// 支付失败
return [
'status' => false,
'message' => '支付失败',
'data' => [
'order_id' => $order['id'],
'order_sn' => $order['order_sn'], // ✅ 返回真正的订单号
'pay_flow_id' => $payFlowId, // 支付流水号
'error_code' => $params['ERRCODE'] ?? '',
'error_msg' => $params['ERRMSG'] ?? ''
]
];
}
} catch (\Exception $e) {
Log::error('建行支付回调处理失败: ' . $e->getMessage());
return [
'status' => false,
'message' => $e->getMessage(),
'data' => null
];
}
}
/**
* 处理异步通知
* 建行支付异步通知处理
*
* ⚠️ 注意:这是唯一可信的支付确认来源!
* 返回订单ID供控制器调用pushOrderToCcb()推送到外联系统
*
* @param array $params 通知参数
* @return array ['status' => 'success'|'fail', 'order_id' => int, 'order_sn' => string]
*/
public function handleNotify($params)
{
try {
// 验证签名
if (!$this->verifyNotifySignature($params)) {
throw new \Exception('签名验证失败');
}
// ⚠️ 重要ORDERID是支付流水号不是订单号
// 优先使用USER_ORDERID查询如果没有则用ccb_pay_flow_id查询
$payFlowId = $params['ORDERID'] ?? ''; // 支付流水号
$userOrderId = $params['USER_ORDERID'] ?? ''; // 商户订单号
if (!empty($userOrderId)) {
$order = Order::where('order_sn', $userOrderId)->find();
} else {
$order = Order::where('ccb_pay_flow_id', $payFlowId)->find();
}
if (!$order) {
throw new \Exception('订单不存在');
}
// 如果订单已支付,直接返回成功(幂等性)
if ($order['status'] == 'paid') {
Log::info('[建行通知] 订单已支付,跳过处理 order_id:' . $order->id);
return [
'status' => 'success',
'order_id' => $order->id,
'order_sn' => $order->order_sn,
'already_paid' => true, // 标记为已支付
];
}
// 更新订单状态
$this->updateOrderPaymentStatus($order, $params);
Log::info('[建行通知] 订单状态更新成功 order_id:' . $order->id . ' order_sn:' . $order->order_sn);
// ✅ 返回订单ID供控制器推送到外联系统
return [
'status' => 'success',
'order_id' => $order->id,
'order_sn' => $order->order_sn,
'already_paid' => false, // 新支付
];
} catch (\Exception $e) {
Log::error('[建行通知] 处理失败: ' . $e->getMessage());
return [
'status' => 'fail',
'message' => $e->getMessage(),
];
}
}
/**
* 验证支付结果
* 主动查询订单支付状态
*
* @param string $orderSn 订单号
* @return bool
*/
public function verifyPayment($orderSn)
{
try {
// 查询建行订单状态
$result = $this->orderService->queryOrder($orderSn);
if ($result['status']) {
$data = $result['data']['CLD_BODY'] ?? [];
$txnStatus = $data['TXN_STATUS'] ?? '';
// 00=交易成功
return $txnStatus == '00';
}
return false;
} catch (\Exception $e) {
Log::error('建行支付验证失败: ' . $e->getMessage());
return false;
}
}
/**
* 更新订单支付状态
*
* @param object $order 订单对象
* @param array $params 支付参数
*/
private function updateOrderPaymentStatus($order, $params)
{
// ⚠️ 重要字段说明:
// 1. paid_time: Shopro使用毫秒时间戳time() * 1000
// 2. pay_type: 建行支付暂用'offline'(建行线下银行支付),后续可扩展枚举
// 3. transaction_id: 存储建行支付流水号ORDERID
Order::where('id', $order['id'])->update([
'status' => 'paid',
'pay_type' => 'offline', // 建行支付归类为线下银行支付
'paid_time' => time() * 1000, // 毫秒时间戳
'transaction_id' => $params['ORDERID'] ?? '', // 建行支付流水号
'updatetime' => time()
]);
// 记录支付日志
$this->recordPaymentLog($order['id'], 'payment_success', $params);
}
/**
* 验证异步通知签名
*
* ⚠️ 建行异步通知签名规则:
* 1. SIGN字段为256字符十六进制字符串(2048位RSA签名)
* 2. NT_TYPE=YS时,使用"建行生活分配的服务商支付验签公钥"
* 3. 签名算法: RSA-SHA256或SHA1(需建行技术支持确认)
*
* 📌 配置说明:
* - 如果配置了ccb_payment_verify_public_key: 使用RSA验签
* - 如果未配置: 降级为POSID验证(临时方案)
*
* @param array $params 通知参数
* @return bool
*/
private function verifyNotifySignature($params)
{
try {
// 1. 提取SIGN字段
$sign = $params['SIGN'] ?? '';
if (empty($sign)) {
Log::error('[建行验签] SIGN字段为空');
return false;
}
// 验证SIGN长度(256个十六进制字符 = 2048位RSA签名)
if (strlen($sign) !== 256) {
Log::error('[建行验签] SIGN长度错误: ' . strlen($sign) . ', 应为256');
return false;
}
// 2. 检查是否配置了建行支付验签公钥
$ccbVerifyPublicKey = $this->config['public_key'] ?? '';
if (empty($ccbVerifyPublicKey)) {
// 降级方案: 未配置验签公钥时,使用POSID验证
Log::warning('[建行验签] 未配置public_key,使用降级验证方案');
// 验证POSID是否匹配
if (($params['POSID'] ?? '') !== $this->config['pos_id']) {
Log::error('[建行验签] POSID不匹配,预期: ' . $this->config['pos_id'] . ', 实际: ' . ($params['POSID'] ?? ''));
return false;
}
// 验证订单号是否存在
$orderSn = $params['USER_ORDERID'] ?? $params['ORDERID'] ?? '';
if (empty($orderSn)) {
Log::error('[建行验签] 订单号为空');
return false;
}
Log::warning('[建行验签] 降级验证通过,建议联系建行技术支持获取验签公钥');
return true;
}
// 3. 移除SIGN字段,构建签名原串
$verifyParams = $params;
unset($verifyParams['SIGN']);
// 4. 按key排序
ksort($verifyParams);
// 5. 构建签名原串(非空参数)
$signStr = '';
foreach ($verifyParams as $key => $value) {
// 跳过空值参数
if ($value !== '' && $value !== null) {
$signStr .= $key . '=' . $value . '&';
}
}
$signStr = rtrim($signStr, '&');
Log::info('[建行验签] 签名原串: ' . $signStr);
Log::info('[建行验签] 收到SIGN前32位: ' . substr($sign, 0, 32) . '...');
// 6. 将十六进制SIGN转为二进制
$signBinary = hex2bin($sign);
if ($signBinary === false) {
Log::error('[建行验签] SIGN十六进制转换失败');
return false;
}
// 7. 加载建行支付验签公钥
$pubKey = openssl_pkey_get_public($ccbVerifyPublicKey);
if (!$pubKey) {
Log::error('[建行验签] 验签公钥加载失败: ' . openssl_error_string());
return false;
}
// 8. 先尝试SHA256验签
$verifyResult = openssl_verify($signStr, $signBinary, $pubKey, OPENSSL_ALGO_SHA256);
if ($verifyResult !== 1) {
// SHA256失败,尝试SHA1
Log::info('[建行验签] SHA256验签失败,尝试SHA1');
$verifyResult = openssl_verify($signStr, $signBinary, $pubKey, OPENSSL_ALGO_SHA1);
}
// PHP 8+ 资源自动释放
if (PHP_VERSION_ID < 80000) {
openssl_free_key($pubKey);
}
if ($verifyResult === 1) {
Log::info('[建行验签] RSA验签成功');
return true;
} elseif ($verifyResult === 0) {
Log::error('[建行验签] RSA验签失败,签名不匹配');
return false;
} else {
Log::error('[建行验签] RSA验签过程发生错误: ' . openssl_error_string());
return false;
}
} catch (\Exception $e) {
Log::error('[建行验签] 验签异常: ' . $e->getMessage());
return false;
}
}
/**
* 记录支付请求
*
* ⚠️ 幂等性说明:
* 1. 当用户多次点击支付按钮时会复用同一个pay_flow_id
* 2. 本方法会先检查是否已存在记录,如果存在则更新,否则插入
* 3. 这样可以避免唯一键冲突,并记录最新的支付串生成时间
*
* @param int $orderId 订单ID
* @param array $paymentData 支付数据
*/
private function recordPaymentRequest($orderId, $paymentData)
{
// 获取订单信息
$order = Order::find($orderId);
$user = Db::name('user')->where('id', $order['user_id'])->field('ccb_user_id')->find();
$payFlowId = $paymentData['pay_flow_id'] ?? '';
// 检查是否已存在记录根据pay_flow_id唯一键
$existingLog = Db::name('ccb_payment_log')
->where('pay_flow_id', $payFlowId)
->find();
$logData = [
'order_id' => $orderId,
'order_sn' => $order['order_sn'],
'payment_string' => $paymentData['payment_string'] ?? '',
'user_id' => $order['user_id'],
'ccb_user_id' => $user['ccb_user_id'] ?? '',
'amount' => $order['pay_fee'], // 使用Shopro的pay_fee字段
'status' => 0, // 待支付
];
if ($existingLog) {
// 已存在记录更新支付串保留原create_time重置status为待支付
// 注意:用户重复点击支付时,会生成新的支付串,需要更新
Db::name('ccb_payment_log')
->where('pay_flow_id', $payFlowId)
->update([
'payment_string' => $logData['payment_string'],
'status' => 0, // 重置为待支付(因为是新的支付串)
'amount' => $logData['amount'], // 更新金额(订单金额可能变化)
]);
Log::info('[建行支付] 更新支付日志 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId);
} else {
// 不存在记录,插入新记录
$logData['pay_flow_id'] = $payFlowId;
$logData['create_time'] = time();
Db::name('ccb_payment_log')->insert($logData);
Log::info('[建行支付] 插入支付日志 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId);
}
}
/**
* 记录支付日志
*
* @param int $orderId 订单ID
* @param string $type 日志类型
* @param array $data 数据
*/
private function recordPaymentLog($orderId, $type, $data)
{
// 更新建行支付日志
if ($type == 'payment_success') {
Db::name('ccb_payment_log')
->where('order_id', $orderId)
->update([
'status' => 1, // 支付成功
'pay_time' => time(),
'trans_id' => $data['ORDERID'] ?? '',
'callback_data' => json_encode($data, JSON_UNESCAPED_UNICODE)
]);
}
}
/**
* 生成退款申请
*
* @param int $orderId 订单ID
* @param float $refundAmount 退款金额
* @param string $refundReason 退款原因
* @return array
*/
public function refund($orderId, $refundAmount, $refundReason = '')
{
try {
// 调用订单服务处理退款
$result = $this->orderService->refundOrder($orderId, $refundAmount, $refundReason);
if ($result['status']) {
// 记录退款日志
$this->recordPaymentLog($orderId, 'refund_request', [
'amount' => $refundAmount,
'reason' => $refundReason
]);
}
return $result;
} catch (\Exception $e) {
Log::error('建行退款申请失败: ' . $e->getMessage());
return [
'status' => false,
'message' => $e->getMessage(),
'data' => null
];
}
}
}