2025-10-22 19:55:52 +08:00

406 lines
14 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 $userId 用户ID
* @param string $orderId 订单ID
* @param string $orderStatus 订单状态
* @param string $refundStatus 退款状态
* @return array 响应数据
* @throws \Exception
*/
public function updateOrderStatus($userId, $orderId, $orderStatus, $refundStatus = '0')
{
$body = [
'USER_ID' => $userId,
'ORDER_ID' => $orderId,
'ORDER_STATUS' => $orderStatus,
'REFUND_STATUS' => $refundStatus
];
return $this->request($this->config['tx_codes']['order_update'], $body);
}
/**
* 订单查询A3341TP03
*
* @param string $onlnPyTxnOrdrId 支付订单ID
* @param string $txnStatus 交易状态
* @return array 响应数据
* @throws \Exception
*/
public function queryOrder($onlnPyTxnOrdrId, $txnStatus = '00')
{
$body = [
'ONLN_PY_TXN_ORDR_ID' => $onlnPyTxnOrdrId,
'PAGE' => '1',
'TXN_PRD_TPCD' => '06',
'TXN_STATUS' => $txnStatus,
'TX_TYPE' => '0'
];
return $this->request($this->config['tx_codes']['order_query'], $body);
}
/**
* 退款接口A3341TP04
*
* @param string $orderId 订单ID
* @param string $refundAmount 退款金额
* @param string $refundReason 退款原因
* @return array 响应数据
* @throws \Exception
*/
public function refund($orderId, $refundAmount, $refundReason = '')
{
$body = [
'ORDER_ID' => $orderId,
'REFUND_AMOUNT' => $refundAmount,
'REFUND_REASON' => $refundReason,
'REFUND_TIME' => date('YmdHis')
];
return $this->request($this->config['tx_codes']['order_refund'], $body);
}
/**
* 测试连接
* 使用查询接口测试连接是否正常
*
* @return bool 是否连接成功
*/
public function testConnection()
{
try {
// 使用一个不存在的订单号进行查询测试
$this->queryOrder('TEST' . time());
return true;
} catch (\Exception $e) {
// 如果是业务错误(订单不存在),说明连接正常
if (strpos($e->getMessage(), '业务处理失败') !== false) {
return true;
}
return false;
}
}
}