refactor(wechat): 迁移并重命名微信协议 Trait

- 将 InteractsProtocol 移动到 src/Contract/Trait,并更名为 WechatInteractsProtocol。

- 更新公众平台、小程序、第三方平台客户端的 Trait 引用和通用调用入口。

- 保留 access_token 缓存、raw/download/upload、消息加解密伪路径等协议层能力。

- 补充客户端工厂与 token 缓存相关测试夹具,确保迁移后调用路径保持兼容。
This commit is contained in:
Anyon 2026-05-08 11:32:46 +08:00
parent 278abbf3b8
commit 80af84bcb9
8 changed files with 233 additions and 98 deletions

View File

@ -1,16 +1,16 @@
<?php
declare(strict_types=1);
/**
* SDK 根入口与通道客户端工厂。
* This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/
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);
}
}

View File

@ -1,21 +1,24 @@
<?php
declare(strict_types=1);
/**
* 微信协议层客户端通用能力。
* This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/
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<string,mixed> $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<string,mixed> $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));
}

View File

@ -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\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
*

View File

@ -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\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;
});

View File

@ -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\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;
/**

View File

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
/**
* 微信公众平台 access_token 缓存行为测试。
* This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/
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<string,mixed> */
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
{

View File

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
/**
* SDK 根入口通道工厂测试。
* 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\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);

68
tests/TestKeys.php Normal file
View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/
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']];
}
}