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); } }