WechatPaymentService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. <?php
  2. namespace app\common\service;
  3. class WechatPaymentService extends BasePaymentService
  4. {
  5. // 微信支付API地址
  6. protected $apiUrl = 'https://api.mch.weixin.qq.com';
  7. // 配置参数
  8. protected $config = [];
  9. /**
  10. * 初始化微信支付配置
  11. */
  12. protected function initConfig()
  13. {
  14. $this->config = [
  15. 'appid' => $this->payConfig['app_id'],
  16. 'mch_id' => $this->payConfig['mch_id'],
  17. 'key' => $this->payConfig['key'],
  18. 'notify_url' => $this->payConfig['notify_url'] ?: url('/api/invitation/payment/wechatNotify', [], true, true)->build(),
  19. 'cert_path' => $this->getCertPath('cert'),
  20. 'key_path' => $this->getCertPath('key'),
  21. ];
  22. if (empty($this->config['appid']) || empty($this->config['mch_id']) || empty($this->config['key'])) {
  23. throw new \Exception('微信支付配置不完整');
  24. }
  25. }
  26. /**
  27. * 获取证书路径
  28. */
  29. protected function getCertPath($type)
  30. {
  31. // 可以从数据库或文件系统获取证书
  32. // 这里简化处理,实际需要根据业务获取
  33. return '';
  34. }
  35. /**
  36. * 生成支付二维码
  37. */
  38. public function createQrCode($orderNo, $amount, $subject, $body = '')
  39. {
  40. try {
  41. // 构建请求参数
  42. $params = [
  43. 'appid' => $this->config['appid'],
  44. 'mch_id' => $this->config['mch_id'],
  45. 'nonce_str' => $this->generateNonceStr(),
  46. 'body' => mb_substr($subject, 0, 127, 'UTF-8'), // 商品描述,128字节限制
  47. 'out_trade_no' => $orderNo,
  48. 'total_fee' => intval($amount * 100), // 单位:分
  49. 'spbill_create_ip' => request()->ip(),
  50. 'notify_url' => $this->config['notify_url'],
  51. 'trade_type' => 'NATIVE', // 扫码支付
  52. 'product_id' => $orderNo,
  53. ];
  54. // 生成签名
  55. $params['sign'] = $this->generateSign($params);
  56. // 调用微信统一下单接口
  57. $result = $this->postXmlCurl($this->apiUrl . '/pay/unifiedorder', $this->arrayToXml($params));
  58. // 解析结果
  59. $response = $this->xmlToArray($result);
  60. if ($response['return_code'] == 'SUCCESS' && $response['result_code'] == 'SUCCESS') {
  61. return [
  62. 'qr_code_url' => $response['code_url'],
  63. 'qr_code_content' => $response['code_url'],
  64. 'prepay_id' => $response['prepay_id'],
  65. ];
  66. } else {
  67. $errorMsg = $response['return_msg'] ?? $response['err_code_des'] ?? '微信支付下单失败';
  68. throw new \Exception($errorMsg);
  69. }
  70. } catch (\Throwable $e) {
  71. $this->log('生成微信支付二维码失败', [
  72. 'order_no' => $orderNo,
  73. 'error' => $e->getMessage()
  74. ], 'error');
  75. throw $e;
  76. }
  77. }
  78. /**
  79. * 处理支付回调
  80. */
  81. public function handleNotify($input)
  82. {
  83. try {
  84. // 解析XML数据
  85. $data = $this->xmlToArray($input);
  86. // 验证签名
  87. if (!$this->verifySign($data, $data['sign'])) {
  88. throw new \Exception('签名验证失败');
  89. }
  90. // 检查支付状态
  91. if ($data['return_code'] != 'SUCCESS' || $data['result_code'] != 'SUCCESS') {
  92. throw new \Exception('支付失败: ' . ($data['return_msg'] ?? '未知错误'));
  93. }
  94. return [
  95. 'order_no' => $data['out_trade_no'],
  96. 'trade_no' => $data['transaction_id'],
  97. 'pay_amount' => $data['total_fee'] / 100,
  98. 'pay_time' => $this->formatWxTime($data['time_end']),
  99. 'raw_data' => $data,
  100. ];
  101. } catch (\Throwable $e) {
  102. $this->log('处理微信支付回调失败', [
  103. 'input' => $input,
  104. 'error' => $e->getMessage()
  105. ], 'error');
  106. throw $e;
  107. }
  108. }
  109. /**
  110. * 查询支付订单
  111. */
  112. public function queryOrder($orderNo)
  113. {
  114. try {
  115. $params = [
  116. 'appid' => $this->config['appid'],
  117. 'mch_id' => $this->config['mch_id'],
  118. 'out_trade_no' => $orderNo,
  119. 'nonce_str' => $this->generateNonceStr(),
  120. ];
  121. $params['sign'] = $this->generateSign($params);
  122. $result = $this->postXmlCurl($this->apiUrl . '/pay/orderquery', $this->arrayToXml($params));
  123. $response = $this->xmlToArray($result);
  124. if ($response['return_code'] == 'SUCCESS' && $response['result_code'] == 'SUCCESS') {
  125. $status = $response['trade_state'];
  126. $payStatus = 0; // 待支付
  127. if ($status == 'SUCCESS') {
  128. $payStatus = 1; // 已支付
  129. } elseif (in_array($status, ['CLOSED', 'REVOKED', 'PAYERROR', 'USERPAYING'])) {
  130. $payStatus = 2; // 支付失败
  131. }
  132. return [
  133. 'order_no' => $response['out_trade_no'],
  134. 'trade_no' => $response['transaction_id'],
  135. 'pay_status' => $payStatus,
  136. 'pay_amount' => $response['total_fee'] / 100,
  137. 'pay_time' => isset($response['time_end']) ? $this->formatWxTime($response['time_end']) : null,
  138. 'trade_state' => $status,
  139. ];
  140. }
  141. throw new \Exception('查询订单失败: ' . ($response['return_msg'] ?? '未知错误'));
  142. } catch (\Throwable $e) {
  143. $this->log('查询微信支付订单失败', [
  144. 'order_no' => $orderNo,
  145. 'error' => $e->getMessage()
  146. ], 'error');
  147. throw $e;
  148. }
  149. }
  150. /**
  151. * 验证签名
  152. */
  153. protected function verifySign($data, $sign)
  154. {
  155. $localSign = $this->generateSign($data);
  156. return $localSign === $sign;
  157. }
  158. /**
  159. * 生成签名
  160. */
  161. protected function generateSign($data)
  162. {
  163. // 过滤空值和sign参数
  164. $filterData = array_filter($data, function($value, $key) {
  165. return $value !== '' && $value !== null && $key !== 'sign';
  166. }, ARRAY_FILTER_USE_BOTH);
  167. // 按参数名ASCII码从小到大排序
  168. ksort($filterData);
  169. // 拼接字符串
  170. $string = '';
  171. foreach ($filterData as $key => $value) {
  172. $string .= $key . '=' . $value . '&';
  173. }
  174. $string .= 'key=' . $this->config['key'];
  175. // MD5加密并转为大写
  176. return strtoupper(md5($string));
  177. }
  178. /**
  179. * 生成随机字符串
  180. */
  181. protected function generateNonceStr($length = 32)
  182. {
  183. $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  184. $str = '';
  185. for ($i = 0; $i < $length; $i++) {
  186. $str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
  187. }
  188. return $str;
  189. }
  190. /**
  191. * 格式化微信支付时间
  192. */
  193. protected function formatWxTime($timeStr)
  194. {
  195. if (empty($timeStr)) return null;
  196. // 微信时间格式:yyyyMMddHHmmss
  197. $year = substr($timeStr, 0, 4);
  198. $month = substr($timeStr, 4, 2);
  199. $day = substr($timeStr, 6, 2);
  200. $hour = substr($timeStr, 8, 2);
  201. $minute = substr($timeStr, 10, 2);
  202. $second = substr($timeStr, 12, 2);
  203. return "{$year}-{$month}-{$day} {$hour}:{$minute}:{$second}";
  204. }
  205. /**
  206. * 发送XML请求
  207. */
  208. private function postXmlCurl($url, $xml, $useCert = false, $timeout = 30)
  209. {
  210. $ch = curl_init();
  211. curl_setopt($ch, CURLOPT_URL, $url);
  212. curl_setopt($ch, CURLOPT_POST, true);
  213. curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
  214. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  215. curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
  216. // 设置SSL
  217. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  218. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  219. // 如果需要证书
  220. if ($useCert) {
  221. curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'PEM');
  222. curl_setopt($ch, CURLOPT_SSLCERT, $this->config['cert_path']);
  223. curl_setopt($ch, CURLOPT_SSLKEYTYPE, 'PEM');
  224. curl_setopt($ch, CURLOPT_SSLKEY, $this->config['key_path']);
  225. }
  226. $response = curl_exec($ch);
  227. if (curl_errno($ch)) {
  228. curl_close($ch);
  229. throw new \Exception('CURL错误: ' . curl_error($ch));
  230. }
  231. curl_close($ch);
  232. return $response;
  233. }
  234. /**
  235. * 数组转XML
  236. */
  237. private function arrayToXml($arr)
  238. {
  239. $xml = '<xml>';
  240. foreach ($arr as $key => $val) {
  241. if (is_numeric($val)) {
  242. $xml .= "<{$key}>{$val}</{$key}>";
  243. } else {
  244. $xml .= "<{$key}><![CDATA[{$val}]]></{$key}>";
  245. }
  246. }
  247. $xml .= '</xml>';
  248. return $xml;
  249. }
  250. /**
  251. * XML转数组(兼容PHP 8.0+)
  252. */
  253. private function xmlToArray($xml)
  254. {
  255. if (empty($xml)) {
  256. return [];
  257. }
  258. try {
  259. // 方法1:使用DOMDocument(推荐,兼容性好)
  260. $dom = new \DOMDocument();
  261. // 针对PHP版本的兼容处理
  262. if (version_compare(PHP_VERSION, '8.0.0', '>=')) {
  263. // PHP 8.0+ 不需要禁用实体加载器,直接加载
  264. $dom->loadXML($xml, LIBXML_NOENT | LIBXML_NOCDATA);
  265. } else {
  266. // PHP 8.0以下需要禁用实体加载器
  267. $previous = libxml_disable_entity_loader(true);
  268. $dom->loadXML($xml, LIBXML_NOENT | LIBXML_NOCDATA);
  269. libxml_disable_entity_loader($previous);
  270. }
  271. // 转换为数组
  272. $data = $this->domToArray($dom->documentElement);
  273. return $data ?: [];
  274. } catch (\Exception $e) {
  275. $this->log('XML解析失败', [
  276. 'xml' => substr($xml, 0, 500), // 只记录前500字符
  277. 'error' => $e->getMessage()
  278. ], 'error');
  279. // 备用解析方法
  280. return $this->fallbackXmlToArray($xml);
  281. }
  282. }
  283. /**
  284. * DOMElement转换为数组
  285. */
  286. private function domToArray($node)
  287. {
  288. $output = [];
  289. switch ($node->nodeType) {
  290. case XML_CDATA_SECTION_NODE:
  291. case XML_TEXT_NODE:
  292. $output = trim($node->textContent);
  293. break;
  294. case XML_ELEMENT_NODE:
  295. for ($i = 0; $i < $node->childNodes->length; $i++) {
  296. $child = $node->childNodes->item($i);
  297. $value = $this->domToArray($child);
  298. if (isset($child->tagName)) {
  299. $tag = $child->tagName;
  300. if (!isset($output[$tag])) {
  301. $output[$tag] = [];
  302. }
  303. if (is_array($value) && isset($value['@value'])) {
  304. $output[$tag][] = $value['@value'];
  305. } else {
  306. $output[$tag][] = $value;
  307. }
  308. // 如果是单一元素,不要数组形式
  309. if (count($output[$tag]) === 1 && !is_array($output[$tag][0])) {
  310. $output[$tag] = $output[$tag][0];
  311. }
  312. } elseif ($value !== '') {
  313. $output = $value;
  314. }
  315. }
  316. if ($node->attributes->length) {
  317. $attributes = [];
  318. foreach ($node->attributes as $attrName => $attrNode) {
  319. $attributes[$attrName] = (string)$attrNode->value;
  320. }
  321. if (!empty($output)) {
  322. $output['@attributes'] = $attributes;
  323. } else {
  324. $output = ['@value' => '', '@attributes' => $attributes];
  325. }
  326. }
  327. break;
  328. }
  329. // 如果是数组且只有一个元素,直接返回那个元素
  330. if (is_array($output) && count($output) == 1 && isset($output[0])) {
  331. $output = $output[0];
  332. }
  333. return $output;
  334. }
  335. /**
  336. * 备用XML解析方法(原来的方法,带版本检查)
  337. */
  338. private function fallbackXmlToArray($xml)
  339. {
  340. try {
  341. // 版本检查,PHP 8.0以上不调用已废弃函数
  342. if (version_compare(PHP_VERSION, '8.0.0', '<')) {
  343. libxml_disable_entity_loader(true);
  344. }
  345. $data = json_decode(json_encode(simplexml_load_string(
  346. $xml,
  347. 'SimpleXMLElement',
  348. LIBXML_NOCDATA | LIBXML_NOENT
  349. )), true);
  350. return $data ?: [];
  351. } catch (\Exception $e) {
  352. $this->log('备用XML解析失败', [
  353. 'error' => $e->getMessage()
  354. ], 'error');
  355. return [];
  356. }
  357. }
  358. }