初始化仓库

This commit is contained in:
wangxiaowei
2025-04-22 14:09:52 +08:00
commit 8b100110bb
5155 changed files with 664201 additions and 0 deletions

View File

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

View File

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

View File

@ -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', [])
);
}
}

View File

@ -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',
];
}

View File

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

View File

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

View File

@ -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&timestamp=%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);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\OfficialAccount;
/**
* @property string $Event
* @property string $MsgType
*/
class Message extends \EasyWeChat\Kernel\Message
{
//
}

View File

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

View File

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