From 09f64b45b189509366ef38ca148e1a503f0aecb8 Mon Sep 17 00:00:00 2001 From: Anyon Date: Fri, 8 May 2026 11:33:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E5=AE=8C=E5=96=84=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E8=B0=83=E7=94=A8=E9=80=8F=E4=BC=A0=E4=B8=8E=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 微信支付 APIv3 call/raw 统一按实际 query/body 生成签名,并继续透传 Guzzle options。 - 微信支付默认关闭 http_errors,解析非 2xx JSON 错误并统一抛出 ApiException。 - 移除默认写入错误的 Wechatpay-Serial,请求敏感信息加密时由业务传平台序列号。 - 支付宝网关请求捕获 Guzzle 异常并转换为 SDK 异常,保留验签和支付快捷调用。 - 补充支付下载、通知解密、错误响应、支付宝验签和 options 透传测试。 --- src/Platform/Alipay/PaymentClient.php | 6 +- src/Platform/Alipay/PlatformClient.php | 53 ++++++--- src/Platform/Wechat/PaymentClient.php | 156 +++++++++++++++++++------ src/Support/JsonClient.php | 13 ++- tests/AlipayPlatformClientTest.php | 9 +- tests/JsonClientTest.php | 90 +++++++++++++- tests/PaymentClientTest.php | 47 +++++++- tests/ProtocolClientTest.php | 71 ++++++++++- 8 files changed, 380 insertions(+), 65 deletions(-) diff --git a/src/Platform/Alipay/PaymentClient.php b/src/Platform/Alipay/PaymentClient.php index 6f0428d..c80918c 100644 --- a/src/Platform/Alipay/PaymentClient.php +++ b/src/Platform/Alipay/PaymentClient.php @@ -1,9 +1,11 @@ */ namespace We\Platform\Alipay; diff --git a/src/Platform/Alipay/PlatformClient.php b/src/Platform/Alipay/PlatformClient.php index 64787d1..22becb0 100644 --- a/src/Platform/Alipay/PlatformClient.php +++ b/src/Platform/Alipay/PlatformClient.php @@ -1,17 +1,22 @@ */ namespace We\Platform\Alipay; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\GuzzleException; use We\Config\AlipayPlatformConfig; +use We\Exception\ApiException; use We\Exception\WechatException; +use We\Support\CredentialValidator; /** * 支付宝开放平台客户端。 @@ -42,10 +47,14 @@ class PlatformClient public function request(string $apiMethod, array $bizContent = [], array $extra = []): array { $params = $this->buildGatewayParams($apiMethod, $bizContent, $extra); - $response = $this->http->request('POST', $this->config->gateway, [ - 'form_params' => $params, - 'headers' => ['Accept' => 'application/json'], - ]); + try { + $response = $this->http->request('POST', $this->config->gateway, [ + 'form_params' => $params, + 'headers' => ['Accept' => 'application/json'], + ]); + } catch (GuzzleException $e) { + throw new ApiException('支付宝网关请求失败: ' . $e->getMessage(), (int)$e->getCode(), $e); + } $body = (string)$response->getBody(); $payload = json_decode($body, true); if (!is_array($payload)) { @@ -67,7 +76,7 @@ class PlatformClient /** * 验证支付宝异步通知签名;业务处理通知前应先完成验签。 * - * @param array $params 支付宝通知完整参数,包含 sign/sign_type。 + * @param array $params 支付宝通知完整参数,包含 sign/sign_type */ public function verifyNotify(array $params): bool { @@ -112,12 +121,18 @@ class PlatformClient */ public function decrypt(string $encryptedData, string $sessionKey, string $iv): array { + $ciphertext = base64_decode($encryptedData, true); + $key = base64_decode($sessionKey, true); + $ivValue = base64_decode($iv, true); + if ($ciphertext === false || $key === false || $ivValue === false) { + throw new WechatException('支付宝数据解密参数 Base64 无效'); + } $plain = openssl_decrypt( - base64_decode($encryptedData, true) ?: '', + $ciphertext, 'AES-128-CBC', - base64_decode($sessionKey, true) ?: '', + $key, OPENSSL_RAW_DATA, - base64_decode($iv, true) ?: '' + $ivValue ); if (!is_string($plain) || $plain === '') { throw new WechatException('支付宝数据解密失败'); @@ -221,7 +236,7 @@ class PlatformClient 'sign_type' => $this->config->signType, 'timestamp' => date('Y-m-d H:i:s'), 'version' => $this->config->version, - 'biz_content' => json_encode($bizContent, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}', + 'biz_content' => $this->jsonString($bizContent, '{}'), ]; foreach ($extra as $key => $value) { $params[(string)$key] = $this->gatewayValue($value); @@ -270,7 +285,17 @@ class PlatformClient */ private function gatewayValue(mixed $value): string { - return is_scalar($value) ? (string)$value : (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''); + return is_scalar($value) ? (string)$value : $this->jsonString($value, ''); + } + + /** + * 将支付宝网关数组参数编码为 JSON 字符串。 + */ + private function jsonString(mixed $value, string $fallback): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return is_string($json) ? $json : $fallback; } /** @@ -297,7 +322,7 @@ class PlatformClient */ private function normalizePrivateKey(string $privateKey): string { - return str_contains($privateKey, 'BEGIN') ? $privateKey : "-----BEGIN PRIVATE KEY-----\n" . chunk_split($privateKey, 64, "\n") . "-----END PRIVATE KEY-----"; + return CredentialValidator::normalizePrivateKey($privateKey, true); } /** @@ -305,7 +330,7 @@ class PlatformClient */ private function normalizePublicKey(string $publicKey): string { - return str_contains($publicKey, 'BEGIN') ? $publicKey : "-----BEGIN PUBLIC KEY-----\n" . chunk_split($publicKey, 64, "\n") . "-----END PUBLIC KEY-----"; + return CredentialValidator::normalizePublicKey($publicKey, true); } /** diff --git a/src/Platform/Wechat/PaymentClient.php b/src/Platform/Wechat/PaymentClient.php index 310d1b7..361d127 100644 --- a/src/Platform/Wechat/PaymentClient.php +++ b/src/Platform/Wechat/PaymentClient.php @@ -1,13 +1,16 @@ */ namespace We\Platform\Wechat; +use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use Psr\Http\Message\ResponseInterface; use We\Config\WechatPaymentConfig; @@ -34,22 +37,28 @@ final class PaymentClient private readonly WechatPaymentConfig $config, ?ClientInterface $http = null, ) { - $this->http = new JsonClient($http ?? new \GuzzleHttp\Client(['base_uri' => 'https://api.mch.weixin.qq.com/', 'timeout' => 20.0])); + $this->http = new JsonClient($http ?? new Client(['base_uri' => 'https://api.mch.weixin.qq.com/', 'timeout' => 20.0])); } /** * 发起微信支付 APIv3 请求并附加商户请求签名。 * * @param array $payload + * @param array $query + * @param array $options * @return array */ - public function request(string $method, string $uri, array $payload = [], array $query = []): array + public function request(string $method, string $uri, array $payload = [], array $query = [], array $options = []): array { - $response = $this->raw($method, $uri, $payload, $query); + $response = $this->raw($method, $uri, $payload, $query, $options); + $statusCode = (int)$response->getStatusCode(); $body = (string)$response->getBody(); $data = $body === '' ? [] : json_decode($body, true); if (!is_array($data)) { - throw new ApiException('微信支付接口响应不是有效 JSON', (int)$response->getStatusCode(), null, ['body' => $body]); + throw new ApiException('微信支付接口响应不是有效 JSON', $statusCode, null, ['body' => $body]); + } + if ($statusCode >= 400) { + throw new ApiException((string)($data['message'] ?? $data['code'] ?? '微信支付接口请求失败'), $statusCode, null, $data); } return $data; @@ -66,9 +75,7 @@ final class PaymentClient { $uri = '/' . ltrim($uri, '/'); $query = $this->mergeQuery($query, $options); - $body = array_key_exists('body', $options) - ? (string)$options['body'] - : ($payload === [] ? '' : (json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')); + $body = $this->resolveRequestBody($payload, $options); $nonce = bin2hex(random_bytes(16)); $timestamp = (string)time(); $authorization = $this->authorization($method, $uri . $this->queryString($query), $timestamp, $nonce, $body); @@ -90,28 +97,6 @@ final class PaymentClient return $this->raw('GET', $uri, [], $query, $options); } - /** - * 校验微信支付通知签名并解密通知 resource。 - * - * @param array $headers - * @param array|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码。 - * @return array - */ - private function decryptNotification(array $headers, array|string $body): array - { - // 微信支付 APIv3 签名串要求使用 HTTP 原始 body;不能先 json_decode 再重新编码,否则字段顺序或转义差异会导致验签失败。 - $rawBody = is_string($body) ? $body : (json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - $this->assertNotificationSignature($headers, $rawBody); - $payload = is_string($body) ? json_decode($body, true) : $body; - if (!is_array($payload)) { - throw new WechatException('微信支付回调 JSON 无效'); - } - /** @var array{ciphertext:string,nonce:string,associated_data?:string} $resource */ - $resource = $payload['resource'] ?? []; - - return PaymentCrypto::decryptResource($this->config->apiV3Key, $resource); - } - /** * 通用 API 调用入口;官方接口使用 API path + 参数数组,特殊路径仅用于支付通知验签与解密。 * @@ -134,8 +119,9 @@ final class PaymentClient return $this->request( $method, $uri, - $method === 'GET' ? (is_array($options['payload'] ?? null) ? $options['payload'] : []) : $params, - $method === 'GET' ? $params : (is_array($options['query'] ?? null) ? $options['query'] : []), + $this->paymentCallPayload($method, $params, $options), + $this->paymentCallQuery($method, $params, $options), + $this->paymentCallOptions($options), ); } @@ -163,6 +149,30 @@ final class PaymentClient return $this->call($uriOrPath, $params, 'GET', $options); } + /** + * 校验微信支付通知签名并解密通知 resource。 + * + * @param array $headers + * @param array|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码 + * @return array + */ + private function decryptNotification(array $headers, array|string $body): array + { + // 微信支付 APIv3 签名串要求使用 HTTP 原始 body;不能先 json_decode 再重新编码,否则字段顺序或转义差异会导致验签失败。 + $rawBody = is_string($body) ? $body : $this->encodeJsonBody($body); + $this->assertNotificationSignature($headers, $rawBody); + $payload = is_string($body) ? json_decode($body, true) : $body; + if (!is_array($payload)) { + throw new WechatException('微信支付回调 JSON 无效'); + } + $resource = $payload['resource'] ?? null; + if (!is_array($resource)) { + throw new WechatException('微信支付回调 resource 无效'); + } + + return PaymentCrypto::decryptResource($this->config->apiV3Key, $resource); + } + /** * 生成微信支付 APIv3 `WECHATPAY2-SHA256-RSA2048` Authorization 请求头。 */ @@ -184,6 +194,8 @@ final class PaymentClient /** * 校验微信支付通知头中的平台证书/公钥序列号与 RSA-SHA256 签名。 + * + * @param array $headers */ private function assertNotificationSignature(array $headers, string $body): void { @@ -235,6 +247,83 @@ final class PaymentClient return array_merge($query, is_array($options['query'] ?? null) ? $options['query'] : []); } + /** + * 解析微信支付 APIv3 请求体,确保参与签名的 body 与实际发送的 body 完全一致。 + * + * @param array $payload + * @param array $options + */ + private function resolveRequestBody(array $payload, array &$options): string + { + if (array_key_exists('body', $options)) { + unset($options['json']); + + return (string)$options['body']; + } + if (array_key_exists('json', $options)) { + $body = $this->encodeJsonBody($options['json']); + unset($options['json']); + + return $body; + } + + return $payload === [] ? '' : $this->encodeJsonBody($payload); + } + + /** + * 编码微信支付 APIv3 JSON 请求体。 + */ + private function encodeJsonBody(mixed $payload): string + { + try { + return json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } catch (\JsonException $e) { + throw new WechatException('微信支付请求 JSON 编码失败', 0, $e); + } + } + + /** + * 构造通用调用的请求 payload;GET 默认无 body,可通过 options.payload 显式传入。 + * + * @param array $params + * @param array $options + * @return array + */ + private function paymentCallPayload(string $method, array $params, array $options): array + { + return $method === 'GET' ? (is_array($options['payload'] ?? null) ? $options['payload'] : []) : $params; + } + + /** + * 构造通用调用的 query,GET 使用 params,其他方法使用 options.query。 + * + * @param array $params + * @param array $options + * @return array + */ + private function paymentCallQuery(string $method, array $params, array $options): array + { + $query = $method === 'GET' ? $params : []; + if (is_array($options['query'] ?? null)) { + $query = array_merge($query, $options['query']); + } + + return $query; + } + + /** + * 移除 SDK 内部控制项,其余 Guzzle options 继续透传到底层 HTTP 客户端。 + * + * @param array $options + * @return array + */ + private function paymentCallOptions(array $options): array + { + unset($options['payload'], $options['query'], $options['raw_body']); + + return $options; + } + /** * 构造签名所需的 query string。 * @@ -257,7 +346,6 @@ final class PaymentClient $headers['Accept'] = $headers['Accept'] ?? 'application/json'; $headers['Content-Type'] = $headers['Content-Type'] ?? 'application/json'; $headers['Authorization'] = $authorization; - $headers['Wechatpay-Serial'] = $this->config->merchantSerial; return $headers; } diff --git a/src/Support/JsonClient.php b/src/Support/JsonClient.php index d0e016b..b5d4535 100644 --- a/src/Support/JsonClient.php +++ b/src/Support/JsonClient.php @@ -1,9 +1,11 @@ */ namespace We\Support; @@ -38,15 +40,19 @@ final class JsonClient public function request(string $method, string $uri, array $query = [], array $options = []): array { $response = $this->raw($method, $uri, $query, $options); + $statusCode = (int)$response->getStatusCode(); $body = (string)$response->getBody(); $data = $body === '' ? [] : json_decode($body, true); if (!is_array($data)) { - throw new ApiException('微信接口响应不是有效 JSON', (int)$response->getStatusCode(), null, ['body' => $body]); + throw new ApiException('微信接口响应不是有效 JSON', $statusCode, null, ['body' => $body]); } $errcode = (int)($data['errcode'] ?? 0); if ($errcode !== 0) { throw new ApiException((string)($data['errmsg'] ?? '微信接口请求失败'), $errcode, null, $data); } + if ($statusCode >= 400) { + throw new ApiException((string)($data['message'] ?? '微信接口请求失败'), $statusCode, null, $data); + } return $data; } @@ -72,6 +78,7 @@ final class JsonClient public function send(string $method, string $uri, array $options = []): ResponseInterface { $this->assertRelativeUri($uri); + $options['http_errors'] = $options['http_errors'] ?? false; try { return $this->http->request($method, $uri, $options); } catch (GuzzleException $exception) { diff --git a/tests/AlipayPlatformClientTest.php b/tests/AlipayPlatformClientTest.php index 165ef3b..519f6d8 100644 --- a/tests/AlipayPlatformClientTest.php +++ b/tests/AlipayPlatformClientTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -21,6 +23,7 @@ use We\Platform\Alipay\PlatformClient as AlipayPlatformClient; /** * 支付宝开放平台网关调用与验签测试用例。 + * @internal */ #[CoversClass(AlipayPlatformClient::class)] final class AlipayPlatformClientTest extends TestCase @@ -118,6 +121,7 @@ final class AlipayFakeHttpClient implements ClientInterface /** * 实现测试 HTTP 客户端请求接口或记录请求。 + * @param mixed $uri */ public function request(string $method, $uri = '', array $options = []): ResponseInterface { @@ -126,6 +130,7 @@ final class AlipayFakeHttpClient implements ClientInterface /** * 实现测试 HTTP 客户端异步请求接口。 + * @param mixed $uri */ public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface { diff --git a/tests/JsonClientTest.php b/tests/JsonClientTest.php index f1f20e7..d986eb2 100644 --- a/tests/JsonClientTest.php +++ b/tests/JsonClientTest.php @@ -1,20 +1,29 @@ */ namespace We\Tests; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use We\Exception\ApiException; use We\Support\JsonClient; /** * JSON HTTP 客户端安全约束测试用例。 + * @internal */ #[CoversClass(JsonClient::class)] final class JsonClientTest extends TestCase @@ -44,4 +53,81 @@ final class JsonClientTest extends TestCase $client->send('GET', '//example.com/evil'); } + + /** + * 测试默认关闭 Guzzle http_errors,并解析非 2xx JSON 错误响应。 + */ + public function testRequestDisablesHttpErrorsAndParsesErrorPayload(): void + { + $http = new JsonClientFakeHttpClient(new Response(400, [], '{"errcode":40001,"errmsg":"invalid credential"}')); + $client = new JsonClient($http); + + try { + $client->request('GET', 'cgi-bin/token'); + self::fail('Expected ApiException was not thrown.'); + } catch (ApiException $exception) { + self::assertSame(40001, $exception->getCode()); + self::assertSame('invalid credential', $exception->getMessage()); + self::assertSame(false, $http->requests[0]['options']['http_errors']); + self::assertSame(40001, $exception->context()['errcode']); + } + } +} + +/** + * JSON 客户端测试用 HTTP 客户端。 + */ +final class JsonClientFakeHttpClient implements ClientInterface +{ + /** @var array}> */ + public array $requests = []; + + /** + * 创建固定响应测试客户端。 + */ + public function __construct(private readonly ResponseInterface $response) {} + + /** + * 实现测试 HTTP 客户端同步发送接口。 + */ + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + return $this->response; + } + + /** + * 实现测试 HTTP 客户端异步发送接口。 + */ + public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface + { + return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test')); + } + + /** + * 实现测试 HTTP 客户端请求接口并记录请求。 + * @param mixed $uri + */ + public function request(string $method, $uri = '', array $options = []): ResponseInterface + { + $this->requests[] = ['method' => $method, 'uri' => $uri, 'options' => $options]; + + return $this->response; + } + + /** + * 实现测试 HTTP 客户端异步请求接口。 + * @param mixed $uri + */ + public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface + { + return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test')); + } + + /** + * 返回测试 HTTP 客户端配置。 + */ + public function getConfig(?string $option = null): mixed + { + return null; + } } diff --git a/tests/PaymentClientTest.php b/tests/PaymentClientTest.php index 8cf91c8..8ca931e 100644 --- a/tests/PaymentClientTest.php +++ b/tests/PaymentClientTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -12,11 +14,13 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use We\Config\WechatPaymentConfig; use We\Exception\SignatureException; +use We\Exception\WechatException; use We\Platform\Wechat\PaymentClient as WechatPaymentClient; use We\Support\Signature; /** * 微信支付 APIv3 通知验签与解密测试用例。 + * @internal */ #[CoversClass(WechatPaymentClient::class)] final class PaymentClientTest extends TestCase @@ -48,7 +52,7 @@ final class PaymentClientTest extends TestCase 'mch_id', $apiV3Key, 'merchant-serial', - 'merchant-private-key', + TestKeys::privateKey(), '', $platformPublicKey, 'platform-serial', @@ -83,7 +87,7 @@ final class PaymentClientTest extends TestCase 'mch_id', str_repeat('k', 32), 'merchant-serial', - 'merchant-private-key', + TestKeys::privateKey(), '', $platformPublicKey, 'platform-serial', @@ -98,6 +102,41 @@ final class PaymentClientTest extends TestCase ]); } + /** + * 测试微信支付回调 resource 结构异常时抛出 SDK 异常而不是 TypeError。 + */ + public function testDecryptNotificationRejectsInvalidResourceShape(): void + { + [$platformPrivateKey, $platformPublicKey] = self::keyPair(); + $rawBody = '{"resource":"invalid"}'; + $timestamp = '1777600000'; + $notifyNonce = 'notify-nonce'; + $headers = [ + 'Wechatpay-Timestamp' => $timestamp, + 'Wechatpay-Nonce' => $notifyNonce, + 'Wechatpay-Serial' => 'platform-serial', + 'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"), + ]; + $client = new WechatPaymentClient(new WechatPaymentConfig( + 'wx_app', + 'mch_id', + str_repeat('k', 32), + 'merchant-serial', + TestKeys::privateKey(), + '', + $platformPublicKey, + 'platform-serial', + )); + + $this->expectException(WechatException::class); + $this->expectExceptionMessage('resource'); + + $client->post('decrypt_notification', [], [ + 'headers' => $headers, + 'raw_body' => $rawBody, + ]); + } + /** * 生成测试使用的 RSA 密钥对。 * diff --git a/tests/ProtocolClientTest.php b/tests/ProtocolClientTest.php index 37f961d..3c039fa 100644 --- a/tests/ProtocolClientTest.php +++ b/tests/ProtocolClientTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -22,6 +24,7 @@ use We\Config\WechatPlatformConfig; use We\Config\WechatServiceConfig; use We\Contract\StoreCacheInterface; use We\Contract\StoreTokenInterface; +use We\Exception\ApiException; use We\Platform\Wechat\PaymentClient as WechatPaymentClient; use We\Platform\Wechat\PlatformClient as WechatPlatformClient; use We\Platform\Wechat\ServiceClient as WechatServiceClient; @@ -31,6 +34,7 @@ use We\Support\TokenCacheKey; /** * 协议层原始响应、下载和上传能力测试用例。 + * @internal */ #[CoversClass(JsonClient::class)] #[CoversClass(WechatPlatformClient::class)] @@ -118,10 +122,67 @@ final class ProtocolClientTest extends TestCase $this->assertSame('BILL-DATA', (string)$response->getBody()); $headers = $http->requests[0]['options']['headers']; $this->assertStringStartsWith('WECHATPAY2-SHA256-RSA2048 ', (string)$headers['Authorization']); - $this->assertSame('merchant-serial', $headers['Wechatpay-Serial']); + $this->assertArrayNotHasKey('Wechatpay-Serial', $headers); $this->assertSame('2026-05-08', $http->requests[0]['options']['query']['bill_date']); } + /** + * 测试微信支付通用调用会透传 Guzzle options,并用 json option 生成参与签名的请求体。 + */ + public function testWechatPaymentCallPassesGuzzleOptions(): void + { + [$merchantPrivateKey] = self::keyPair(); + $http = new ProtocolHttpClient([new Response(200, [], '{"ok":true}')]); + $payment = new WechatPaymentClient(new WechatPaymentConfig( + 'wx_app', + 'mch_id', + str_repeat('k', 32), + 'merchant-serial', + $merchantPrivateKey, + ), $http); + + $data = $payment->post('v3/custom/request', ['ignored' => 'payload'], [ + 'query' => ['debug' => '1'], + 'headers' => ['X-Request-Id' => 'RID-20260508', 'Wechatpay-Serial' => 'platform-serial'], + 'timeout' => 5.0, + 'json' => ['custom' => 'body'], + ]); + + $this->assertTrue($data['ok']); + $this->assertSame('1', $http->requests[0]['options']['query']['debug']); + $this->assertSame('RID-20260508', $http->requests[0]['options']['headers']['X-Request-Id']); + $this->assertSame('platform-serial', $http->requests[0]['options']['headers']['Wechatpay-Serial']); + $this->assertSame(5.0, $http->requests[0]['options']['timeout']); + $this->assertSame('{"custom":"body"}', $http->requests[0]['options']['body']); + $this->assertArrayNotHasKey('json', $http->requests[0]['options']); + } + + /** + * 测试微信支付非 2xx JSON 错误响应会被解析并转换为 ApiException。 + */ + public function testWechatPaymentRequestThrowsOnHttpErrorPayload(): void + { + [$merchantPrivateKey] = self::keyPair(); + $http = new ProtocolHttpClient([new Response(400, [], '{"code":"PARAM_ERROR","message":"参数错误"}')]); + $payment = new WechatPaymentClient(new WechatPaymentConfig( + 'wx_app', + 'mch_id', + str_repeat('k', 32), + 'merchant-serial', + $merchantPrivateKey, + ), $http); + + try { + $payment->post('v3/pay/transactions/jsapi', ['appid' => 'wx_app']); + self::fail('Expected ApiException was not thrown.'); + } catch (ApiException $exception) { + self::assertSame(400, $exception->getCode()); + self::assertSame('参数错误', $exception->getMessage()); + self::assertSame('PARAM_ERROR', $exception->context()['code']); + self::assertSame(false, $http->requests[0]['options']['http_errors']); + } + } + /** * 测试微信服务平台代授权方 GET 调用会把 params 作为 query 并附加授权方 access_token。 */ @@ -135,7 +196,7 @@ final class ProtocolClientTest extends TestCase ), 'authorizer-token', 3600); $http = new ProtocolHttpClient([new Response(200, [], '{"ok":true}')]); $service = (new Client(cache: $cache, authorizers: new ProtocolAuthorizerTokenStore(), http: $http)) - ->wechatService(new WechatServiceConfig('component_app', 'component_secret', 'component_token', 'encoding_key')); + ->wechatService(new WechatServiceConfig('component_app', 'component_secret', 'componentToken123', TestKeys::encodingAesKey())); $data = $service->get('cgi-bin/user/get', ['next_openid' => 'NEXT'], [ 'authorizer_appid' => 'authorizer_app', @@ -197,6 +258,7 @@ final class ProtocolHttpClient implements ClientInterface /** * 实现测试 HTTP 客户端请求接口并记录请求。 + * @param mixed $uri */ public function request(string $method, $uri = '', array $options = []): ResponseInterface { @@ -207,6 +269,7 @@ final class ProtocolHttpClient implements ClientInterface /** * 实现测试 HTTP 客户端异步请求接口。 + * @param mixed $uri */ public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface {