mirror of
https://gitee.com/zoujingli/WeChatDeveloper.git
synced 2026-09-04 23:12:09 +08:00
feat(wechat): 完善微信协议层通用能力
- 新增 InteractsProtocol,复用 access_token 注入、JSON 请求、raw/download/upload 与消息加解密伪路径处理。 - 微信公众平台、小程序、服务平台统一按官方 path + params/options 调用,服务平台保留授权链路能力。 - 微信支付 APIv3 支持 raw/download 原始响应,并确保签名 query 与实际请求一致。 - 增强 XML 重复节点/嵌套节点编解码和消息安全模式密文校验。
This commit is contained in:
parent
05cb47d3fd
commit
9c3fd5b59d
210
src/Platform/Wechat/Concerns/InteractsProtocol.php
Normal file
210
src/Platform/Wechat/Concerns/InteractsProtocol.php
Normal file
@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 微信协议层客户端通用能力。
|
||||
*/
|
||||
|
||||
namespace We\Platform\Wechat\Concerns;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* 微信协议层客户端通用能力。
|
||||
*
|
||||
* 复用 client_credential access_token 缓存、原始响应、下载、上传和 JSON 请求选项构造逻辑。
|
||||
*/
|
||||
trait InteractsProtocol
|
||||
{
|
||||
/**
|
||||
* 获取并缓存 client_credential access_token。
|
||||
*/
|
||||
private function clientCredentialAccessToken(string $logicalKey, string $appid, string $appSecret, bool $refresh): string
|
||||
{
|
||||
$key = $this->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<string,mixed> $query
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function withAccessToken(array $query, bool $withToken): array
|
||||
{
|
||||
if ($withToken) {
|
||||
$query['access_token'] = $query['access_token'] ?? $this->accessToken();
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求微信 JSON API 并解析响应数组。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function jsonWechatRequest(string $method, string $uri, array $query = [], array $options = []): array
|
||||
{
|
||||
return $this->http->request($method, ltrim($uri, '/'), $query, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求微信 API 并返回原始响应。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
private function rawWechatRequest(string $method, string $uri, array $query = [], array $options = []): ResponseInterface
|
||||
{
|
||||
return $this->http->raw($method, ltrim($uri, '/'), $query, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载微信二进制资源并返回原始响应。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
private function downloadWechatResource(string $uri, array $query = [], array $options = []): ResponseInterface
|
||||
{
|
||||
return $this->rawWechatRequest('GET', $uri, $query, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 multipart/form-data 上传文件或媒体资源并解析 JSON 响应。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $multipart
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @return null|array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @param array<int,string> $internalKeys
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @param array<int,string> $internalKeys
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function wechatCallQuery(string $method, array $params, array $options): array
|
||||
{
|
||||
return $method === 'GET' ? $params : (is_array($options['query'] ?? null) ? $options['query'] : []);
|
||||
}
|
||||
}
|
||||
@ -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<string,mixed> $payload @return array<string,mixed> */
|
||||
/**
|
||||
* 发起微信支付 APIv3 请求并附加商户请求签名。
|
||||
*
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $payload @return array<string,mixed> */
|
||||
public function refund(array $payload): array
|
||||
{
|
||||
return $this->request('POST', 'v3/refund/domestic/refunds', $payload);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起微信支付 APIv3 请求并返回原始响应。
|
||||
*
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $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<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
public function download(string $uri, array $query = [], array $options = []): ResponseInterface
|
||||
{
|
||||
return $this->raw('GET', $uri, [], $query, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验微信支付通知签名并解密通知 resource。
|
||||
*
|
||||
* @param array<string,string> $headers
|
||||
* @param array<string,mixed>|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码。
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -108,6 +152,8 @@ final class PaymentClient
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 GET 方法调用微信支付 APIv3。
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -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<string,mixed> $headers
|
||||
*/
|
||||
private function headerValue(array $headers, string $name): string
|
||||
@ -165,4 +222,43 @@ final class PaymentClient
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并显式 query 与 Guzzle options 中的 query,确保签名串与实际请求一致。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function mergeQuery(array $query, array $options): array
|
||||
{
|
||||
return array_merge($query, is_array($options['query'] ?? null) ? $options['query'] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造签名所需的 query string。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
*/
|
||||
private function queryString(array $query): string
|
||||
{
|
||||
return $query === [] ? '' : '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并默认微信支付 APIv3 请求头。
|
||||
*
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $menu @return array<string,mixed> */
|
||||
public function createMenu(array $menu): array
|
||||
{
|
||||
return $this->request('POST', 'cgi-bin/menu/create', options: ['json' => $menu]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
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<int,string> $openids
|
||||
* @return array<string,mixed>
|
||||
* 请求微信公众平台 API 并返回原始响应,适合图片、媒体、文件等非 JSON 接口。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $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<string,mixed> $query
|
||||
* @param array<string,mixed> $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<int,array<string,mixed>> $multipart
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -148,6 +155,8 @@ final class PlatformClient
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 GET 方法调用微信公众平台 API。
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -158,20 +167,50 @@ final class PlatformClient
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成微信网页授权地址(open.weixin.qq.com/connect/oauth2/authorize)。
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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<string,mixed> $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);
|
||||
|
||||
@ -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<string,mixed> */
|
||||
/**
|
||||
* 请求微信服务平台(第三方平台) API。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> */
|
||||
/**
|
||||
* 请求微信服务平台 API 并返回原始响应,适合图片、媒体、文件等非 JSON 接口。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
public function raw(string $method, string $uri, array $query = [], array $options = []): ResponseInterface
|
||||
{
|
||||
return $this->rawWechatRequest($method, $uri, $query, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载微信服务平台二进制资源并返回原始响应。
|
||||
*
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
public function download(string $uri, array $query = [], array $options = []): ResponseInterface
|
||||
{
|
||||
return $this->downloadWechatResource($uri, $query, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 multipart/form-data 上传文件或媒体资源。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $multipart
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function upload(string $uri, array $multipart, array $query = [], array $options = []): array
|
||||
{
|
||||
return $this->uploadWechatResource($uri, $multipart, $query, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用创建预授权码接口,生成 pre_auth_code。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> */
|
||||
/**
|
||||
* 使用 authorization_code 调用查询授权信息接口。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> */
|
||||
/**
|
||||
* 调用获取授权方账号基本信息接口。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> */
|
||||
/**
|
||||
* 使用授权方 authorizer_access_token 代调用微信公众平台或小程序接口。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -179,6 +243,8 @@ final class ServiceClient
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 GET 方法调用微信服务平台 API。
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -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<string,mixed> */
|
||||
/**
|
||||
* 调用刷新授权方接口调用凭据接口。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
* 生成当前第三方平台通道下的完整缓存键。
|
||||
*/
|
||||
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);
|
||||
|
||||
@ -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<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $query
|
||||
* @param array<string,mixed> $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<string,mixed> $query
|
||||
* @param array<string,mixed> $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<int,array<string,mixed>> $multipart
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -92,6 +127,8 @@ final class WxappClient
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 GET 方法调用微信小程序 API。
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
@ -102,20 +139,8 @@ final class WxappClient
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
* 生成当前小程序通道下的完整缓存键。
|
||||
*/
|
||||
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);
|
||||
|
||||
@ -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<string,mixed> $query
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $query
|
||||
* @param array<string,mixed> $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<string,mixed> $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);
|
||||
|
||||
@ -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<string,mixed>
|
||||
*/
|
||||
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);
|
||||
|
||||
@ -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<string,mixed>
|
||||
*/
|
||||
|
||||
@ -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<int,string> $items
|
||||
*/
|
||||
@ -21,6 +30,8 @@ final class Signature
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验微信服务器消息签名。
|
||||
*
|
||||
* @param array<int,string> $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);
|
||||
|
||||
@ -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<string,mixed>
|
||||
*/
|
||||
public static function decode(string $xml): array
|
||||
@ -30,23 +41,52 @@ final class Xml
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数组编码为微信消息 XML。
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public static function encode(array $data): string
|
||||
{
|
||||
$content = '<xml>';
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE) ?: '';
|
||||
}
|
||||
$value = (string)$value;
|
||||
$content .= sprintf('<%1$s><![CDATA[%2$s]]></%1$s>', $key, $value);
|
||||
$content .= self::encodeNode((string)$key, $value);
|
||||
}
|
||||
|
||||
return $content . '</xml>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归编码单个 XML 节点;列表数组会重复输出同名节点。
|
||||
*/
|
||||
private static function encodeNode(string $key, mixed $value): string
|
||||
{
|
||||
self::assertNodeName($key);
|
||||
if ($value === []) {
|
||||
return sprintf('<%1$s></%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</%1$s>', $key, $content);
|
||||
}
|
||||
|
||||
return sprintf('<%1$s><![CDATA[%2$s]]></%1$s>', $key, self::cdata((string)$value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归转换 SimpleXML 节点为普通数组。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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(']]>', ']]]]><![CDATA[>', $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user