diff --git a/src/Platform/Wechat/Concerns/InteractsProtocol.php b/src/Platform/Wechat/Concerns/InteractsProtocol.php new file mode 100644 index 0000000..2bbcdae --- /dev/null +++ b/src/Platform/Wechat/Concerns/InteractsProtocol.php @@ -0,0 +1,210 @@ +cacheKey($logicalKey); + if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { + return $token; + } + + return $this->cache->lock('lock:' . $key, 30, function () use ($key, $appid, $appSecret, $refresh): string { + if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { + return $token; + } + $data = $this->jsonWechatRequest('GET', 'cgi-bin/token', [ + 'grant_type' => 'client_credential', + 'appid' => $appid, + 'secret' => $appSecret, + ]); + $token = (string)($data['access_token'] ?? ''); + $this->cache->set($key, $token, max(1, (int)($data['expires_in'] ?? 7200) - 300)); + + return $token; + }); + } + + /** + * 按需在 query 中附加 access_token。 + * + * @param array $query + * @return array + */ + private function withAccessToken(array $query, bool $withToken): array + { + if ($withToken) { + $query['access_token'] = $query['access_token'] ?? $this->accessToken(); + } + + return $query; + } + + /** + * 请求微信 JSON API 并解析响应数组。 + * + * @param array $query + * @param array $options + * @return array + */ + private function jsonWechatRequest(string $method, string $uri, array $query = [], array $options = []): array + { + return $this->http->request($method, ltrim($uri, '/'), $query, $options); + } + + /** + * 请求微信 API 并返回原始响应。 + * + * @param array $query + * @param array $options + */ + private function rawWechatRequest(string $method, string $uri, array $query = [], array $options = []): ResponseInterface + { + return $this->http->raw($method, ltrim($uri, '/'), $query, $options); + } + + /** + * 下载微信二进制资源并返回原始响应。 + * + * @param array $query + * @param array $options + */ + private function downloadWechatResource(string $uri, array $query = [], array $options = []): ResponseInterface + { + return $this->rawWechatRequest('GET', $uri, $query, $options); + } + + /** + * 使用 multipart/form-data 上传文件或媒体资源并解析 JSON 响应。 + * + * @param array> $multipart + * @param array $query + * @param array $options + * @return array + */ + private function uploadWechatResource(string $uri, array $multipart, array $query = [], array $options = []): array + { + $options['multipart'] = $multipart; + + return $this->jsonWechatRequest('POST', $uri, $query, $options); + } + + /** + * 处理微信消息安全模式伪路径。 + * + * 使用该伪路径的宿主客户端需要提供 messageCrypto() 工厂。 + * + * @param array $params + * @return null|array + */ + private function handleWechatMessageCryptoCall(string $uri, array $params): ?array + { + if ($uri === 'decrypt_message') { + return $this->messageCrypto()->decryptMessage( + (string)($params['body'] ?? ''), + (string)($params['msg_signature'] ?? ''), + (string)($params['timestamp'] ?? ''), + (string)($params['nonce'] ?? ''), + ); + } + if ($uri === 'encrypt_message') { + return [ + 'xml' => $this->messageCrypto()->encryptMessage( + (string)($params['body'] ?? ''), + (string)($params['timestamp'] ?? time()), + (string)($params['nonce'] ?? ''), + ), + ]; + } + + return null; + } + + /** + * 按官方 path 与参数调用微信 JSON API。 + * + * $tokenAware 为 true 时通过宿主 request() 的 withToken 参数控制 access_token 注入。 + * + * @param array $params + * @param array $options + * @param array $internalKeys + * @return array + */ + private function callWechatJsonApi( + string $uriOrPath, + array $params, + string $httpMethod, + array $options, + bool $tokenAware, + array $internalKeys = [], + ): array { + $method = $this->normalizeWechatHttpMethod($httpMethod); + $uri = ltrim($uriOrPath, '/'); + $query = $this->wechatCallQuery($method, $params, $options); + $requestOptions = $this->buildWechatJsonOptions($method, $params, $options, $internalKeys); + if ($tokenAware) { + return $this->request($method, $uri, $query, $requestOptions, (bool)($options['with_token'] ?? true)); + } + + return $this->request($method, $uri, $query, $requestOptions); + } + + /** + * 构造 Guzzle 请求选项;非 GET 且未显式传 body 时默认使用 JSON 请求体。 + * + * @param array $params + * @param array $options + * @param array $internalKeys + * @return array + */ + private function buildWechatJsonOptions(string $method, array $params, array $options, array $internalKeys = []): array + { + foreach ($internalKeys as $key) { + unset($options[$key]); + } + if ($method === 'GET' || isset($options['json']) || isset($options['body']) || isset($options['form_params']) || isset($options['multipart'])) { + return $options; + } + $options['json'] = $params; + + return $options; + } + + /** + * 规范化 HTTP 方法;空字符串按 POST 处理。 + */ + private function normalizeWechatHttpMethod(string $httpMethod): string + { + return strtoupper($httpMethod === '' ? 'POST' : $httpMethod); + } + + /** + * 根据 HTTP 方法从调用参数中提取 query。 + * + * @param array $params + * @param array $options + * @return array + */ + private function wechatCallQuery(string $method, array $params, array $options): array + { + return $method === 'GET' ? $params : (is_array($options['query'] ?? null) ? $options['query'] : []); + } +} diff --git a/src/Platform/Wechat/PaymentClient.php b/src/Platform/Wechat/PaymentClient.php index e858130..310d1b7 100644 --- a/src/Platform/Wechat/PaymentClient.php +++ b/src/Platform/Wechat/PaymentClient.php @@ -2,20 +2,34 @@ declare(strict_types=1); +/** + * 微信支付 APIv3 客户端。 + */ + namespace We\Platform\Wechat; use GuzzleHttp\ClientInterface; +use Psr\Http\Message\ResponseInterface; use We\Config\WechatPaymentConfig; +use We\Exception\ApiException; use We\Exception\SignatureException; use We\Exception\WechatException; use We\Support\JsonClient; use We\Support\PaymentCrypto; use We\Support\Signature; +/** + * 微信支付 APIv3 客户端。 + * + * 负责生成微信支付 APIv3 请求签名,发送商户平台 API 请求,并完成支付通知验签与 resource 解密。 + */ final class PaymentClient { private JsonClient $http; + /** + * 创建微信支付 APIv3 客户端并初始化商户平台 API HTTP 客户端。 + */ public function __construct( private readonly WechatPaymentConfig $config, ?ClientInterface $http = null, @@ -23,38 +37,67 @@ final class PaymentClient $this->http = new JsonClient($http ?? new \GuzzleHttp\Client(['base_uri' => 'https://api.mch.weixin.qq.com/', 'timeout' => 20.0])); } - /** @param array $payload @return array */ + /** + * 发起微信支付 APIv3 请求并附加商户请求签名。 + * + * @param array $payload + * @return array + */ public function request(string $method, string $uri, array $payload = [], array $query = []): array { - $uri = '/' . ltrim($uri, '/'); - $body = $payload === [] ? '' : (json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - $nonce = bin2hex(random_bytes(16)); - $timestamp = (string)time(); - $authorization = $this->authorization($method, $uri . ($query === [] ? '' : '?' . http_build_query($query)), $timestamp, $nonce, $body); + $response = $this->raw($method, $uri, $payload, $query); + $body = (string)$response->getBody(); + $data = $body === '' ? [] : json_decode($body, true); + if (!is_array($data)) { + throw new ApiException('微信支付接口响应不是有效 JSON', (int)$response->getStatusCode(), null, ['body' => $body]); + } - return $this->http->request($method, ltrim($uri, '/'), $query, [ - 'body' => $body, - 'headers' => [ - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - 'Authorization' => $authorization, - 'Wechatpay-Serial' => $this->config->merchantSerial, - ], - ]); - } - - /** @param array $payload @return array */ - public function refund(array $payload): array - { - return $this->request('POST', 'v3/refund/domestic/refunds', $payload); + return $data; } /** + * 发起微信支付 APIv3 请求并返回原始响应。 + * + * @param array $payload + * @param array $query + * @param array $options + */ + public function raw(string $method, string $uri, array $payload = [], array $query = [], array $options = []): ResponseInterface + { + $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) ?: '{}')); + $nonce = bin2hex(random_bytes(16)); + $timestamp = (string)time(); + $authorization = $this->authorization($method, $uri . $this->queryString($query), $timestamp, $nonce, $body); + $options['body'] = $body; + $options['headers'] = $this->headers($options, $authorization); + unset($options['query']); + + return $this->http->raw($method, ltrim($uri, '/'), $query, $options); + } + + /** + * 下载微信支付 APIv3 资源并返回原始响应。 + * + * @param array $query + * @param array $options + */ + public function download(string $uri, array $query = [], array $options = []): ResponseInterface + { + return $this->raw('GET', $uri, [], $query, $options); + } + + /** + * 校验微信支付通知签名并解密通知 resource。 + * * @param array $headers * @param array|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码。 * @return array */ - public function decryptNotification(array $headers, array|string $body): 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) ?: '{}'); @@ -70,6 +113,8 @@ final class PaymentClient } /** + * 通用 API 调用入口;官方接口使用 API path + 参数数组,特殊路径仅用于支付通知验签与解密。 + * * @param array $params * @param array $options * @return array @@ -78,9 +123,6 @@ final class PaymentClient { $uri = ltrim($uriOrPath, '/'); $method = strtoupper($httpMethod === '' ? 'POST' : $httpMethod); - if ($uri === 'refund') { - return $this->refund($params); - } if ($uri === 'decrypt_notification') { $body = $options['raw_body'] ?? $options['body'] ?? $params; return $this->decryptNotification( @@ -98,6 +140,8 @@ final class PaymentClient } /** + * 按 POST 方法调用微信支付 APIv3。 + * * @param array $params * @param array $options * @return array @@ -108,6 +152,8 @@ final class PaymentClient } /** + * 按 GET 方法调用微信支付 APIv3。 + * * @param array $params * @param array $options * @return array @@ -117,6 +163,9 @@ final class PaymentClient return $this->call($uriOrPath, $params, 'GET', $options); } + /** + * 生成微信支付 APIv3 `WECHATPAY2-SHA256-RSA2048` Authorization 请求头。 + */ private function authorization(string $method, string $uri, string $timestamp, string $nonce, string $body): string { $message = strtoupper($method) . "\n{$uri}\n{$timestamp}\n{$nonce}\n{$body}\n"; @@ -133,12 +182,18 @@ final class PaymentClient return $schema . ' ' . implode(',', array_map(static fn (string $key, string $value): string => $key . '="' . $value . '"', array_keys($fields), $fields)); } + /** + * 校验微信支付通知头中的平台证书/公钥序列号与 RSA-SHA256 签名。 + */ private function assertNotificationSignature(array $headers, string $body): void { $timestamp = $this->headerValue($headers, 'Wechatpay-Timestamp'); $nonce = $this->headerValue($headers, 'Wechatpay-Nonce'); $signature = $this->headerValue($headers, 'Wechatpay-Signature'); $serial = $this->headerValue($headers, 'Wechatpay-Serial'); + if ($timestamp === '' || $nonce === '' || $signature === '' || $serial === '') { + throw new SignatureException('微信支付回调验签请求头不完整'); + } if ($this->config->platformSerial !== '' && !hash_equals($this->config->platformSerial, $serial)) { throw new SignatureException('微信支付平台序列号不匹配'); } @@ -153,6 +208,8 @@ final class PaymentClient } /** + * 按名称读取微信支付通知请求头,兼容大小写差异。 + * * @param array $headers */ private function headerValue(array $headers, string $name): string @@ -165,4 +222,43 @@ final class PaymentClient return ''; } + + /** + * 合并显式 query 与 Guzzle options 中的 query,确保签名串与实际请求一致。 + * + * @param array $query + * @param array $options + * @return array + */ + private function mergeQuery(array $query, array $options): array + { + return array_merge($query, is_array($options['query'] ?? null) ? $options['query'] : []); + } + + /** + * 构造签名所需的 query string。 + * + * @param array $query + */ + private function queryString(array $query): string + { + return $query === [] ? '' : '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); + } + + /** + * 合并默认微信支付 APIv3 请求头。 + * + * @param array $options + * @return array + */ + private function headers(array $options, string $authorization): array + { + $headers = is_array($options['headers'] ?? null) ? $options['headers'] : []; + $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/Platform/Wechat/PlatformClient.php b/src/Platform/Wechat/PlatformClient.php index 8b0b230..6e4dbab 100644 --- a/src/Platform/Wechat/PlatformClient.php +++ b/src/Platform/Wechat/PlatformClient.php @@ -2,27 +2,45 @@ declare(strict_types=1); +/** + * 微信公众平台客户端。 + */ + namespace We\Platform\Wechat; use GuzzleHttp\ClientInterface; +use Psr\Http\Message\ResponseInterface; use We\Client; use We\Config\WechatPlatformConfig; use We\Contract\StoreCacheInterface; +use We\Platform\Wechat\Concerns\InteractsProtocol; use We\Support\CacheKey; use We\Support\JsonClient; use We\Support\MessageCrypto; use We\Support\NullCacheStore; use We\Support\TokenCacheKey; +/** + * 微信公众平台客户端。 + * + * 负责获取和缓存微信公众平台接口调用凭据 access_token,向微信公众平台 API 请求自动附加 access_token,并提供消息安全模式加解密能力。 + */ final class PlatformClient { + use InteractsProtocol; + /** 与 {@see \We\Client::get} 通道标识一致,用于 Token 键平台段 */ private const TOKEN_PLATFORM_CHANNEL = 'wechat.platform'; private const API = 'https://api.weixin.qq.com/'; + private const OPEN = 'https://open.weixin.qq.com/'; + private JsonClient $http; + /** + * 创建微信公众平台客户端并初始化官方 API HTTP 客户端。 + */ public function __construct( private readonly WechatPlatformConfig $config, ?ClientInterface $http = null, @@ -32,75 +50,76 @@ final class PlatformClient $this->http = new JsonClient($http ?? new \GuzzleHttp\Client(['base_uri' => self::API, 'timeout' => 20.0])); } + /** + * 获取微信公众平台接口调用凭据 access_token;缓存未命中或强制刷新时调用官方 token 接口。 + */ public function accessToken(bool $refresh = false): string { - $key = $this->cacheKey(TokenCacheKey::wechatOfficialAccessToken($this->config->appid, $this->config->storageScope)); - if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { - return $token; - } - - return $this->cache->lock('lock:' . $key, 30, function () use ($key, $refresh): string { - if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { - return $token; - } - $data = $this->http->request('GET', 'cgi-bin/token', [ - 'grant_type' => 'client_credential', - 'appid' => $this->config->appid, - 'secret' => $this->config->appSecret, - ]); - $token = (string)($data['access_token'] ?? ''); - $this->cache->set($key, $token, max(1, (int)($data['expires_in'] ?? 7200) - 300)); - - return $token; - }); + return $this->clientCredentialAccessToken( + TokenCacheKey::wechatPlatformAccessToken($this->config->appid, $this->config->storageScope), + $this->config->appid, + $this->config->appSecret, + $refresh, + ); } /** + * 请求微信公众平台 API;默认自动在 query 中附加 access_token。 + * * @param array $query * @param array $options * @return array */ public function request(string $method, string $uri, array $query = [], array $options = [], bool $withToken = true): array { - if ($withToken) { - $query['access_token'] = $query['access_token'] ?? $this->accessToken(); - } - - return $this->http->request($method, ltrim($uri, '/'), $query, $options); - } - - /** @param array $menu @return array */ - public function createMenu(array $menu): array - { - return $this->request('POST', 'cgi-bin/menu/create', options: ['json' => $menu]); - } - - /** @return array */ - public function userList(string $nextOpenid = ''): array - { - return $this->request('GET', 'cgi-bin/user/get', ['next_openid' => $nextOpenid]); + return $this->jsonWechatRequest($method, $uri, $this->withAccessToken($query, $withToken), $options); } /** - * @param array $openids - * @return array + * 请求微信公众平台 API 并返回原始响应,适合图片、媒体、文件等非 JSON 接口。 + * + * @param array $query + * @param array $options */ - public function batchUserInfo(array $openids, string $lang = 'zh_CN'): array + public function raw(string $method, string $uri, array $query = [], array $options = [], bool $withToken = true): ResponseInterface { - return $this->request('POST', 'cgi-bin/user/info/batchget', options: [ - 'json' => [ - 'user_list' => array_map(static fn (string $openid): array => ['openid' => $openid, 'lang' => $lang], $openids), - ], - ]); + return $this->rawWechatRequest($method, $uri, $this->withAccessToken($query, $withToken), $options); } - public function messageCrypto(): MessageCrypto + /** + * 下载微信公众平台二进制资源并返回原始响应。 + * + * @param array $query + * @param array $options + */ + public function download(string $uri, array $query = [], array $options = [], bool $withToken = true): ResponseInterface + { + return $this->downloadWechatResource($uri, $this->withAccessToken($query, $withToken), $options); + } + + /** + * 使用 multipart/form-data 上传文件或媒体资源。 + * + * @param array> $multipart + * @param array $query + * @param array $options + * @return array + */ + public function upload(string $uri, array $multipart, array $query = [], array $options = [], bool $withToken = true): array + { + 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); } /** - * 统一调用入口:除 token 基础接口外,默认按 URI + 参数发起请求。 + * 通用 API 调用入口:按官方接口 path 与参数发起请求。 * * @param array $params * @param array $options @@ -108,36 +127,24 @@ final class PlatformClient */ public function call(string $uriOrPath, array $params = [], string $httpMethod = 'POST', array $options = []): array { - $method = strtoupper($httpMethod === '' ? 'POST' : $httpMethod); $uri = ltrim($uriOrPath, '/'); - if ($uri === 'decrypt_message') { - return $this->messageCrypto()->decryptMessage( - (string)($params['body'] ?? ''), - (string)($params['msg_signature'] ?? ''), - (string)($params['timestamp'] ?? ''), - (string)($params['nonce'] ?? ''), - ); + $messageCryptoResult = $this->handleWechatMessageCryptoCall($uri, $params); + if ($messageCryptoResult !== null) { + return $messageCryptoResult; } - if ($uri === 'encrypt_message') { - return [ - 'xml' => $this->messageCrypto()->encryptMessage( - (string)($params['body'] ?? ''), - (string)($params['timestamp'] ?? time()), - (string)($params['nonce'] ?? ''), - ), - ]; + if (in_array($uri, ['connect/oauth2/authorize', 'oauth2/authorize', 'open/oauth2/authorize'], true)) { + return ['url' => $this->openAuthorizeUrl($params)]; + } + if (in_array($uri, ['connect/qrconnect', 'qrconnect', 'open/qrconnect'], true)) { + return ['url' => $this->openQrconnectUrl($params)]; } - return $this->request( - $method, - $uri, - $method === 'GET' ? $params : (is_array($options['query'] ?? null) ? $options['query'] : []), - $this->buildOptions($method, $params, $options), - (bool)($options['with_token'] ?? true), - ); + return $this->callWechatJsonApi($uri, $params, $httpMethod, $options, true, ['with_token']); } /** + * 按 POST 方法调用微信公众平台 API。 + * * @param array $params * @param array $options * @return array @@ -148,6 +155,8 @@ final class PlatformClient } /** + * 按 GET 方法调用微信公众平台 API。 + * * @param array $params * @param array $options * @return array @@ -158,20 +167,50 @@ final class PlatformClient } /** + * 生成微信网页授权地址(open.weixin.qq.com/connect/oauth2/authorize)。 + * * @param array $params - * @param array $options - * @return array */ - private function buildOptions(string $method, array $params, array $options): array + private function openAuthorizeUrl(array $params): string { - if ($method === 'GET' || isset($options['json']) || isset($options['body']) || isset($options['form_params']) || isset($options['multipart'])) { - return $options; - } - $options['json'] = $params; - - return $options; + return $this->openConnectUrl('connect/oauth2/authorize', array_merge([ + 'appid' => $this->config->appid, + 'redirect_uri' => '', + 'response_type' => 'code', + 'scope' => 'snsapi_base', + 'state' => '', + ], $params)); } + /** + * 生成微信网站应用扫码登录地址(open.weixin.qq.com/connect/qrconnect)。 + * + * @param array $params + */ + private function openQrconnectUrl(array $params): string + { + return $this->openConnectUrl('connect/qrconnect', array_merge([ + 'appid' => $this->config->appid, + 'redirect_uri' => '', + 'response_type' => 'code', + 'scope' => 'snsapi_login', + 'state' => '', + ], $params)); + } + + /** + * 拼接 open.weixin.qq.com 授权类地址,统一追加微信重定向片段。 + * + * @param array $query + */ + private function openConnectUrl(string $path, array $query): string + { + return self::OPEN . $path . '?' . http_build_query($query) . '#wechat_redirect'; + } + + /** + * 生成当前微信公众平台通道下的完整缓存键。 + */ private function cacheKey(string $logicalKey): string { return CacheKey::compose($this->cacheKeyPrefix, self::TOKEN_PLATFORM_CHANNEL, $logicalKey); diff --git a/src/Platform/Wechat/ServiceClient.php b/src/Platform/Wechat/ServiceClient.php index 95276f8..16dda0b 100644 --- a/src/Platform/Wechat/ServiceClient.php +++ b/src/Platform/Wechat/ServiceClient.php @@ -2,26 +2,42 @@ declare(strict_types=1); +/** + * 微信服务平台(第三方平台)客户端。 + */ + namespace We\Platform\Wechat; use GuzzleHttp\ClientInterface; +use Psr\Http\Message\ResponseInterface; use We\Client; use We\Config\WechatServiceConfig; use We\Contract\StoreCacheInterface; use We\Contract\StoreTokenInterface; use We\Exception\WechatException; +use We\Platform\Wechat\Concerns\InteractsProtocol; use We\Support\CacheKey; use We\Support\JsonClient; use We\Support\MessageCrypto; use We\Support\NullCacheStore; use We\Support\TokenCacheKey; +/** + * 微信服务平台(第三方平台)客户端。 + * + * 负责第三方平台 component_access_token 缓存、授权方 authorizer_access_token 刷新和代授权方调用微信公众平台或小程序接口。 + */ final class ServiceClient { + use InteractsProtocol; + /** 与 {@see \We\Client::get} 通道标识一致 */ private const TOKEN_PLATFORM_CHANNEL = 'wechat.service'; private JsonClient $http; + /** + * 创建微信服务平台(第三方平台)客户端并初始化官方 API HTTP 客户端。 + */ public function __construct( private readonly WechatServiceConfig $config, ?ClientInterface $http = null, @@ -35,9 +51,12 @@ final class ServiceClient private readonly ?StoreTokenInterface $authorizers; + /** + * 获取第三方平台接口调用凭据 component_access_token;缓存未命中或强制刷新时调用官方 component_token 接口。 + */ public function componentAccessToken(string $componentVerifyTicket, bool $refresh = false): string { - $key = $this->cacheKey(TokenCacheKey::wechatOpenComponentAccessToken($this->config->componentAppid, $this->config->storageScope)); + $key = $this->cacheKey(TokenCacheKey::wechatServiceComponentAccessToken($this->config->componentAppid, $this->config->storageScope)); if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { return $token; } @@ -46,7 +65,7 @@ final class ServiceClient if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { return $token; } - $data = $this->http->request('POST', 'cgi-bin/component/api_component_token', options: [ + $data = $this->jsonWechatRequest('POST', 'cgi-bin/component/api_component_token', [], [ 'json' => [ 'component_appid' => $this->config->componentAppid, 'component_appsecret' => $this->config->componentAppSecret, @@ -60,20 +79,66 @@ final class ServiceClient }); } - /** @return array */ + /** + * 请求微信服务平台(第三方平台) API。 + * + * @return array + */ public function request(string $method, string $uri, array $query = [], array $options = []): array { - return $this->http->request($method, ltrim($uri, '/'), $query, $options); + return $this->jsonWechatRequest($method, $uri, $query, $options); } - /** @return array */ + /** + * 请求微信服务平台 API 并返回原始响应,适合图片、媒体、文件等非 JSON 接口。 + * + * @param array $query + * @param array $options + */ + public function raw(string $method, string $uri, array $query = [], array $options = []): ResponseInterface + { + return $this->rawWechatRequest($method, $uri, $query, $options); + } + + /** + * 下载微信服务平台二进制资源并返回原始响应。 + * + * @param array $query + * @param array $options + */ + public function download(string $uri, array $query = [], array $options = []): ResponseInterface + { + return $this->downloadWechatResource($uri, $query, $options); + } + + /** + * 使用 multipart/form-data 上传文件或媒体资源。 + * + * @param array> $multipart + * @param array $query + * @param array $options + * @return array + */ + public function upload(string $uri, array $multipart, array $query = [], array $options = []): array + { + return $this->uploadWechatResource($uri, $multipart, $query, $options); + } + + /** + * 调用创建预授权码接口,生成 pre_auth_code。 + * + * @return array + */ public function createPreAuthCode(string $componentAccessToken): array { - return $this->http->request('POST', 'cgi-bin/component/api_create_preauthcode', ['component_access_token' => $componentAccessToken], [ + return $this->request('POST', 'cgi-bin/component/api_create_preauthcode', ['component_access_token' => $componentAccessToken], [ 'json' => ['component_appid' => $this->config->componentAppid], ]); } + /** + * 生成第三方平台授权登录页地址。 + */ public function authorizationUrl(string $preAuthCode, string $redirectUri, int $authType = 3, string $state = ''): string { return 'https://mp.weixin.qq.com/cgi-bin/componentloginpage?' . http_build_query([ @@ -86,10 +151,14 @@ final class ServiceClient ]); } - /** @return array */ + /** + * 使用 authorization_code 调用查询授权信息接口。 + * + * @return array + */ public function queryAuth(string $componentAccessToken, string $authorizationCode): array { - return $this->http->request('POST', 'cgi-bin/component/api_query_auth', ['component_access_token' => $componentAccessToken], [ + return $this->request('POST', 'cgi-bin/component/api_query_auth', ['component_access_token' => $componentAccessToken], [ 'json' => [ 'component_appid' => $this->config->componentAppid, 'authorization_code' => $authorizationCode, @@ -97,10 +166,14 @@ final class ServiceClient ]); } - /** @return array */ + /** + * 调用获取授权方账号基本信息接口。 + * + * @return array + */ public function authorizerInfo(string $componentAccessToken, string $authorizerAppid): array { - return $this->http->request('POST', 'cgi-bin/component/api_get_authorizer_info', ['component_access_token' => $componentAccessToken], [ + return $this->request('POST', 'cgi-bin/component/api_get_authorizer_info', ['component_access_token' => $componentAccessToken], [ 'json' => [ 'component_appid' => $this->config->componentAppid, 'authorizer_appid' => $authorizerAppid, @@ -108,21 +181,28 @@ final class ServiceClient ]); } - /** @return array */ + /** + * 使用授权方 authorizer_access_token 代调用微信公众平台或小程序接口。 + * + * @return array + */ public function requestAsAuthorizer(string $method, string $uri, string $authorizerAppid, string $componentAccessToken, array $query = [], array $options = []): array { $query['access_token'] = $this->authorizerAccessToken($componentAccessToken, $authorizerAppid); - return $this->http->request($method, ltrim($uri, '/'), $query, $options); + return $this->jsonWechatRequest($method, $uri, $query, $options); } - public function messageCrypto(): MessageCrypto + /** + * 创建第三方平台授权事件消息加解密工具。 + */ + private function messageCrypto(): MessageCrypto { return new MessageCrypto($this->config->componentToken, $this->config->componentEncodingAesKey, $this->config->componentAppid); } /** - * 统一调用入口:除 token 基础接口外,其他能力默认按 URI + 参数执行。 + * 通用 API 调用入口:按官方接口 path、授权方上下文和参数发起请求。 * * @param array $params * @param array $options @@ -130,24 +210,11 @@ final class ServiceClient */ public function call(string $uriOrPath, array $params = [], string $httpMethod = 'POST', array $options = []): array { - $method = strtoupper($httpMethod === '' ? 'POST' : $httpMethod); + $method = $this->normalizeWechatHttpMethod($httpMethod); $uri = ltrim($uriOrPath, '/'); - if ($uri === 'decrypt_message') { - return $this->messageCrypto()->decryptMessage( - (string)($params['body'] ?? ''), - (string)($params['msg_signature'] ?? ''), - (string)($params['timestamp'] ?? ''), - (string)($params['nonce'] ?? ''), - ); - } - if ($uri === 'encrypt_message') { - return [ - 'xml' => $this->messageCrypto()->encryptMessage( - (string)($params['body'] ?? ''), - (string)($params['timestamp'] ?? time()), - (string)($params['nonce'] ?? ''), - ), - ]; + $messageCryptoResult = $this->handleWechatMessageCryptoCall($uri, $params); + if ($messageCryptoResult !== null) { + return $messageCryptoResult; } if (isset($options['authorizer_appid'], $options['component_access_token'])) { return $this->requestAsAuthorizer( @@ -155,20 +222,17 @@ final class ServiceClient $uri, (string)$options['authorizer_appid'], (string)$options['component_access_token'], - is_array($options['query'] ?? null) ? $options['query'] : [], - $this->buildOptions($method, $params, $options), + $this->wechatCallQuery($method, $params, $options), + $this->buildWechatJsonOptions($method, $params, $options, ['authorizer_appid', 'component_access_token']), ); } - return $this->request( - $method, - $uri, - $method === 'GET' ? $params : (is_array($options['query'] ?? null) ? $options['query'] : []), - $this->buildOptions($method, $params, $options), - ); + return $this->callWechatJsonApi($uri, $params, $httpMethod, $options, false, ['authorizer_appid', 'component_access_token']); } /** + * 按 POST 方法调用微信服务平台 API。 + * * @param array $params * @param array $options * @return array @@ -179,6 +243,8 @@ final class ServiceClient } /** + * 按 GET 方法调用微信服务平台 API。 + * * @param array $params * @param array $options * @return array @@ -188,12 +254,15 @@ final class ServiceClient return $this->call($uriOrPath, $params, 'GET', $options); } + /** + * 获取授权方接口调用凭据 authorizer_access_token;缓存未命中时使用 authorizer_refresh_token 刷新。 + */ private function authorizerAccessToken(string $componentAccessToken, string $authorizerAppid): string { if (!$this->authorizers) { throw new WechatException('未配置授权账号 Token 仓库'); } - $key = $this->cacheKey(TokenCacheKey::wechatOpenAuthorizerAccessToken( + $key = $this->cacheKey(TokenCacheKey::wechatServiceAuthorizerAccessToken( $this->config->componentAppid, $authorizerAppid, $this->config->storageScope, @@ -215,10 +284,14 @@ final class ServiceClient }); } - /** @return array */ + /** + * 调用刷新授权方接口调用凭据接口。 + * + * @return array + */ private function refreshAuthorizerToken(string $componentAccessToken, string $authorizerAppid, string $refreshToken): array { - return $this->http->request('POST', 'cgi-bin/component/api_authorizer_token', ['component_access_token' => $componentAccessToken], [ + return $this->jsonWechatRequest('POST', 'cgi-bin/component/api_authorizer_token', ['component_access_token' => $componentAccessToken], [ 'json' => [ 'component_appid' => $this->config->componentAppid, 'authorizer_appid' => $authorizerAppid, @@ -228,20 +301,8 @@ final class ServiceClient } /** - * @param array $params - * @param array $options - * @return array + * 生成当前第三方平台通道下的完整缓存键。 */ - private function buildOptions(string $method, array $params, array $options): array - { - if ($method === 'GET' || isset($options['json']) || isset($options['body']) || isset($options['form_params']) || isset($options['multipart'])) { - return $options; - } - $options['json'] = $params; - - return $options; - } - private function cacheKey(string $logicalKey): string { return CacheKey::compose($this->cacheKeyPrefix, self::TOKEN_PLATFORM_CHANNEL, $logicalKey); diff --git a/src/Platform/Wechat/WxappClient.php b/src/Platform/Wechat/WxappClient.php index 54d2e12..1cd3614 100644 --- a/src/Platform/Wechat/WxappClient.php +++ b/src/Platform/Wechat/WxappClient.php @@ -2,23 +2,39 @@ declare(strict_types=1); +/** + * 微信小程序客户端。 + */ + namespace We\Platform\Wechat; use GuzzleHttp\ClientInterface; +use Psr\Http\Message\ResponseInterface; use We\Client; use We\Config\WechatWxappConfig; use We\Contract\StoreCacheInterface; +use We\Platform\Wechat\Concerns\InteractsProtocol; use We\Support\CacheKey; use We\Support\JsonClient; use We\Support\NullCacheStore; use We\Support\TokenCacheKey; +/** + * 微信小程序客户端。 + * + * 负责获取和缓存小程序接口调用凭据 access_token,并向小程序 API 请求自动附加 access_token。 + */ final class WxappClient { + use InteractsProtocol; + /** 与 {@see \We\Client::get} 通道标识一致 */ private const TOKEN_PLATFORM_CHANNEL = 'wechat.wxapp'; private JsonClient $http; + /** + * 创建微信小程序客户端并初始化官方 API HTTP 客户端。 + */ public function __construct( private readonly WechatWxappConfig $config, ?ClientInterface $http = null, @@ -28,60 +44,79 @@ final class WxappClient $this->http = new JsonClient($http ?? new \GuzzleHttp\Client(['base_uri' => 'https://api.weixin.qq.com/', 'timeout' => 20.0])); } + /** + * 获取小程序接口调用凭据 access_token;缓存未命中或强制刷新时调用官方 token 接口。 + */ public function accessToken(bool $refresh = false): string { - $key = $this->cacheKey(TokenCacheKey::wechatMiniAccessToken($this->config->appid, $this->config->storageScope)); - if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { - return $token; - } - - return $this->cache->lock('lock:' . $key, 30, function () use ($key, $refresh): string { - if (!$refresh && is_string($token = $this->cache->get($key, '')) && $token !== '') { - return $token; - } - $data = $this->http->request('GET', 'cgi-bin/token', [ - 'grant_type' => 'client_credential', - 'appid' => $this->config->appid, - 'secret' => $this->config->appSecret, - ]); - $token = (string)$data['access_token']; - $this->cache->set($key, $token, max(1, (int)($data['expires_in'] ?? 7200) - 300)); - - return $token; - }); + return $this->clientCredentialAccessToken( + TokenCacheKey::wechatWxappAccessToken($this->config->appid, $this->config->storageScope), + $this->config->appid, + $this->config->appSecret, + $refresh, + ); } /** + * 请求微信小程序 API;默认自动在 query 中附加 access_token。 + * * @return array */ public function request(string $method, string $uri, array $query = [], array $options = [], bool $withToken = true): array { - if ($withToken) { - $query['access_token'] = $query['access_token'] ?? $this->accessToken(); - } - - return $this->http->request($method, ltrim($uri, '/'), $query, $options); + return $this->jsonWechatRequest($method, $uri, $this->withAccessToken($query, $withToken), $options); } /** + * 请求微信小程序 API 并返回原始响应,适合图片、媒体、文件等非 JSON 接口。 + * + * @param array $query + * @param array $options + */ + public function raw(string $method, string $uri, array $query = [], array $options = [], bool $withToken = true): ResponseInterface + { + return $this->rawWechatRequest($method, $uri, $this->withAccessToken($query, $withToken), $options); + } + + /** + * 下载微信小程序二进制资源并返回原始响应。 + * + * @param array $query + * @param array $options + */ + public function download(string $uri, array $query = [], array $options = [], bool $withToken = true): ResponseInterface + { + return $this->downloadWechatResource($uri, $this->withAccessToken($query, $withToken), $options); + } + + /** + * 使用 multipart/form-data 上传文件或媒体资源。 + * + * @param array> $multipart + * @param array $query + * @param array $options + * @return array + */ + public function upload(string $uri, array $multipart, array $query = [], array $options = [], bool $withToken = true): array + { + return $this->uploadWechatResource($uri, $multipart, $this->withAccessToken($query, $withToken), $options); + } + + /** + * 通用 API 调用入口:按官方接口 path 与参数发起请求。 + * * @param array $params * @param array $options * @return array */ public function call(string $uriOrPath, array $params = [], string $httpMethod = 'POST', array $options = []): array { - $method = strtoupper($httpMethod === '' ? 'POST' : $httpMethod); - - return $this->request( - $method, - ltrim($uriOrPath, '/'), - $method === 'GET' ? $params : (is_array($options['query'] ?? null) ? $options['query'] : []), - $this->buildOptions($method, $params, $options), - (bool)($options['with_token'] ?? true), - ); + return $this->callWechatJsonApi($uriOrPath, $params, $httpMethod, $options, true, ['with_token']); } /** + * 按 POST 方法调用微信小程序 API。 + * * @param array $params * @param array $options * @return array @@ -92,6 +127,8 @@ final class WxappClient } /** + * 按 GET 方法调用微信小程序 API。 + * * @param array $params * @param array $options * @return array @@ -102,20 +139,8 @@ final class WxappClient } /** - * @param array $params - * @param array $options - * @return array + * 生成当前小程序通道下的完整缓存键。 */ - private function buildOptions(string $method, array $params, array $options): array - { - if ($method === 'GET' || isset($options['json']) || isset($options['body']) || isset($options['form_params'])) { - return $options; - } - $options['json'] = $params; - - return $options; - } - private function cacheKey(string $logicalKey): string { return CacheKey::compose($this->cacheKeyPrefix, self::TOKEN_PLATFORM_CHANNEL, $logicalKey); diff --git a/src/Support/JsonClient.php b/src/Support/JsonClient.php index 6941d0e..d0e016b 100644 --- a/src/Support/JsonClient.php +++ b/src/Support/JsonClient.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * JSON HTTP 客户端封装。 + */ + namespace We\Support; use GuzzleHttp\Client; @@ -10,21 +14,30 @@ use GuzzleHttp\Exception\GuzzleException; use Psr\Http\Message\ResponseInterface; use We\Exception\ApiException; +/** + * JSON HTTP 客户端封装。 + * + * 统一发送 JSON 风格平台接口请求,只允许相对路径,并将平台错误响应转换为 {@see ApiException}。 + */ final class JsonClient { + /** + * 创建 JSON HTTP 客户端封装。 + */ public function __construct( private ClientInterface $http = new Client(['timeout' => 20.0]), ) {} /** + * 发送 HTTP 请求并将 JSON 响应解析为数组。 + * * @param array $query * @param array $options * @return array */ public function request(string $method, string $uri, array $query = [], array $options = []): array { - $options['query'] = array_merge($query, $options['query'] ?? []); - $response = $this->send($method, $uri, $options); + $response = $this->raw($method, $uri, $query, $options); $body = (string)$response->getBody(); $data = $body === '' ? [] : json_decode($body, true); if (!is_array($data)) { @@ -39,6 +52,21 @@ final class JsonClient } /** + * 发送 HTTP 请求并返回原始 PSR-7 响应,不执行 JSON 解析。 + * + * @param array $query + * @param array $options + */ + public function raw(string $method, string $uri, array $query = [], array $options = []): ResponseInterface + { + $options['query'] = array_merge($query, is_array($options['query'] ?? null) ? $options['query'] : []); + + return $this->send($method, $uri, $options); + } + + /** + * 发送底层 Guzzle 请求,并禁止绝对 URL。 + * * @param array $options */ public function send(string $method, string $uri, array $options = []): ResponseInterface @@ -51,6 +79,9 @@ final class JsonClient } } + /** + * 校验接口 path 必须为相对路径,避免调用方传入绝对 URL。 + */ private function assertRelativeUri(string $uri): void { $uri = trim($uri); diff --git a/src/Support/MessageCrypto.php b/src/Support/MessageCrypto.php index 435ed0c..ebb90b5 100644 --- a/src/Support/MessageCrypto.php +++ b/src/Support/MessageCrypto.php @@ -2,20 +2,35 @@ declare(strict_types=1); +/** + * 微信消息安全模式加解密工具。 + */ + namespace We\Support; use We\Exception\SignatureException; use We\Exception\WechatException; +/** + * 微信消息安全模式加解密工具。 + * + * 实现微信服务器推送消息安全模式下的 AES-CBC 解密、加密和 msg_signature 校验。 + */ final class MessageCrypto { private string $aesKey; + /** + * 创建微信消息加解密工具并解析 EncodingAESKey。 + */ public function __construct( private string $token, string $encodingAesKey, private string $appid, ) { + if (strlen($encodingAesKey) !== 43) { + throw new WechatException('EncodingAESKey 必须是 43 位有效字符串'); + } $key = base64_decode($encodingAesKey . '=', true); if ($key === false || strlen($key) !== 32) { throw new WechatException('EncodingAESKey 必须是 43 位有效字符串'); @@ -24,6 +39,8 @@ final class MessageCrypto } /** + * 校验 msg_signature 并解密微信安全模式 XML 消息。 + * * @return array */ public function decryptMessage(string $xml, string $msgSignature, string $timestamp, string $nonce): array @@ -36,15 +53,28 @@ final class MessageCrypto Signature::assertSha1($msgSignature, [$this->token, $timestamp, $nonce, $encrypt]); - $plain = openssl_decrypt(base64_decode($encrypt, true) ?: '', 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($this->aesKey, 0, 16)); + $cipher = base64_decode($encrypt, true); + if ($cipher === false || $cipher === '') { + throw new WechatException('微信加密消息密文 Base64 无效'); + } + $plain = openssl_decrypt($cipher, 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($this->aesKey, 0, 16)); if (!is_string($plain) || $plain === '') { throw new WechatException('微信加密消息解密失败'); } $plain = $this->removePadding($plain); + if (strlen($plain) < 20) { + throw new WechatException('微信加密消息明文长度无效'); + } $length = unpack('N', substr($plain, 16, 4))[1] ?? 0; + if (!is_int($length) || strlen($plain) < 20 + $length) { + throw new WechatException('微信加密消息内容长度无效'); + } $message = substr($plain, 20, (int)$length); $appid = substr($plain, 20 + (int)$length); + if ($message === '' || $appid === '') { + throw new WechatException('微信加密消息内容无效'); + } if (!hash_equals($this->appid, $appid)) { throw new SignatureException('微信加密消息 AppID 不匹配'); } @@ -52,12 +82,19 @@ final class MessageCrypto return Xml::decode($message); } + /** + * 加密回复 XML,并生成包含 Encrypt、MsgSignature、TimeStamp、Nonce 的安全模式响应 XML。 + */ public function encryptMessage(string $xml, string $timestamp, string $nonce): string { $random = random_bytes(16); $payload = $random . pack('N', strlen($xml)) . $xml . $this->appid; $payload .= str_repeat(chr($pad = 32 - strlen($payload) % 32), $pad); - $encrypted = base64_encode(openssl_encrypt($payload, 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($this->aesKey, 0, 16)) ?: ''); + $cipher = openssl_encrypt($payload, 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($this->aesKey, 0, 16)); + if (!is_string($cipher) || $cipher === '') { + throw new WechatException('微信加密消息加密失败'); + } + $encrypted = base64_encode($cipher); $signature = Signature::sha1([$this->token, $timestamp, $nonce, $encrypted]); return Xml::encode([ @@ -68,11 +105,20 @@ final class MessageCrypto ]); } + /** + * 移除微信消息加解密协议中的 PKCS#7 填充。 + */ private function removePadding(string $data): string { + if ($data === '') { + throw new WechatException('微信加密消息填充无效'); + } $pad = ord(substr($data, -1)); if ($pad < 1 || $pad > 32) { - $pad = 0; + throw new WechatException('微信加密消息填充无效'); + } + if (strlen($data) < $pad || substr($data, -$pad) !== str_repeat(chr($pad), $pad)) { + throw new WechatException('微信加密消息填充无效'); } return substr($data, 0, strlen($data) - $pad); diff --git a/src/Support/PaymentCrypto.php b/src/Support/PaymentCrypto.php index 699d6cd..e867d31 100644 --- a/src/Support/PaymentCrypto.php +++ b/src/Support/PaymentCrypto.php @@ -2,13 +2,24 @@ declare(strict_types=1); +/** + * 微信支付 APIv3 通知资源解密工具。 + */ + namespace We\Support; use We\Exception\WechatException; +/** + * 微信支付 APIv3 通知资源解密工具。 + * + * 使用商户 APIv3 密钥对通知 resource.ciphertext 执行 AES-256-GCM 解密,并解析明文 JSON。 + */ final class PaymentCrypto { /** + * 解密微信支付 APIv3 通知中的 resource 字段。 + * * @param array{ciphertext:string,nonce:string,associated_data?:string} $resource * @return array */ diff --git a/src/Support/Signature.php b/src/Support/Signature.php index 18f15a4..4557884 100644 --- a/src/Support/Signature.php +++ b/src/Support/Signature.php @@ -2,14 +2,23 @@ declare(strict_types=1); +/** + * 签名与验签工具。 + */ + namespace We\Support; use We\Exception\SignatureException; +/** + * 签名与验签工具集合。 + * + * 覆盖微信消息 SHA1 签名、微信支付 APIv3 商户 RSA-SHA256 签名和平台证书/公钥验签。 + */ final class Signature { /** - * 微信公众号与开放平台回调签名,参数按字典序拼接后 SHA1。 + * 生成微信服务器消息签名:参数按字典序排序后拼接并计算 SHA1。 * * @param array $items */ @@ -21,6 +30,8 @@ final class Signature } /** + * 校验微信服务器消息签名。 + * * @param array $items */ public static function assertSha1(string $expected, array $items): void @@ -31,6 +42,9 @@ final class Signature } } + /** + * 使用微信支付商户私钥生成 RSA-SHA256 签名。 + */ public static function paymentV3Sign(string $privateKey, string $message): string { $key = openssl_pkey_get_private($privateKey); @@ -44,6 +58,9 @@ final class Signature return base64_encode($signature); } + /** + * 使用微信支付平台证书或平台公钥验证 RSA-SHA256 签名。 + */ public static function verifyPaymentV3(string $publicKey, string $message, string $signature): bool { $key = openssl_pkey_get_public($publicKey); diff --git a/src/Support/Xml.php b/src/Support/Xml.php index 1a38815..196fe71 100644 --- a/src/Support/Xml.php +++ b/src/Support/Xml.php @@ -2,14 +2,25 @@ declare(strict_types=1); +/** + * 微信 XML 编解码工具。 + */ + namespace We\Support; use SimpleXMLElement; use We\Exception\WechatException; +/** + * 微信 XML 编解码工具。 + * + * 用于处理微信消息 XML:解码时读取 CDATA 文本,编码时将字段值写入 CDATA。 + */ final class Xml { /** + * 将微信消息 XML 解析为数组。 + * * @return array */ public static function decode(string $xml): array @@ -30,23 +41,52 @@ final class Xml } /** + * 将数组编码为微信消息 XML。 + * * @param array $data */ public static function encode(array $data): string { $content = ''; foreach ($data as $key => $value) { - if (is_array($value)) { - $value = json_encode($value, JSON_UNESCAPED_UNICODE) ?: ''; - } - $value = (string)$value; - $content .= sprintf('<%1$s>', $key, $value); + $content .= self::encodeNode((string)$key, $value); } return $content . ''; } /** + * 递归编码单个 XML 节点;列表数组会重复输出同名节点。 + */ + private static function encodeNode(string $key, mixed $value): string + { + self::assertNodeName($key); + if ($value === []) { + return sprintf('<%1$s>', $key); + } + if (is_array($value) && array_is_list($value)) { + $content = ''; + foreach ($value as $item) { + $content .= self::encodeNode($key, $item); + } + + return $content; + } + if (is_array($value)) { + $content = ''; + foreach ($value as $childKey => $childValue) { + $content .= self::encodeNode((string)$childKey, $childValue); + } + + return sprintf('<%1$s>%2$s', $key, $content); + } + + return sprintf('<%1$s>', $key, self::cdata((string)$value)); + } + + /** + * 递归转换 SimpleXML 节点为普通数组。 + * * @return array */ private static function normalize(SimpleXMLElement $element): array @@ -54,9 +94,35 @@ final class Xml $result = []; foreach ($element->children() as $key => $value) { $children = $value->children(); - $result[$key] = $children->count() > 0 ? self::normalize($value) : (string)$value; + $node = $children->count() > 0 ? self::normalize($value) : (string)$value; + if (array_key_exists($key, $result)) { + if (!is_array($result[$key]) || !array_is_list($result[$key])) { + $result[$key] = [$result[$key]]; + } + $result[$key][] = $node; + continue; + } + $result[$key] = $node; } return $result; } + + /** + * 转义 CDATA 结束标记,避免字段值破坏 XML 结构。 + */ + private static function cdata(string $value): string + { + return str_replace(']]>', ']]]]>', $value); + } + + /** + * 校验 XML 节点名,避免生成非法 XML。 + */ + private static function assertNodeName(string $key): void + { + if (preg_match('/^[A-Za-z_][A-Za-z0-9_.:-]*$/', $key) !== 1) { + throw new WechatException('微信 XML 节点名无效: ' . $key); + } + } }