config = [ 'appid' => $this->payConfig['app_id'], 'mch_id' => $this->payConfig['mch_id'], 'key' => $this->payConfig['key'], 'notify_url' => $this->payConfig['notify_url'] ?: url('/api/invitation/payment/wechatNotify', [], true, true)->build(), 'cert_path' => $this->getCertPath('cert'), 'key_path' => $this->getCertPath('key'), ]; if (empty($this->config['appid']) || empty($this->config['mch_id']) || empty($this->config['key'])) { throw new \Exception('微信支付配置不完整'); } } /** * 获取证书路径 */ protected function getCertPath($type) { // 可以从数据库或文件系统获取证书 // 这里简化处理,实际需要根据业务获取 return ''; } /** * 生成支付二维码 */ public function createQrCode($orderNo, $amount, $subject, $body = '') { try { // 构建请求参数 $params = [ 'appid' => $this->config['appid'], 'mch_id' => $this->config['mch_id'], 'nonce_str' => $this->generateNonceStr(), 'body' => mb_substr($subject, 0, 127, 'UTF-8'), // 商品描述,128字节限制 'out_trade_no' => $orderNo, 'total_fee' => intval($amount * 100), // 单位:分 'spbill_create_ip' => request()->ip(), 'notify_url' => $this->config['notify_url'], 'trade_type' => 'NATIVE', // 扫码支付 'product_id' => $orderNo, ]; // 生成签名 $params['sign'] = $this->generateSign($params); // 调用微信统一下单接口 $result = $this->postXmlCurl($this->apiUrl . '/pay/unifiedorder', $this->arrayToXml($params)); // 解析结果 $response = $this->xmlToArray($result); if ($response['return_code'] == 'SUCCESS' && $response['result_code'] == 'SUCCESS') { return [ 'qr_code_url' => $response['code_url'], 'qr_code_content' => $response['code_url'], 'prepay_id' => $response['prepay_id'], ]; } else { $errorMsg = $response['return_msg'] ?? $response['err_code_des'] ?? '微信支付下单失败'; throw new \Exception($errorMsg); } } catch (\Throwable $e) { $this->log('生成微信支付二维码失败', [ 'order_no' => $orderNo, 'error' => $e->getMessage() ], 'error'); throw $e; } } /** * 处理支付回调 */ public function handleNotify($input) { try { // 解析XML数据 $data = $this->xmlToArray($input); // 验证签名 if (!$this->verifySign($data, $data['sign'])) { throw new \Exception('签名验证失败'); } // 检查支付状态 if ($data['return_code'] != 'SUCCESS' || $data['result_code'] != 'SUCCESS') { throw new \Exception('支付失败: ' . ($data['return_msg'] ?? '未知错误')); } return [ 'order_no' => $data['out_trade_no'], 'trade_no' => $data['transaction_id'], 'pay_amount' => $data['total_fee'] / 100, 'pay_time' => $this->formatWxTime($data['time_end']), 'raw_data' => $data, ]; } catch (\Throwable $e) { $this->log('处理微信支付回调失败', [ 'input' => $input, 'error' => $e->getMessage() ], 'error'); throw $e; } } /** * 查询支付订单 */ public function queryOrder($orderNo) { try { $params = [ 'appid' => $this->config['appid'], 'mch_id' => $this->config['mch_id'], 'out_trade_no' => $orderNo, 'nonce_str' => $this->generateNonceStr(), ]; $params['sign'] = $this->generateSign($params); $result = $this->postXmlCurl($this->apiUrl . '/pay/orderquery', $this->arrayToXml($params)); $response = $this->xmlToArray($result); if ($response['return_code'] == 'SUCCESS' && $response['result_code'] == 'SUCCESS') { $status = $response['trade_state']; $payStatus = 0; // 待支付 if ($status == 'SUCCESS') { $payStatus = 1; // 已支付 } elseif (in_array($status, ['CLOSED', 'REVOKED', 'PAYERROR', 'USERPAYING'])) { $payStatus = 2; // 支付失败 } return [ 'order_no' => $response['out_trade_no'], 'trade_no' => $response['transaction_id'], 'pay_status' => $payStatus, 'pay_amount' => $response['total_fee'] / 100, 'pay_time' => isset($response['time_end']) ? $this->formatWxTime($response['time_end']) : null, 'trade_state' => $status, ]; } throw new \Exception('查询订单失败: ' . ($response['return_msg'] ?? '未知错误')); } catch (\Throwable $e) { $this->log('查询微信支付订单失败', [ 'order_no' => $orderNo, 'error' => $e->getMessage() ], 'error'); throw $e; } } /** * 验证签名 */ protected function verifySign($data, $sign) { $localSign = $this->generateSign($data); return $localSign === $sign; } /** * 生成签名 */ protected function generateSign($data) { // 过滤空值和sign参数 $filterData = array_filter($data, function($value, $key) { return $value !== '' && $value !== null && $key !== 'sign'; }, ARRAY_FILTER_USE_BOTH); // 按参数名ASCII码从小到大排序 ksort($filterData); // 拼接字符串 $string = ''; foreach ($filterData as $key => $value) { $string .= $key . '=' . $value . '&'; } $string .= 'key=' . $this->config['key']; // MD5加密并转为大写 return strtoupper(md5($string)); } /** * 生成随机字符串 */ protected function generateNonceStr($length = 32) { $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; $str = ''; for ($i = 0; $i < $length; $i++) { $str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1); } return $str; } /** * 格式化微信支付时间 */ protected function formatWxTime($timeStr) { if (empty($timeStr)) return null; // 微信时间格式:yyyyMMddHHmmss $year = substr($timeStr, 0, 4); $month = substr($timeStr, 4, 2); $day = substr($timeStr, 6, 2); $hour = substr($timeStr, 8, 2); $minute = substr($timeStr, 10, 2); $second = substr($timeStr, 12, 2); return "{$year}-{$month}-{$day} {$hour}:{$minute}:{$second}"; } /** * 发送XML请求 */ private function postXmlCurl($url, $xml, $useCert = false, $timeout = 30) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $xml); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); // 设置SSL curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 如果需要证书 if ($useCert) { curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'PEM'); curl_setopt($ch, CURLOPT_SSLCERT, $this->config['cert_path']); curl_setopt($ch, CURLOPT_SSLKEYTYPE, 'PEM'); curl_setopt($ch, CURLOPT_SSLKEY, $this->config['key_path']); } $response = curl_exec($ch); if (curl_errno($ch)) { curl_close($ch); throw new \Exception('CURL错误: ' . curl_error($ch)); } curl_close($ch); return $response; } /** * 数组转XML */ private function arrayToXml($arr) { $xml = ''; foreach ($arr as $key => $val) { if (is_numeric($val)) { $xml .= "<{$key}>{$val}"; } else { $xml .= "<{$key}>"; } } $xml .= ''; return $xml; } /** * XML转数组(兼容PHP 8.0+) */ private function xmlToArray($xml) { if (empty($xml)) { return []; } try { // 方法1:使用DOMDocument(推荐,兼容性好) $dom = new \DOMDocument(); // 针对PHP版本的兼容处理 if (version_compare(PHP_VERSION, '8.0.0', '>=')) { // PHP 8.0+ 不需要禁用实体加载器,直接加载 $dom->loadXML($xml, LIBXML_NOENT | LIBXML_NOCDATA); } else { // PHP 8.0以下需要禁用实体加载器 $previous = libxml_disable_entity_loader(true); $dom->loadXML($xml, LIBXML_NOENT | LIBXML_NOCDATA); libxml_disable_entity_loader($previous); } // 转换为数组 $data = $this->domToArray($dom->documentElement); return $data ?: []; } catch (\Exception $e) { $this->log('XML解析失败', [ 'xml' => substr($xml, 0, 500), // 只记录前500字符 'error' => $e->getMessage() ], 'error'); // 备用解析方法 return $this->fallbackXmlToArray($xml); } } /** * DOMElement转换为数组 */ private function domToArray($node) { $output = []; switch ($node->nodeType) { case XML_CDATA_SECTION_NODE: case XML_TEXT_NODE: $output = trim($node->textContent); break; case XML_ELEMENT_NODE: for ($i = 0; $i < $node->childNodes->length; $i++) { $child = $node->childNodes->item($i); $value = $this->domToArray($child); if (isset($child->tagName)) { $tag = $child->tagName; if (!isset($output[$tag])) { $output[$tag] = []; } if (is_array($value) && isset($value['@value'])) { $output[$tag][] = $value['@value']; } else { $output[$tag][] = $value; } // 如果是单一元素,不要数组形式 if (count($output[$tag]) === 1 && !is_array($output[$tag][0])) { $output[$tag] = $output[$tag][0]; } } elseif ($value !== '') { $output = $value; } } if ($node->attributes->length) { $attributes = []; foreach ($node->attributes as $attrName => $attrNode) { $attributes[$attrName] = (string)$attrNode->value; } if (!empty($output)) { $output['@attributes'] = $attributes; } else { $output = ['@value' => '', '@attributes' => $attributes]; } } break; } // 如果是数组且只有一个元素,直接返回那个元素 if (is_array($output) && count($output) == 1 && isset($output[0])) { $output = $output[0]; } return $output; } /** * 备用XML解析方法(原来的方法,带版本检查) */ private function fallbackXmlToArray($xml) { try { // 版本检查,PHP 8.0以上不调用已废弃函数 if (version_compare(PHP_VERSION, '8.0.0', '<')) { libxml_disable_entity_loader(true); } $data = json_decode(json_encode(simplexml_load_string( $xml, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NOENT )), true); return $data ?: []; } catch (\Exception $e) { $this->log('备用XML解析失败', [ 'error' => $e->getMessage() ], 'error'); return []; } } }