2025-10-22 20:32:53 +08:00

468 lines
18 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 think\Log;
/**
* 建行生活HTTP客户端
* 处理与建行API的通信包括加密、签名、发送请求和解密响应
*/
class CcbHttpClient
{
/**
* 默认超时时间(秒)
*/
const DEFAULT_TIMEOUT = 30;
/**
* 商户配置
*/
private $config;
/**
* 构造函数
*
* @param array $config 配置数组包含merchant_id, pos_id, branch_id, private_key, public_key, service_id
*/
public function __construct($config)
{
$this->config = $config;
$this->validateConfig();
}
/**
* 发送API请求
*
* @param string $txCode 交易码如svc_occMebOrderPush
* @param array $body 请求体数据
* @param string $txSeq 交易流水号,不传则自动生成
* @return array 响应数据
* @throws \Exception
*/
public function request($txCode, $body, $txSeq = null)
{
// 生成交易流水号
if (empty($txSeq)) {
$txSeq = CcbMD5::generateTransactionSeq();
}
// 构建请求报文
$message = $this->buildMessage($txCode, $body, $txSeq);
// 📝 记录原始请求报文(加密前)
Log::info('建行生活API原始请求报文 [txCode=' . $txCode . '] [txSeq=' . $txSeq . ']');
Log::info('原始报文内容: ' . $message);
// 使用商户公钥加密请求报文
$encryptPublicKey = $this->config['public_key'];
if (empty($encryptPublicKey)) {
throw new \Exception('RSA公钥未配置请检查.env中的public_key配置');
}
Log::info('使用公钥加密(前64字符): ' . substr($encryptPublicKey, 0, 64));
// 第一次加密和BASE64编码
$encryptedMessage = CcbRSA::encryptForCcb($message, $encryptPublicKey);
// 第二次BASE64编码按照建行demo要求
// demo1.java第120行: enc_msg = encoder.encode(enc_msg.getBytes("UTF-8"));
$encryptedMessage = base64_encode($encryptedMessage);
// 移除BASE64中的换行符
$encryptedMessage = str_replace(["\r", "\n", "\r\n"], '', $encryptedMessage);
// ✅ 使用商户私钥签名
$mac = CcbMD5::signApiMessage($message, $this->config['private_key']);
Log::info('生成的MAC签名: ' . $mac);
// 发送HTTP请求
$response = $this->sendHttpRequest($txCode, $encryptedMessage, $mac);
// 处理响应
return $this->handleResponse($response);
}
/**
* 构建请求报文
*
* @param string $txCode 交易码
* @param array $body 请求体
* @param string $txSeq 交易流水号
* @return string JSON格式的报文
*/
private function buildMessage($txCode, $body, $txSeq)
{
$message = [
'CLD_HEADER' => [
'CLD_TX_CHNL' => $this->config['service_id'],
'CLD_TX_TIME' => date('YmdHis'),
'CLD_TX_CODE' => $txCode,
'CLD_TX_SEQ' => $txSeq
],
'CLD_BODY' => $body
];
// 转换为JSON不转义中文
return json_encode($message, JSON_UNESCAPED_UNICODE);
}
/**
* 发送HTTP请求
*
* @param string $txCode 交易代码用于构建URL
* @param string $cnt 加密后的报文内容
* @param string $mac 签名
* @return string 响应内容
* @throws \Exception
*/
private function sendHttpRequest($txCode, $cnt, $mac)
{
// 记录请求开始时间
$startTime = microtime(true);
// 构建完整的API URL基础URL + ?txcode=交易代码)
$apiUrl = $this->config['api_base_url'] . '?txcode=' . $txCode;
// 构建请求参数按照建行报文规范cnt、mac、svcid
$params = [
'cnt' => $cnt,
'mac' => $mac,
'svcid' => $this->config['service_id']
];
// ✅ 将请求参数转为JSON格式按照建行报文示例要求
$jsonParams = json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// 📝 记录请求参数(加密后的完整内容)
Log::info('建行生活API加密请求 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [timeout=' . self::DEFAULT_TIMEOUT . 's]');
Log::info('JSON请求体: ' . $jsonParams);
// 初始化CURL
$ch = curl_init();
// 设置CURL选项按照建行要求发送JSON格式
curl_setopt_array($ch, [
CURLOPT_URL => $apiUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonParams, // ✅ 发送JSON格式不是URL编码
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::DEFAULT_TIMEOUT,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8', // ✅ JSON格式
'Accept: application/json'
]
]);
// 执行请求
$response = curl_exec($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlInfo = curl_getinfo($ch);
curl_close($ch);
// 计算请求耗时
$costTime = round((microtime(true) - $startTime) * 1000, 2); // 毫秒
// 检查错误
if ($error) {
// 📝 记录错误日志
Log::error('建行生活API请求失败 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [error=' . $error . '] [cost_time=' . $costTime . 'ms] [total_time=' . ($curlInfo['total_time'] ?? 0) . 's] [connect_time=' . ($curlInfo['connect_time'] ?? 0) . 's]');
throw new \Exception('HTTP请求失败: ' . $error);
}
if ($httpCode !== 200) {
// 📝 记录HTTP状态码异常日志
$responsePreview = mb_substr($response, 0, 300);
Log::error('建行生活API响应状态码异常 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [http_code=' . $httpCode . '] [cost_time=' . $costTime . 'ms] [response=' . $responsePreview . ']');
throw new \Exception('HTTP状态码异常: ' . $httpCode . ', 响应内容: ' . $response);
}
// 📝 记录成功响应日志
$totalTime = round(($curlInfo['total_time'] ?? 0) * 1000, 2);
$connectTime = round(($curlInfo['connect_time'] ?? 0) * 1000, 2);
$responseLength = mb_strlen($response);
Log::info('建行生活API请求成功 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [http_code=' . $httpCode . '] [response_length=' . $responseLength . '] [cost_time=' . $costTime . 'ms] [total_time=' . $totalTime . 'ms] [connect_time=' . $connectTime . 'ms]');
return $response;
}
/**
* 处理响应
*
* @param string $response 原始响应内容
* @return array 解密后的响应数据
* @throws \Exception
*/
private function handleResponse($response)
{
// 📝 记录原始响应内容
Log::info('建行生活API原始响应内容: ' . $response);
// 解析JSON响应
$responseData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('响应JSON解析失败: ' . json_last_error_msg());
}
// 检查响应结构
if (!isset($responseData['cnt']) || !isset($responseData['mac'])) {
throw new \Exception('响应格式错误缺少cnt或mac字段');
}
// 📝 记录加密响应参数
Log::info('加密响应参数 cnt: ' . $responseData['cnt']);
Log::info('加密响应参数 mac: ' . $responseData['mac']);
// 第一次BASE64解码按照建行demo要求
// demo1.java第139行: enc_msg = new String(decoder.decodeBuffer(enc_msg),"UTF-8");
$cntDecoded = base64_decode($responseData['cnt']);
// 第二次解密和BASE64解码
$decryptedContent = CcbRSA::decryptFromCcb($cntDecoded, $this->config['private_key']);
// 📝 记录解密后的响应内容
Log::info('解密后响应内容: ' . $decryptedContent);
// 验证签名
$isValid = CcbMD5::verifyApiSignature($decryptedContent, $responseData['mac'], $this->config['private_key']);
if (!$isValid) {
Log::error('响应签名验证失败 [expected_mac=' . $responseData['mac'] . ']');
throw new \Exception('响应签名验证失败');
}
Log::info('响应签名验证成功');
// 解析解密后的内容
$decryptedData = json_decode($decryptedContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('解密后的JSON解析失败: ' . json_last_error_msg());
}
// 检查业务响应码
$this->checkBusinessResponse($decryptedData);
return $decryptedData;
}
/**
* 检查业务响应码
*
* @param array $data 响应数据
* @throws \Exception
*/
private function checkBusinessResponse($data)
{
// 检查响应头
if (!isset($data['CLD_HEADER'])) {
throw new \Exception('响应数据结构错误缺少CLD_HEADER');
}
// 检查 CLD_HEADER.CLD_TX_RESP建行标准响应格式
if (isset($data['CLD_HEADER']['CLD_TX_RESP'])) {
$txResp = $data['CLD_HEADER']['CLD_TX_RESP'];
$code = isset($txResp['CLD_CODE']) ? $txResp['CLD_CODE'] : 'UNKNOWN';
$desc = isset($txResp['CLD_DESC']) ? $txResp['CLD_DESC'] : '未知错误';
// 只有非成功状态才抛出异常
if ($code !== 'CLD_SUCCESS') {
throw new \Exception('建行业务错误[' . $code . ']: ' . $desc);
}
}
}
/**
* 验证配置
*
* @throws \Exception
*/
private function validateConfig()
{
$requiredFields = [
'api_base_url',
'merchant_id',
'pos_id',
'branch_id',
'private_key',
'public_key',
'service_id',
'tx_codes'
];
foreach ($requiredFields as $field) {
if (!isset($this->config[$field]) || empty($this->config[$field])) {
throw new \Exception('配置缺少必要字段: ' . $field);
}
}
// 验证交易代码配置完整性
$requiredTxCodes = ['order_push', 'order_update', 'order_query', 'order_refund'];
foreach ($requiredTxCodes as $txCode) {
if (!isset($this->config['tx_codes'][$txCode])) {
throw new \Exception('交易代码配置缺少: ' . $txCode);
}
}
}
/**
* 订单推送A3341TP01
*
* @param array $orderData 订单数据
* @return array 响应数据
* @throws \Exception
*/
public function pushOrder($orderData)
{
return $this->request($this->config['tx_codes']['order_push'], $orderData);
}
/**
* 订单状态更新A3341TP02
*
* @param string $orderId 订单编号用户订单号对应收银台USER_ORDERID字段
* @param string $informId 通知类型0-支付状态修改 1-退款状态修改)
* @param string $payFlowId 支付流水号对应收银台ORDERID字段
* @param string $payMrchId 支付商户号
* @param array $additionalParams 额外参数PAY_STATUS、REFUND_STATUS、PAY_AMT、DISCOUNT_AMT、CUS_ORDER_URL等
* @return array 响应数据
* @throws \Exception
*/
public function updateOrderStatus($orderId, $informId, $payFlowId, $payMrchId, $additionalParams = [])
{
// 验证通知类型
if (!in_array($informId, ['0', '1'])) {
throw new \Exception('通知类型INFORM_ID必须为0支付状态修改或1退款状态修改');
}
// 验证互斥规则
if ($informId == '0') {
// 支付状态修改时PAY_STATUS必填REFUND_STATUS为空
if (empty($additionalParams['PAY_STATUS'])) {
throw new \Exception('支付状态修改时PAY_STATUS不能为空');
}
$additionalParams['REFUND_STATUS'] = null;
} elseif ($informId == '1') {
// 退款状态修改时REFUND_STATUS必填PAY_STATUS为空
if (empty($additionalParams['REFUND_STATUS'])) {
throw new \Exception('退款状态修改时REFUND_STATUS不能为空');
}
$additionalParams['PAY_STATUS'] = null;
}
// 构建请求体(必填字段)
$body = [
'ORDER_ID' => $orderId,
'INFORM_ID' => $informId,
'PAY_FLOW_ID' => $payFlowId,
'PAY_MRCH_ID' => $payMrchId
];
// 合并额外参数
$body = array_merge($body, $additionalParams);
// 移除空值字段
$body = array_filter($body, function($value) {
return $value !== null && $value !== '';
});
return $this->request($this->config['tx_codes']['order_update'], $body);
}
/**
* 订单查询A3341TP03
*
* @param string $onlnPyTxnOrdrId 订单编号调用收银台时支付流水号对应字段ORDERID
* @param string|null $startTime 开始日期时间格式yyyyMMddHHmmss默认为7天前
* @param string|null $endTime 结束日期时间格式yyyyMMddHHmmss默认为当前时间
* @param int $page 当前页次默认1
* @param string $txType 交易类型0-支付交易 1-退款交易 a-查询可退款的订单)
* @param string $txnStatus 交易状态00-交易成功 01-交易失败 02-不确定)
* @param array $additionalParams 额外参数PLAT_MCT_ID、CUSTOMERID、BRANCHID、SCN_IDR等
* @return array 响应数据
* @throws \Exception
*/
public function queryOrder($onlnPyTxnOrdrId, $startTime = null, $endTime = null, $page = 1, $txType = '0', $txnStatus = '00', $additionalParams = [])
{
// 默认查询最近7天
if (empty($startTime)) {
$startTime = date('YmdHis', strtotime('-7 days'));
}
if (empty($endTime)) {
$endTime = date('YmdHis');
}
// 构建请求体(必填字段)
$body = [
'TX_TYPE' => $txType,
'TXN_PRD_TPCD' => '99', // 99-自定义时间段查询(文档要求)
'STDT_TM' => $startTime,
'EDDT_TM' => $endTime,
'ONLN_PY_TXN_ORDR_ID' => $onlnPyTxnOrdrId,
'TXN_STATUS' => $txnStatus,
'PAGE' => (string)$page
];
// 合并额外参数(商户信息等)
$body = array_merge($body, $additionalParams);
// 移除空值字段
$body = array_filter($body, function($value) {
return $value !== null && $value !== '';
});
return $this->request($this->config['tx_codes']['order_query'], $body);
}
/**
* 退款接口A3341TP04
*
* @param string $orderId 订单号调用收银台时支付流水号对应字段ORDERID
* @param float|string $refundAmount 退款金额(单位:元)
* @param string|int $payTime 支付时间(时间戳或日期时间字符串)
* @param string|null $refundCode 退款流水号(可选,建议填写用于查询退款结果)
* @param array $additionalParams 额外参数PLAT_MCT_ID、CUSTOMERID、BRANCHID等
* @return array 响应数据
* @throws \Exception
*/
public function refund($orderId, $refundAmount, $payTime, $refundCode = null, $additionalParams = [])
{
// 计算时间范围支付时间前后4小时
$payTimestamp = is_numeric($payTime) ? $payTime : strtotime($payTime);
if (!$payTimestamp) {
throw new \Exception('支付时间格式错误,请传入时间戳或有效的日期时间字符串');
}
$stat_tm = date('YmdHis', $payTimestamp - 4*3600); // 支付时间往前4小时
$edit_tm = date('YmdHis', min($payTimestamp + 4*3600, time())); // 支付时间往后4小时但不超过当前时间
// 生成退款流水号(如果未提供)
if (empty($refundCode)) {
$refundCode = 'RF' . date('YmdHis') . str_pad(mt_rand(0, 9999), 4, '0', STR_PAD_LEFT);
}
// 格式化退款金额保留2位小数
$refundAmount = number_format((float)$refundAmount, 2, '.', '');
// 构建请求体(必填字段)
$body = [
'ORDER' => $orderId, // 注意:字段名是 ORDER不是 ORDER_ID
'MONEY' => $refundAmount, // 注意:字段名是 MONEY不是 REFUND_AMOUNT
'STDT_TM' => $stat_tm,
'EDDT_TM' => $edit_tm,
'REFUND_CODE' => $refundCode
];
// 合并额外参数(商户信息等)
$body = array_merge($body, $additionalParams);
// 移除空值字段
$body = array_filter($body, function($value) {
return $value !== null && $value !== '';
});
return $this->request($this->config['tx_codes']['order_refund'], $body);
}
}