SupefinaSpei.php 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. <?php
  2. namespace App\Services;
  3. use App\Util;
  4. class SupefinaSpei
  5. {
  6. /**
  7. * @var array<string,mixed>
  8. */
  9. public $config = [];
  10. public function __construct(string $configKey = 'SupefinaSpeiOut')
  11. {
  12. $payConfigService = new PayConfig();
  13. $this->config = $payConfigService->getConfig($configKey) ?? [];
  14. }
  15. /**
  16. * 生成随机字符串
  17. */
  18. public function generateNonceStr(int $length = 32): string
  19. {
  20. $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  21. $str = '';
  22. $max = strlen($chars) - 1;
  23. for ($i = 0; $i < $length; $i++) {
  24. $str .= $chars[mt_rand(0, $max)];
  25. }
  26. return $str;
  27. }
  28. /**
  29. * 对参数进行签名(MD5,大写),完全遵循 Supefina 文档:
  30. * 1. 排除 sign、null、空字符串
  31. * 2. 参数名 ASCII 升序
  32. * 3. key1=value1&key2=value2&...&key=商户密钥
  33. * 4. boolean 转成 "true"/"false",与 Java toString() 一致
  34. *
  35. * @param array<string,mixed> $params
  36. * @return array<string,mixed>
  37. */
  38. public function sign(array $params): array
  39. {
  40. $key = trim((string)($this->config['key'] ?? ''));
  41. if ($key === '') {
  42. throw new \RuntimeException('SupefinaSpei key missing in config');
  43. }
  44. $signStr = $this->buildSignString($params, $key);
  45. $sign = strtoupper(md5($signStr));
  46. Util::WriteLog('SupefinaSpei', 'sign string (key redacted): ' . str_replace($key, '***', $signStr));
  47. $params['sign'] = $sign;
  48. return $params;
  49. }
  50. /**
  51. * 按 Supefina 文档构建待签名字符串
  52. */
  53. private function buildSignString(array $params, string $merchantKey): string
  54. {
  55. $filtered = [];
  56. foreach ($params as $k => $v) {
  57. if (strtolower($k) === 'sign') {
  58. continue;
  59. }
  60. if ($v === null) {
  61. continue;
  62. }
  63. if ($v === '' || (is_string($v) && trim($v) === '')) {
  64. continue;
  65. }
  66. $filtered[$k] = $this->valueToString($v);
  67. }
  68. ksort($filtered);
  69. $parts = [];
  70. foreach ($filtered as $k => $v) {
  71. $parts[] = $k . '=' . $v;
  72. }
  73. $parts[] = 'key=' . $merchantKey;
  74. return implode('&', $parts);
  75. }
  76. /**
  77. * 将值转为签名字符串,与 Java toString() 行为一致
  78. */
  79. private function valueToString($v): string
  80. {
  81. if (is_bool($v)) {
  82. return $v ? 'true' : 'false';
  83. }
  84. if (is_array($v)) {
  85. return json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  86. }
  87. return (string)$v;
  88. }
  89. /**
  90. * 验证回调签名。
  91. *
  92. * @param array<string,mixed> $data
  93. */
  94. public function verify(array $data): bool
  95. {
  96. if (!isset($data['sign'])) {
  97. return false;
  98. }
  99. $key = trim((string)($this->config['key'] ?? ''));
  100. if ($key === '') {
  101. return false;
  102. }
  103. $sign = strtoupper(trim((string)$data['sign']));
  104. $params = $data;
  105. unset($params['sign']);
  106. $signStr = $this->buildSignString($params, $key);
  107. $expected = strtoupper(md5($signStr));
  108. return $sign === $expected;
  109. }
  110. /**
  111. * 发送 JSON POST 请求。
  112. *
  113. * @param array<string,mixed> $payload
  114. */
  115. public function postJson(string $url, array $payload, int $timeout = 20): string
  116. {
  117. $data = json_encode($payload, JSON_UNESCAPED_UNICODE);
  118. $headers = [
  119. 'Content-Type: application/json',
  120. ];
  121. $ch = curl_init();
  122. curl_setopt($ch, CURLOPT_URL, $url);
  123. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  124. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  125. curl_setopt($ch, CURLOPT_POST, 1);
  126. curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
  127. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
  128. curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
  129. curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
  130. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  131. $result = curl_exec($ch);
  132. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  133. if (curl_errno($ch)) {
  134. $error = curl_error($ch);
  135. Util::WriteLog('SupefinaSpei_error', 'CURL Error: '.$error);
  136. curl_close($ch);
  137. throw new \RuntimeException('CURL error: '.$error);
  138. }
  139. if ($httpCode !== 200) {
  140. Util::WriteLog('SupefinaSpei_error', 'HTTP Code: '.$httpCode.' | Response: '.$result);
  141. }
  142. curl_close($ch);
  143. return (string)$result;
  144. }
  145. }