WechatPayComplaintService.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. <?php
  2. namespace plugin\saiadmin\app\service\user;
  3. use GuzzleHttp\Client;
  4. use GuzzleHttp\Exception\RequestException;
  5. use plugin\saiadmin\exception\ApiException;
  6. /**
  7. * 微信支付商户投诉 API v3 客户端。
  8. *
  9. * 签名串和 Authorization 头严格按照微信支付 API v3 规范生成,配置直接复用
  10. * sa_wechat_mini_program 中的商户号、证书序列号和商户私钥。
  11. */
  12. class WechatPayComplaintService
  13. {
  14. private const BASE_URL = 'https://api.mch.weixin.qq.com';
  15. private Client $client;
  16. public function __construct(?Client $client = null)
  17. {
  18. $this->client = $client ?? new Client([
  19. 'base_uri' => self::BASE_URL,
  20. 'connect_timeout' => 8,
  21. 'timeout' => 20,
  22. 'verify' => true,
  23. 'http_errors' => true,
  24. ]);
  25. }
  26. public function complaints(array $config, array $query): array
  27. {
  28. return $this->request('GET', '/v3/merchant-service/complaints-v2', $config, $query);
  29. }
  30. public function detail(array $config, string $complaintId): array
  31. {
  32. return $this->request(
  33. 'GET',
  34. '/v3/merchant-service/complaints-v2/' . rawurlencode($complaintId),
  35. $config
  36. );
  37. }
  38. public function history(array $config, string $complaintId): array
  39. {
  40. return $this->request(
  41. 'GET',
  42. '/v3/merchant-service/complaints-v2/' . rawurlencode($complaintId) . '/negotiation-historys',
  43. $config
  44. );
  45. }
  46. public function reply(array $config, string $complaintId, string $content): array
  47. {
  48. return $this->request(
  49. 'POST',
  50. '/v3/merchant-service/complaints-v2/' . rawurlencode($complaintId) . '/response',
  51. $config,
  52. [],
  53. [
  54. 'complainted_mchid' => (string) $config['mchid'],
  55. 'response_content' => $content,
  56. ]
  57. );
  58. }
  59. public function complete(array $config, string $complaintId): array
  60. {
  61. return $this->request(
  62. 'POST',
  63. '/v3/merchant-service/complaints-v2/' . rawurlencode($complaintId) . '/complete',
  64. $config,
  65. [],
  66. ['complainted_mchid' => (string) $config['mchid']]
  67. );
  68. }
  69. public function decryptSensitive(string $ciphertext, string $privateKey): string
  70. {
  71. if ($ciphertext === '') {
  72. return '';
  73. }
  74. $key = openssl_pkey_get_private($this->normalizePrivateKey($privateKey));
  75. if ($key === false) {
  76. throw new ApiException('微信支付商户私钥格式错误,请在微信小程序配置中重新填写');
  77. }
  78. $plainText = '';
  79. $success = openssl_private_decrypt(
  80. base64_decode($ciphertext, true) ?: '',
  81. $plainText,
  82. $key,
  83. OPENSSL_PKCS1_OAEP_PADDING
  84. );
  85. return $success ? $plainText : '';
  86. }
  87. private function request(
  88. string $method,
  89. string $path,
  90. array $config,
  91. array $query = [],
  92. ?array $payload = null
  93. ): array {
  94. $this->assertConfig($config);
  95. $queryString = $query === [] ? '' : http_build_query($query, '', '&', PHP_QUERY_RFC3986);
  96. $canonicalUrl = $path . ($queryString !== '' ? '?' . $queryString : '');
  97. $body = $payload === null
  98. ? ''
  99. : (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  100. $timestamp = time();
  101. $nonce = bin2hex(random_bytes(16));
  102. $message = strtoupper($method) . "\n{$canonicalUrl}\n{$timestamp}\n{$nonce}\n{$body}\n";
  103. $privateKey = openssl_pkey_get_private($this->normalizePrivateKey((string) $config['apiclient_key']));
  104. if ($privateKey === false) {
  105. throw new ApiException('微信支付商户私钥格式错误,请在微信小程序配置中重新填写');
  106. }
  107. $signature = '';
  108. if (!openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
  109. throw new ApiException('微信支付请求签名失败,请检查商户私钥');
  110. }
  111. $token = sprintf(
  112. 'mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"',
  113. $config['mchid'],
  114. $nonce,
  115. base64_encode($signature),
  116. $timestamp,
  117. $config['certificate_serial_no']
  118. );
  119. $options = [
  120. 'headers' => [
  121. 'Accept' => 'application/json',
  122. 'Content-Type' => 'application/json',
  123. 'User-Agent' => 'SaiAdmin-WechatPay/1.0',
  124. 'Authorization' => 'WECHATPAY2-SHA256-RSA2048 ' . $token,
  125. ],
  126. ];
  127. if ($body !== '') {
  128. $options['body'] = $body;
  129. }
  130. try {
  131. $response = $this->client->request($method, $canonicalUrl, $options);
  132. $content = trim((string) $response->getBody());
  133. if ($content === '') {
  134. return [];
  135. }
  136. $result = json_decode($content, true);
  137. if (!is_array($result)) {
  138. throw new ApiException('微信支付返回了无法识别的数据');
  139. }
  140. return $result;
  141. } catch (RequestException $exception) {
  142. $responseBody = $exception->getResponse()
  143. ? (string) $exception->getResponse()->getBody()
  144. : '';
  145. $error = json_decode($responseBody, true);
  146. $message = is_array($error) ? (string) ($error['message'] ?? '') : '';
  147. $code = is_array($error) ? (string) ($error['code'] ?? '') : '';
  148. $detail = $message !== '' ? $message : $exception->getMessage();
  149. if ($code !== '') {
  150. $detail .= '(' . $code . ')';
  151. }
  152. throw new ApiException('微信支付投诉接口请求失败:' . $detail);
  153. }
  154. }
  155. private function assertConfig(array $config): void
  156. {
  157. foreach (['mchid', 'certificate_serial_no', 'apiclient_key'] as $field) {
  158. if (trim((string) ($config[$field] ?? '')) === '') {
  159. throw new ApiException('微信小程序支付配置不完整,请补充商户号、证书序列号和商户私钥');
  160. }
  161. }
  162. }
  163. private function normalizePrivateKey(string $privateKey): string
  164. {
  165. return str_replace(["\\r\\n", "\\n"], "\n", trim($privateKey));
  166. }
  167. }