mirror of
https://gitee.com/zoujingli/WeChatDeveloper.git
synced 2026-09-04 23:12:09 +08:00
feat(payment): 完善支付调用透传与错误处理
- 微信支付 APIv3 call/raw 统一按实际 query/body 生成签名,并继续透传 Guzzle options。 - 微信支付默认关闭 http_errors,解析非 2xx JSON 错误并统一抛出 ApiException。 - 移除默认写入错误的 Wechatpay-Serial,请求敏感信息加密时由业务传平台序列号。 - 支付宝网关请求捕获 Guzzle 异常并转换为 SDK 异常,保留验签和支付快捷调用。 - 补充支付下载、通知解密、错误响应、支付宝验签和 options 透传测试。
This commit is contained in:
parent
e8cd998852
commit
09f64b45b1
@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 支付宝支付客户端。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Platform\Alipay;
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 支付宝开放平台客户端。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<string,mixed> $params 支付宝通知完整参数,包含 sign/sign_type。
|
||||
* @param array<string,mixed> $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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 微信支付 APIv3 客户端。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<string,mixed> $payload
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,string> $headers
|
||||
* @param array<string,mixed>|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码。
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,string> $headers
|
||||
* @param array<string,mixed>|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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<string,mixed> $payload
|
||||
* @param array<string,mixed> $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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* JSON HTTP 客户端封装。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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) {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 支付宝开放平台网关调用与验签测试。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@ -1,20 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* JSON HTTP 客户端安全约束测试。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<int,array{method:string,uri:mixed,options:array<string,mixed>}> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 微信支付 APIv3 通知验签与解密测试。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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 密钥对。
|
||||
*
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 协议层原始响应、下载和上传能力测试。
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user