From 80af84bcb943e4f8c48cd42a06c7b635483cbd4e Mon Sep 17 00:00:00 2001 From: Anyon Date: Fri, 8 May 2026 11:32:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor(wechat):=20=E8=BF=81=E7=A7=BB=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E5=BE=AE=E4=BF=A1=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=20Trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 InteractsProtocol 移动到 src/Contract/Trait,并更名为 WechatInteractsProtocol。 - 更新公众平台、小程序、第三方平台客户端的 Trait 引用和通用调用入口。 - 保留 access_token 缓存、raw/download/upload、消息加解密伪路径等协议层能力。 - 补充客户端工厂与 token 缓存相关测试夹具,确保迁移后调用路径保持兼容。 --- src/Client.php | 71 ++++++++++--------- .../Trait/WechatInteractsProtocol.php} | 67 ++++++++++++++--- src/Platform/Wechat/PlatformClient.php | 28 ++++---- src/Platform/Wechat/ServiceClient.php | 54 ++++++++------ src/Platform/Wechat/WxappClient.php | 13 ++-- tests/AccessTokenCacheTest.php | 13 ++-- tests/ClientTest.php | 17 +++-- tests/TestKeys.php | 68 ++++++++++++++++++ 8 files changed, 233 insertions(+), 98 deletions(-) rename src/{Platform/Wechat/Concerns/InteractsProtocol.php => Contract/Trait/WechatInteractsProtocol.php} (75%) create mode 100644 tests/TestKeys.php diff --git a/src/Client.php b/src/Client.php index a8a14a5..c08a6b5 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,16 +1,16 @@ */ namespace We; use GuzzleHttp\ClientInterface; -use ReflectionClass; -use ReflectionException; use We\Config\AlipayPaymentConfig; use We\Config\AlipayPlatformConfig; use We\Config\WechatPaymentConfig; @@ -27,20 +27,21 @@ use We\Platform\Wechat\PaymentClient as WechatPaymentClient; use We\Platform\Wechat\PlatformClient as WechatPlatformClient; use We\Platform\Wechat\ServiceClient as WechatServiceClient; use We\Platform\Wechat\WxappClient as WechatWxappClient; +use We\Support\CacheKey; use We\Support\FileCacheStore; /** * SDK 根入口:按平台与业务域创建微信、支付宝客户端,工厂方法命名与配置对象语义保持一致。 * - * access_token 等运行态数据的缓存键固定为 `{cacheKeyPrefix}:{platformChannel}:{logicalKey}`,见 {@see \We\Support\CacheKey::compose}。 + * access_token 等运行态数据的缓存键固定为 `{cacheKeyPrefix}:{platformChannel}:{logicalKey}`,见 {@see CacheKey::compose}。 * 未注入缓存实现时使用 {@see FileCacheStore},默认目录为 {@see self::defaultCacheStoreDirectory()}。 * * @method WechatPlatformClient wechatPlatform(WechatPlatformConfig $config) - * @method WechatWxappClient wechatWxapp(WechatWxappConfig $config) - * @method WechatServiceClient wechatService(WechatServiceConfig $config) - * @method WechatPaymentClient wechatPayment(WechatPaymentConfig $config) + * @method WechatWxappClient wechatWxapp(WechatWxappConfig $config) + * @method WechatServiceClient wechatService(WechatServiceConfig $config) + * @method WechatPaymentClient wechatPayment(WechatPaymentConfig $config) * @method AlipayPlatformClient alipayPlatform(AlipayPlatformConfig $config) - * @method AlipayPaymentClient alipayPayment(AlipayPaymentConfig $config) + * @method AlipayPaymentClient alipayPayment(AlipayPaymentConfig $config) */ final class Client { @@ -78,30 +79,6 @@ final class Client $this->http = $http; } - /** 默认缓存落盘目录:`sys_get_temp_dir()` + 包级子目录名,供未显式传入 `cache` 时构造 {@see FileCacheStore}。 */ - public static function defaultCacheStoreDirectory(): string - { - return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR . '/') . DIRECTORY_SEPARATOR . self::DEFAULT_CACHE_STORE_DIR_NAME; - } - - /** - * 按字符串通道创建客户端;通道名称与配置对象一一对应。 - */ - public function get(string $channel, ConfigInterface $config): object - { - [$factory, $expected] = match ($channel) { - 'wechat.platform' => ['wechatPlatform', WechatPlatformConfig::class], - 'wechat.wxapp' => ['wechatWxapp', WechatWxappConfig::class], - 'wechat.service' => ['wechatService', WechatServiceConfig::class], - 'wechat.payment' => ['wechatPayment', WechatPaymentConfig::class], - 'alipay.platform' => ['alipayPlatform', AlipayPlatformConfig::class], - 'alipay.payment' => ['alipayPayment', AlipayPaymentConfig::class], - default => throw new WechatException('不支持的通道标识: ' . $channel), - }; - - return $this->__call($factory, [$this->ensureConfig($config, $expected, $factory)]); - } - /** * 魔术工厂:只暴露平台前缀明确的客户端创建方法。 * @@ -150,6 +127,30 @@ final class Client }; } + /** 默认缓存落盘目录:`sys_get_temp_dir()` + 包级子目录名,供未显式传入 `cache` 时构造 {@see FileCacheStore}。 */ + public static function defaultCacheStoreDirectory(): string + { + return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR . '/') . DIRECTORY_SEPARATOR . self::DEFAULT_CACHE_STORE_DIR_NAME; + } + + /** + * 按字符串通道创建客户端;通道名称与配置对象一一对应。 + */ + public function get(string $channel, ConfigInterface $config): object + { + [$factory, $expected] = match ($channel) { + 'wechat.platform' => ['wechatPlatform', WechatPlatformConfig::class], + 'wechat.wxapp' => ['wechatWxapp', WechatWxappConfig::class], + 'wechat.service' => ['wechatService', WechatServiceConfig::class], + 'wechat.payment' => ['wechatPayment', WechatPaymentConfig::class], + 'alipay.platform' => ['alipayPlatform', AlipayPlatformConfig::class], + 'alipay.payment' => ['alipayPayment', AlipayPaymentConfig::class], + default => throw new WechatException('不支持的通道标识: ' . $channel), + }; + + return $this->__call($factory, [$this->ensureConfig($config, $expected, $factory)]); + } + /** * 使用反射创建具体平台客户端实例。 * @@ -159,8 +160,8 @@ final class Client private function instantiate(string $class, array $args): object { try { - return (new ReflectionClass($class))->newInstanceArgs($args); - } catch (ReflectionException $e) { + return (new \ReflectionClass($class))->newInstanceArgs($args); + } catch (\ReflectionException $e) { throw new WechatException('通道客户端实例化失败: ' . $e->getMessage(), 0, $e); } } diff --git a/src/Platform/Wechat/Concerns/InteractsProtocol.php b/src/Contract/Trait/WechatInteractsProtocol.php similarity index 75% rename from src/Platform/Wechat/Concerns/InteractsProtocol.php rename to src/Contract/Trait/WechatInteractsProtocol.php index 2bbcdae..3f53345 100644 --- a/src/Platform/Wechat/Concerns/InteractsProtocol.php +++ b/src/Contract/Trait/WechatInteractsProtocol.php @@ -1,21 +1,24 @@ */ -namespace We\Platform\Wechat\Concerns; +namespace We\Contract\Trait; use Psr\Http\Message\ResponseInterface; +use We\Exception\ApiException; /** * 微信协议层客户端通用能力。 * * 复用 client_credential access_token 缓存、原始响应、下载、上传和 JSON 请求选项构造逻辑。 */ -trait InteractsProtocol +trait WechatInteractsProtocol { /** * 获取并缓存 client_credential access_token。 @@ -36,13 +39,43 @@ trait InteractsProtocol 'appid' => $appid, 'secret' => $appSecret, ]); - $token = (string)($data['access_token'] ?? ''); - $this->cache->set($key, $token, max(1, (int)($data['expires_in'] ?? 7200) - 300)); + $token = $this->wechatTokenValue($data, 'access_token', '微信 access_token'); + $this->cache->set($key, $token, $this->wechatTokenTtl($data, '微信 access_token')); return $token; }); } + /** + * 从微信 Token 响应中提取非空字符串 Token。 + * + * @param array $data + */ + private function wechatTokenValue(array $data, string $field, string $label): string + { + $token = $data[$field] ?? null; + if (!is_string($token) || trim($token) === '') { + throw new ApiException($label . ' 响应缺少 ' . $field, 0, null, $data); + } + + return $token; + } + + /** + * 从微信 Token 响应中计算缓存 TTL。 + * + * @param array $data + */ + private function wechatTokenTtl(array $data, string $label): int + { + $expiresIn = $data['expires_in'] ?? 7200; + if (!is_numeric($expiresIn) || (int)$expiresIn <= 0) { + throw new ApiException($label . ' 响应 expires_in 无效', 0, null, $data); + } + + return max(1, (int)$expiresIn - 300); + } + /** * 按需在 query 中附加 access_token。 * @@ -52,6 +85,9 @@ trait InteractsProtocol private function withAccessToken(array $query, bool $withToken): array { if ($withToken) { + /** + * @phpstan-ignore-next-line 宿主为公众平台或小程序客户端时提供 accessToken()。 + */ $query['access_token'] = $query['access_token'] ?? $this->accessToken(); } @@ -118,6 +154,9 @@ trait InteractsProtocol private function handleWechatMessageCryptoCall(string $uri, array $params): ?array { if ($uri === 'decrypt_message') { + /** + * @phpstan-ignore-next-line 只有提供 messageCrypto() 的宿主会调用消息加解密伪路径。 + */ return $this->messageCrypto()->decryptMessage( (string)($params['body'] ?? ''), (string)($params['msg_signature'] ?? ''), @@ -126,12 +165,17 @@ trait InteractsProtocol ); } if ($uri === 'encrypt_message') { + /** + * @phpstan-ignore-next-line 只有提供 messageCrypto() 的宿主会调用消息加解密伪路径。 + */ + $xml = $this->messageCrypto()->encryptMessage( + (string)($params['body'] ?? ''), + (string)($params['timestamp'] ?? time()), + (string)($params['nonce'] ?? ''), + ); + return [ - 'xml' => $this->messageCrypto()->encryptMessage( - (string)($params['body'] ?? ''), - (string)($params['timestamp'] ?? time()), - (string)($params['nonce'] ?? ''), - ), + 'xml' => $xml, ]; } @@ -161,6 +205,7 @@ trait InteractsProtocol $query = $this->wechatCallQuery($method, $params, $options); $requestOptions = $this->buildWechatJsonOptions($method, $params, $options, $internalKeys); if ($tokenAware) { + /** @phpstan-ignore-next-line tokenAware 仅由支持 withToken 参数的公众平台和小程序客户端启用。 */ return $this->request($method, $uri, $query, $requestOptions, (bool)($options['with_token'] ?? true)); } diff --git a/src/Platform/Wechat/PlatformClient.php b/src/Platform/Wechat/PlatformClient.php index 6e4dbab..f472d11 100644 --- a/src/Platform/Wechat/PlatformClient.php +++ b/src/Platform/Wechat/PlatformClient.php @@ -1,9 +1,11 @@ */ namespace We\Platform\Wechat; @@ -13,7 +15,7 @@ use Psr\Http\Message\ResponseInterface; use We\Client; use We\Config\WechatPlatformConfig; use We\Contract\StoreCacheInterface; -use We\Platform\Wechat\Concerns\InteractsProtocol; +use We\Contract\Trait\WechatInteractsProtocol; use We\Support\CacheKey; use We\Support\JsonClient; use We\Support\MessageCrypto; @@ -27,9 +29,9 @@ use We\Support\TokenCacheKey; */ final class PlatformClient { - use InteractsProtocol; + use WechatInteractsProtocol; - /** 与 {@see \We\Client::get} 通道标识一致,用于 Token 键平台段 */ + /** 与 {@see Client::get} 通道标识一致,用于 Token 键平台段 */ private const TOKEN_PLATFORM_CHANNEL = 'wechat.platform'; private const API = 'https://api.weixin.qq.com/'; @@ -110,14 +112,6 @@ final class PlatformClient return $this->uploadWechatResource($uri, $multipart, $this->withAccessToken($query, $withToken), $options); } - /** - * 创建微信公众平台消息安全模式加解密工具。 - */ - private function messageCrypto(): MessageCrypto - { - return new MessageCrypto($this->config->token, $this->config->encodingAesKey, $this->config->appid); - } - /** * 通用 API 调用入口:按官方接口 path 与参数发起请求。 * @@ -166,6 +160,14 @@ final class PlatformClient return $this->call($uriOrPath, $params, 'GET', $options); } + /** + * 创建微信公众平台消息安全模式加解密工具。 + */ + private function messageCrypto(): MessageCrypto + { + return new MessageCrypto($this->config->token, $this->config->encodingAesKey, $this->config->appid); + } + /** * 生成微信网页授权地址(open.weixin.qq.com/connect/oauth2/authorize)。 * diff --git a/src/Platform/Wechat/ServiceClient.php b/src/Platform/Wechat/ServiceClient.php index 16dda0b..e4827c1 100644 --- a/src/Platform/Wechat/ServiceClient.php +++ b/src/Platform/Wechat/ServiceClient.php @@ -1,9 +1,11 @@ */ namespace We\Platform\Wechat; @@ -14,8 +16,8 @@ use We\Client; use We\Config\WechatServiceConfig; use We\Contract\StoreCacheInterface; use We\Contract\StoreTokenInterface; +use We\Contract\Trait\WechatInteractsProtocol; use We\Exception\WechatException; -use We\Platform\Wechat\Concerns\InteractsProtocol; use We\Support\CacheKey; use We\Support\JsonClient; use We\Support\MessageCrypto; @@ -29,12 +31,15 @@ use We\Support\TokenCacheKey; */ final class ServiceClient { - use InteractsProtocol; + use WechatInteractsProtocol; - /** 与 {@see \We\Client::get} 通道标识一致 */ + /** 与 {@see Client::get} 通道标识一致 */ private const TOKEN_PLATFORM_CHANNEL = 'wechat.service'; + private JsonClient $http; + private readonly ?StoreTokenInterface $authorizers; + /** * 创建微信服务平台(第三方平台)客户端并初始化官方 API HTTP 客户端。 */ @@ -49,8 +54,6 @@ final class ServiceClient $this->authorizers = $authorizers; } - private readonly ?StoreTokenInterface $authorizers; - /** * 获取第三方平台接口调用凭据 component_access_token;缓存未命中或强制刷新时调用官方 component_token 接口。 */ @@ -72,8 +75,8 @@ final class ServiceClient 'component_verify_ticket' => $componentVerifyTicket, ], ]); - $token = (string)$data['component_access_token']; - $this->cache->set($key, $token, max(1, (int)($data['expires_in'] ?? 7200) - 300)); + $token = $this->wechatTokenValue($data, 'component_access_token', '微信 component_access_token'); + $this->cache->set($key, $token, $this->wechatTokenTtl($data, '微信 component_access_token')); return $token; }); @@ -193,14 +196,6 @@ final class ServiceClient return $this->jsonWechatRequest($method, $uri, $query, $options); } - /** - * 创建第三方平台授权事件消息加解密工具。 - */ - private function messageCrypto(): MessageCrypto - { - return new MessageCrypto($this->config->componentToken, $this->config->componentEncodingAesKey, $this->config->componentAppid); - } - /** * 通用 API 调用入口:按官方接口 path、授权方上下文和参数发起请求。 * @@ -254,12 +249,21 @@ final class ServiceClient return $this->call($uriOrPath, $params, 'GET', $options); } + /** + * 创建第三方平台授权事件消息加解密工具。 + */ + private function messageCrypto(): MessageCrypto + { + return new MessageCrypto($this->config->componentToken, $this->config->componentEncodingAesKey, $this->config->componentAppid); + } + /** * 获取授权方接口调用凭据 authorizer_access_token;缓存未命中时使用 authorizer_refresh_token 刷新。 */ private function authorizerAccessToken(string $componentAccessToken, string $authorizerAppid): string { - if (!$this->authorizers) { + $authorizers = $this->authorizers; + if (!$authorizers) { throw new WechatException('未配置授权账号 Token 仓库'); } $key = $this->cacheKey(TokenCacheKey::wechatServiceAuthorizerAccessToken( @@ -271,14 +275,18 @@ final class ServiceClient return $token; } - return $this->cache->lock('lock:' . $key, 30, function () use ($key, $componentAccessToken, $authorizerAppid): string { + return $this->cache->lock('lock:' . $key, 30, function () use ($key, $componentAccessToken, $authorizerAppid, $authorizers): string { if (is_string($token = $this->cache->get($key, '')) && $token !== '') { return $token; } - $data = $this->refreshAuthorizerToken($componentAccessToken, $authorizerAppid, $this->authorizers->refreshToken($authorizerAppid)); - $this->authorizers->saveAuthorizerToken($authorizerAppid, $data); - $token = (string)$data['authorizer_access_token']; - $this->cache->set($key, $token, max(1, (int)($data['expires_in'] ?? 7200) - 300)); + $refreshToken = $authorizers->refreshToken($authorizerAppid); + if (trim($refreshToken) === '') { + throw new WechatException('授权方 authorizer_refresh_token 不能为空'); + } + $data = $this->refreshAuthorizerToken($componentAccessToken, $authorizerAppid, $refreshToken); + $authorizers->saveAuthorizerToken($authorizerAppid, $data); + $token = $this->wechatTokenValue($data, 'authorizer_access_token', '微信 authorizer_access_token'); + $this->cache->set($key, $token, $this->wechatTokenTtl($data, '微信 authorizer_access_token')); return $token; }); diff --git a/src/Platform/Wechat/WxappClient.php b/src/Platform/Wechat/WxappClient.php index 1cd3614..f6d8b56 100644 --- a/src/Platform/Wechat/WxappClient.php +++ b/src/Platform/Wechat/WxappClient.php @@ -1,9 +1,11 @@ */ namespace We\Platform\Wechat; @@ -13,7 +15,7 @@ use Psr\Http\Message\ResponseInterface; use We\Client; use We\Config\WechatWxappConfig; use We\Contract\StoreCacheInterface; -use We\Platform\Wechat\Concerns\InteractsProtocol; +use We\Contract\Trait\WechatInteractsProtocol; use We\Support\CacheKey; use We\Support\JsonClient; use We\Support\NullCacheStore; @@ -26,10 +28,11 @@ use We\Support\TokenCacheKey; */ final class WxappClient { - use InteractsProtocol; + use WechatInteractsProtocol; - /** 与 {@see \We\Client::get} 通道标识一致 */ + /** 与 {@see Client::get} 通道标识一致 */ private const TOKEN_PLATFORM_CHANNEL = 'wechat.wxapp'; + private JsonClient $http; /** diff --git a/tests/AccessTokenCacheTest.php b/tests/AccessTokenCacheTest.php index 054ca72..930777b 100644 --- a/tests/AccessTokenCacheTest.php +++ b/tests/AccessTokenCacheTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -25,6 +27,7 @@ use We\Support\TokenCacheKey; /** * 微信公众平台 access_token 缓存行为测试用例。 + * @internal */ #[CoversClass(WechatPlatformClient::class)] final class AccessTokenCacheTest extends TestCase @@ -75,11 +78,11 @@ final class AccessTokenCacheTest extends TestCase */ final class ArrayCacheStore implements StoreCacheInterface { + public int $lockCalls = 0; + /** @var array */ private array $values = []; - public int $lockCalls = 0; - /** * 读取测试缓存值。 */ @@ -147,6 +150,7 @@ final class FakeHttpClient implements ClientInterface /** * 实现测试 HTTP 客户端请求接口或记录请求。 + * @param mixed $uri */ public function request(string $method, $uri = '', array $options = []): ResponseInterface { @@ -157,6 +161,7 @@ final class FakeHttpClient implements ClientInterface /** * 实现测试 HTTP 客户端异步请求接口。 + * @param mixed $uri */ public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface { diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 87bc140..9f4dfda 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -21,6 +23,7 @@ use We\Platform\Wechat\ServiceClient as WechatServiceClient; /** * SDK 根入口通道工厂测试用例。 + * @internal */ #[CoversClass(Client::class)] final class ClientTest extends TestCase @@ -66,7 +69,7 @@ final class ClientTest extends TestCase $this->expectException(WechatException::class); $this->expectExceptionMessage('WechatPlatformConfig'); - $client->get('wechat.platform', new WechatServiceConfig('app', 'sec', 'token', 'encoding')); + $client->get('wechat.platform', new WechatServiceConfig('app', 'sec', 'token', TestKeys::encodingAesKey())); } /** @@ -109,8 +112,8 @@ final class ClientTest extends TestCase $service = $client->wechatService(new WechatServiceConfig( 'wx_component', 'component_secret', - 'component_token', - 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG' + 'componentToken123', + TestKeys::encodingAesKey() )); $url = $service->authorizationUrl('preauthcode', 'https://example.com/callback', 3, 'STATE_TEST'); @@ -126,7 +129,7 @@ final class ClientTest extends TestCase public function testAlipayPlatformCallReturnsAuthorizationUrl(): void { $client = new Client(); - $alipay = $client->alipayPlatform(new AlipayPlatformConfig('202605010001', str_repeat('a', 64))); + $alipay = $client->alipayPlatform(new AlipayPlatformConfig('202605010001', TestKeys::privateKey())); $result = $alipay->get('auth', [ 'redirect_uri' => 'https://example.com/alipay/callback', 'scope' => 'auth_user', @@ -143,7 +146,7 @@ final class ClientTest extends TestCase public function testGetCanReturnSpecificChannelClient(): void { $client = new Client(); - $channelClient = $client->get('alipay.platform', new AlipayPlatformConfig('202605010001', str_repeat('a', 64))); + $channelClient = $client->get('alipay.platform', new AlipayPlatformConfig('202605010001', TestKeys::privateKey())); $this->assertInstanceOf(AlipayPlatformClient::class, $channelClient); $this->assertNotInstanceOf(WechatServiceClient::class, $channelClient); diff --git a/tests/TestKeys.php b/tests/TestKeys.php new file mode 100644 index 0000000..d88e356 --- /dev/null +++ b/tests/TestKeys.php @@ -0,0 +1,68 @@ + + */ + +namespace We\Tests; + +/** + * 测试密钥夹具。 + */ +final class TestKeys +{ + /** @var null|array{0:string,1:string} */ + private static ?array $keyPair = null; + + /** + * 返回测试用 EncodingAESKey。 + */ + public static function encodingAesKey(): string + { + return 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'; + } + + /** + * 返回测试用 RSA 私钥。 + */ + public static function privateKey(): string + { + return self::keyPair()[0]; + } + + /** + * 返回测试用 RSA 公钥。 + */ + public static function publicKey(): string + { + return self::keyPair()[1]; + } + + /** + * 生成并缓存测试使用的 RSA 密钥对。 + * + * @return array{0:string,1:string} + */ + public static function keyPair(): array + { + if (self::$keyPair !== null) { + return self::$keyPair; + } + + $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + if ($resource === false) { + throw new \RuntimeException('Unable to create test RSA key pair.'); + } + openssl_pkey_export($resource, $privateKey); + $details = openssl_pkey_get_details($resource); + if (!is_array($details)) { + throw new \RuntimeException('Unable to read test RSA public key.'); + } + + return self::$keyPair = [$privateKey, (string)$details['key']]; + } +}