baseUrl = rtrim($baseUrl, '/'); $this->appId = $appId; $this->signingKey = $signingKey; $this->signType = $signType; } /** 仅生成请求参数,不发送网络请求;query 与正文都参与同一个签名。 */ public function buildParameters(array $business = [], array $query = []): array { foreach (['app_id', 'sign_type', 'timestamp', 'nonce', 'sign'] as $field) { if (array_key_exists($field, $query)) { throw new InvalidArgumentException('公共参数由客户端生成,不要放入 query'); } } $body = array_merge($business, [ 'app_id' => $this->appId, 'sign_type' => $this->signType, 'timestamp' => (string)time(), 'nonce' => bin2hex(random_bytes(16)), ]); unset($body['sign']); $params = array_merge($query, $body); $signString = self::signString($params); if ($this->signType === 'RSA2') { $key = openssl_pkey_get_private($this->signingKey); if ($key === false || !openssl_sign($signString, $signature, $key, OPENSSL_ALGO_SHA256)) { throw new RuntimeException('RSA2 私钥无效或签名失败'); } $body['sign'] = base64_encode($signature); } else { $body['sign'] = base64_encode(hash_hmac('sha256', $signString, $this->signingKey, true)); } return $body; } /** 仅校验通知签名/应用/时间;还须核对订单、金额,并按 notify_id 在你方事务中去重。 */ public function verifyNotification(array $payload, string $platformPublicKey = ''): bool { foreach (['app_id', 'sign_type', 'timestamp', 'nonce', 'sign'] as $field) { if (!isset($payload[$field]) || !is_string($payload[$field]) || $payload[$field] === '') { return false; } } if ($payload['app_id'] !== $this->appId || $payload['sign_type'] !== $this->signType || !ctype_digit($payload['timestamp']) || abs(time() - (int)$payload['timestamp']) > 300) { return false; } $message = self::signString($payload); if ($this->signType === 'RSA2') { $signature = base64_decode($payload['sign'], true); return $platformPublicKey !== '' && $signature !== false && openssl_verify($message, $signature, $platformPublicKey, OPENSSL_ALGO_SHA256) === 1; } return hash_equals(base64_encode(hash_hmac('sha256', $message, $this->signingKey, true)), $payload['sign']); } private static function signString(array $params): string { $values = []; foreach ($params as $key => $value) { if ($key === 'sign') { continue; } if ($value === '' || $value === null) { continue; } if (is_array($value)) { $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } $values[$key] = (string)$value; } ksort($values); $pairs = []; foreach ($values as $key => $value) { $pairs[] = $key . '=' . $value; } return implode('&', $pairs); } /** 常规接口使用 JSON;仅 upload 传 files,文件内容不参与签名。 */ public function request(string $path, array $business = [], array $query = [], array $files = []): array { if (strpos($path, '/api/open.') !== 0 || strpos($path, '?') !== false || strpos($path, '#') !== false) { throw new InvalidArgumentException('请提供开放接口路径,查询参数放入 query'); } if ($files && $path !== '/api/open.merchant/upload') { throw new InvalidArgumentException('仅 upload 支持文件上传'); } $params = $this->buildParameters($business, $query); $url = $this->baseUrl . $path; if ($query) { $url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); } $options = [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 15, ]; if ($files) { foreach ($files as $field => $filePath) { if ($field !== 'file' || !is_file($filePath)) { throw new InvalidArgumentException('文件字段必须为 file,且本地文件必须存在'); } $params[$field] = new CURLFile($filePath); } $options[CURLOPT_POSTFIELDS] = $params; // multipart 的 Content-Type/boundary 由 cURL 自动生成。 } else { $options[CURLOPT_HTTPHEADER] = ['Content-Type: application/json']; $options[CURLOPT_POSTFIELDS] = json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } $ch = curl_init($url); curl_setopt_array($ch, $options); $raw = curl_exec($ch); if ($raw === false) { $error = curl_error($ch); curl_close($ch); throw new RuntimeException('网络请求失败:' . $error); } $httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $body = json_decode($raw, true); if (!is_array($body) || !array_key_exists('code', $body)) { throw new RuntimeException('接口未返回约定的 JSON 响应,HTTP ' . $httpStatus); } return ['http_status' => $httpStatus, 'body' => $body, 'raw' => $raw]; } }