feat(wechat): 完善微信协议层通用能力

- 新增 InteractsProtocol,复用 access_token 注入、JSON 请求、raw/download/upload 与消息加解密伪路径处理。

- 微信公众平台、小程序、服务平台统一按官方 path + params/options 调用,服务平台保留授权链路能力。

- 微信支付 APIv3 支持 raw/download 原始响应,并确保签名 query 与实际请求一致。

- 增强 XML 重复节点/嵌套节点编解码和消息安全模式密文校验。
This commit is contained in:
Anyon 2026-05-08 00:29:10 +08:00
parent 05cb47d3fd
commit 9c3fd5b59d
10 changed files with 818 additions and 216 deletions

View 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'] : []);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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