*/ private array $callbackKeys = []; public function __construct( private string $baseUrl, private string $merchantCode, private string $apiKey, private int $connectTimeout = 3, private int $timeout = 10, private int $maxRetries = 2, array $callbackKeys = [] ) { $this->baseUrl = $this->validateBaseUrl($baseUrl); if (!preg_match('/^[A-Za-z0-9_-]{3,64}$/', $merchantCode) || $apiKey === '') { throw new InvalidArgumentException('商户编号或 API 密钥格式不合法'); } if (!extension_loaded('curl')) { throw new RuntimeException('SMPAY SDK 需要启用 PHP curl 扩展'); } $this->connectTimeout = max(1, $connectTimeout); $this->timeout = max($this->connectTimeout, $timeout); $this->maxRetries = max(0, min(5, $maxRetries)); foreach ($callbackKeys as $version => $key) { $this->setCallbackKey((int)$version, (string)$key); } } /** * Creates an order. Retried POST requests are safe only because this method * requires either a stable merchant order number or an idempotency key. */ public function createOrder(array $payload, ?string $idempotencyKey = null): array { $merchantOrderNo = trim((string)($payload['merchant_order_no'] ?? '')); $outTradeNo = trim((string)($payload['out_trade_no'] ?? '')); if ($merchantOrderNo !== '' && $outTradeNo !== '' && !hash_equals($merchantOrderNo, $outTradeNo)) { throw new InvalidArgumentException('merchant_order_no 与 out_trade_no 不一致'); } $reference = $merchantOrderNo !== '' ? $merchantOrderNo : $outTradeNo; if ($reference !== '' && (mb_strlen($reference) > 64 || preg_match('/\s/', $reference))) { throw new InvalidArgumentException('商户订单号格式不合法'); } if ($idempotencyKey !== null && $idempotencyKey !== '' && !preg_match('/^[A-Za-z0-9._-]{16,128}$/', $idempotencyKey)) { throw new InvalidArgumentException('幂等键必须为 16-128 位字母、数字、点、下划线或连字符'); } if ($reference === '' && ($idempotencyKey === null || $idempotencyKey === '')) { throw new InvalidArgumentException('未提供商户订单号时必须提供幂等键'); } $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $headers = $idempotencyKey === null || $idempotencyKey === '' ? [] : ['X-SMPAY-Idempotency-Key' => $idempotencyKey]; return $this->request('POST', '/api/v1/orders', $body, $headers, true); } /** Queries an order by platform order number or merchant order number. */ public function queryOrder(string $reference, bool $isPlatformOrderNo = true): array { $reference = trim($reference); if ($reference === '' || mb_strlen($reference) > 64) { throw new InvalidArgumentException('查询订单号不能为空且不能超过 64 个字符'); } $query = http_build_query([ $isPlatformOrderNo ? 'platform_order_no' : 'merchant_order_no' => $reference, ], '', '&', PHP_QUERY_RFC3986); return $this->request('GET', '/api/v1/orders/query?' . $query, '', [], true); } /** Retains a callback key for events emitted before API key rotation. */ public function setCallbackKey(int $version, string $key): self { if ($version < 1 || $key === '') { throw new InvalidArgumentException('回调密钥版本或密钥不合法'); } $this->callbackKeys[(string)$version] = $key; return $this; } /** Verifies a raw callback body. Pass the X-SMPAY-Key-Version value when it exists. */ public function verifyCallback(string $rawBody, string $signature, ?int $keyVersion = null): bool { if (!preg_match('/^[a-f0-9]{64}$/i', $signature)) { return false; } $key = $keyVersion === null ? $this->apiKey : ($this->callbackKeys[(string)$keyVersion] ?? null); if ($key === null || $key === '') { return false; } return hash_equals(hash_hmac('sha256', $rawBody, $key), strtolower($signature)); } /** * Verifies callback signature, key version and event identifier together. * Header names may use any casing. */ public function verifyCallbackRequest(string $rawBody, array $headers): bool { $normalized = []; foreach ($headers as $name => $value) { $normalized[strtolower((string)$name)] = trim((string)$value); } $eventId = $normalized['x-smpay-event-id'] ?? ''; $version = $normalized['x-smpay-key-version'] ?? ''; if ($eventId === '' || !preg_match('/^[1-9]\d*$/', $version) || !$this->verifyCallback($rawBody, $normalized['x-smpay-signature'] ?? '', (int)$version)) { return false; } try { $payload = json_decode($rawBody, true, 32, JSON_THROW_ON_ERROR); } catch (JsonException) { return false; } return is_array($payload) && isset($payload['event_id']) && hash_equals($eventId, (string)$payload['event_id']); } /** @param array $extraHeaders */ private function request(string $method, string $pathWithQuery, string $body, array $extraHeaders, bool $safeToRetry): array { for ($attempt = 0; ; $attempt++) { $response = $this->send($method, $pathWithQuery, $body, $extraHeaders); if ($response['transport_error'] !== '') { if ($safeToRetry && $attempt < $this->maxRetries) { $this->sleepBeforeRetry($attempt, null); continue; } throw new SmpayTransportException('SMPAY 请求失败:' . $response['transport_error'], $response['transport_error']); } try { $decoded = json_decode($response['body'], true, 32, JSON_THROW_ON_ERROR); } catch (JsonException) { throw new SmpayApiException('SMPAY 返回了非 JSON 响应', $response['status'], 'INVALID_RESPONSE', $response['retry_after']); } if (!is_array($decoded)) { throw new SmpayApiException('SMPAY 返回格式不正确', $response['status'], 'INVALID_RESPONSE', $response['retry_after']); } $code = $decoded['code'] ?? null; if ($response['status'] >= 200 && $response['status'] < 300 && ($code === 0 || $code === '0')) { return is_array($decoded['data'] ?? null) ? $decoded['data'] : []; } $exception = new SmpayApiException( (string)($decoded['message'] ?? 'SMPAY 请求未成功'), $response['status'], (string)($code ?? 'UNKNOWN'), $response['retry_after'] ); if ($safeToRetry && $attempt < $this->maxRetries && $this->isRetryableStatus($response['status'])) { $this->sleepBeforeRetry($attempt, $response['retry_after']); continue; } throw $exception; } } /** @param array $extraHeaders */ private function send(string $method, string $pathWithQuery, string $body, array $extraHeaders): array { $timestamp = (string)time(); $nonce = bin2hex(random_bytes(16)); $headers = [ 'Accept: application/json', 'X-SMPAY-Merchant-Code: ' . $this->merchantCode, 'X-SMPAY-Timestamp: ' . $timestamp, 'X-SMPAY-Nonce: ' . $nonce, 'X-SMPAY-Signature: ' . $this->signature($method, $pathWithQuery, $timestamp, $nonce, $body), ]; if ($method === 'POST') { $headers[] = 'Content-Type: application/json'; } foreach ($extraHeaders as $name => $value) { $headers[] = $name . ': ' . $value; } $responseHeaders = []; $handle = curl_init($this->baseUrl . $pathWithQuery); if ($handle === false) { return ['status' => 0, 'body' => '', 'retry_after' => null, 'transport_error' => '无法初始化 cURL']; } curl_setopt_array($handle, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_HEADERFUNCTION => static function ($curl, string $header) use (&$responseHeaders): int { $line = trim($header); if (str_contains($line, ':')) { [$name, $value] = explode(':', $line, 2); $responseHeaders[strtolower(trim($name))] = trim($value); } return strlen($header); }, CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); if ($method === 'POST') { curl_setopt($handle, CURLOPT_POSTFIELDS, $body); } $responseBody = curl_exec($handle); $status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $error = curl_error($handle); curl_close($handle); return [ 'status' => $status, 'body' => is_string($responseBody) ? $responseBody : '', 'retry_after' => $this->retryAfter($responseHeaders['retry-after'] ?? ''), 'transport_error' => is_string($responseBody) ? '' : ($error !== '' ? $error : '未知网络错误'), ]; } private function signature(string $method, string $path, string $timestamp, string $nonce, string $body): string { $canonical = implode("\n", [strtoupper($method), $path, $timestamp, $nonce, hash('sha256', $body)]); return hash_hmac('sha256', $canonical, $this->apiKey); } private function validateBaseUrl(string $baseUrl): string { $baseUrl = rtrim(trim($baseUrl), '/'); $parts = parse_url($baseUrl); if (!is_array($parts) || strtolower((string)($parts['scheme'] ?? '')) !== 'https' || !isset($parts['host']) || isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) { throw new InvalidArgumentException('SMPAY API 地址必须是无查询参数的 HTTPS 根地址'); } return $baseUrl; } private function isRetryableStatus(int $status): bool { return $status === 408 || $status === 429 || $status >= 500; } private function sleepBeforeRetry(int $attempt, ?int $retryAfter): void { $backoff = min(5, 1 << $attempt); $seconds = $retryAfter === null ? $backoff : min(self::MAX_RETRY_AFTER_SECONDS, max($backoff, $retryAfter)); usleep((int)(($seconds * 1000000) + random_int(0, 250000))); } private function retryAfter(string $value): ?int { if (ctype_digit($value)) { return max(1, (int)$value); } $timestamp = strtotime($value); return $timestamp === false ? null : max(1, $timestamp - time()); } } final class SmpayApiException extends RuntimeException { public function __construct( string $message, public readonly int $httpStatus, public readonly string $apiCode, public readonly ?int $retryAfter = null ) { parent::__construct($message, $httpStatus); } } final class SmpayTransportException extends RuntimeException { public function __construct(string $message, public readonly string $transportError) { parent::__construct($message); } } /* $client = new SmpayClient( getenv('SMPAY_BASE_URL'), getenv('SMPAY_MERCHANT_CODE'), getenv('SMPAY_API_KEY') ); $order = $client->createOrder([ 'merchant_order_no' => 'M202609250001', 'payment_method' => 'alipay', 'amount' => '100.00', 'province' => '广东省', 'city' => '深圳市', 'subject' => '会员充值', ]); */