新增
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel;
|
||||
|
||||
use ArrayAccess;
|
||||
use EasyWeChat\Kernel\Contracts\Config as ConfigInterface;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Support\Arr;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use function strval;
|
||||
|
||||
/**
|
||||
* @implements ArrayAccess<mixed, mixed>
|
||||
*/
|
||||
class Config implements ArrayAccess, ConfigInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredKeys = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $items = [],
|
||||
) {
|
||||
$this->checkMissingKeys();
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return Arr::has($this->items, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|string $key
|
||||
*/
|
||||
#[Pure]
|
||||
public function get(array|string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (is_array($key)) {
|
||||
return $this->getMany($key);
|
||||
}
|
||||
|
||||
return Arr::get($this->items, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $keys
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[Pure]
|
||||
public function getMany(array $keys): array
|
||||
{
|
||||
$config = [];
|
||||
|
||||
foreach ($keys as $key => $default) {
|
||||
if (is_numeric($key)) {
|
||||
[$key, $default] = [$default, null];
|
||||
}
|
||||
|
||||
$config[$key] = Arr::get($this->items, $key, $default);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @param mixed|null $value
|
||||
*/
|
||||
public function set(string $key, mixed $value = null): void
|
||||
{
|
||||
Arr::set($this->items, $key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function offsetExists(mixed $key): bool
|
||||
{
|
||||
return $this->has(strval($key));
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function offsetGet(mixed $key): mixed
|
||||
{
|
||||
return $this->get(strval($key));
|
||||
}
|
||||
|
||||
public function offsetSet(mixed $key, mixed $value): void
|
||||
{
|
||||
$this->set(strval($key), $value);
|
||||
}
|
||||
|
||||
public function offsetUnset(mixed $key): void
|
||||
{
|
||||
$this->set(strval($key), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function checkMissingKeys(): bool
|
||||
{
|
||||
if (empty($this->requiredKeys)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$missingKeys = [];
|
||||
|
||||
foreach ($this->requiredKeys as $key) {
|
||||
if (! $this->has($key)) {
|
||||
$missingKeys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($missingKeys)) {
|
||||
throw new InvalidArgumentException(sprintf("\"%s\" cannot be empty.\r\n", implode(',', $missingKeys)));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
interface AccessToken
|
||||
{
|
||||
public function getToken(): string;
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function toQuery(): array;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
interface AccessTokenAwareHttpClient extends HttpClientInterface
|
||||
{
|
||||
public function withAccessToken(AccessTokenInterface $accessToken): static;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
interface Aes
|
||||
{
|
||||
public static function encrypt(string $plaintext, string $key, string $iv = null): string;
|
||||
|
||||
public static function decrypt(string $ciphertext, string $key, string $iv = null): string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
interface Arrayable
|
||||
{
|
||||
/**
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function toArray(): array;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
use ArrayAccess;
|
||||
|
||||
/**
|
||||
* @extends ArrayAccess<string, mixed>
|
||||
*/
|
||||
interface Config extends ArrayAccess
|
||||
{
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function all(): array;
|
||||
|
||||
public function has(string $key): bool;
|
||||
|
||||
public function set(string $key, mixed $value = null): void;
|
||||
|
||||
/**
|
||||
* @param array<string>|string $key
|
||||
*/
|
||||
public function get(array|string $key, mixed $default = null): mixed;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
interface Jsonable
|
||||
{
|
||||
public function toJson(): string|false;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
interface RefreshableAccessToken extends AccessToken
|
||||
{
|
||||
public function refresh(): string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Contracts;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
interface Server
|
||||
{
|
||||
public function serve(): ResponseInterface;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel;
|
||||
|
||||
use function base64_decode;
|
||||
use function base64_encode;
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\Kernel\Support\Pkcs7;
|
||||
use EasyWeChat\Kernel\Support\Str;
|
||||
use EasyWeChat\Kernel\Support\Xml;
|
||||
use Exception;
|
||||
use function implode;
|
||||
use function openssl_decrypt;
|
||||
use function openssl_encrypt;
|
||||
use const OPENSSL_NO_PADDING;
|
||||
use function pack;
|
||||
use function random_bytes;
|
||||
use function sha1;
|
||||
use function sort;
|
||||
use const SORT_STRING;
|
||||
use function strlen;
|
||||
use function substr;
|
||||
use Throwable;
|
||||
use function time;
|
||||
use function trim;
|
||||
use function unpack;
|
||||
|
||||
class Encryptor
|
||||
{
|
||||
public const ERROR_INVALID_SIGNATURE = -40001; // Signature verification failed
|
||||
|
||||
public const ERROR_PARSE_XML = -40002; // Parse XML failed
|
||||
|
||||
public const ERROR_CALC_SIGNATURE = -40003; // Calculating the signature failed
|
||||
|
||||
public const ERROR_INVALID_AES_KEY = -40004; // Invalid AESKey
|
||||
|
||||
public const ERROR_INVALID_APP_ID = -40005; // Check AppID failed
|
||||
|
||||
public const ERROR_ENCRYPT_AES = -40006; // AES EncryptionInterface failed
|
||||
|
||||
public const ERROR_DECRYPT_AES = -40007; // AES decryption failed
|
||||
|
||||
public const ERROR_INVALID_XML = -40008; // Invalid XML
|
||||
|
||||
public const ERROR_BASE64_ENCODE = -40009; // Base64 encoding failed
|
||||
|
||||
public const ERROR_BASE64_DECODE = -40010; // Base64 decoding failed
|
||||
|
||||
public const ERROR_XML_BUILD = -40011; // XML build failed
|
||||
|
||||
public const ILLEGAL_BUFFER = -41003; // Illegal buffer
|
||||
|
||||
protected string $appId;
|
||||
|
||||
protected string $token;
|
||||
|
||||
protected string $aesKey;
|
||||
|
||||
protected int $blockSize = 32;
|
||||
|
||||
protected ?string $receiveId = null;
|
||||
|
||||
public function __construct(string $appId, string $token, string $aesKey, ?string $receiveId = null)
|
||||
{
|
||||
$this->appId = $appId;
|
||||
$this->token = $token;
|
||||
$this->receiveId = $receiveId;
|
||||
$this->aesKey = base64_decode($aesKey.'=', true) ?: '';
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function encrypt(string $plaintext, string|null $nonce = null, int|string $timestamp = null): string
|
||||
{
|
||||
try {
|
||||
$plaintext = Pkcs7::padding(random_bytes(16).pack('N', strlen($plaintext)).$plaintext.$this->appId, 32);
|
||||
$ciphertext = base64_encode(
|
||||
openssl_encrypt(
|
||||
$plaintext,
|
||||
'aes-256-cbc',
|
||||
$this->aesKey,
|
||||
OPENSSL_NO_PADDING,
|
||||
substr($this->aesKey, 0, 16)
|
||||
) ?: ''
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException($e->getMessage(), self::ERROR_ENCRYPT_AES);
|
||||
}
|
||||
|
||||
$nonce ??= Str::random();
|
||||
$timestamp ??= time();
|
||||
|
||||
$response = [
|
||||
'Encrypt' => $ciphertext,
|
||||
'MsgSignature' => $this->createSignature($this->token, $timestamp, $nonce, $ciphertext),
|
||||
'TimeStamp' => $timestamp,
|
||||
'Nonce' => $nonce,
|
||||
];
|
||||
|
||||
return Xml::build($response);
|
||||
}
|
||||
|
||||
public function createSignature(mixed ...$attributes): string
|
||||
{
|
||||
sort($attributes, SORT_STRING);
|
||||
|
||||
return sha1(implode($attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function decrypt(string $ciphertext, string $msgSignature, string $nonce, int|string $timestamp): string
|
||||
{
|
||||
$signature = $this->createSignature($this->token, $timestamp, $nonce, $ciphertext);
|
||||
|
||||
if ($signature !== $msgSignature) {
|
||||
throw new RuntimeException('Invalid Signature.', self::ERROR_INVALID_SIGNATURE);
|
||||
}
|
||||
|
||||
$plaintext = Pkcs7::unpadding(
|
||||
openssl_decrypt(
|
||||
base64_decode($ciphertext, true) ?: '',
|
||||
'aes-256-cbc',
|
||||
$this->aesKey,
|
||||
OPENSSL_NO_PADDING,
|
||||
substr($this->aesKey, 0, 16)
|
||||
) ?: '',
|
||||
32
|
||||
);
|
||||
$plaintext = substr($plaintext, 16);
|
||||
$contentLength = (unpack('N', substr($plaintext, 0, 4)) ?: [])[1];
|
||||
|
||||
if ($this->receiveId && trim(substr($plaintext, $contentLength + 4)) !== $this->receiveId) {
|
||||
throw new RuntimeException('Invalid appId.', self::ERROR_INVALID_APP_ID);
|
||||
}
|
||||
|
||||
return substr($plaintext, 4, $contentLength);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class BadMethodCallException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class BadRequestException extends Exception
|
||||
{
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class BadResponseException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class DecryptException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
use Exception as BaseException;
|
||||
|
||||
class Exception extends BaseException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class HttpException extends Exception
|
||||
{
|
||||
public ?ResponseInterface $response;
|
||||
|
||||
/**
|
||||
* HttpException constructor.
|
||||
*
|
||||
* @param string $message
|
||||
* @param ResponseInterface|null $response
|
||||
* @param int $code
|
||||
*/
|
||||
public function __construct(string $message, ResponseInterface $response = null, int $code = 0)
|
||||
{
|
||||
parent::__construct($message, $code);
|
||||
|
||||
$this->response = $response;
|
||||
|
||||
if ($response) {
|
||||
$response->getBody()->rewind();
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class InvalidArgumentException extends Exception
|
||||
{
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class InvalidConfigException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class RuntimeException extends Exception
|
||||
{
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Exceptions;
|
||||
|
||||
class ServiceNotFoundException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Form;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use function file_put_contents;
|
||||
use function md5;
|
||||
use function pathinfo;
|
||||
use const PATHINFO_EXTENSION;
|
||||
use function strtolower;
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use function sys_get_temp_dir;
|
||||
use function tempnam;
|
||||
|
||||
class File extends DataPart
|
||||
{
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public static function withContents(
|
||||
string $contents,
|
||||
?string $filename = null,
|
||||
?string $contentType = null,
|
||||
?string $encoding = null
|
||||
): DataPart {
|
||||
if (null === $contentType) {
|
||||
$mimeTypes = new MimeTypes();
|
||||
|
||||
if ($filename) {
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
$contentType = $mimeTypes->getMimeTypes($ext)[0] ?? 'application/octet-stream';
|
||||
} else {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'easywechat');
|
||||
if (! $tmp) {
|
||||
throw new RuntimeException('Failed to create temporary file.');
|
||||
}
|
||||
|
||||
file_put_contents($tmp, $contents);
|
||||
$contentType = $mimeTypes->guessMimeType($tmp) ?? 'application/octet-stream';
|
||||
$filename = md5($contents).'.'.($mimeTypes->getExtensions($contentType)[0] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
return new self($contents, $filename, $contentType, $encoding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Form;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
|
||||
|
||||
class Form
|
||||
{
|
||||
/**
|
||||
* @param array<string|array|DataPart> $fields
|
||||
*/
|
||||
public function __construct(protected array $fields)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|array|DataPart> $fields
|
||||
*/
|
||||
public static function create(array $fields): Form
|
||||
{
|
||||
return new self($fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
#[ArrayShape(['headers' => 'array', 'body' => 'string'])]
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->toOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
#[ArrayShape(['headers' => 'array', 'body' => 'string'])]
|
||||
public function toOptions(): array
|
||||
{
|
||||
$formData = new FormDataPart($this->fields);
|
||||
|
||||
return [
|
||||
'headers' => $formData->getPreparedHeaders()->toArray(),
|
||||
'body' => $formData->bodyToString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\HttpClient;
|
||||
|
||||
use function array_merge;
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Contracts\AccessTokenAwareHttpClient as AccessTokenAwareHttpClientInterface;
|
||||
use EasyWeChat\Kernel\Traits\MockableHttpClient;
|
||||
use Symfony\Component\HttpClient\AsyncDecoratorTrait;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Class AccessTokenAwareClient.
|
||||
*
|
||||
*
|
||||
* @method HttpClientInterface withAppId(string $value = null)
|
||||
*/
|
||||
class AccessTokenAwareClient implements AccessTokenAwareHttpClientInterface
|
||||
{
|
||||
use AsyncDecoratorTrait;
|
||||
use HttpClientMethods;
|
||||
use RetryableClient;
|
||||
use MockableHttpClient;
|
||||
use RequestWithPresets;
|
||||
|
||||
public function __construct(
|
||||
?HttpClientInterface $client = null,
|
||||
protected ?AccessTokenInterface $accessToken = null,
|
||||
protected ?Closure $failureJudge = null,
|
||||
protected bool $throw = true
|
||||
) {
|
||||
$this->client = $client ?? HttpClient::create();
|
||||
}
|
||||
|
||||
public function withAccessToken(AccessTokenInterface $accessToken): static
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function request(string $method, string $url, array $options = []): Response
|
||||
{
|
||||
if ($this->accessToken) {
|
||||
$options['query'] = array_merge((array) ($options['query'] ?? []), $this->accessToken->toQuery());
|
||||
}
|
||||
|
||||
$options = RequestUtil::formatBody($this->mergeThenResetPrepends($options));
|
||||
|
||||
return new Response(
|
||||
response: $this->client->request($method, ltrim($url, '/'), $options),
|
||||
failureJudge: $this->failureJudge,
|
||||
throw: $this->throw
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $arguments
|
||||
*/
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
if (\str_starts_with($name, 'with')) {
|
||||
return $this->handleMagicWithCall($name, $arguments[0] ?? null);
|
||||
}
|
||||
|
||||
return $this->client->$name(...$arguments);
|
||||
}
|
||||
|
||||
public static function createMockClient(MockHttpClient $mockHttpClient): HttpClientInterface
|
||||
{
|
||||
return new self($mockHttpClient);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\HttpClient;
|
||||
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Contracts\RefreshableAccessToken as RefreshableAccessTokenInterface;
|
||||
use Symfony\Component\HttpClient\Response\AsyncContext;
|
||||
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
|
||||
class AccessTokenExpiredRetryStrategy extends GenericRetryStrategy
|
||||
{
|
||||
protected AccessTokenInterface $accessToken;
|
||||
|
||||
protected ?Closure $decider = null;
|
||||
|
||||
public function withAccessToken(AccessTokenInterface $accessToken): self
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function decideUsing(Closure $decider): self
|
||||
{
|
||||
$this->decider = $decider;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function shouldRetry(
|
||||
AsyncContext $context,
|
||||
?string $responseContent,
|
||||
?TransportExceptionInterface $exception
|
||||
): ?bool {
|
||||
if ((bool) $responseContent && $this->decider && ($this->decider)($context, $responseContent, $exception)) {
|
||||
if ($this->accessToken instanceof RefreshableAccessTokenInterface) {
|
||||
return (bool) $this->accessToken->refresh();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRetry($context, $responseContent, $exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\HttpClient;
|
||||
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface as ResponseInterfaceAlias;
|
||||
|
||||
trait HttpClientMethods
|
||||
{
|
||||
/**
|
||||
* @param string $url
|
||||
* @param array<string, mixed> $options
|
||||
* @return Response|ResponseInterfaceAlias
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function get(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
return $this->request('GET', $url, RequestUtil::formatOptions($options, 'GET'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function post(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function postJson(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
$options['headers']['Content-Type'] = 'application/json';
|
||||
|
||||
return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function postXml(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
$options['headers']['Content-Type'] = 'text/xml';
|
||||
|
||||
return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function patch(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
return $this->request('PATCH', $url, RequestUtil::formatOptions($options, 'PATCH'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function patchJson(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
$options['headers']['Content-Type'] = 'application/json';
|
||||
|
||||
return $this->request('PATCH', $url, RequestUtil::formatOptions($options, 'PATCH'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function put(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
return $this->request('PUT', $url, RequestUtil::formatOptions($options, 'PUT'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function delete(string $url, array $options = []): Response|ResponseInterfaceAlias
|
||||
{
|
||||
return $this->request('DELETE', $url, RequestUtil::formatOptions($options, 'DELETE'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\HttpClient;
|
||||
|
||||
use const ARRAY_FILTER_USE_KEY;
|
||||
use function array_key_exists;
|
||||
use EasyWeChat\Kernel\Support\UserAgent;
|
||||
use EasyWeChat\Kernel\Support\Xml;
|
||||
use function in_array;
|
||||
use InvalidArgumentException;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use function json_encode;
|
||||
use const JSON_FORCE_OBJECT;
|
||||
use const JSON_UNESCAPED_UNICODE;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class RequestUtil
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[ArrayShape([
|
||||
'status_codes' => 'array',
|
||||
'delay' => 'int',
|
||||
'max_delay' => 'int',
|
||||
'max_retries' => 'int',
|
||||
'multiplier' => 'float',
|
||||
'jitter' => 'float',
|
||||
])]
|
||||
public static function mergeDefaultRetryOptions(array $options): array
|
||||
{
|
||||
return \array_merge([
|
||||
'status_codes' => GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES,
|
||||
'delay' => 1000,
|
||||
'max_delay' => 0,
|
||||
'max_retries' => 3,
|
||||
'multiplier' => 2.0,
|
||||
'jitter' => 0.1,
|
||||
], $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array|mixed> $options
|
||||
* @return array<string, array|mixed>
|
||||
*/
|
||||
public static function formatDefaultOptions(array $options): array
|
||||
{
|
||||
$defaultOptions = \array_filter(
|
||||
array: $options,
|
||||
callback: fn ($key) => array_key_exists($key, HttpClientInterface::OPTIONS_DEFAULTS),
|
||||
mode: ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (! isset($options['headers']['User-Agent']) && ! isset($options['headers']['user-agent'])) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$defaultOptions['headers']['User-Agent'] = UserAgent::create();
|
||||
}
|
||||
|
||||
return $defaultOptions;
|
||||
}
|
||||
|
||||
public static function formatOptions(array $options, string $method): array
|
||||
{
|
||||
if (array_key_exists('query', $options) && is_array($options['query']) && empty($options['query'])) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
if (array_key_exists('body', $options)
|
||||
|| array_key_exists('json', $options)
|
||||
|| array_key_exists('xml', $options)
|
||||
) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
$name = in_array($method, ['GET', 'HEAD', 'DELETE']) ? 'query' : 'body';
|
||||
|
||||
if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'application/json') {
|
||||
$name = 'json';
|
||||
}
|
||||
|
||||
foreach ($options as $key => $value) {
|
||||
if (! array_key_exists($key, HttpClientInterface::OPTIONS_DEFAULTS)) {
|
||||
$options[$name][trim($key, '"')] = $value;
|
||||
unset($options[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string,mixed>|mixed> $options
|
||||
* @return array<string, array|mixed>
|
||||
*/
|
||||
public static function formatBody(array $options): array
|
||||
{
|
||||
if (isset($options['xml'])) {
|
||||
if (is_array($options['xml'])) {
|
||||
$options['xml'] = Xml::build($options['xml']);
|
||||
}
|
||||
|
||||
if (! is_string($options['xml'])) {
|
||||
throw new InvalidArgumentException('The type of `xml` must be string or array.');
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (! isset($options['headers']['Content-Type']) && ! isset($options['headers']['content-type'])) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$options['headers']['Content-Type'] = [$options['headers'][] = 'Content-Type: text/xml'];
|
||||
}
|
||||
|
||||
$options['body'] = $options['xml'];
|
||||
unset($options['xml']);
|
||||
}
|
||||
|
||||
if (isset($options['json'])) {
|
||||
if (is_array($options['json'])) {
|
||||
/** XXX: 微信的 JSON 是比较奇葩的,比如菜单不能把中文 encode 为 unicode */
|
||||
$options['json'] = json_encode(
|
||||
$options['json'],
|
||||
empty($options['json']) ? JSON_FORCE_OBJECT : JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($options['json'])) {
|
||||
throw new InvalidArgumentException('The type of `json` must be string or array.');
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (! isset($options['headers']['Content-Type']) && ! isset($options['headers']['content-type'])) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$options['headers']['Content-Type'] = [$options['headers'][] = 'Content-Type: application/json'];
|
||||
}
|
||||
|
||||
$options['body'] = $options['json'];
|
||||
unset($options['json']);
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public static function createDefaultServerRequest(): ServerRequestInterface
|
||||
{
|
||||
$psr17Factory = new Psr17Factory();
|
||||
|
||||
$creator = new ServerRequestCreator(
|
||||
serverRequestFactory: $psr17Factory,
|
||||
uriFactory: $psr17Factory,
|
||||
uploadedFileFactory: $psr17Factory,
|
||||
streamFactory: $psr17Factory
|
||||
);
|
||||
|
||||
return $creator->fromGlobals();
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\HttpClient;
|
||||
|
||||
use function array_merge;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\Kernel\Form\File;
|
||||
use EasyWeChat\Kernel\Form\Form;
|
||||
use EasyWeChat\Kernel\Support\Str;
|
||||
use function in_array;
|
||||
use function is_file;
|
||||
use function is_string;
|
||||
use function str_ends_with;
|
||||
use function str_starts_with;
|
||||
use function strtoupper;
|
||||
use function substr;
|
||||
|
||||
trait RequestWithPresets
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $prependHeaders = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $prependParts = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $presets = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $presets
|
||||
*/
|
||||
public function setPresets(array $presets): static
|
||||
{
|
||||
$this->presets = $presets;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withHeader(string $key, string $value): static
|
||||
{
|
||||
$this->prependHeaders[$key] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withHeaders(array $headers): static
|
||||
{
|
||||
foreach ($headers as $key => $value) {
|
||||
$this->withHeader($key, $value);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function with(string|array $key, mixed $value = null): static
|
||||
{
|
||||
if (\is_array($key)) {
|
||||
// $client->with(['appid', 'mchid'])
|
||||
// $client->with(['appid' => 'wx1234567', 'mchid'])
|
||||
foreach ($key as $k => $v) {
|
||||
if (\is_int($k) && is_string($v)) {
|
||||
[$k, $v] = [$v, null];
|
||||
}
|
||||
|
||||
$this->with($k, $v ?? $this->presets[$k] ?? null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->prependParts[$key] = $value ?? $this->presets[$key] ?? null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withFile(string $pathOrContents, string $formName = 'file', string $filename = null): static
|
||||
{
|
||||
$file = is_file($pathOrContents) ? File::fromPath(
|
||||
$pathOrContents,
|
||||
$filename
|
||||
) : File::withContents($pathOrContents, $filename);
|
||||
|
||||
/**
|
||||
* @var array{headers: array<string, string>, body: string}
|
||||
*/
|
||||
$options = Form::create([$formName => $file])->toOptions();
|
||||
|
||||
$this->withHeaders($options['headers']);
|
||||
|
||||
return $this->withOptions([
|
||||
'body' => $options['body'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withFileContents(string $contents, string $formName = 'file', string $filename = null): static
|
||||
{
|
||||
return $this->withFile($contents, $formName, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withFiles(array $files): static
|
||||
{
|
||||
foreach ($files as $key => $value) {
|
||||
$this->withFile($value, $key);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function mergeThenResetPrepends(array $options, string $method = 'GET'): array
|
||||
{
|
||||
$name = in_array(strtoupper($method), ['GET', 'HEAD', 'DELETE']) ? 'query' : 'body';
|
||||
|
||||
if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'application/json' || ! empty($options['json'])) {
|
||||
$name = 'json';
|
||||
}
|
||||
|
||||
if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'text/xml' || ! empty($options['xml'])) {
|
||||
$name = 'xml';
|
||||
}
|
||||
|
||||
if (! empty($this->prependParts)) {
|
||||
$options[$name] = array_merge($this->prependParts, $options[$name] ?? []);
|
||||
}
|
||||
|
||||
if (! empty($this->prependHeaders)) {
|
||||
$options['headers'] = array_merge($this->prependHeaders, $options['headers'] ?? []);
|
||||
}
|
||||
|
||||
$this->prependParts = [];
|
||||
$this->prependHeaders = [];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handleMagicWithCall(string $method, mixed $value = null): static
|
||||
{
|
||||
// $client->withAppid();
|
||||
// $client->withAppid('wxf8b4f85f3a794e77');
|
||||
// $client->withAppidAs('sub_appid');
|
||||
if (! str_starts_with($method, 'with')) {
|
||||
throw new InvalidArgumentException(sprintf('The method "%s" is not supported.', $method));
|
||||
}
|
||||
|
||||
$key = Str::snakeCase(substr($method, 4));
|
||||
|
||||
// $client->withAppidAs('sub_appid');
|
||||
if (str_ends_with($key, '_as')) {
|
||||
$key = substr($key, 0, -3);
|
||||
|
||||
[$key, $value] = [is_string($value) ? $value : $key, $this->presets[$key] ?? null];
|
||||
}
|
||||
|
||||
return $this->with($key, $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\HttpClient;
|
||||
|
||||
use function array_key_exists;
|
||||
use ArrayAccess;
|
||||
use function base64_encode;
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Contracts\Arrayable;
|
||||
use EasyWeChat\Kernel\Contracts\Jsonable;
|
||||
use EasyWeChat\Kernel\Exceptions\BadMethodCallException;
|
||||
use EasyWeChat\Kernel\Exceptions\BadResponseException;
|
||||
use EasyWeChat\Kernel\Support\Xml;
|
||||
use function file_put_contents;
|
||||
use Http\Discovery\Exception\NotFoundException;
|
||||
use Http\Discovery\Psr17FactoryDiscovery;
|
||||
use function json_encode;
|
||||
use const JSON_UNESCAPED_UNICODE;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Component\HttpClient\Response\StreamableInterface;
|
||||
use Symfony\Component\HttpClient\Response\StreamWrapper;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @implements \ArrayAccess<array-key, mixed>
|
||||
*
|
||||
* @see \Symfony\Contracts\HttpClient\ResponseInterface
|
||||
*/
|
||||
class Response implements Jsonable, Arrayable, ArrayAccess, ResponseInterface, StreamableInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected ResponseInterface $response,
|
||||
protected ?Closure $failureJudge = null,
|
||||
protected bool $throw = true
|
||||
) {
|
||||
}
|
||||
|
||||
public function throw(bool $throw = true): static
|
||||
{
|
||||
$this->throw = $throw;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function throwOnFailure(): static
|
||||
{
|
||||
return $this->throw(true);
|
||||
}
|
||||
|
||||
public function quietly(): static
|
||||
{
|
||||
return $this->throw(false);
|
||||
}
|
||||
|
||||
public function judgeFailureUsing(callable $callback): static
|
||||
{
|
||||
$this->failureJudge = $callback instanceof Closure ? $callback : fn (Response $response) => $callback($response);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return ! $this->isFailed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
if ($this->is('text') && $this->failureJudge) {
|
||||
return (bool) ($this->failureJudge)($this);
|
||||
}
|
||||
|
||||
try {
|
||||
return 400 <= $this->getStatusCode();
|
||||
} catch (Throwable $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function toArray(?bool $throw = null): array
|
||||
{
|
||||
$throw ??= $this->throw;
|
||||
|
||||
if ('' === $content = $this->response->getContent($throw)) {
|
||||
throw new BadResponseException('Response body is empty.');
|
||||
}
|
||||
|
||||
$contentType = $this->getHeaderLine('content-type', $throw);
|
||||
|
||||
if (str_contains($contentType, 'text/xml')
|
||||
|| str_contains($contentType, 'application/xml')
|
||||
|| str_starts_with($content, '<xml>')) {
|
||||
try {
|
||||
return Xml::parse($content) ?? [];
|
||||
} catch (Throwable $e) {
|
||||
throw new BadResponseException('Response body is not valid xml.', 400, $e);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->toArray($throw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function toJson(?bool $throw = null): string|false
|
||||
{
|
||||
return json_encode($this->toArray($throw), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function toStream(?bool $throw = null)
|
||||
{
|
||||
if ($this->response instanceof StreamableInterface) {
|
||||
return $this->response->toStream($throw ?? $this->throw);
|
||||
}
|
||||
|
||||
if ($throw) {
|
||||
throw new BadMethodCallException(sprintf('%s does\'t implements %s', \get_class($this->response), StreamableInterface::class));
|
||||
}
|
||||
|
||||
return StreamWrapper::createResource(new MockResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function toDataUrl(): string
|
||||
{
|
||||
return 'data:'.$this->getHeaderLine('content-type').';base64,'.base64_encode($this->getContent());
|
||||
}
|
||||
|
||||
public function toPsrResponse(ResponseFactoryInterface $responseFactory = null, StreamFactoryInterface $streamFactory = null): \Psr\Http\Message\ResponseInterface
|
||||
{
|
||||
$streamFactory ??= $responseFactory instanceof StreamFactoryInterface ? $responseFactory : null;
|
||||
|
||||
if (null === $responseFactory || null === $streamFactory) {
|
||||
if (! class_exists(Psr17Factory::class) && ! class_exists(Psr17FactoryDiscovery::class)) {
|
||||
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as no PSR-17 factories have been provided. Try running "composer require nyholm/psr7".');
|
||||
}
|
||||
|
||||
try {
|
||||
$psr17Factory = class_exists(Psr17Factory::class, false) ? new Psr17Factory() : null;
|
||||
$responseFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findResponseFactory(); /** @phpstan-ignore-line */
|
||||
$streamFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findStreamFactory(); /** @phpstan-ignore-line */
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
} catch (NotFoundException $e) {
|
||||
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been found. Try running "composer require nyholm/psr7".', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
$psrResponse = $responseFactory->createResponse($this->getStatusCode());
|
||||
|
||||
foreach ($this->getHeaders(false) as $name => $values) {
|
||||
foreach ($values as $value) {
|
||||
$psrResponse = $psrResponse->withAddedHeader($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$body = $this->response instanceof StreamableInterface ? $this->toStream(false) : StreamWrapper::createResource($this->response);
|
||||
$body = $streamFactory->createStreamFromResource($body);
|
||||
|
||||
if ($body->isSeekable()) {
|
||||
$body->seek(0);
|
||||
}
|
||||
|
||||
return $psrResponse->withBody($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function saveAs(string $filename): string
|
||||
{
|
||||
try {
|
||||
file_put_contents($filename, $this->response->getContent(true));
|
||||
} catch (Throwable $e) {
|
||||
throw new BadResponseException(sprintf(
|
||||
'Cannot save response to %s: %s',
|
||||
$filename,
|
||||
$this->response->getContent(false)
|
||||
), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
return array_key_exists($offset, $this->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function offsetGet(mixed $offset): mixed
|
||||
{
|
||||
return $this->toArray()[$offset] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadMethodCallException
|
||||
*/
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
{
|
||||
throw new BadMethodCallException('Response is immutable.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadMethodCallException
|
||||
*/
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
throw new BadMethodCallException('Response is immutable.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $arguments
|
||||
*/
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
return $this->response->{$name}(...$arguments);
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->response->getStatusCode();
|
||||
}
|
||||
|
||||
public function getHeaders(?bool $throw = null): array
|
||||
{
|
||||
return $this->response->getHeaders($throw ?? $this->throw);
|
||||
}
|
||||
|
||||
public function getContent(?bool $throw = null): string
|
||||
{
|
||||
return $this->response->getContent($throw ?? $this->throw);
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->response->cancel();
|
||||
}
|
||||
|
||||
public function getInfo(string $type = null): mixed
|
||||
{
|
||||
return $this->response->getInfo($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toJson() ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function hasHeader(string $name, ?bool $throw = null): bool
|
||||
{
|
||||
return isset($this->getHeaders($throw)[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function getHeader(string $name, ?bool $throw = null): array
|
||||
{
|
||||
$name = strtolower($name);
|
||||
$throw ??= $this->throw;
|
||||
|
||||
return $this->hasHeader($name, $throw) ? $this->getHeaders($throw)[$name] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function getHeaderLine(string $name, ?bool $throw = null): string
|
||||
{
|
||||
$name = strtolower($name);
|
||||
$throw ??= $this->throw;
|
||||
|
||||
return $this->hasHeader($name, $throw) ? implode(',', $this->getHeader($name, $throw)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function is(string $type): bool
|
||||
{
|
||||
$contentType = $this->getHeaderLine('content-type');
|
||||
|
||||
return match (strtolower($type)) {
|
||||
'json' => str_contains($contentType, '/json'),
|
||||
'xml' => str_contains($contentType, '/xml'),
|
||||
'html' => str_contains($contentType, '/html'),
|
||||
'image' => str_contains($contentType, 'image/'),
|
||||
'audio' => str_contains($contentType, 'audio/'),
|
||||
'video' => str_contains($contentType, 'video/'),
|
||||
'text' => str_contains($contentType, 'text/')
|
||||
|| str_contains($contentType, '/json')
|
||||
|| str_contains($contentType, '/xml'),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\HttpClient;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
|
||||
use Symfony\Component\HttpClient\Retry\RetryStrategyInterface;
|
||||
use Symfony\Component\HttpClient\RetryableHttpClient;
|
||||
|
||||
trait RetryableClient
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function retry(array $config = []): static
|
||||
{
|
||||
$config = RequestUtil::mergeDefaultRetryOptions($config);
|
||||
|
||||
$strategy = new GenericRetryStrategy(
|
||||
// @phpstan-ignore-next-line
|
||||
(array) $config['status_codes'],
|
||||
// @phpstan-ignore-next-line
|
||||
(int) $config['delay'],
|
||||
// @phpstan-ignore-next-line
|
||||
(float) $config['multiplier'],
|
||||
// @phpstan-ignore-next-line
|
||||
(int) $config['max_delay'],
|
||||
// @phpstan-ignore-next-line
|
||||
(float) $config['jitter']
|
||||
);
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
return $this->retryUsing($strategy, (int) $config['max_retries']);
|
||||
}
|
||||
|
||||
public function retryUsing(
|
||||
RetryStrategyInterface $strategy,
|
||||
int $maxRetries = 3,
|
||||
LoggerInterface $logger = null
|
||||
): static {
|
||||
$this->client = new RetryableHttpClient($this->client, $strategy, $maxRetries, $logger);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel;
|
||||
|
||||
use ArrayAccess;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Kernel\Support\Xml;
|
||||
use EasyWeChat\Kernel\Traits\HasAttributes;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* @property string $FromUserName
|
||||
* @property string $ToUserName
|
||||
* @property string $Encrypt
|
||||
* @implements ArrayAccess<array-key, mixed>
|
||||
*/
|
||||
abstract class Message implements ArrayAccess
|
||||
{
|
||||
use HasAttributes;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $attributes
|
||||
*/
|
||||
final public function __construct(array $attributes = [], protected ?string $originContent = '')
|
||||
{
|
||||
$this->attributes = $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @return Message
|
||||
*
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
public static function createFromRequest(ServerRequestInterface $request): Message
|
||||
{
|
||||
$attributes = self::format($originContent = strval($request->getBody()));
|
||||
|
||||
return new static($attributes, $originContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
public static function format(string $originContent): array
|
||||
{
|
||||
if (0 === stripos($originContent, '<')) {
|
||||
$attributes = Xml::parse($originContent);
|
||||
}
|
||||
|
||||
// Handle JSON format.
|
||||
$dataSet = json_decode($originContent, true);
|
||||
|
||||
if (JSON_ERROR_NONE === json_last_error() && $originContent) {
|
||||
$attributes = $dataSet;
|
||||
}
|
||||
|
||||
if (empty($attributes) || ! is_array($attributes)) {
|
||||
throw new BadRequestException('Failed to decode request contents.');
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
public function getOriginalContents(): string
|
||||
{
|
||||
return $this->originContent ?? '';
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->toJson() ?: '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel;
|
||||
|
||||
use function array_keys;
|
||||
use function array_map;
|
||||
use function count;
|
||||
use function header;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use function max;
|
||||
use const PHP_OUTPUT_HANDLER_CLEANABLE;
|
||||
use const PHP_OUTPUT_HANDLER_FLUSHABLE;
|
||||
use const PHP_OUTPUT_HANDLER_REMOVABLE;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use function sprintf;
|
||||
use function ucwords;
|
||||
|
||||
class ServerResponse implements ResponseInterface
|
||||
{
|
||||
public function __construct(protected ResponseInterface $response)
|
||||
{
|
||||
$this->response->getBody()->rewind();
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public static function make(ResponseInterface $response): ServerResponse
|
||||
{
|
||||
if ($response instanceof ServerResponse) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return new self($response);
|
||||
}
|
||||
|
||||
public function getProtocolVersion(): string
|
||||
{
|
||||
return $this->response->getProtocolVersion();
|
||||
}
|
||||
|
||||
public function withProtocolVersion($version): ServerResponse|ResponseInterface
|
||||
{
|
||||
return $this->response->withProtocolVersion($version);
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->response->getHeaders();
|
||||
}
|
||||
|
||||
public function hasHeader($name): bool
|
||||
{
|
||||
return $this->response->hasHeader($name);
|
||||
}
|
||||
|
||||
public function getHeader($name): array
|
||||
{
|
||||
return $this->response->getHeader($name);
|
||||
}
|
||||
|
||||
public function getHeaderLine($name): string
|
||||
{
|
||||
return $this->response->getHeaderLine($name);
|
||||
}
|
||||
|
||||
public function withHeader($name, $value): ServerResponse|ResponseInterface
|
||||
{
|
||||
return $this->response->withHeader($name, $value);
|
||||
}
|
||||
|
||||
public function withAddedHeader($name, $value): ServerResponse|ResponseInterface
|
||||
{
|
||||
return $this->response->withAddedHeader($name, $value);
|
||||
}
|
||||
|
||||
public function withoutHeader($name): ServerResponse|ResponseInterface
|
||||
{
|
||||
return $this->response->withoutHeader($name);
|
||||
}
|
||||
|
||||
public function getBody(): StreamInterface
|
||||
{
|
||||
return $this->response->getBody();
|
||||
}
|
||||
|
||||
public function withBody(StreamInterface $body): ServerResponse|ResponseInterface
|
||||
{
|
||||
return $this->response->withBody($body);
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->response->getStatusCode();
|
||||
}
|
||||
|
||||
public function withStatus($code, $reasonPhrase = ''): ServerResponse|ResponseInterface
|
||||
{
|
||||
$this->response->withStatus($code, $reasonPhrase);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReasonPhrase(): string
|
||||
{
|
||||
return $this->response->getReasonPhrase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @link https://github.com/symfony/http-foundation/blob/6.1/Response.php
|
||||
*/
|
||||
public function send(): static
|
||||
{
|
||||
$this->sendHeaders();
|
||||
$this->sendContent();
|
||||
|
||||
if (\function_exists('fastcgi_finish_request')) {
|
||||
\fastcgi_finish_request();
|
||||
} elseif (\function_exists('litespeed_finish_request')) {
|
||||
\litespeed_finish_request();
|
||||
} elseif (! \in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
|
||||
static::closeOutputBuffers(0, true);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function sendHeaders(): static
|
||||
{
|
||||
// headers have already been sent by the developer
|
||||
if (\headers_sent()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
foreach ($this->getHeaders() as $name => $values) {
|
||||
$replace = 0 === \strcasecmp($name, 'Content-Type');
|
||||
|
||||
foreach ($values as $value) {
|
||||
header($name.': '.$value, $replace, $this->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
header(
|
||||
header: sprintf(
|
||||
'HTTP/%s %s %s',
|
||||
$this->getProtocolVersion(),
|
||||
$this->getStatusCode(),
|
||||
$this->getReasonPhrase()
|
||||
),
|
||||
replace: true,
|
||||
response_code: $this->getStatusCode()
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function sendContent(): static
|
||||
{
|
||||
echo (string) $this->getBody();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans or flushes output buffers up to target level.
|
||||
*
|
||||
* Resulting level can be greater than target level if a non-removable buffer has been encountered.
|
||||
*
|
||||
* @link https://github.com/symfony/http-foundation/blob/6.1/Response.php
|
||||
* @final
|
||||
*/
|
||||
public static function closeOutputBuffers(int $targetLevel, bool $flush): void
|
||||
{
|
||||
$status = ob_get_status(true);
|
||||
$level = count($status);
|
||||
$flags = PHP_OUTPUT_HANDLER_REMOVABLE | ($flush ? PHP_OUTPUT_HANDLER_FLUSHABLE : PHP_OUTPUT_HANDLER_CLEANABLE);
|
||||
|
||||
while ($level-- > $targetLevel && ($s = $status[$level]) && (! isset($s['del']) ? ! isset($s['flags']) || ($s['flags'] & $flags) === $flags : $s['del'])) {
|
||||
if ($flush) {
|
||||
ob_end_flush();
|
||||
} else {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$headers = $this->getHeaders();
|
||||
|
||||
ksort($headers);
|
||||
|
||||
$max = max(array_map('strlen', array_keys($headers))) + 1;
|
||||
$headersString = '';
|
||||
|
||||
foreach ($headers as $name => $values) {
|
||||
$name = ucwords($name, '-');
|
||||
foreach ($values as $value) {
|
||||
$headersString .= sprintf("%-{$max}s %s\r\n", $name.':', $value);
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'HTTP/%s %s %s',
|
||||
$this->getProtocolVersion(),
|
||||
$this->getStatusCode(),
|
||||
$this->getReasonPhrase()
|
||||
)."\r\n".
|
||||
$headersString."\r\n".
|
||||
$this->getBody();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use function base64_decode;
|
||||
use EasyWeChat\Kernel\Contracts\Aes;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use function openssl_decrypt;
|
||||
use function openssl_error_string;
|
||||
use const OPENSSL_RAW_DATA;
|
||||
|
||||
class AesCbc implements Aes
|
||||
{
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public static function encrypt(string $plaintext, string $key, string $iv = null): string
|
||||
{
|
||||
$ciphertext = \openssl_encrypt($plaintext, 'aes-128-cbc', $key, OPENSSL_RAW_DATA, (string) $iv);
|
||||
|
||||
if (false === $ciphertext) {
|
||||
throw new InvalidArgumentException(openssl_error_string() ?: 'Encrypt AES CBC error.');
|
||||
}
|
||||
|
||||
return base64_encode($ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public static function decrypt(string $ciphertext, string $key, string $iv = null): string
|
||||
{
|
||||
$plaintext = openssl_decrypt(
|
||||
base64_decode($ciphertext),
|
||||
'aes-128-cbc',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
(string) $iv
|
||||
);
|
||||
|
||||
if (false === $plaintext) {
|
||||
throw new InvalidArgumentException(openssl_error_string() ?: 'Decrypt AES CBC error.');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use function base64_decode;
|
||||
use EasyWeChat\Kernel\Contracts\Aes;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use function openssl_decrypt;
|
||||
use function openssl_error_string;
|
||||
use const OPENSSL_RAW_DATA;
|
||||
|
||||
class AesEcb implements Aes
|
||||
{
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public static function encrypt(string $plaintext, string $key, string $iv = null): string
|
||||
{
|
||||
$ciphertext = \openssl_encrypt($plaintext, 'aes-256-ecb', $key, OPENSSL_RAW_DATA, (string) $iv);
|
||||
|
||||
if (false === $ciphertext) {
|
||||
throw new InvalidArgumentException(openssl_error_string() ?: 'Encrypt AES ECB failed.');
|
||||
}
|
||||
|
||||
return \base64_encode($ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public static function decrypt(string $ciphertext, string $key, string $iv = null): string
|
||||
{
|
||||
$plaintext = openssl_decrypt(
|
||||
base64_decode($ciphertext, true) ?: '',
|
||||
'aes-256-ecb',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
(string) $iv
|
||||
);
|
||||
|
||||
if (false === $plaintext) {
|
||||
throw new InvalidArgumentException(openssl_error_string() ?: 'Decrypt AES ECB failed.');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use function base64_decode;
|
||||
use function base64_encode;
|
||||
use EasyWeChat\Kernel\Contracts\Aes;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use function openssl_decrypt;
|
||||
use function openssl_encrypt;
|
||||
use function openssl_error_string;
|
||||
use const OPENSSL_RAW_DATA;
|
||||
|
||||
class AesGcm implements Aes
|
||||
{
|
||||
public const BLOCK_SIZE = 16;
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function encrypt(string $plaintext, string $key, string $iv = null, string $aad = ''): string
|
||||
{
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
(string) $iv,
|
||||
$tag,
|
||||
$aad,
|
||||
self::BLOCK_SIZE
|
||||
);
|
||||
|
||||
if (false === $ciphertext) {
|
||||
throw new InvalidArgumentException(openssl_error_string() ?: 'Encrypt failed');
|
||||
}
|
||||
|
||||
return base64_encode($ciphertext.$tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function decrypt(string $ciphertext, string $key, string $iv = null, string $aad = ''): string
|
||||
{
|
||||
$ciphertext = base64_decode($ciphertext);
|
||||
|
||||
$tag = substr($ciphertext, -self::BLOCK_SIZE);
|
||||
|
||||
$ciphertext = substr($ciphertext, 0, -self::BLOCK_SIZE);
|
||||
|
||||
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, (string) $iv, $tag, $aad);
|
||||
|
||||
if (false === $plaintext) {
|
||||
throw new InvalidArgumentException(openssl_error_string() ?: 'Decrypt failed');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use function is_string;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
|
||||
class Arr
|
||||
{
|
||||
/**
|
||||
* @param array<string|int, mixed> $array
|
||||
* @param string|int|null $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
#[Pure]
|
||||
public static function get(array $array, string|int|null $key, mixed $default = null): mixed
|
||||
{
|
||||
if (is_null($key)) {
|
||||
return $array;
|
||||
}
|
||||
|
||||
if (static::exists($array, $key)) {
|
||||
return $array[$key];
|
||||
}
|
||||
|
||||
foreach (explode('.', (string) $key) as $segment) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (static::exists($array, $segment)) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$array = $array[$segment];
|
||||
} else {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $array
|
||||
* @param string|int $key
|
||||
* @return bool
|
||||
*/
|
||||
public static function exists(array $array, string|int $key): bool
|
||||
{
|
||||
return array_key_exists($key, $array);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $array
|
||||
* @param string|int|null $key
|
||||
* @param mixed $value
|
||||
* @return array<string|int, mixed>
|
||||
*/
|
||||
public static function set(array &$array, string|int|null $key, mixed $value): array
|
||||
{
|
||||
if (! is_string($key)) {
|
||||
$key = (string) $key;
|
||||
}
|
||||
|
||||
$keys = explode('.', $key);
|
||||
|
||||
while (count($keys) > 1) {
|
||||
$key = array_shift($keys);
|
||||
|
||||
// If the key doesn't exist at this depth, we will just create an empty array
|
||||
// to hold the next value, allowing us to create the arrays to hold final
|
||||
// values at the correct depth. Then we'll keep digging into the array.
|
||||
if (! isset($array[$key]) || ! is_array($array[$key])) {
|
||||
$array[$key] = [];
|
||||
}
|
||||
|
||||
$array = &$array[$key];
|
||||
}
|
||||
|
||||
$array[array_shift($keys)] = $value;
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $array
|
||||
* @param string $prepend
|
||||
* @return array<string|int, mixed>
|
||||
*/
|
||||
public static function dot(array $array, string $prepend = ''): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if (is_array($value) && ! empty($value)) {
|
||||
$results = array_merge($results, static::dot($value, $prepend.$key.'.'));
|
||||
} else {
|
||||
$results[$prepend.$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $array
|
||||
* @param string|int|array<string|int, mixed>|null $keys
|
||||
* @return bool
|
||||
*/
|
||||
#[Pure]
|
||||
public static function has(array $array, string|int|array|null $keys): bool
|
||||
{
|
||||
if (is_null($keys)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keys = (array) $keys;
|
||||
|
||||
if (empty($array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ([] === $keys) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$subKeyArray = $array;
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (static::exists($array, $key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
foreach (explode('.', (string) $key) as $segment) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (static::exists($subKeyArray, $segment)) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$subKeyArray = $subKeyArray[$segment];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
|
||||
class Pkcs7
|
||||
{
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function padding(string $contents, int $blockSize): string
|
||||
{
|
||||
if ($blockSize > 256) {
|
||||
throw new InvalidArgumentException('$blockSize may not be more than 256');
|
||||
}
|
||||
$padding = $blockSize - (strlen($contents) % $blockSize);
|
||||
$pattern = chr($padding);
|
||||
|
||||
return $contents.str_repeat($pattern, $padding);
|
||||
}
|
||||
|
||||
public static function unpadding(string $contents, int $blockSize): string
|
||||
{
|
||||
$pad = ord(substr($contents, -1));
|
||||
if ($pad < 1 || $pad > $blockSize) {
|
||||
$pad = 0;
|
||||
}
|
||||
|
||||
return substr($contents, 0, (strlen($contents) - $pad));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use function file_exists;
|
||||
use function file_get_contents;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use function str_starts_with;
|
||||
|
||||
class PrivateKey
|
||||
{
|
||||
public function __construct(protected string $key, protected ?string $passphrase = null)
|
||||
{
|
||||
if (file_exists($key)) {
|
||||
$this->key = "file://{$key}";
|
||||
}
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
if (str_starts_with($this->key, 'file://')) {
|
||||
return file_get_contents($this->key) ?: '';
|
||||
}
|
||||
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function getPassphrase(): ?string
|
||||
{
|
||||
return $this->passphrase;
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
|
||||
use function file_exists;
|
||||
use function file_get_contents;
|
||||
use function openssl_x509_parse;
|
||||
use function str_starts_with;
|
||||
use function strtoupper;
|
||||
|
||||
class PublicKey
|
||||
{
|
||||
public function __construct(public string $certificate)
|
||||
{
|
||||
if (file_exists($certificate)) {
|
||||
$this->certificate = "file://{$certificate}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidConfigException
|
||||
*/
|
||||
public function getSerialNo(): string
|
||||
{
|
||||
$info = openssl_x509_parse($this->certificate);
|
||||
|
||||
if (false === $info || ! isset($info['serialNumberHex'])) {
|
||||
throw new InvalidConfigException('Read the $certificate failed, please check it whether or nor correct');
|
||||
}
|
||||
|
||||
return strtoupper($info['serialNumberHex'] ?? '');
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if (str_starts_with($this->certificate, 'file://')) {
|
||||
return file_get_contents($this->certificate) ?: '';
|
||||
}
|
||||
|
||||
return $this->certificate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use function base64_encode;
|
||||
use Exception;
|
||||
use function preg_replace;
|
||||
use function random_bytes;
|
||||
use function str_replace;
|
||||
use function strlen;
|
||||
use function strtolower;
|
||||
use function substr;
|
||||
use function trim;
|
||||
|
||||
class Str
|
||||
{
|
||||
/**
|
||||
* From https://github.com/laravel/framework/blob/9.x/src/Illuminate/Support/Str.php#L632-L644
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function random(int $length = 16): string
|
||||
{
|
||||
$string = '';
|
||||
|
||||
while (($len = strlen($string)) < $length) {
|
||||
$size = $length - $len;
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$bytes = random_bytes($size);
|
||||
|
||||
$string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
public static function snakeCase(string $string): string
|
||||
{
|
||||
return trim(strtolower((string) preg_replace('/[A-Z]([A-Z](?![a-z]))*/', '_$0', $string)), '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use function array_map;
|
||||
use function array_unshift;
|
||||
use function class_exists;
|
||||
use Composer\InstalledVersions;
|
||||
use function curl_version;
|
||||
use function defined;
|
||||
use function explode;
|
||||
use function extension_loaded;
|
||||
use function function_exists;
|
||||
use function ini_get;
|
||||
|
||||
class UserAgent
|
||||
{
|
||||
/**
|
||||
* @param array<string> $appends
|
||||
* @return string
|
||||
*/
|
||||
public static function create(array $appends = []): string
|
||||
{
|
||||
$value = array_map('strval', $appends);
|
||||
|
||||
if (defined('HHVM_VERSION')) {
|
||||
array_unshift($value, 'HHVM/'.HHVM_VERSION);
|
||||
}
|
||||
|
||||
$disabledFunctions = explode(',', ini_get('disable_functions') ?: '');
|
||||
|
||||
if (extension_loaded('curl') && function_exists('curl_version')) {
|
||||
array_unshift($value, 'curl/'.(curl_version() ?: ['version' => 'unknown'])['version']);
|
||||
}
|
||||
|
||||
if (! ini_get('safe_mode')
|
||||
&& function_exists('php_uname')
|
||||
&& ! in_array('php_uname', $disabledFunctions, true)
|
||||
) {
|
||||
$osName = 'OS/'.php_uname('s').'/'.php_uname('r');
|
||||
array_unshift($value, $osName);
|
||||
}
|
||||
|
||||
if (class_exists(InstalledVersions::class)) {
|
||||
array_unshift($value, 'easywechat-sdk/'.((string) InstalledVersions::getVersion('w7corp/easywechat')));
|
||||
}
|
||||
|
||||
return trim(implode(' ', $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Support;
|
||||
|
||||
use TheNorthMemory\Xml\Transformer;
|
||||
|
||||
class Xml
|
||||
{
|
||||
public static function parse(string $xml): array|null
|
||||
{
|
||||
return Transformer::toArray($xml);
|
||||
}
|
||||
|
||||
public static function build(array $data): string
|
||||
{
|
||||
return Transformer::toXml($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Kernel\Message;
|
||||
use EasyWeChat\Kernel\Support\Xml;
|
||||
|
||||
trait DecryptXmlMessage
|
||||
{
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\RuntimeException
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
public function decryptMessage(
|
||||
Message $message,
|
||||
Encryptor $encryptor,
|
||||
string $signature,
|
||||
int|string $timestamp,
|
||||
string $nonce
|
||||
): Message {
|
||||
$ciphertext = $message->Encrypt;
|
||||
|
||||
$this->validateSignature($encryptor->getToken(), $ciphertext, $signature, $timestamp, $nonce);
|
||||
|
||||
$message->merge(Xml::parse(
|
||||
$encryptor->decrypt(
|
||||
ciphertext: $ciphertext,
|
||||
msgSignature: $signature,
|
||||
nonce: $nonce,
|
||||
timestamp: $timestamp
|
||||
)
|
||||
) ?? []);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
protected function validateSignature(
|
||||
string $token,
|
||||
string $ciphertext,
|
||||
string $signature,
|
||||
int|string $timestamp,
|
||||
string $nonce
|
||||
): void {
|
||||
if (empty($signature)) {
|
||||
throw new BadRequestException('Request signature must not be empty.');
|
||||
}
|
||||
|
||||
$params = [$token, $timestamp, $nonce, $ciphertext];
|
||||
|
||||
sort($params, SORT_STRING);
|
||||
|
||||
if ($signature !== sha1(implode($params))) {
|
||||
throw new BadRequestException('Invalid request signature.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use function array_key_exists;
|
||||
use function array_merge;
|
||||
use function json_encode;
|
||||
|
||||
trait HasAttributes
|
||||
{
|
||||
/**
|
||||
* @var array<int|string,mixed>
|
||||
*/
|
||||
protected array $attributes = [];
|
||||
|
||||
/**
|
||||
* @param array<int|string,mixed> $attributes
|
||||
*/
|
||||
public function __construct(array $attributes)
|
||||
{
|
||||
$this->attributes = $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string,mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
public function toJson(): string|false
|
||||
{
|
||||
return json_encode($this->attributes);
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string,mixed> $attributes
|
||||
*/
|
||||
public function merge(array $attributes): static
|
||||
{
|
||||
$this->attributes = array_merge($this->attributes, $attributes);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string,mixed> $attributes
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
public function __set(string $attribute, mixed $value): void
|
||||
{
|
||||
$this->attributes[$attribute] = $value;
|
||||
}
|
||||
|
||||
public function __get(string $attribute): mixed
|
||||
{
|
||||
return $this->attributes[$attribute] ?? null;
|
||||
}
|
||||
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
/** @phpstan-ignore-next-line */
|
||||
return array_key_exists($offset, $this->attributes);
|
||||
}
|
||||
|
||||
public function offsetGet(mixed $offset): mixed
|
||||
{
|
||||
return $this->attributes[$offset];
|
||||
}
|
||||
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
{
|
||||
if (null === $offset) {
|
||||
$this->attributes[] = $value;
|
||||
} else {
|
||||
$this->attributes[$offset] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
unset($this->attributes[$offset]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
|
||||
trait InteractWithCache
|
||||
{
|
||||
protected ?CacheInterface $cache = null;
|
||||
|
||||
protected int $cacheLifetime = 1500;
|
||||
|
||||
protected string $cacheNamespace = 'easywechat';
|
||||
|
||||
public function getCacheLifetime(): int
|
||||
{
|
||||
return $this->cacheLifetime;
|
||||
}
|
||||
|
||||
public function setCacheLifetime(int $cacheLifetime): void
|
||||
{
|
||||
$this->cacheLifetime = $cacheLifetime;
|
||||
}
|
||||
|
||||
public function getCacheNamespace(): string
|
||||
{
|
||||
return $this->cacheNamespace;
|
||||
}
|
||||
|
||||
public function setCacheNamespace(string $cacheNamespace): void
|
||||
{
|
||||
$this->cacheNamespace = $cacheNamespace;
|
||||
}
|
||||
|
||||
public function setCache(CacheInterface $cache): static
|
||||
{
|
||||
$this->cache = $cache;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCache(): CacheInterface
|
||||
{
|
||||
if (! $this->cache) {
|
||||
$this->cache = new Psr16Cache(new FilesystemAdapter($this->cacheNamespace, $this->cacheLifetime));
|
||||
}
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
|
||||
trait InteractWithClient
|
||||
{
|
||||
protected ?AccessTokenAwareClient $client = null;
|
||||
|
||||
public function getClient(): AccessTokenAwareClient
|
||||
{
|
||||
if (! $this->client) {
|
||||
$this->client = $this->createClient();
|
||||
}
|
||||
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(AccessTokenAwareClient $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
abstract public function createClient(): AccessTokenAwareClient;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use EasyWeChat\Kernel\Config;
|
||||
use EasyWeChat\Kernel\Contracts\Config as ConfigInterface;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use function is_array;
|
||||
|
||||
trait InteractWithConfig
|
||||
{
|
||||
protected ConfigInterface $config;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|ConfigInterface $config
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(array|ConfigInterface $config)
|
||||
{
|
||||
$this->config = is_array($config) ? new Config($config) : $config;
|
||||
}
|
||||
|
||||
public function getConfig(): ConfigInterface
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function setConfig(ConfigInterface $config): static
|
||||
{
|
||||
$this->config = $config;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use function array_reverse;
|
||||
use function array_unshift;
|
||||
use function call_user_func;
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use function func_get_args;
|
||||
use function gettype;
|
||||
use function is_array;
|
||||
use function is_callable;
|
||||
use function is_string;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use function method_exists;
|
||||
use function spl_object_hash;
|
||||
|
||||
trait InteractWithHandlers
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{hash: string, handler: callable}>
|
||||
*/
|
||||
protected array $handlers = [];
|
||||
|
||||
/**
|
||||
* @return array<int, array{hash: string, handler: callable}>
|
||||
*/
|
||||
public function getHandlers(): array
|
||||
{
|
||||
return $this->handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function with(callable|string $handler): static
|
||||
{
|
||||
return $this->withHandler($handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withHandler(callable|string $handler): static
|
||||
{
|
||||
$this->handlers[] = $this->createHandlerItem($handler);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable|string $handler
|
||||
* @return array{hash: string, handler: callable}
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[ArrayShape(['hash' => 'string', 'handler' => 'callable'])]
|
||||
public function createHandlerItem(callable|string $handler): array
|
||||
{
|
||||
return [
|
||||
'hash' => $this->getHandlerHash($handler),
|
||||
'handler' => $this->makeClosure($handler),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function getHandlerHash(callable|string $handler): string
|
||||
{
|
||||
return match (true) {
|
||||
is_string($handler) => $handler,
|
||||
is_array($handler) => is_string($handler[0]) ? $handler[0].'::'.$handler[1] : get_class(
|
||||
$handler[0]
|
||||
).$handler[1],
|
||||
$handler instanceof Closure => spl_object_hash($handler),
|
||||
default => throw new InvalidArgumentException('Invalid handler: '.gettype($handler)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function makeClosure(callable|string $handler): callable
|
||||
{
|
||||
if (is_callable($handler)) {
|
||||
return $handler;
|
||||
}
|
||||
|
||||
if (class_exists($handler) && method_exists($handler, '__invoke')) {
|
||||
/**
|
||||
* @psalm-suppress InvalidFunctionCall
|
||||
* @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/5867
|
||||
*/
|
||||
return fn (): mixed => (new $handler())(...func_get_args());
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(sprintf('Invalid handler: %s.', $handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function prepend(callable|string $handler): static
|
||||
{
|
||||
return $this->prependHandler($handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function prependHandler(callable|string $handler): static
|
||||
{
|
||||
array_unshift($this->handlers, $this->createHandlerItem($handler));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function without(callable|string $handler): static
|
||||
{
|
||||
return $this->withoutHandler($handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withoutHandler(callable|string $handler): static
|
||||
{
|
||||
$index = $this->indexOf($handler);
|
||||
|
||||
if ($index > -1) {
|
||||
unset($this->handlers[$index]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function indexOf(callable|string $handler): int
|
||||
{
|
||||
foreach ($this->handlers as $index => $item) {
|
||||
if ($item['hash'] === $this->getHandlerHash($handler)) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function when(mixed $value, callable|string $handler): static
|
||||
{
|
||||
if (is_callable($value)) {
|
||||
$value = call_user_func($value, $this);
|
||||
}
|
||||
|
||||
if ($value) {
|
||||
return $this->withHandler($handler);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handle(mixed $result, mixed $payload = null): mixed
|
||||
{
|
||||
$next = $result = is_callable($result) ? $result : fn (mixed $p): mixed => $result;
|
||||
|
||||
foreach (array_reverse($this->handlers) as $item) {
|
||||
$next = fn (mixed $p): mixed => $item['handler']($p, $next) ?? $result($p);
|
||||
}
|
||||
|
||||
return $next($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function has(callable|string $handler): bool
|
||||
{
|
||||
return $this->indexOf($handler) > -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use function property_exists;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
trait InteractWithHttpClient
|
||||
{
|
||||
protected ?HttpClientInterface $httpClient = null;
|
||||
|
||||
public function getHttpClient(): HttpClientInterface
|
||||
{
|
||||
if (! $this->httpClient) {
|
||||
$this->httpClient = $this->createHttpClient();
|
||||
}
|
||||
|
||||
return $this->httpClient;
|
||||
}
|
||||
|
||||
public function setHttpClient(HttpClientInterface $httpClient): static
|
||||
{
|
||||
$this->httpClient = $httpClient;
|
||||
|
||||
if ($this instanceof LoggerAwareInterface && $httpClient instanceof LoggerAwareInterface
|
||||
&& property_exists($this, 'logger')
|
||||
&& $this->logger) {
|
||||
$httpClient->setLogger($this->logger);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function createHttpClient(): HttpClientInterface
|
||||
{
|
||||
return HttpClient::create(RequestUtil::formatDefaultOptions($this->getHttpClientDefaultOptions()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function getHttpClientDefaultOptions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
trait InteractWithServerRequest
|
||||
{
|
||||
protected ?ServerRequestInterface $request = null;
|
||||
|
||||
public function getRequest(): ServerRequestInterface
|
||||
{
|
||||
if (! $this->request) {
|
||||
$this->request = RequestUtil::createDefaultServerRequest();
|
||||
}
|
||||
|
||||
return $this->request;
|
||||
}
|
||||
|
||||
public function setRequest(ServerRequestInterface $request): static
|
||||
{
|
||||
$this->request = $request;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setRequestFromSymfonyRequest(Request $symfonyRequest): static
|
||||
{
|
||||
$psr17Factory = new Psr17Factory();
|
||||
$psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
|
||||
|
||||
$this->request = $psrHttpFactory->createRequest($symfonyRequest);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use Mockery\Mock;
|
||||
use Symfony\Component\HttpClient\DecoratorTrait;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
trait MockableHttpClient
|
||||
{
|
||||
public static function createMockClient(MockHttpClient $mockHttpClient): HttpClientInterface
|
||||
{
|
||||
return new self($mockHttpClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $headers
|
||||
*/
|
||||
public static function mock(
|
||||
string $response = '',
|
||||
?int $status = 200,
|
||||
array $headers = [],
|
||||
string $baseUri = 'https://example.com'
|
||||
): object {
|
||||
$mockResponse = new MockResponse(
|
||||
$response,
|
||||
array_merge([
|
||||
'http_code' => $status,
|
||||
'content_type' => 'application/json',
|
||||
], $headers)
|
||||
);
|
||||
|
||||
$client = self::createMockClient(new MockHttpClient($mockResponse, $baseUri));
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
return new class($client, $mockResponse)
|
||||
{
|
||||
use DecoratorTrait;
|
||||
|
||||
public function __construct(Mock|HttpClientInterface $client, public MockResponse $mockResponse)
|
||||
{
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $arguments
|
||||
*/
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
return $this->client->$name(...$arguments);
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function getRequestMethod(): string
|
||||
{
|
||||
return $this->mockResponse->getRequestMethod();
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function getRequestUrl(): string
|
||||
{
|
||||
return $this->mockResponse->getRequestUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[Pure]
|
||||
public function getRequestOptions(): array
|
||||
{
|
||||
return $this->mockResponse->getRequestOptions();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\Kernel\Traits;
|
||||
|
||||
use function array_merge;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\Kernel\Message;
|
||||
use EasyWeChat\Kernel\Support\Xml;
|
||||
use function is_array;
|
||||
use function is_callable;
|
||||
use Nyholm\Psr7\Response;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use function time;
|
||||
|
||||
trait RespondXmlMessage
|
||||
{
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function transformToReply(mixed $response, Message $message, ?Encryptor $encryptor = null): ResponseInterface
|
||||
{
|
||||
if (empty($response)) {
|
||||
return new Response(200, [], 'success');
|
||||
}
|
||||
|
||||
return $this->createXmlResponse(
|
||||
attributes: array_filter(
|
||||
array_merge(
|
||||
[
|
||||
'ToUserName' => $message->FromUserName,
|
||||
'FromUserName' => $message->ToUserName,
|
||||
'CreateTime' => time(),
|
||||
],
|
||||
$this->normalizeResponse($response),
|
||||
)
|
||||
),
|
||||
encryptor: $encryptor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function normalizeResponse(mixed $response): array
|
||||
{
|
||||
if (is_callable($response)) {
|
||||
$response = $response();
|
||||
}
|
||||
|
||||
if (is_array($response)) {
|
||||
if (! isset($response['MsgType'])) {
|
||||
throw new InvalidArgumentException('MsgType cannot be empty.');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (is_string($response) || is_numeric($response)) {
|
||||
return [
|
||||
'MsgType' => 'text',
|
||||
'Content' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('Invalid Response type "%s".', gettype($response))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
protected function createXmlResponse(array $attributes, ?Encryptor $encryptor = null): ResponseInterface
|
||||
{
|
||||
$xml = Xml::build($attributes);
|
||||
|
||||
return new Response(200, ['Content-Type' => 'application/xml'], $encryptor ? $encryptor->encrypt($xml) : $xml);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\MiniApp;
|
||||
|
||||
class AccessToken extends \EasyWeChat\OfficialAccount\AccessToken
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\MiniApp;
|
||||
|
||||
use EasyWeChat\MiniApp\Contracts\Account as AccountInterface;
|
||||
|
||||
class Account extends \EasyWeChat\OfficialAccount\Account implements AccountInterface
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\MiniApp;
|
||||
|
||||
use function array_merge;
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenExpiredRetryStrategy;
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use EasyWeChat\Kernel\HttpClient\Response;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithCache;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithConfig;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHttpClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithServerRequest;
|
||||
use EasyWeChat\MiniApp\Contracts\Account as AccountInterface;
|
||||
use EasyWeChat\MiniApp\Contracts\Application as ApplicationInterface;
|
||||
use function is_null;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use function str_contains;
|
||||
use Symfony\Component\HttpClient\Response\AsyncContext;
|
||||
use Symfony\Component\HttpClient\RetryableHttpClient;
|
||||
|
||||
/**
|
||||
* @psalm-suppress PropertyNotSetInConstructor
|
||||
*/
|
||||
class Application implements ApplicationInterface
|
||||
{
|
||||
use InteractWithConfig;
|
||||
use InteractWithCache;
|
||||
use InteractWithServerRequest;
|
||||
use InteractWithHttpClient;
|
||||
use InteractWithClient;
|
||||
use LoggerAwareTrait;
|
||||
|
||||
protected ?Encryptor $encryptor = null;
|
||||
|
||||
protected ?ServerInterface $server = null;
|
||||
|
||||
protected ?AccountInterface $account = null;
|
||||
|
||||
protected ?AccessTokenInterface $accessToken = null;
|
||||
|
||||
public function getAccount(): AccountInterface
|
||||
{
|
||||
if (! $this->account) {
|
||||
$this->account = new Account(
|
||||
appId: (string) $this->config->get('app_id'), /** @phpstan-ignore-line */
|
||||
secret: (string) $this->config->get('secret'), /** @phpstan-ignore-line */
|
||||
token: (string) $this->config->get('token'), /** @phpstan-ignore-line */
|
||||
aesKey: (string) $this->config->get('aes_key'),/** @phpstan-ignore-line */
|
||||
);
|
||||
}
|
||||
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
public function setAccount(AccountInterface $account): static
|
||||
{
|
||||
$this->account = $account;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
*/
|
||||
public function getEncryptor(): Encryptor
|
||||
{
|
||||
if (! $this->encryptor) {
|
||||
$token = $this->getAccount()->getToken();
|
||||
$aesKey = $this->getAccount()->getAesKey();
|
||||
|
||||
if (empty($token) || empty($aesKey)) {
|
||||
throw new InvalidConfigException('token or aes_key cannot be empty.');
|
||||
}
|
||||
|
||||
$this->encryptor = new Encryptor(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
token: $token,
|
||||
aesKey: $aesKey,
|
||||
receiveId: $this->getAccount()->getAppId()
|
||||
);
|
||||
}
|
||||
|
||||
return $this->encryptor;
|
||||
}
|
||||
|
||||
public function setEncryptor(Encryptor $encryptor): static
|
||||
{
|
||||
$this->encryptor = $encryptor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ReflectionException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function getServer(): Server|ServerInterface
|
||||
{
|
||||
if (! $this->server) {
|
||||
$this->server = new Server(
|
||||
request: $this->getRequest(),
|
||||
encryptor: $this->getAccount()->getAesKey() ? $this->getEncryptor() : null
|
||||
);
|
||||
}
|
||||
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
public function setServer(ServerInterface $server): static
|
||||
{
|
||||
$this->server = $server;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccessToken(): AccessTokenInterface
|
||||
{
|
||||
if (! $this->accessToken) {
|
||||
$this->accessToken = new AccessToken(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
secret: $this->getAccount()->getSecret(),
|
||||
cache: $this->getCache(),
|
||||
httpClient: $this->getHttpClient(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
public function setAccessToken(AccessTokenInterface $accessToken): static
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function getUtils(): Utils
|
||||
{
|
||||
return new Utils($this);
|
||||
}
|
||||
|
||||
public function createClient(): AccessTokenAwareClient
|
||||
{
|
||||
$httpClient = $this->getHttpClient();
|
||||
|
||||
if ((bool) $this->config->get('http.retry', false)) {
|
||||
$httpClient = new RetryableHttpClient(
|
||||
$httpClient,
|
||||
$this->getRetryStrategy(),
|
||||
(int) $this->config->get('http.max_retries', 2) // @phpstan-ignore-line
|
||||
);
|
||||
}
|
||||
|
||||
return (new AccessTokenAwareClient(
|
||||
client: $httpClient,
|
||||
accessToken: $this->getAccessToken(),
|
||||
failureJudge: fn (
|
||||
Response $response
|
||||
) => (bool) ($response->toArray()['errcode'] ?? 0) || ! is_null($response->toArray()['error'] ?? null),
|
||||
throw: (bool) $this->config->get('http.throw', true),
|
||||
))->setPresets($this->config->all());
|
||||
}
|
||||
|
||||
public function getRetryStrategy(): AccessTokenExpiredRetryStrategy
|
||||
{
|
||||
$retryConfig = RequestUtil::mergeDefaultRetryOptions((array) $this->config->get('http.retry', []));
|
||||
|
||||
return (new AccessTokenExpiredRetryStrategy($retryConfig))
|
||||
->decideUsing(function (AsyncContext $context, ?string $responseContent): bool {
|
||||
return ! empty($responseContent)
|
||||
&& str_contains($responseContent, '42001')
|
||||
&& str_contains($responseContent, 'access_token expired');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function getHttpClientDefaultOptions(): array
|
||||
{
|
||||
return array_merge(
|
||||
['base_uri' => 'https://api.weixin.qq.com/'],
|
||||
(array) $this->config->get('http', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\MiniApp\Contracts;
|
||||
|
||||
interface Account
|
||||
{
|
||||
public function getAppId(): string;
|
||||
|
||||
public function getSecret(): string;
|
||||
|
||||
public function getToken(): ?string;
|
||||
|
||||
public function getAesKey(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\MiniApp\Contracts;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken;
|
||||
use EasyWeChat\Kernel\Contracts\Config;
|
||||
use EasyWeChat\Kernel\Contracts\Server;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
interface Application
|
||||
{
|
||||
public function getAccount(): Account;
|
||||
|
||||
public function getEncryptor(): Encryptor;
|
||||
|
||||
public function getServer(): Server;
|
||||
|
||||
public function getRequest(): ServerRequestInterface;
|
||||
|
||||
public function getClient(): AccessTokenAwareClient;
|
||||
|
||||
public function getHttpClient(): HttpClientInterface;
|
||||
|
||||
public function getConfig(): Config;
|
||||
|
||||
public function getAccessToken(): AccessToken;
|
||||
|
||||
public function getCache(): CacheInterface;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\MiniApp;
|
||||
|
||||
use function base64_decode;
|
||||
use EasyWeChat\Kernel\Exceptions\DecryptException;
|
||||
use EasyWeChat\Kernel\Support\AesCbc;
|
||||
use function is_array;
|
||||
use function json_decode;
|
||||
use function sprintf;
|
||||
use Throwable;
|
||||
|
||||
class Decryptor
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws DecryptException
|
||||
*/
|
||||
public static function decrypt(string $sessionKey, string $iv, string $ciphertext): array
|
||||
{
|
||||
try {
|
||||
$decrypted = AesCbc::decrypt(
|
||||
$ciphertext,
|
||||
base64_decode($sessionKey, false),
|
||||
base64_decode($iv, false)
|
||||
);
|
||||
|
||||
$decrypted = json_decode($decrypted, true);
|
||||
|
||||
if (! $decrypted || ! is_array($decrypted)) {
|
||||
throw new DecryptException('The given payload is invalid.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
throw new DecryptException(sprintf('The given payload is invalid: %s', $e->getMessage()));
|
||||
}
|
||||
|
||||
return $decrypted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\MiniApp;
|
||||
|
||||
class Server extends \EasyWeChat\OfficialAccount\Server
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\MiniApp;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\DecryptException;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
|
||||
class Utils
|
||||
{
|
||||
public function __construct(protected Application $app)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpException
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function codeToSession(string $code): array
|
||||
{
|
||||
$response = $this->app->getHttpClient()->request('GET', '/sns/jscode2session', [
|
||||
'query' => [
|
||||
'appid' => $this->app->getAccount()->getAppId(),
|
||||
'secret' => $this->app->getAccount()->getSecret(),
|
||||
'js_code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
],
|
||||
])->toArray(false);
|
||||
|
||||
if (empty($response['openid'])) {
|
||||
throw new HttpException('code2Session error: '.json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DecryptException
|
||||
*/
|
||||
public function decryptSession(string $sessionKey, string $iv, string $ciphertext): array
|
||||
{
|
||||
return Decryptor::decrypt($sessionKey, $iv, $ciphertext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\RefreshableAccessToken as RefreshableAccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use function intval;
|
||||
use function is_string;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use function json_encode;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use function sprintf;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class AccessToken implements RefreshableAccessTokenInterface
|
||||
{
|
||||
protected HttpClientInterface $httpClient;
|
||||
|
||||
protected CacheInterface $cache;
|
||||
|
||||
public function __construct(
|
||||
protected string $appId,
|
||||
protected string $secret,
|
||||
protected ?string $key = null,
|
||||
?CacheInterface $cache = null,
|
||||
?HttpClientInterface $httpClient = null,
|
||||
) {
|
||||
$this->httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://api.weixin.qq.com/']);
|
||||
$this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key ?? $this->key = sprintf('official_account.access_token.%s.%s', $this->appId, $this->secret);
|
||||
}
|
||||
|
||||
public function setKey(string $key): static
|
||||
{
|
||||
$this->key = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpException
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
$token = $this->cache->get($this->getKey());
|
||||
|
||||
if ((bool) $token && is_string($token)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*
|
||||
* @throws HttpException
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
#[ArrayShape(['access_token' => 'string'])]
|
||||
public function toQuery(): array
|
||||
{
|
||||
return ['access_token' => $this->getToken()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws HttpException
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
*/
|
||||
public function refresh(): string
|
||||
{
|
||||
$response = $this->httpClient->request(
|
||||
'GET',
|
||||
'cgi-bin/token',
|
||||
[
|
||||
'query' => [
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $this->appId,
|
||||
'secret' => $this->secret,
|
||||
],
|
||||
]
|
||||
)->toArray(false);
|
||||
|
||||
if (empty($response['access_token'])) {
|
||||
throw new HttpException('Failed to get access_token: '.json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
$this->cache->set($this->getKey(), $response['access_token'], intval($response['expires_in']));
|
||||
|
||||
return $response['access_token'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
use EasyWeChat\OfficialAccount\Contracts\Account as AccountInterface;
|
||||
use RuntimeException;
|
||||
|
||||
class Account implements AccountInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected string $appId,
|
||||
protected ?string $secret,
|
||||
protected ?string $token = null,
|
||||
protected ?string $aesKey = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAppId(): string
|
||||
{
|
||||
return $this->appId;
|
||||
}
|
||||
|
||||
public function getSecret(): string
|
||||
{
|
||||
if (null === $this->secret) {
|
||||
throw new RuntimeException('No secret configured.');
|
||||
}
|
||||
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function getToken(): ?string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function getAesKey(): ?string
|
||||
{
|
||||
return $this->aesKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
use function array_merge;
|
||||
use function call_user_func;
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Contracts\RefreshableAccessToken as RefreshableAccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenExpiredRetryStrategy;
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use EasyWeChat\Kernel\HttpClient\Response;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithCache;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithConfig;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHttpClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithServerRequest;
|
||||
use EasyWeChat\OfficialAccount\Contracts\Account as AccountInterface;
|
||||
use EasyWeChat\OfficialAccount\Contracts\Application as ApplicationInterface;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use Overtrue\Socialite\Contracts\ProviderInterface as SocialiteProviderInterface;
|
||||
use Overtrue\Socialite\Providers\WeChat;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
use Symfony\Component\HttpClient\Response\AsyncContext;
|
||||
use Symfony\Component\HttpClient\RetryableHttpClient;
|
||||
|
||||
class Application implements ApplicationInterface
|
||||
{
|
||||
use InteractWithConfig;
|
||||
use InteractWithCache;
|
||||
use InteractWithServerRequest;
|
||||
use InteractWithHttpClient;
|
||||
use InteractWithClient;
|
||||
use LoggerAwareTrait;
|
||||
|
||||
protected ?Encryptor $encryptor = null;
|
||||
|
||||
protected ?ServerInterface $server = null;
|
||||
|
||||
protected ?AccountInterface $account = null;
|
||||
|
||||
protected AccessTokenInterface|RefreshableAccessTokenInterface|null $accessToken = null;
|
||||
|
||||
protected ?JsApiTicket $ticket = null;
|
||||
|
||||
protected ?\Closure $oauthFactory = null;
|
||||
|
||||
public function getAccount(): AccountInterface
|
||||
{
|
||||
if (! $this->account) {
|
||||
$this->account = new Account(
|
||||
appId: (string) $this->config->get('app_id'), /** @phpstan-ignore-line */
|
||||
secret: (string) $this->config->get('secret'), /** @phpstan-ignore-line */
|
||||
token: (string) $this->config->get('token'), /** @phpstan-ignore-line */
|
||||
aesKey: (string) $this->config->get('aes_key'),/** @phpstan-ignore-line */
|
||||
);
|
||||
}
|
||||
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
public function setAccount(AccountInterface $account): static
|
||||
{
|
||||
$this->account = $account;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
*/
|
||||
public function getEncryptor(): Encryptor
|
||||
{
|
||||
if (! $this->encryptor) {
|
||||
$token = $this->getAccount()->getToken();
|
||||
$aesKey = $this->getAccount()->getAesKey();
|
||||
|
||||
if (empty($token) || empty($aesKey)) {
|
||||
throw new InvalidConfigException('token or aes_key cannot be empty.');
|
||||
}
|
||||
|
||||
$this->encryptor = new Encryptor(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
token: $token,
|
||||
aesKey: $aesKey,
|
||||
receiveId: $this->getAccount()->getAppId()
|
||||
);
|
||||
}
|
||||
|
||||
return $this->encryptor;
|
||||
}
|
||||
|
||||
public function setEncryptor(Encryptor $encryptor): static
|
||||
{
|
||||
$this->encryptor = $encryptor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ReflectionException
|
||||
* @throws InvalidArgumentException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function getServer(): Server|ServerInterface
|
||||
{
|
||||
if (! $this->server) {
|
||||
$this->server = new Server(
|
||||
request: $this->getRequest(),
|
||||
encryptor: $this->getAccount()->getAesKey() ? $this->getEncryptor() : null
|
||||
);
|
||||
}
|
||||
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
public function setServer(ServerInterface $server): static
|
||||
{
|
||||
$this->server = $server;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccessToken(): AccessTokenInterface|RefreshableAccessTokenInterface
|
||||
{
|
||||
if (! $this->accessToken) {
|
||||
$this->accessToken = new AccessToken(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
secret: $this->getAccount()->getSecret(),
|
||||
cache: $this->getCache(),
|
||||
httpClient: $this->getHttpClient(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
public function setAccessToken(AccessTokenInterface|RefreshableAccessTokenInterface $accessToken): static
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setOAuthFactory(callable $factory): static
|
||||
{
|
||||
$this->oauthFactory = fn (Application $app): WeChat => $factory($app);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getOAuth(): SocialiteProviderInterface
|
||||
{
|
||||
if (! $this->oauthFactory) {
|
||||
$this->oauthFactory = fn (self $app): SocialiteProviderInterface => (new WeChat(
|
||||
[
|
||||
'client_id' => $this->getAccount()->getAppId(),
|
||||
'client_secret' => $this->getAccount()->getSecret(),
|
||||
'redirect_url' => $this->config->get('oauth.redirect_url'),
|
||||
]
|
||||
))->scopes((array) $this->config->get('oauth.scopes', ['snsapi_userinfo']));
|
||||
}
|
||||
|
||||
$provider = call_user_func($this->oauthFactory, $this);
|
||||
|
||||
if (! $provider instanceof SocialiteProviderInterface) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'The factory must return a %s instance.',
|
||||
SocialiteProviderInterface::class
|
||||
));
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
public function getTicket(): JsApiTicket
|
||||
{
|
||||
if (! $this->ticket) {
|
||||
$this->ticket = new JsApiTicket(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
secret: $this->getAccount()->getSecret(),
|
||||
cache: $this->getCache(),
|
||||
httpClient: $this->getClient(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->ticket;
|
||||
}
|
||||
|
||||
public function setTicket(JsApiTicket $ticket): static
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function getUtils(): Utils
|
||||
{
|
||||
return new Utils($this);
|
||||
}
|
||||
|
||||
public function createClient(): AccessTokenAwareClient
|
||||
{
|
||||
$httpClient = $this->getHttpClient();
|
||||
|
||||
if ((bool) $this->config->get('http.retry', false)) {
|
||||
$httpClient = new RetryableHttpClient(
|
||||
$httpClient,
|
||||
$this->getRetryStrategy(),
|
||||
(int) $this->config->get('http.max_retries', 2) // @phpstan-ignore-line
|
||||
);
|
||||
}
|
||||
|
||||
return (new AccessTokenAwareClient(
|
||||
client: $httpClient,
|
||||
accessToken: $this->getAccessToken(),
|
||||
failureJudge: fn (Response $response) => (bool) ($response->toArray()['errcode'] ?? 0),
|
||||
throw: (bool) $this->config->get('http.throw', true),
|
||||
))->setPresets($this->config->all());
|
||||
}
|
||||
|
||||
public function getRetryStrategy(): AccessTokenExpiredRetryStrategy
|
||||
{
|
||||
$retryConfig = RequestUtil::mergeDefaultRetryOptions((array) $this->config->get('http.retry', []));
|
||||
|
||||
return (new AccessTokenExpiredRetryStrategy($retryConfig))
|
||||
->decideUsing(function (AsyncContext $context, ?string $responseContent): bool {
|
||||
return ! empty($responseContent)
|
||||
&& str_contains($responseContent, '42001')
|
||||
&& str_contains($responseContent, 'access_token expired');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function getHttpClientDefaultOptions(): array
|
||||
{
|
||||
return array_merge(
|
||||
['base_uri' => 'https://api.weixin.qq.com/'],
|
||||
(array) $this->config->get('http', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
class Config extends \EasyWeChat\Kernel\Config
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredKeys = [
|
||||
'app_id',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount\Contracts;
|
||||
|
||||
interface Account
|
||||
{
|
||||
public function getAppId(): string;
|
||||
|
||||
public function getSecret(): string;
|
||||
|
||||
public function getToken(): ?string;
|
||||
|
||||
public function getAesKey(): ?string;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount\Contracts;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken;
|
||||
use EasyWeChat\Kernel\Contracts\Config;
|
||||
use EasyWeChat\Kernel\Contracts\Server;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use Overtrue\Socialite\Contracts\ProviderInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
interface Application
|
||||
{
|
||||
public function getAccount(): Account;
|
||||
|
||||
public function getEncryptor(): Encryptor;
|
||||
|
||||
public function getServer(): Server;
|
||||
|
||||
public function getRequest(): ServerRequestInterface;
|
||||
|
||||
public function getClient(): AccessTokenAwareClient;
|
||||
|
||||
public function getHttpClient(): HttpClientInterface;
|
||||
|
||||
public function getConfig(): Config;
|
||||
|
||||
public function getAccessToken(): AccessToken;
|
||||
|
||||
public function getCache(): CacheInterface;
|
||||
|
||||
public function getOAuth(): ProviderInterface;
|
||||
|
||||
public function setOAuthFactory(callable $factory): static;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use function sprintf;
|
||||
|
||||
class JsApiTicket extends AccessToken
|
||||
{
|
||||
/**
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
*/
|
||||
public function getTicket(): string
|
||||
{
|
||||
$key = $this->getKey();
|
||||
$ticket = $this->cache->get($key);
|
||||
|
||||
if ((bool) $ticket && \is_string($ticket)) {
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request('GET', '/cgi-bin/ticket/getticket', ['query' => ['type' => 'jsapi']])
|
||||
->toArray(false);
|
||||
|
||||
if (empty($response['ticket'])) {
|
||||
throw new HttpException('Failed to get jssdk ticket: '.\json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
$this->cache->set($key, $response['ticket'], \intval($response['expires_in']));
|
||||
|
||||
return $response['ticket'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
*/
|
||||
#[ArrayShape([
|
||||
'url' => 'string',
|
||||
'nonceStr' => 'string',
|
||||
'timestamp' => 'int',
|
||||
'appId' => 'string',
|
||||
'signature' => 'string',
|
||||
])]
|
||||
public function configSignature(string $url, string $nonce, int $timestamp): array
|
||||
{
|
||||
return [
|
||||
'url' => $url,
|
||||
'nonceStr' => $nonce,
|
||||
'timestamp' => $timestamp,
|
||||
'appId' => $this->appId,
|
||||
'signature' => sha1(sprintf(
|
||||
'jsapi_ticket=%s&noncestr=%s×tamp=%s&url=%s',
|
||||
$this->getTicket(),
|
||||
$nonce,
|
||||
$timestamp,
|
||||
$url
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key ?? $this->key = sprintf('official_account.jsapi_ticket.%s', $this->appId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
/**
|
||||
* @property string $Event
|
||||
* @property string $MsgType
|
||||
*/
|
||||
class Message extends \EasyWeChat\Kernel\Message
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use EasyWeChat\Kernel\ServerResponse;
|
||||
use EasyWeChat\Kernel\Traits\DecryptXmlMessage;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHandlers;
|
||||
use EasyWeChat\Kernel\Traits\RespondXmlMessage;
|
||||
use Nyholm\Psr7\Response;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Throwable;
|
||||
|
||||
class Server implements ServerInterface
|
||||
{
|
||||
use RespondXmlMessage;
|
||||
use DecryptXmlMessage;
|
||||
use InteractWithHandlers;
|
||||
|
||||
protected ServerRequestInterface $request;
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function __construct(
|
||||
?ServerRequestInterface $request = null,
|
||||
protected ?Encryptor $encryptor = null,
|
||||
) {
|
||||
$this->request = $request ?? RequestUtil::createDefaultServerRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
* @throws BadRequestException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function serve(): ResponseInterface
|
||||
{
|
||||
if ((bool) ($str = $this->request->getQueryParams()['echostr'] ?? '')) {
|
||||
return new Response(200, [], $str);
|
||||
}
|
||||
|
||||
$message = $this->getRequestMessage($this->request);
|
||||
$query = $this->request->getQueryParams();
|
||||
|
||||
if ($this->encryptor && ! empty($query['msg_signature'])) {
|
||||
$this->prepend($this->decryptRequestMessage($query));
|
||||
}
|
||||
|
||||
$response = $this->handle(new Response(200, [], 'success'), $message);
|
||||
|
||||
if (! ($response instanceof ResponseInterface)) {
|
||||
$response = $this->transformToReply($response, $message, $this->encryptor);
|
||||
}
|
||||
|
||||
return ServerResponse::make($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function addMessageListener(string $type, callable|string $handler): static
|
||||
{
|
||||
$handler = $this->makeClosure($handler);
|
||||
$this->withHandler(
|
||||
function (Message $message, Closure $next) use ($type, $handler): mixed {
|
||||
return $message->MsgType === $type ? $handler($message, $next) : $next($message);
|
||||
}
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function addEventListener(string $event, callable|string $handler): static
|
||||
{
|
||||
$handler = $this->makeClosure($handler);
|
||||
$this->withHandler(
|
||||
function (Message $message, Closure $next) use ($event, $handler): mixed {
|
||||
return $message->Event === $event ? $handler($message, $next) : $next($message);
|
||||
}
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $query
|
||||
* @psalm-suppress PossiblyNullArgument
|
||||
*/
|
||||
protected function decryptRequestMessage(array $query): Closure
|
||||
{
|
||||
return function (Message $message, Closure $next) use ($query): mixed {
|
||||
if (! $this->encryptor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->decryptMessage(
|
||||
message: $message,
|
||||
encryptor: $this->encryptor,
|
||||
signature: $query['msg_signature'] ?? '',
|
||||
timestamp: $query['timestamp'] ?? '',
|
||||
nonce: $query['nonce'] ?? ''
|
||||
);
|
||||
|
||||
return $next($message);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
public function getRequestMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message
|
||||
{
|
||||
return Message::createFromRequest($request ?? $this->request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function getDecryptedMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message
|
||||
{
|
||||
$request = $request ?? $this->request;
|
||||
$message = $this->getRequestMessage($request);
|
||||
$query = $request->getQueryParams();
|
||||
|
||||
if (! $this->encryptor || empty($query['msg_signature'])) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return $this->decryptMessage(
|
||||
message: $message,
|
||||
encryptor: $this->encryptor,
|
||||
signature: $query['msg_signature'],
|
||||
timestamp: $query['timestamp'] ?? '',
|
||||
nonce: $query['nonce'] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\OfficialAccount;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use EasyWeChat\Kernel\Support\Str;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use function time;
|
||||
|
||||
class Utils
|
||||
{
|
||||
public function __construct(protected Application $app)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param array<string> $jsApiList
|
||||
* @param array<string> $openTagList
|
||||
* @param bool $debug
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws HttpException
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function buildJsSdkConfig(
|
||||
string $url,
|
||||
array $jsApiList = [],
|
||||
array $openTagList = [],
|
||||
bool $debug = false
|
||||
): array {
|
||||
return array_merge(
|
||||
compact('jsApiList', 'openTagList', 'debug'),
|
||||
$this->app->getTicket()->configSignature($url, Str::random(), time())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
use EasyWeChat\OpenPlatform\Contracts\Account as AccountInterface;
|
||||
|
||||
class Account implements AccountInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected string $appId,
|
||||
protected string $secret,
|
||||
protected string $token,
|
||||
protected string $aesKey
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAppId(): string
|
||||
{
|
||||
return $this->appId;
|
||||
}
|
||||
|
||||
public function getSecret(): string
|
||||
{
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function getAesKey(): string
|
||||
{
|
||||
return $this->aesKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
use function array_merge;
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\BadResponseException;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use EasyWeChat\Kernel\HttpClient\Response;
|
||||
use EasyWeChat\Kernel\Support\Arr;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithCache;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithConfig;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHttpClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithServerRequest;
|
||||
use EasyWeChat\MiniApp\Application as MiniAppApplication;
|
||||
use EasyWeChat\OfficialAccount\Application as OfficialAccountApplication;
|
||||
use EasyWeChat\OfficialAccount\Config as OfficialAccountConfig;
|
||||
use EasyWeChat\OpenPlatform\Contracts\Account as AccountInterface;
|
||||
use EasyWeChat\OpenPlatform\Contracts\Application as ApplicationInterface;
|
||||
use EasyWeChat\OpenPlatform\Contracts\VerifyTicket as VerifyTicketInterface;
|
||||
use function is_string;
|
||||
use function md5;
|
||||
use Overtrue\Socialite\Contracts\ProviderInterface as SocialiteProviderInterface;
|
||||
use Overtrue\Socialite\Providers\WeChat;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use function sprintf;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
|
||||
class Application implements ApplicationInterface
|
||||
{
|
||||
use InteractWithCache;
|
||||
use InteractWithConfig;
|
||||
use InteractWithClient;
|
||||
use InteractWithHttpClient;
|
||||
use InteractWithServerRequest;
|
||||
|
||||
protected ?Encryptor $encryptor = null;
|
||||
|
||||
protected ?ServerInterface $server = null;
|
||||
|
||||
protected ?AccountInterface $account = null;
|
||||
|
||||
protected ?AccessTokenInterface $componentAccessToken = null;
|
||||
|
||||
protected ?VerifyTicketInterface $verifyTicket = null;
|
||||
|
||||
public function getAccount(): AccountInterface
|
||||
{
|
||||
if (! $this->account) {
|
||||
$this->account = new Account(
|
||||
appId: (string) $this->config->get('app_id'), /** @phpstan-ignore-line */
|
||||
secret: (string) $this->config->get('secret'), /** @phpstan-ignore-line */
|
||||
token: (string) $this->config->get('token'), /** @phpstan-ignore-line */
|
||||
aesKey: (string) $this->config->get('aes_key'),/** @phpstan-ignore-line */
|
||||
);
|
||||
}
|
||||
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
public function setAccount(AccountInterface $account): static
|
||||
{
|
||||
$this->account = $account;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVerifyTicket(): VerifyTicketInterface
|
||||
{
|
||||
if (! $this->verifyTicket) {
|
||||
$this->verifyTicket = new VerifyTicket(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
cache: $this->getCache(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->verifyTicket;
|
||||
}
|
||||
|
||||
public function setVerifyTicket(VerifyTicketInterface $verifyTicket): static
|
||||
{
|
||||
$this->verifyTicket = $verifyTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptor(): Encryptor
|
||||
{
|
||||
if (! $this->encryptor) {
|
||||
$this->encryptor = new Encryptor(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
token: $this->getAccount()->getToken(),
|
||||
aesKey: $this->getAccount()->getAesKey(),
|
||||
receiveId: $this->getAccount()->getAppId(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->encryptor;
|
||||
}
|
||||
|
||||
public function setEncryptor(Encryptor $encryptor): static
|
||||
{
|
||||
$this->encryptor = $encryptor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ReflectionException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function getServer(): Server|ServerInterface
|
||||
{
|
||||
if (! $this->server) {
|
||||
$this->server = new Server(
|
||||
encryptor: $this->getEncryptor(),
|
||||
request: $this->getRequest()
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->server instanceof Server) {
|
||||
$this->server->withDefaultVerifyTicketHandler(
|
||||
function (Message $message, Closure $next): mixed {
|
||||
$ticket = $this->getVerifyTicket();
|
||||
if (\is_callable([$ticket, 'setTicket'])) {
|
||||
$ticket->setTicket($message->ComponentVerifyTicket);
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
public function setServer(ServerInterface $server): static
|
||||
{
|
||||
$this->server = $server;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccessToken(): AccessTokenInterface
|
||||
{
|
||||
return $this->getComponentAccessToken();
|
||||
}
|
||||
|
||||
public function getComponentAccessToken(): AccessTokenInterface
|
||||
{
|
||||
if (! $this->componentAccessToken) {
|
||||
$this->componentAccessToken = new ComponentAccessToken(
|
||||
appId: $this->getAccount()->getAppId(),
|
||||
secret: $this->getAccount()->getSecret(),
|
||||
verifyTicket: $this->getVerifyTicket(),
|
||||
cache: $this->getCache(),
|
||||
httpClient: $this->getHttpClient(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->componentAccessToken;
|
||||
}
|
||||
|
||||
public function setComponentAccessToken(AccessTokenInterface $componentAccessToken): static
|
||||
{
|
||||
$this->componentAccessToken = $componentAccessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpException
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function getAuthorization(string $authorizationCode): Authorization
|
||||
{
|
||||
$response = $this->getClient()->request(
|
||||
'POST',
|
||||
'cgi-bin/component/api_query_auth',
|
||||
[
|
||||
'json' => [
|
||||
'component_appid' => $this->getAccount()->getAppId(),
|
||||
'authorization_code' => $authorizationCode,
|
||||
],
|
||||
]
|
||||
)->toArray(false);
|
||||
|
||||
if (empty($response['authorization_info'])) {
|
||||
throw new HttpException('Failed to get authorization_info: '.json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
return new Authorization($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws HttpException
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function refreshAuthorizerToken(string $authorizerAppId, string $authorizerRefreshToken): array
|
||||
{
|
||||
$response = $this->getClient()->request(
|
||||
'POST',
|
||||
'cgi-bin/component/api_authorizer_token',
|
||||
[
|
||||
'json' => [
|
||||
'component_appid' => $this->getAccount()->getAppId(),
|
||||
'authorizer_appid' => $authorizerAppId,
|
||||
'authorizer_refresh_token' => $authorizerRefreshToken,
|
||||
],
|
||||
]
|
||||
)->toArray(false);
|
||||
|
||||
if (empty($response['authorizer_access_token'])) {
|
||||
throw new HttpException('Failed to get authorizer_access_token: '.json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpException
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function createPreAuthorizationCode(): array
|
||||
{
|
||||
$response = $this->getClient()->request(
|
||||
'POST',
|
||||
'cgi-bin/component/api_create_preauthcode',
|
||||
[
|
||||
'json' => [
|
||||
'component_appid' => $this->getAccount()->getAppId(),
|
||||
],
|
||||
]
|
||||
)->toArray(false);
|
||||
|
||||
if (empty($response['pre_auth_code'])) {
|
||||
throw new HttpException('Failed to get authorizer_access_token: '.json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws HttpException
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function createPreAuthorizationUrl(string $callbackUrl, array|string $optional = []): string
|
||||
{
|
||||
// 兼容旧版 API 设计
|
||||
if (is_string($optional)) {
|
||||
$optional = [
|
||||
'pre_auth_code' => $optional,
|
||||
];
|
||||
} else {
|
||||
$optional['pre_auth_code'] = Arr::get($this->createPreAuthorizationCode(), 'pre_auth_code');
|
||||
}
|
||||
|
||||
$queries = array_merge(
|
||||
$optional,
|
||||
[
|
||||
'component_appid' => $this->getAccount()->getAppId(),
|
||||
'redirect_uri' => $callbackUrl,
|
||||
]
|
||||
);
|
||||
|
||||
return 'https://mp.weixin.qq.com/cgi-bin/componentloginpage?'.http_build_query($queries);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Overtrue\Socialite\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public function getOAuth(): SocialiteProviderInterface
|
||||
{
|
||||
return (new WeChat(
|
||||
[
|
||||
'client_id' => $this->getAccount()->getAppId(),
|
||||
'client_secret' => $this->getAccount()->getSecret(),
|
||||
'redirect_url' => $this->config->get('oauth.redirect_url'),
|
||||
]
|
||||
))->scopes((array) $this->config->get('oauth.scopes', ['snsapi_userinfo']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws HttpException
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function getOfficialAccountWithRefreshToken(
|
||||
string $appId,
|
||||
string $refreshToken,
|
||||
array $config = []
|
||||
): OfficialAccountApplication {
|
||||
return $this->getOfficialAccountWithAccessToken(
|
||||
$appId,
|
||||
$this->getAuthorizerAccessToken($appId, $refreshToken),
|
||||
$config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public function getOfficialAccountWithAccessToken(
|
||||
string $appId,
|
||||
string $accessToken,
|
||||
array $config = []
|
||||
): OfficialAccountApplication {
|
||||
return $this->getOfficialAccount(new AuthorizerAccessToken($appId, $accessToken), $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public function getOfficialAccount(
|
||||
AuthorizerAccessToken $authorizerAccessToken,
|
||||
array $config = []
|
||||
): OfficialAccountApplication {
|
||||
$config = new OfficialAccountConfig(
|
||||
array_merge(
|
||||
[
|
||||
'app_id' => $authorizerAccessToken->getAppId(),
|
||||
'token' => $this->config->get('token'),
|
||||
'aes_key' => $this->config->get('aes_key'),
|
||||
'logging' => $this->config->get('logging'),
|
||||
'http' => $this->config->get('http'),
|
||||
],
|
||||
$config
|
||||
)
|
||||
);
|
||||
|
||||
$app = new OfficialAccountApplication($config);
|
||||
|
||||
$app->setAccessToken($authorizerAccessToken);
|
||||
$app->setEncryptor($this->getEncryptor());
|
||||
$app->setOAuthFactory($this->createAuthorizerOAuthFactory($authorizerAccessToken->getAppId(), $config));
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws HttpException
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function getMiniAppWithRefreshToken(
|
||||
string $appId,
|
||||
string $refreshToken,
|
||||
array $config = []
|
||||
): MiniAppApplication {
|
||||
return $this->getMiniAppWithAccessToken(
|
||||
$appId,
|
||||
$this->getAuthorizerAccessToken($appId, $refreshToken),
|
||||
$config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public function getMiniAppWithAccessToken(
|
||||
string $appId,
|
||||
string $accessToken,
|
||||
array $config = []
|
||||
): MiniAppApplication {
|
||||
return $this->getMiniApp(new AuthorizerAccessToken($appId, $accessToken), $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
*/
|
||||
public function getMiniApp(AuthorizerAccessToken $authorizerAccessToken, array $config = []): MiniAppApplication
|
||||
{
|
||||
$app = new MiniAppApplication(
|
||||
array_merge(
|
||||
[
|
||||
'app_id' => $authorizerAccessToken->getAppId(),
|
||||
'token' => $this->config->get('token'),
|
||||
'aes_key' => $this->config->get('aes_key'),
|
||||
'logging' => $this->config->get('logging'),
|
||||
'http' => $this->config->get('http'),
|
||||
],
|
||||
$config
|
||||
)
|
||||
);
|
||||
|
||||
$app->setAccessToken($authorizerAccessToken);
|
||||
$app->setEncryptor($this->getEncryptor());
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function createAuthorizerOAuthFactory(string $authorizerAppId, OfficialAccountConfig $config): Closure
|
||||
{
|
||||
return fn () => (new WeChat(
|
||||
[
|
||||
'client_id' => $authorizerAppId,
|
||||
|
||||
'component' => [
|
||||
'component_app_id' => $this->getAccount()->getAppId(),
|
||||
'component_access_token' => fn () => $this->getComponentAccessToken()->getToken(),
|
||||
],
|
||||
|
||||
'redirect_url' => $this->config->get('oauth.redirect_url'),
|
||||
]
|
||||
))->scopes((array) $config->get('oauth.scopes', ['snsapi_userinfo']));
|
||||
}
|
||||
|
||||
public function createClient(): AccessTokenAwareClient
|
||||
{
|
||||
return (new AccessTokenAwareClient(
|
||||
client: $this->getHttpClient(),
|
||||
accessToken: $this->getComponentAccessToken(),
|
||||
failureJudge: fn (Response $response) => (bool) ($response->toArray()['errcode'] ?? 0),
|
||||
throw: (bool) $this->config->get('http.throw', true),
|
||||
))->setPresets($this->config->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws DecodingExceptionInterface
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws InvalidArgumentException
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws HttpException
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws BadResponseException
|
||||
*/
|
||||
public function getAuthorizerAccessToken(string $appId, string $refreshToken): string
|
||||
{
|
||||
$cacheKey = sprintf('open-platform.authorizer_access_token.%s.%s', $appId, md5($refreshToken));
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$authorizerAccessToken = (string) $this->getCache()->get($cacheKey);
|
||||
|
||||
if (! $authorizerAccessToken) {
|
||||
$response = $this->refreshAuthorizerToken($appId, $refreshToken);
|
||||
$authorizerAccessToken = (string) $response['authorizer_access_token'];
|
||||
$this->getCache()->set($cacheKey, $authorizerAccessToken, intval($response['expires_in'] ?? 7200) - 500);
|
||||
}
|
||||
|
||||
return $authorizerAccessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getHttpClientDefaultOptions(): array
|
||||
{
|
||||
return array_merge(
|
||||
['base_uri' => 'https://api.weixin.qq.com/'],
|
||||
(array) $this->config->get('http', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
use ArrayAccess;
|
||||
use EasyWeChat\Kernel\Contracts\Arrayable;
|
||||
use EasyWeChat\Kernel\Contracts\Jsonable;
|
||||
use EasyWeChat\Kernel\Traits\HasAttributes;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
|
||||
/**
|
||||
* @implements ArrayAccess<string, mixed>
|
||||
*/
|
||||
class Authorization implements ArrayAccess, Jsonable, Arrayable
|
||||
{
|
||||
use HasAttributes;
|
||||
|
||||
public function getAppId(): string
|
||||
{
|
||||
/** @phpstan-ignore-next-line */
|
||||
return (string) $this->attributes['authorization_info']['authorizer_appid'] ?? '';
|
||||
}
|
||||
|
||||
#[Pure]
|
||||
public function getAccessToken(): AuthorizerAccessToken
|
||||
{
|
||||
return new AuthorizerAccessToken(
|
||||
/** @phpstan-ignore-next-line */
|
||||
$this->attributes['authorization_info']['authorizer_appid'] ?? '',
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$this->attributes['authorization_info']['authorizer_access_token'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
public function getRefreshToken(): string
|
||||
{
|
||||
/** @phpstan-ignore-next-line */
|
||||
return $this->attributes['authorization_info']['authorizer_refresh_token'] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use Stringable;
|
||||
|
||||
class AuthorizerAccessToken implements AccessToken, Stringable
|
||||
{
|
||||
public function __construct(protected string $appId, protected string $accessToken)
|
||||
{
|
||||
}
|
||||
|
||||
public function getAppId(): string
|
||||
{
|
||||
return $this->appId;
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
#[Pure]
|
||||
#[ArrayShape(['access_token' => 'string'])]
|
||||
public function toQuery(): array
|
||||
{
|
||||
return ['access_token' => $this->getToken()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
use function abs;
|
||||
use EasyWeChat\Kernel\Contracts\RefreshableAccessToken as RefreshableAccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use EasyWeChat\OpenPlatform\Contracts\VerifyTicket as VerifyTicketInterface;
|
||||
use function intval;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use function json_encode;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class ComponentAccessToken implements RefreshableAccessTokenInterface
|
||||
{
|
||||
protected HttpClientInterface $httpClient;
|
||||
|
||||
protected CacheInterface $cache;
|
||||
|
||||
public function __construct(
|
||||
protected string $appId,
|
||||
protected string $secret,
|
||||
protected VerifyTicketInterface $verifyTicket,
|
||||
protected ?string $key = null,
|
||||
?CacheInterface $cache = null,
|
||||
?HttpClientInterface $httpClient = null,
|
||||
) {
|
||||
$this->httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://api.weixin.qq.com/']);
|
||||
$this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key ?? $this->key = \sprintf('open_platform.component_access_token.%s', $this->appId);
|
||||
}
|
||||
|
||||
public function setKey(string $key): static
|
||||
{
|
||||
$this->key = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
$token = $this->cache->get($this->getKey());
|
||||
|
||||
if ((bool) $token && \is_string($token)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
*/
|
||||
#[ArrayShape(['component_access_token' => 'string'])]
|
||||
public function toQuery(): array
|
||||
{
|
||||
return ['component_access_token' => $this->getToken()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
*/
|
||||
public function refresh(): string
|
||||
{
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
'cgi-bin/component/api_component_token',
|
||||
[
|
||||
'json' => [
|
||||
'component_appid' => $this->appId,
|
||||
'component_appsecret' => $this->secret,
|
||||
'component_verify_ticket' => $this->verifyTicket->getTicket(),
|
||||
],
|
||||
]
|
||||
)->toArray(false);
|
||||
|
||||
if (empty($response['component_access_token'])) {
|
||||
throw new HttpException('Failed to get component_access_token: '.json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
$this->cache->set(
|
||||
$this->getKey(),
|
||||
$response['component_access_token'],
|
||||
abs(intval($response['expires_in']) - 100)
|
||||
);
|
||||
|
||||
return $response['component_access_token'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
class Config extends \EasyWeChat\Kernel\Config
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredKeys = [
|
||||
'app_id',
|
||||
'secret',
|
||||
'aes_key',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform\Contracts;
|
||||
|
||||
interface Account
|
||||
{
|
||||
public function getAppId(): string;
|
||||
|
||||
public function getSecret(): string;
|
||||
|
||||
public function getToken(): string;
|
||||
|
||||
public function getAesKey(): string;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform\Contracts;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken;
|
||||
use EasyWeChat\Kernel\Contracts\Config;
|
||||
use EasyWeChat\Kernel\Contracts\Server;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use EasyWeChat\MiniApp\Application as MiniAppApplication;
|
||||
use EasyWeChat\OfficialAccount\Application as OfficialAccountApplication;
|
||||
use EasyWeChat\OpenPlatform\AuthorizerAccessToken;
|
||||
use Overtrue\Socialite\Contracts\ProviderInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
interface Application
|
||||
{
|
||||
public function getAccount(): Account;
|
||||
|
||||
public function getEncryptor(): Encryptor;
|
||||
|
||||
public function getServer(): Server;
|
||||
|
||||
public function getRequest(): ServerRequestInterface;
|
||||
|
||||
public function getClient(): AccessTokenAwareClient;
|
||||
|
||||
public function getHttpClient(): HttpClientInterface;
|
||||
|
||||
public function getConfig(): Config;
|
||||
|
||||
public function getComponentAccessToken(): AccessToken;
|
||||
|
||||
public function getCache(): CacheInterface;
|
||||
|
||||
public function getOAuth(): ProviderInterface;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function getMiniApp(AuthorizerAccessToken $authorizerAccessToken, array $config): MiniAppApplication;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function getOfficialAccount(
|
||||
AuthorizerAccessToken $authorizerAccessToken,
|
||||
array $config
|
||||
): OfficialAccountApplication;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform\Contracts;
|
||||
|
||||
interface VerifyTicket
|
||||
{
|
||||
public function getTicket(): string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
/**
|
||||
* @property string $InfoType
|
||||
* @property string $ComponentVerifyTicket
|
||||
*/
|
||||
class Message extends \EasyWeChat\Kernel\Message
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use EasyWeChat\Kernel\ServerResponse;
|
||||
use EasyWeChat\Kernel\Traits\DecryptXmlMessage;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHandlers;
|
||||
use EasyWeChat\Kernel\Traits\RespondXmlMessage;
|
||||
use function func_get_args;
|
||||
use Nyholm\Psr7\Response;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class Server implements ServerInterface
|
||||
{
|
||||
use InteractWithHandlers;
|
||||
use RespondXmlMessage;
|
||||
use DecryptXmlMessage;
|
||||
|
||||
protected ?Closure $defaultVerifyTicketHandler = null;
|
||||
|
||||
protected ServerRequestInterface $request;
|
||||
|
||||
/**
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function __construct(
|
||||
protected Encryptor $encryptor,
|
||||
?ServerRequestInterface $request = null,
|
||||
) {
|
||||
$this->request = $request ?? RequestUtil::createDefaultServerRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
* @throws BadRequestException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function serve(): ResponseInterface
|
||||
{
|
||||
if ((bool) ($str = $this->request->getQueryParams()['echostr'] ?? '')) {
|
||||
return new Response(200, [], $str);
|
||||
}
|
||||
|
||||
$message = $this->getRequestMessage($this->request);
|
||||
|
||||
$this->prepend($this->decryptRequestMessage());
|
||||
|
||||
$response = $this->handle(new Response(200, [], 'success'), $message);
|
||||
|
||||
if (! ($response instanceof ResponseInterface)) {
|
||||
$response = $this->transformToReply($response, $message, $this->encryptor);
|
||||
}
|
||||
|
||||
return ServerResponse::make($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handleAuthorized(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'authorized' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handleUnauthorized(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'unauthorized' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handleAuthorizeUpdated(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'updateauthorized' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withDefaultVerifyTicketHandler(callable $handler): void
|
||||
{
|
||||
$this->defaultVerifyTicketHandler = fn (): mixed => $handler(...func_get_args());
|
||||
$this->handleVerifyTicketRefreshed($this->defaultVerifyTicketHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handleVerifyTicketRefreshed(callable $handler): static
|
||||
{
|
||||
if ($this->defaultVerifyTicketHandler) {
|
||||
$this->withoutHandler($this->defaultVerifyTicketHandler);
|
||||
}
|
||||
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'component_verify_ticket' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function decryptRequestMessage(): Closure
|
||||
{
|
||||
$query = $this->request->getQueryParams();
|
||||
|
||||
return function (Message $message, Closure $next) use ($query): mixed {
|
||||
$message = $this->decryptMessage(
|
||||
message: $message,
|
||||
encryptor: $this->encryptor,
|
||||
signature: $query['msg_signature'] ?? '',
|
||||
timestamp: $query['timestamp'] ?? '',
|
||||
nonce: $query['nonce'] ?? ''
|
||||
);
|
||||
|
||||
return $next($message);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
public function getRequestMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message
|
||||
{
|
||||
return Message::createFromRequest($request ?? $this->request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function getDecryptedMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message
|
||||
{
|
||||
$request = $request ?? $this->request;
|
||||
$message = $this->getRequestMessage($request);
|
||||
$query = $request->getQueryParams();
|
||||
|
||||
return $this->decryptMessage(
|
||||
message: $message,
|
||||
encryptor: $this->encryptor,
|
||||
signature: $query['msg_signature'] ?? '',
|
||||
timestamp: $query['timestamp'] ?? '',
|
||||
nonce: $query['nonce'] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenPlatform;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\OpenPlatform\Contracts\VerifyTicket as VerifyTicketInterface;
|
||||
use function is_string;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use function sprintf;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
|
||||
class VerifyTicket implements VerifyTicketInterface
|
||||
{
|
||||
protected CacheInterface $cache;
|
||||
|
||||
public function __construct(
|
||||
protected string $appId,
|
||||
protected ?string $key = null,
|
||||
?CacheInterface $cache = null,
|
||||
) {
|
||||
$this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key ?? $this->key = sprintf('open_platform.verify_ticket.%s', $this->appId);
|
||||
}
|
||||
|
||||
public function setKey(string $key): static
|
||||
{
|
||||
$this->key = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function setTicket(string $ticket): static
|
||||
{
|
||||
$this->cache->set($this->getKey(), $ticket, 6000);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getTicket(): string
|
||||
{
|
||||
$ticket = $this->cache->get($this->getKey());
|
||||
|
||||
if (! $ticket || ! is_string($ticket)) {
|
||||
throw new RuntimeException('No component_verify_ticket found.');
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use EasyWeChat\OpenWork\Contracts\Account as AccountInterface;
|
||||
|
||||
class Account implements AccountInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected string $corpId,
|
||||
protected string $providerSecret,
|
||||
protected string $suiteId,
|
||||
protected string $suiteSecret,
|
||||
protected string $token,
|
||||
protected string $aesKey
|
||||
) {
|
||||
}
|
||||
|
||||
public function getCorpId(): string
|
||||
{
|
||||
return $this->corpId;
|
||||
}
|
||||
|
||||
public function getProviderSecret(): string
|
||||
{
|
||||
return $this->providerSecret;
|
||||
}
|
||||
|
||||
public function getSuiteId(): string
|
||||
{
|
||||
return $this->suiteId;
|
||||
}
|
||||
|
||||
public function getSuiteSecret(): string
|
||||
{
|
||||
return $this->suiteSecret;
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function getAesKey(): string
|
||||
{
|
||||
return $this->aesKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use function array_merge;
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use EasyWeChat\Kernel\HttpClient\Response;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithCache;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithConfig;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHttpClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithServerRequest;
|
||||
use EasyWeChat\OpenWork\Contracts\Account as AccountInterface;
|
||||
use EasyWeChat\OpenWork\Contracts\Application as ApplicationInterface;
|
||||
use EasyWeChat\OpenWork\Contracts\SuiteTicket as SuiteTicketInterface;
|
||||
use Overtrue\Socialite\Contracts\ProviderInterface as SocialiteProviderInterface;
|
||||
use Overtrue\Socialite\Providers\OpenWeWork;
|
||||
|
||||
class Application implements ApplicationInterface
|
||||
{
|
||||
use InteractWithCache;
|
||||
use InteractWithConfig;
|
||||
use InteractWithHttpClient;
|
||||
use InteractWithServerRequest;
|
||||
use InteractWithClient;
|
||||
|
||||
protected ?ServerInterface $server = null;
|
||||
|
||||
protected ?AccountInterface $account = null;
|
||||
|
||||
protected ?Encryptor $encryptor = null;
|
||||
|
||||
protected ?SuiteEncryptor $suiteEncryptor = null;
|
||||
|
||||
protected ?SuiteTicketInterface $suiteTicket = null;
|
||||
|
||||
protected ?AccessTokenInterface $accessToken = null;
|
||||
|
||||
protected ?AccessTokenInterface $suiteAccessToken = null;
|
||||
|
||||
public function getAccount(): AccountInterface
|
||||
{
|
||||
if (! $this->account) {
|
||||
$this->account = new Account(
|
||||
corpId: (string) $this->config->get('corp_id'), /** @phpstan-ignore-line */
|
||||
providerSecret: (string) $this->config->get('provider_secret'), /** @phpstan-ignore-line */
|
||||
suiteId: (string) $this->config->get('suite_id'), /** @phpstan-ignore-line */
|
||||
suiteSecret: (string) $this->config->get('suite_secret'), /** @phpstan-ignore-line */
|
||||
token: (string) $this->config->get('token'), /** @phpstan-ignore-line */
|
||||
aesKey: (string) $this->config->get('aes_key'),/** @phpstan-ignore-line */
|
||||
);
|
||||
}
|
||||
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
public function setAccount(AccountInterface $account): static
|
||||
{
|
||||
$this->account = $account;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptor(): Encryptor
|
||||
{
|
||||
if (! $this->encryptor) {
|
||||
$this->encryptor = new Encryptor(
|
||||
corpId: $this->getAccount()->getCorpId(),
|
||||
token: $this->getAccount()->getToken(),
|
||||
aesKey: $this->getAccount()->getAesKey(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->encryptor;
|
||||
}
|
||||
|
||||
public function setEncryptor(Encryptor $encryptor): static
|
||||
{
|
||||
$this->encryptor = $encryptor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSuiteEncryptor(): SuiteEncryptor
|
||||
{
|
||||
if (! $this->suiteEncryptor) {
|
||||
$this->suiteEncryptor = new SuiteEncryptor(
|
||||
suiteId: $this->getAccount()->getSuiteId(),
|
||||
token: $this->getAccount()->getToken(),
|
||||
aesKey: $this->getAccount()->getAesKey(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->suiteEncryptor;
|
||||
}
|
||||
|
||||
public function setSuiteEncryptor(SuiteEncryptor $encryptor): static
|
||||
{
|
||||
$this->suiteEncryptor = $encryptor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ReflectionException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function getServer(): Server|ServerInterface
|
||||
{
|
||||
if (! $this->server) {
|
||||
$this->server = new Server(
|
||||
encryptor: $this->getSuiteEncryptor(),
|
||||
providerEncryptor: $this->getEncryptor(),
|
||||
request: $this->getRequest(),
|
||||
);
|
||||
|
||||
$this->server->withDefaultSuiteTicketHandler(function (Message $message, \Closure $next): mixed {
|
||||
if ($message->SuiteId === $this->getAccount()->getSuiteId()) {
|
||||
$this->getSuiteTicket()->setTicket($message->SuiteTicket);
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
}
|
||||
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
public function setServer(ServerInterface $server): static
|
||||
{
|
||||
$this->server = $server;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProviderAccessToken(): AccessTokenInterface
|
||||
{
|
||||
if (! $this->accessToken) {
|
||||
$this->accessToken = new ProviderAccessToken(
|
||||
corpId: $this->getAccount()->getCorpId(),
|
||||
providerSecret: $this->getAccount()->getProviderSecret(),
|
||||
cache: $this->getCache(),
|
||||
httpClient: $this->getHttpClient(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
public function setProviderAccessToken(AccessTokenInterface $accessToken): static
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSuiteAccessToken(): AccessTokenInterface
|
||||
{
|
||||
if (! $this->suiteAccessToken) {
|
||||
$this->suiteAccessToken = new SuiteAccessToken(
|
||||
suiteId: $this->getAccount()->getSuiteId(),
|
||||
suiteSecret: $this->getAccount()->getSuiteSecret(),
|
||||
suiteTicket: $this->getSuiteTicket(),
|
||||
cache: $this->getCache(),
|
||||
httpClient: $this->getHttpClient(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->suiteAccessToken;
|
||||
}
|
||||
|
||||
public function setSuiteAccessToken(AccessTokenInterface $accessToken): static
|
||||
{
|
||||
$this->suiteAccessToken = $accessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSuiteTicket(): SuiteTicketInterface
|
||||
{
|
||||
if (! $this->suiteTicket) {
|
||||
$this->suiteTicket = new SuiteTicket(
|
||||
suiteId: $this->getAccount()->getSuiteId(),
|
||||
cache: $this->getCache(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->suiteTicket;
|
||||
}
|
||||
|
||||
public function setSuiteTicket(SuiteTicketInterface $suiteTicket): SuiteTicketInterface
|
||||
{
|
||||
$this->suiteTicket = $suiteTicket;
|
||||
|
||||
return $this->suiteTicket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
*/
|
||||
public function getAuthorization(
|
||||
string $corpId,
|
||||
string $permanentCode,
|
||||
?AccessTokenInterface $suiteAccessToken = null
|
||||
): Authorization {
|
||||
$suiteAccessToken = $suiteAccessToken ?? $this->getSuiteAccessToken();
|
||||
|
||||
$response = $this->getHttpClient()->request('POST', 'cgi-bin/service/get_auth_info', [
|
||||
'query' => [
|
||||
'suite_access_token' => $suiteAccessToken->getToken(),
|
||||
],
|
||||
'json' => [
|
||||
'auth_corpid' => $corpId,
|
||||
'permanent_code' => $permanentCode,
|
||||
],
|
||||
])->toArray(false);
|
||||
|
||||
if (empty($response['auth_corp_info'])) {
|
||||
throw new HttpException('Failed to get auth_corp_info: '.json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
return new Authorization($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
*/
|
||||
public function getAuthorizerAccessToken(
|
||||
string $corpId,
|
||||
string $permanentCode,
|
||||
?AccessTokenInterface $suiteAccessToken = null
|
||||
): AuthorizerAccessToken {
|
||||
$suiteAccessToken = $suiteAccessToken ?? $this->getSuiteAccessToken();
|
||||
$response = $this->getHttpClient()->request('POST', 'cgi-bin/service/get_corp_token', [
|
||||
'query' => [
|
||||
'suite_access_token' => $suiteAccessToken->getToken(),
|
||||
],
|
||||
'json' => [
|
||||
'auth_corpid' => $corpId,
|
||||
'permanent_code' => $permanentCode,
|
||||
],
|
||||
])->toArray(false);
|
||||
|
||||
if (empty($response['access_token'])) {
|
||||
throw new HttpException('Failed to get access_token: '.json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
return new AuthorizerAccessToken($corpId, accessToken: $response['access_token']);
|
||||
}
|
||||
|
||||
public function createClient(): AccessTokenAwareClient
|
||||
{
|
||||
return (new AccessTokenAwareClient(
|
||||
client: $this->getHttpClient(),
|
||||
accessToken: $this->getProviderAccessToken(),
|
||||
failureJudge: fn (Response $response) => (bool) ($response->toArray()['errcode'] ?? 0),
|
||||
throw: (bool) $this->config->get('http.throw', true),
|
||||
))->setPresets($this->config->all());
|
||||
}
|
||||
|
||||
public function getOAuth(
|
||||
string $suiteId,
|
||||
?AccessTokenInterface $suiteAccessToken = null
|
||||
): SocialiteProviderInterface {
|
||||
$suiteAccessToken = $suiteAccessToken ?? $this->getSuiteAccessToken();
|
||||
|
||||
return (new OpenWeWork([
|
||||
'client_id' => $suiteId,
|
||||
'redirect_url' => $this->config->get('oauth.redirect_url'),
|
||||
]))->withSuiteTicket($this->getSuiteTicket()->getTicket())
|
||||
->withSuiteAccessToken($suiteAccessToken->getToken())
|
||||
->scopes((array) $this->config->get('oauth.scopes', ['snsapi_base']));
|
||||
}
|
||||
|
||||
public function getCorpOAuth(
|
||||
string $corpId,
|
||||
?AccessTokenInterface $suiteAccessToken = null
|
||||
): SocialiteProviderInterface {
|
||||
$suiteAccessToken = $suiteAccessToken ?? $this->getSuiteAccessToken();
|
||||
|
||||
return (new OpenWeWork([
|
||||
'client_id' => $corpId,
|
||||
'redirect_url' => $this->config->get('oauth.redirect_url'),
|
||||
]))->withSuiteTicket($this->getSuiteTicket()->getTicket())
|
||||
->withSuiteAccessToken($suiteAccessToken->getToken())
|
||||
->scopes((array) $this->config->get('oauth.scopes', ['snsapi_base']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getHttpClientDefaultOptions(): array
|
||||
{
|
||||
return array_merge(
|
||||
['base_uri' => 'https://qyapi.weixin.qq.com/'],
|
||||
(array) $this->config->get('http', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use ArrayAccess;
|
||||
use EasyWeChat\Kernel\Contracts\Arrayable;
|
||||
use EasyWeChat\Kernel\Contracts\Jsonable;
|
||||
use EasyWeChat\Kernel\Traits\HasAttributes;
|
||||
|
||||
/**
|
||||
* @implements ArrayAccess<string, mixed>
|
||||
*/
|
||||
class Authorization implements ArrayAccess, Jsonable, Arrayable
|
||||
{
|
||||
use HasAttributes;
|
||||
|
||||
public function getAppId(): string
|
||||
{
|
||||
/** @phpstan-ignore-next-line */
|
||||
return (string) $this->attributes['auth_corp_info']['corpid'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
use Stringable;
|
||||
|
||||
class AuthorizerAccessToken implements AccessToken, Stringable
|
||||
{
|
||||
public function __construct(protected string $corpId, protected string $accessToken)
|
||||
{
|
||||
}
|
||||
|
||||
public function getCorpId(): string
|
||||
{
|
||||
return $this->corpId;
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
#[Pure]
|
||||
#[ArrayShape(['access_token' => 'string'])]
|
||||
public function toQuery(): array
|
||||
{
|
||||
return ['access_token' => $this->getToken()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
class Config extends \EasyWeChat\Kernel\Config
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredKeys = [
|
||||
'corp_id',
|
||||
'suite_id',
|
||||
'provider_secret',
|
||||
'suite_secret',
|
||||
'token',
|
||||
'aes_key',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork\Contracts;
|
||||
|
||||
interface Account
|
||||
{
|
||||
public function getCorpId(): string;
|
||||
|
||||
public function getProviderSecret(): string;
|
||||
|
||||
public function getSuiteId(): string;
|
||||
|
||||
public function getSuiteSecret(): string;
|
||||
|
||||
public function getToken(): string;
|
||||
|
||||
public function getAesKey(): string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork\Contracts;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\AccessToken;
|
||||
use EasyWeChat\Kernel\Contracts\Config;
|
||||
use EasyWeChat\Kernel\Contracts\Server;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
interface Application
|
||||
{
|
||||
public function getAccount(): Account;
|
||||
|
||||
public function getEncryptor(): Encryptor;
|
||||
|
||||
public function getSuiteEncryptor(): Encryptor;
|
||||
|
||||
public function getServer(): Server;
|
||||
|
||||
public function getRequest(): ServerRequestInterface;
|
||||
|
||||
public function getClient(): AccessTokenAwareClient;
|
||||
|
||||
public function getHttpClient(): HttpClientInterface;
|
||||
|
||||
public function getConfig(): Config;
|
||||
|
||||
public function getProviderAccessToken(): AccessToken;
|
||||
|
||||
public function getCache(): CacheInterface;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork\Contracts;
|
||||
|
||||
interface SuiteTicket
|
||||
{
|
||||
public function getTicket(): string;
|
||||
|
||||
public function setTicket(string $ticket): static;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
|
||||
class Encryptor extends \EasyWeChat\Kernel\Encryptor
|
||||
{
|
||||
#[Pure]
|
||||
public function __construct(string $corpId, string $token, string $aesKey)
|
||||
{
|
||||
parent::__construct($corpId, $token, $aesKey, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
/**
|
||||
* @property string $InfoType
|
||||
* @property string $ChangeType
|
||||
* @property string $SuiteTicket
|
||||
* @property string $SuiteId
|
||||
*/
|
||||
class Message extends \EasyWeChat\Kernel\Message
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\RefreshableAccessToken as RefreshableAccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use function intval;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use const JSON_UNESCAPED_UNICODE;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class ProviderAccessToken implements RefreshableAccessTokenInterface
|
||||
{
|
||||
protected HttpClientInterface $httpClient;
|
||||
|
||||
protected CacheInterface $cache;
|
||||
|
||||
public function __construct(
|
||||
protected string $corpId,
|
||||
protected string $providerSecret,
|
||||
protected ?string $key = null,
|
||||
?CacheInterface $cache = null,
|
||||
?HttpClientInterface $httpClient = null,
|
||||
) {
|
||||
$this->httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']);
|
||||
$this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key ?? $this->key = \sprintf('open_work.access_token.%s.%s', $this->corpId, $this->providerSecret);
|
||||
}
|
||||
|
||||
public function setKey(string $key): static
|
||||
{
|
||||
$this->key = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
$token = $this->cache->get($this->getKey());
|
||||
|
||||
if ((bool) $token && \is_string($token)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
*/
|
||||
#[ArrayShape(['provider_access_token' => 'string'])]
|
||||
public function toQuery(): array
|
||||
{
|
||||
return ['provider_access_token' => $this->getToken()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
*/
|
||||
public function refresh(): string
|
||||
{
|
||||
$response = $this->httpClient->request('POST', 'cgi-bin/service/get_provider_token', [
|
||||
'json' => [
|
||||
'corpid' => $this->corpId,
|
||||
'provider_secret' => $this->providerSecret,
|
||||
],
|
||||
])->toArray(false);
|
||||
|
||||
if (empty($response['provider_access_token'])) {
|
||||
throw new HttpException('Failed to get provider_access_token: '.\json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
$this->cache->set($this->getKey(), $response['provider_access_token'], intval($response['expires_in']));
|
||||
|
||||
return $response['provider_access_token'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use Closure;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use EasyWeChat\Kernel\ServerResponse;
|
||||
use EasyWeChat\Kernel\Traits\DecryptXmlMessage;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHandlers;
|
||||
use EasyWeChat\Kernel\Traits\RespondXmlMessage;
|
||||
use function func_get_args;
|
||||
use Nyholm\Psr7\Response;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class Server implements ServerInterface
|
||||
{
|
||||
use InteractWithHandlers;
|
||||
use RespondXmlMessage;
|
||||
use DecryptXmlMessage;
|
||||
|
||||
protected ServerRequestInterface $request;
|
||||
|
||||
protected Closure|null $defaultSuiteTicketHandler = null;
|
||||
|
||||
/**
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function __construct(
|
||||
protected Encryptor $encryptor,
|
||||
protected Encryptor $providerEncryptor,
|
||||
?ServerRequestInterface $request = null,
|
||||
) {
|
||||
$this->request = $request ?? RequestUtil::createDefaultServerRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
* @throws BadRequestException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function serve(): ResponseInterface
|
||||
{
|
||||
$query = $this->request->getQueryParams();
|
||||
|
||||
if ((bool) ($str = $query['echostr'] ?? '')) {
|
||||
$response = $this->providerEncryptor->decrypt(
|
||||
$str,
|
||||
$query['msg_signature'] ?? '',
|
||||
$query['nonce'] ?? '',
|
||||
$query['timestamp'] ?? ''
|
||||
);
|
||||
|
||||
return new Response(200, [], $response);
|
||||
}
|
||||
|
||||
$message = $this->getRequestMessage($this->request);
|
||||
|
||||
$this->prepend($this->decryptRequestMessage());
|
||||
|
||||
$response = $this->handle(new Response(200, [], 'success'), $message);
|
||||
|
||||
if (! ($response instanceof ResponseInterface)) {
|
||||
$response = $this->transformToReply($response, $message, $this->encryptor);
|
||||
}
|
||||
|
||||
return ServerResponse::make($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withDefaultSuiteTicketHandler(callable $handler): void
|
||||
{
|
||||
$this->defaultSuiteTicketHandler = fn (): mixed => $handler(...func_get_args());
|
||||
$this->handleSuiteTicketRefreshed($this->defaultSuiteTicketHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handleSuiteTicketRefreshed(callable $handler): static
|
||||
{
|
||||
if ($this->defaultSuiteTicketHandler) {
|
||||
$this->withoutHandler($this->defaultSuiteTicketHandler);
|
||||
}
|
||||
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'suite_ticket' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleAuthCreated(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'create_auth' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleAuthChanged(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_auth' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleAuthCancelled(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'cancel_auth' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleUserCreated(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_contact' && $message->ChangeType === 'create_user' ? $handler(
|
||||
$message,
|
||||
$next
|
||||
) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleUserUpdated(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_contact' && $message->ChangeType === 'update_user' ? $handler(
|
||||
$message,
|
||||
$next
|
||||
) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleUserDeleted(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_contact' && $message->ChangeType === 'delete_user' ? $handler(
|
||||
$message,
|
||||
$next
|
||||
) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handlePartyCreated(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_contact' && $message->ChangeType === 'create_party' ? $handler(
|
||||
$message,
|
||||
$next
|
||||
) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handlePartyUpdated(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_contact' && $message->ChangeType === 'update_party' ? $handler(
|
||||
$message,
|
||||
$next
|
||||
) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handlePartyDeleted(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_contact' && $message->ChangeType === 'delete_party' ? $handler(
|
||||
$message,
|
||||
$next
|
||||
) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleUserTagUpdated(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'change_contact' && $message->ChangeType === 'update_tag' ? $handler(
|
||||
$message,
|
||||
$next
|
||||
) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function handleShareAgentChanged(callable $handler): static
|
||||
{
|
||||
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
|
||||
return $message->InfoType === 'share_agent_change' ? $handler($message, $next) : $next($message);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function decryptRequestMessage(): Closure
|
||||
{
|
||||
$query = $this->request->getQueryParams();
|
||||
|
||||
return function (Message $message, Closure $next) use ($query): mixed {
|
||||
$this->decryptMessage(
|
||||
$message,
|
||||
$this->encryptor,
|
||||
$query['msg_signature'],
|
||||
$query['timestamp'],
|
||||
$query['nonce']
|
||||
);
|
||||
|
||||
return $next($message);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
public function getRequestMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message
|
||||
{
|
||||
return Message::createFromRequest($request ?? $this->request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function getDecryptedMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message
|
||||
{
|
||||
$request = $request ?? $this->request;
|
||||
$message = $this->getRequestMessage($request);
|
||||
$query = $request->getQueryParams();
|
||||
|
||||
return $this->decryptMessage(
|
||||
message: $message,
|
||||
encryptor: $this->encryptor,
|
||||
signature: $query['msg_signature'] ?? '',
|
||||
timestamp: $query['timestamp'] ?? '',
|
||||
nonce: $query['nonce'] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use function abs;
|
||||
use EasyWeChat\Kernel\Contracts\RefreshableAccessToken as RefreshableAccessTokenInterface;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use EasyWeChat\OpenWork\Contracts\SuiteTicket as SuiteTicketInterface;
|
||||
use function intval;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use function json_encode;
|
||||
use const JSON_UNESCAPED_UNICODE;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class SuiteAccessToken implements RefreshableAccessTokenInterface
|
||||
{
|
||||
protected HttpClientInterface $httpClient;
|
||||
|
||||
protected CacheInterface $cache;
|
||||
|
||||
public function __construct(
|
||||
protected string $suiteId,
|
||||
protected string $suiteSecret,
|
||||
protected ?SuiteTicketInterface $suiteTicket = null,
|
||||
protected ?string $key = null,
|
||||
?CacheInterface $cache = null,
|
||||
?HttpClientInterface $httpClient = null,
|
||||
) {
|
||||
$this->httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']);
|
||||
$this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500));
|
||||
$this->suiteTicket ??= new SuiteTicket($this->suiteId, $this->cache);
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key ?? $this->key = \sprintf('open_work.suite_access_token.%s.%s', $this->suiteId, $this->suiteSecret);
|
||||
}
|
||||
|
||||
public function setKey(string $key): static
|
||||
{
|
||||
$this->key = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface|\Psr\SimpleCache\InvalidArgumentException
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
$token = $this->cache->get($this->getKey());
|
||||
|
||||
if ((bool) $token && \is_string($token)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
*/
|
||||
#[ArrayShape(['suite_access_token' => 'string'])]
|
||||
public function toQuery(): array
|
||||
{
|
||||
return ['suite_access_token' => $this->getToken()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
*/
|
||||
public function refresh(): string
|
||||
{
|
||||
$response = $this->httpClient->request('POST', 'cgi-bin/service/get_suite_token', [
|
||||
'json' => [
|
||||
'suite_id' => $this->suiteId,
|
||||
'suite_secret' => $this->suiteSecret,
|
||||
'suite_ticket' => $this->suiteTicket?->getTicket(),
|
||||
],
|
||||
])->toArray(false);
|
||||
|
||||
if (empty($response['suite_access_token'])) {
|
||||
throw new HttpException('Failed to get suite_access_token: '.json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
$this->cache->set(
|
||||
$this->getKey(),
|
||||
$response['suite_access_token'],
|
||||
abs(intval($response['expires_in']) - 100)
|
||||
);
|
||||
|
||||
return $response['suite_access_token'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use EasyWeChat\Kernel\Encryptor;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
|
||||
class SuiteEncryptor extends Encryptor
|
||||
{
|
||||
#[Pure]
|
||||
public function __construct(string $suiteId, string $token, string $aesKey)
|
||||
{
|
||||
parent::__construct($suiteId, $token, $aesKey, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\OpenWork;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\OpenWork\Contracts\SuiteTicket as SuiteTicketInterface;
|
||||
use function is_string;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use function sprintf;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
|
||||
class SuiteTicket implements SuiteTicketInterface
|
||||
{
|
||||
protected CacheInterface $cache;
|
||||
|
||||
public function __construct(
|
||||
protected string $suiteId,
|
||||
?CacheInterface $cache = null,
|
||||
protected ?string $key = null,
|
||||
) {
|
||||
$this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key ?? $this->key = sprintf('open_work.suite_ticket.%s', $this->suiteId);
|
||||
}
|
||||
|
||||
public function setKey(string $key): static
|
||||
{
|
||||
$this->key = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function setTicket(string $ticket): static
|
||||
{
|
||||
$this->cache->set($this->getKey(), $ticket, 6000);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getTicket(): string
|
||||
{
|
||||
$ticket = $this->cache->get($this->getKey());
|
||||
|
||||
if (! $ticket || ! is_string($ticket)) {
|
||||
throw new RuntimeException('No suite_ticket found.');
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Pay;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\Config as ConfigInterface;
|
||||
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
|
||||
use EasyWeChat\Kernel\Support\PrivateKey;
|
||||
use EasyWeChat\Kernel\Support\PublicKey;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithConfig;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithHttpClient;
|
||||
use EasyWeChat\Kernel\Traits\InteractWithServerRequest;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class Application implements \EasyWeChat\Pay\Contracts\Application
|
||||
{
|
||||
use InteractWithConfig;
|
||||
use InteractWithHttpClient;
|
||||
use InteractWithServerRequest;
|
||||
|
||||
protected ?ServerInterface $server = null;
|
||||
|
||||
protected ?HttpClientInterface $client = null;
|
||||
|
||||
protected ?Merchant $merchant = null;
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
*/
|
||||
public function getUtils(): Utils
|
||||
{
|
||||
return new Utils($this->getMerchant());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
*/
|
||||
public function getMerchant(): Merchant
|
||||
{
|
||||
if (! $this->merchant) {
|
||||
$this->merchant = new Merchant(
|
||||
mchId: $this->config['mch_id'], /** @phpstan-ignore-line */
|
||||
privateKey: new PrivateKey((string) $this->config['private_key']), /** @phpstan-ignore-line */
|
||||
certificate: new PublicKey((string) $this->config['certificate']), /** @phpstan-ignore-line */
|
||||
secretKey: (string) $this->config['secret_key'], /** @phpstan-ignore-line */
|
||||
v2SecretKey: (string) $this->config['v2_secret_key'], /** @phpstan-ignore-line */
|
||||
platformCerts: $this->config->has('platform_certs') ? (array) $this->config['platform_certs'] : [],/** @phpstan-ignore-line */
|
||||
);
|
||||
}
|
||||
|
||||
return $this->merchant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ReflectionException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function getServer(): Server|ServerInterface
|
||||
{
|
||||
if (! $this->server) {
|
||||
$this->server = new Server(
|
||||
merchant: $this->getMerchant(),
|
||||
request: $this->getRequest(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
public function setServer(ServerInterface $server): static
|
||||
{
|
||||
$this->server = $server;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setConfig(ConfigInterface $config): static
|
||||
{
|
||||
$this->config = $config;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConfig(): ConfigInterface
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
*/
|
||||
public function getClient(): HttpClientInterface
|
||||
{
|
||||
return $this->client ?? $this->client = (new Client(
|
||||
$this->getMerchant(),
|
||||
$this->getHttpClient(),
|
||||
(array) $this->config->get('http', [])
|
||||
))->setPresets($this->config->all());
|
||||
}
|
||||
|
||||
public function setClient(HttpClientInterface $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Pay;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
|
||||
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
|
||||
use EasyWeChat\Kernel\HttpClient\HttpClientMethods;
|
||||
use EasyWeChat\Kernel\HttpClient\RequestUtil;
|
||||
use EasyWeChat\Kernel\HttpClient\RequestWithPresets;
|
||||
use EasyWeChat\Kernel\HttpClient\Response;
|
||||
use EasyWeChat\Kernel\Support\PrivateKey;
|
||||
use EasyWeChat\Kernel\Support\PublicKey;
|
||||
use EasyWeChat\Kernel\Support\UserAgent;
|
||||
use EasyWeChat\Kernel\Support\Xml;
|
||||
use EasyWeChat\Kernel\Traits\MockableHttpClient;
|
||||
use Exception;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use Mockery;
|
||||
use Mockery\Mock;
|
||||
use Nyholm\Psr7\Uri;
|
||||
use function str_starts_with;
|
||||
use Symfony\Component\HttpClient\DecoratorTrait;
|
||||
use Symfony\Component\HttpClient\HttpClient as SymfonyHttpClient;
|
||||
use Symfony\Component\HttpClient\HttpClientTrait;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* @method ResponseInterface get(string $uri, array $options = [])
|
||||
* @method ResponseInterface post(string $uri, array $options = [])
|
||||
* @method ResponseInterface put(string $uri, array $options = [])
|
||||
* @method ResponseInterface patch(string $uri, array $options = [])
|
||||
* @method ResponseInterface delete(string $uri, array $options = [])
|
||||
* @method HttpClientInterface withMchId(string $value = null)
|
||||
* @method HttpClientInterface withMchIdAs(string $key)
|
||||
*/
|
||||
class Client implements HttpClientInterface
|
||||
{
|
||||
use DecoratorTrait {
|
||||
DecoratorTrait::withOptions insteadof HttpClientTrait;
|
||||
}
|
||||
use HttpClientTrait;
|
||||
use HttpClientMethods;
|
||||
use MockableHttpClient;
|
||||
use RequestWithPresets;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $defaultOptions = [
|
||||
'base_uri' => 'https://api.mch.weixin.qq.com/',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
];
|
||||
|
||||
public const V3_URI_PREFIXES = [
|
||||
'/v3/',
|
||||
'/sandbox/v3/',
|
||||
'/hk/v3/',
|
||||
'/global/v3/',
|
||||
];
|
||||
|
||||
protected bool $throw = true;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $defaultOptions
|
||||
*/
|
||||
public function __construct(
|
||||
protected Merchant $merchant,
|
||||
?HttpClientInterface $client = null,
|
||||
array $defaultOptions = []
|
||||
) {
|
||||
$this->throw = (bool) ($defaultOptions['throw'] ?? true);
|
||||
|
||||
$this->defaultOptions = array_merge(self::OPTIONS_DEFAULTS, $this->defaultOptions);
|
||||
|
||||
if (! empty($defaultOptions)) {
|
||||
$defaultOptions = RequestUtil::formatDefaultOptions($this->defaultOptions);
|
||||
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
|
||||
}
|
||||
|
||||
$this->client = ($client ?? SymfonyHttpClient::create())->withOptions($this->defaultOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array|mixed> $options
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws Exception
|
||||
*/
|
||||
public function request(string $method, string $url, array $options = []): ResponseInterface
|
||||
{
|
||||
if (empty($options['headers'])) {
|
||||
$options['headers'] = [];
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$options['headers']['User-Agent'] = UserAgent::create();
|
||||
|
||||
if ($this->isV3Request($url)) {
|
||||
[, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
|
||||
$options['headers']['Authorization'] = $this->createSignature($method, $url, $options);
|
||||
} else {
|
||||
// v2 全部为 xml 请求
|
||||
if (! empty($options['xml'])) {
|
||||
if (is_array($options['xml'])) {
|
||||
$options['xml'] = Xml::build($this->attachLegacySignature($options['xml']));
|
||||
}
|
||||
|
||||
if (! is_string($options['xml'])) {
|
||||
throw new \InvalidArgumentException('The `xml` option must be a string or array.');
|
||||
}
|
||||
|
||||
$options['body'] = $options['xml'];
|
||||
unset($options['xml']);
|
||||
}
|
||||
|
||||
if (! empty($options['body']) && is_array($options['body'])) {
|
||||
$options['body'] = Xml::build($this->attachLegacySignature($options['body']));
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (! isset($options['headers']['Content-Type']) && ! isset($options['headers']['content-type'])) {
|
||||
$options['headers']['Content-Type'] = 'text/xml'; /** @phpstan-ignore-line */
|
||||
}
|
||||
}
|
||||
|
||||
// 合并通过 withHeader 和 withHeaders 设置的信息
|
||||
if (! empty($this->prependHeaders)) {
|
||||
$options['headers'] = array_merge($this->prependHeaders, $options['headers'] ?? []);
|
||||
}
|
||||
|
||||
return new Response($this->client->request($method, $url, $options), throw: $this->throw);
|
||||
}
|
||||
|
||||
protected function isV3Request(string $url): bool
|
||||
{
|
||||
$uri = new Uri($url);
|
||||
|
||||
foreach (self::V3_URI_PREFIXES as $prefix) {
|
||||
if (str_starts_with('/'.ltrim($uri->getPath(), '/'), $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $arguments
|
||||
*/
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
if (\str_starts_with($name, 'with')) {
|
||||
return $this->handleMagicWithCall($name, $arguments[0] ?? null);
|
||||
}
|
||||
|
||||
return $this->client->$name(...$arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function createSignature(string $method, string $url, array $options): string
|
||||
{
|
||||
return (new Signature($this->merchant))->createHeader($method, $url, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function attachLegacySignature(array $body): array
|
||||
{
|
||||
return (new LegacySignature($this->merchant))->sign($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
* @throws InvalidConfigException
|
||||
*/
|
||||
public static function createMockClient(MockHttpClient $mockHttpClient): HttpClientInterface|Mock
|
||||
{
|
||||
$mockMerchant = new Merchant(
|
||||
'mch_id',
|
||||
/** @phpstan-ignore-next-line */
|
||||
Mockery::mock(PrivateKey::class),
|
||||
/** @phpstan-ignore-next-line */
|
||||
Mockery::mock(PublicKey::class),
|
||||
'mock-v3-key',
|
||||
'mock-v2-key',
|
||||
);
|
||||
|
||||
return Mockery::mock(static::class, [$mockMerchant, $mockHttpClient])
|
||||
->shouldAllowMockingProtectedMethods()
|
||||
->makePartial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Pay;
|
||||
|
||||
class Config extends \EasyWeChat\Kernel\Config
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredKeys = [
|
||||
'mch_id',
|
||||
'secret_key',
|
||||
'private_key',
|
||||
'certificate',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace EasyWeChat\Pay\Contracts;
|
||||
|
||||
use EasyWeChat\Kernel\Contracts\Config;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
interface Application
|
||||
{
|
||||
public function getMerchant(): Merchant;
|
||||
|
||||
public function getConfig(): Config;
|
||||
|
||||
public function getHttpClient(): HttpClientInterface;
|
||||
|
||||
public function getClient(): HttpClientInterface;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user