├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── composer.json └── src ├── Kernel ├── Config.php ├── Contracts │ ├── AccessToken.php │ ├── AccessTokenAwareHttpClient.php │ ├── Aes.php │ ├── Arrayable.php │ ├── Config.php │ ├── JsApiTicket.php │ ├── Jsonable.php │ ├── RefreshableAccessToken.php │ ├── RefreshableJsApiTicket.php │ └── Server.php ├── Encryptor.php ├── Exceptions │ ├── BadMethodCallException.php │ ├── BadRequestException.php │ ├── BadResponseException.php │ ├── DecryptException.php │ ├── Exception.php │ ├── HttpException.php │ ├── InvalidArgumentException.php │ ├── InvalidConfigException.php │ ├── RuntimeException.php │ └── ServiceNotFoundException.php ├── Form │ ├── File.php │ └── Form.php ├── HttpClient │ ├── AccessTokenAwareClient.php │ ├── AccessTokenExpiredRetryStrategy.php │ ├── HttpClientMethods.php │ ├── RequestUtil.php │ ├── RequestWithPresets.php │ ├── Response.php │ ├── RetryableClient.php │ └── ScopingHttpClient.php ├── Message.php ├── ServerResponse.php ├── Support │ ├── AesCbc.php │ ├── AesEcb.php │ ├── AesGcm.php │ ├── Arr.php │ ├── Pkcs7.php │ ├── PrivateKey.php │ ├── PublicKey.php │ ├── Str.php │ ├── UserAgent.php │ └── Xml.php └── Traits │ ├── DecryptXmlMessage.php │ ├── HasAttributes.php │ ├── InteractWithCache.php │ ├── InteractWithClient.php │ ├── InteractWithConfig.php │ ├── InteractWithHandlers.php │ ├── InteractWithHttpClient.php │ ├── InteractWithServerRequest.php │ ├── MockableHttpClient.php │ └── RespondXmlMessage.php ├── MiniApp ├── AccessToken.php ├── Account.php ├── Application.php ├── Contracts │ ├── Account.php │ └── Application.php ├── Decryptor.php ├── Server.php └── Utils.php ├── OfficialAccount ├── AccessToken.php ├── Account.php ├── Application.php ├── Config.php ├── Contracts │ ├── Account.php │ └── Application.php ├── JsApiTicket.php ├── Message.php ├── Server.php └── Utils.php ├── OpenPlatform ├── Account.php ├── Application.php ├── Authorization.php ├── AuthorizerAccessToken.php ├── ComponentAccessToken.php ├── Config.php ├── Contracts │ ├── Account.php │ ├── Application.php │ └── VerifyTicket.php ├── Message.php ├── Server.php └── VerifyTicket.php ├── OpenWork ├── Account.php ├── Application.php ├── Authorization.php ├── AuthorizerAccessToken.php ├── Config.php ├── Contracts │ ├── Account.php │ ├── Application.php │ └── SuiteTicket.php ├── Encryptor.php ├── JsApiTicket.php ├── Message.php ├── ProviderAccessToken.php ├── Server.php ├── SuiteAccessToken.php ├── SuiteEncryptor.php └── SuiteTicket.php ├── Pay ├── Application.php ├── Client.php ├── Config.php ├── Contracts │ ├── Application.php │ ├── Merchant.php │ ├── ResponseValidator.php │ └── Validator.php ├── Exceptions │ ├── EncryptionFailureException.php │ └── InvalidSignatureException.php ├── LegacySignature.php ├── Merchant.php ├── Message.php ├── ResponseValidator.php ├── Server.php ├── Signature.php ├── URLSchemeBuilder.php ├── Utils.php └── Validator.php └── Work ├── AccessToken.php ├── Account.php ├── Application.php ├── Config.php ├── Contracts ├── Account.php └── Application.php ├── Encryptor.php ├── JsApiTicket.php ├── Message.php ├── Server.php └── Utils.php /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | ## Introduction 4 | 5 | First, thank you for considering contributing to wechat! It's people like you that make the open source community such a great community! 😊 6 | 7 | We welcome any type of contribution, not only code. You can help with 8 | - **QA**: file bug reports, the more details you can give the better (e.g. screenshots with the console open) 9 | - **Marketing**: writing blog posts, howto's, printing stickers, ... 10 | - **Community**: presenting the project at meetups, organizing a dedicated meetup for the local community, ... 11 | - **Code**: take a look at the [open issues](issues). Even if you can't write code, commenting on them, showing that you care about a given issue matters. It helps us triage them. 12 | - **Money**: we welcome financial contributions in full transparency on our [open collective](https://opencollective.com/wechat). 13 | 14 | ## Your First Contribution 15 | 16 | Working on your first Pull Request? You can learn how from this *free* series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 17 | 18 | ## Submitting code 19 | 20 | Any code change should be submitted as a pull request. The description should explain what the code does and give steps to execute it. The pull request should also contain tests. 21 | 22 | ## Code review process 23 | 24 | The bigger the pull request, the longer it will take to review and merge. Try to break down large pull requests in smaller chunks that are easier to review and merge. 25 | It is also always helpful to have some context for your pull request. What was the purpose? Why does it matter to you? 26 | 27 | ## Financial contributions 28 | 29 | We also welcome financial contributions in full transparency on our [open collective](https://opencollective.com/wechat). 30 | Anyone can file an expense. If the expense makes sense for the development of the community, it will be "merged" in the ledger of our open collective by the core contributors and the person who filed the expense will be reimbursed. 31 | 32 | ## Questions 33 | 34 | If you have any questions, create an [issue](issue) (protip: do a quick search first to see if someone else didn't ask the same question before!). 35 | You can also reach us at hello@wechat.opencollective.com. 36 | 37 | ## Credits 38 | 39 | ### Contributors 40 | 41 | Thank you to all the people who have already contributed to wechat! 42 | 43 | 44 | 45 | ### Backers 46 | 47 | Thank you to all our backers! [[Become a backer](https://opencollective.com/wechat#backer)] 48 | 49 | 50 | 51 | 52 | ### Sponsors 53 | 54 | Thank you to all our sponsors! (please ask your company to also support this open source project by [becoming a sponsor](https://opencollective.com/wechat#sponsor)) 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) overtrue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [EasyWeChat](https://easywechat.com) 2 | 3 | 📦 一个 PHP 微信开发 SDK,开源 SaaS 平台提供商 [微擎](https://www.w7.cc/) 旗下开源产品。 4 | 5 | [![Test Status](https://github.com/w7corp/easywechat/workflows/Test/badge.svg)](https://github.com/w7corp/easywechat/actions) 6 | [![Lint Status](https://github.com/w7corp/easywechat/workflows/Lint/badge.svg)](https://github.com/w7corp/easywechat/actions) 7 | [![Latest Stable Version](https://poser.pugx.org/w7corp/easywechat/v/stable.svg)](https://packagist.org/packages/w7corp/easywechat) 8 | [![Latest Unstable Version](https://poser.pugx.org/w7corp/easywechat/v/unstable.svg)](https://packagist.org/packages/w7corp/easywechat) 9 | [![Total Downloads](https://poser.pugx.org/w7corp/easywechat/downloads)](https://packagist.org/packages/w7corp/easywechat) 10 | [![License](https://poser.pugx.org/w7corp/easywechat/license)](https://packagist.org/packages/w7corp/easywechat) 11 | 12 | ## 环境需求 13 | 14 | - PHP >= 8.0.2 15 | - [Composer](https://getcomposer.org/) >= 2.0 16 | 17 | ## 安装 18 | 19 | ```bash 20 | composer require w7corp/easywechat 21 | ``` 22 | 23 | ## 使用示例 24 | 25 | 基本使用(以公众号服务端为例): 26 | 27 | ```php 28 | 'wx3cf0f39249eb0exxx', 34 | 'secret' => 'f1c242f4f28f735d4687abb469072xxx', 35 | 'aes_key' => 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', 36 | 'token' => 'easywechat', 37 | ]; 38 | 39 | $app = new Application($config); 40 | 41 | $server = $app->getServer(); 42 | 43 | $server->with(fn() => "您好!EasyWeChat!"); 44 | 45 | $response = $server->serve(); 46 | ``` 47 | 48 | ## 文档和链接 49 | 50 | [官网](https://easywechat.com) · [讨论](https://github.com/w7corp/easywechat/discussions) · [更新策略](https://github.com/w7corp/easywechat/security/policy) 51 | 52 | ## :heart: 支持我 53 | 54 | 如果你喜欢我的项目并想支持它,[点击这里 :heart:](https://github.com/sponsors/overtrue) 55 | 56 | ## 由 JetBrains 赞助 57 | 58 | 非常感谢 Jetbrains 为我提供的 IDE 开源许可,让我完成此项目和其他开源项目上的开发工作。 59 | 60 | [![](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://www.jetbrains.com/?from=https://github.com/overtrue) 61 | 62 | ## 可爱的贡献者们 63 | 64 | 65 | 66 | ## License 67 | 68 | MIT 69 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # 更新策略 Security Policy 2 | 3 | ## 支持的版本 Supported Versions 4 | 5 | | Version | 状态 | 6 | | ------- | ------------------ | 7 | | 6.x | ✅ 维护中| 8 | | 5.x | 🚨 仅安全修复,不建议旧项目升级至 6.x | 9 | | 4.x | ❌ 不再维护,建议升级至 5.x(仅 OAuth 方法有变化) | 10 | | 3.x | ❌ 不再维护 | 11 | | 2.x | ❌ 不再维护 | 12 | | 1.x | ❌ 不再维护 | 13 | 14 | ## 漏洞报告 Reporting a Vulnerability 15 | 16 | 如果你发现了安全漏洞,请发邮件给我 `anzhengchao@gmail.com`,或者提 issue。 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "w7corp/easywechat", 3 | "description": "微信SDK", 4 | "keywords": [ 5 | "easywechat", 6 | "wechat", 7 | "weixin", 8 | "weixin-sdk", 9 | "sdk" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "overtrue", 15 | "email": "anzhengchao@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.0.2", 20 | "ext-fileinfo": "*", 21 | "ext-openssl": "*", 22 | "ext-simplexml": "*", 23 | "ext-libxml": "*", 24 | "ext-curl": "*", 25 | "nyholm/psr7": "^1.5", 26 | "nyholm/psr7-server": "^1.0", 27 | "overtrue/socialite": "^3.5.4|^4.0.1", 28 | "psr/simple-cache": "^1.0|^2.0|^3.0", 29 | "psr/http-client": "^1.0", 30 | "symfony/cache": "^5.4|^6.0|^7.0", 31 | "symfony/http-foundation": "^5.4|^6.0|^7.0", 32 | "symfony/psr-http-message-bridge": "^2.1.2|^6.4.0|^7.1", 33 | "symfony/http-client": "^5.4|^6.0|^7.0", 34 | "symfony/mime": "^5.4|^6.0|^7.0", 35 | "symfony/polyfill-php81": "^1.25", 36 | "thenorthmemory/xml": "^1.0" 37 | }, 38 | "require-dev": { 39 | "mikey179/vfsstream": "^1.6", 40 | "mockery/mockery": "^1.4.4", 41 | "phpstan/phpstan": "^1.0 | ^2", 42 | "phpunit/phpunit": "^9.5", 43 | "symfony/var-dumper": "^5.2|^6|^7", 44 | "jetbrains/phpstorm-attributes": "^1.0", 45 | "laravel/pint": "^1.2" 46 | }, 47 | "autoload": { 48 | "psr-4": { 49 | "EasyWeChat\\": "src/" 50 | } 51 | }, 52 | "autoload-dev": { 53 | "psr-4": { 54 | "EasyWeChat\\Tests\\": "tests/" 55 | } 56 | }, 57 | "scripts": { 58 | "post-merge": "composer install", 59 | "phpstan": "phpstan analyse --memory-limit=-1", 60 | "check-style": "vendor/bin/pint --test", 61 | "fix-style": "vendor/bin/pint", 62 | "test": "phpunit --colors" 63 | }, 64 | "conflict": { 65 | "overtrue/wechat": "*" 66 | }, 67 | "config": { 68 | "allow-plugins": { 69 | "composer/package-versions-deprecated": true, 70 | "php-http/discovery": true 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Kernel/Config.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class Config implements ArrayAccess, ConfigInterface 19 | { 20 | /** 21 | * @var array 22 | */ 23 | protected array $requiredKeys = []; 24 | 25 | /** 26 | * @param array $items 27 | * 28 | * @throws InvalidArgumentException 29 | */ 30 | public function __construct( 31 | protected array $items = [], 32 | ) { 33 | $this->checkMissingKeys(); 34 | } 35 | 36 | #[Pure] 37 | public function has(string $key): bool 38 | { 39 | return Arr::has($this->items, $key); 40 | } 41 | 42 | /** 43 | * @param array|string $key 44 | */ 45 | #[Pure] 46 | public function get(array|string $key, mixed $default = null): mixed 47 | { 48 | if (is_array($key)) { 49 | return $this->getMany($key); 50 | } 51 | 52 | return Arr::get($this->items, $key, $default); 53 | } 54 | 55 | /** 56 | * @param array $keys 57 | * @return array 58 | */ 59 | #[Pure] 60 | public function getMany(array $keys): array 61 | { 62 | $config = []; 63 | 64 | foreach ($keys as $key => $default) { 65 | if (is_numeric($key)) { 66 | [$key, $default] = [$default, null]; 67 | } 68 | 69 | $config[$key] = Arr::get($this->items, $key, $default); 70 | } 71 | 72 | return $config; 73 | } 74 | 75 | public function set(string $key, mixed $value = null): void 76 | { 77 | Arr::set($this->items, $key, $value); 78 | } 79 | 80 | /** 81 | * @return array 82 | */ 83 | public function all(): array 84 | { 85 | return $this->items; 86 | } 87 | 88 | #[Pure] 89 | public function offsetExists(mixed $offset): bool 90 | { 91 | return $this->has(strval($offset)); 92 | } 93 | 94 | #[Pure] 95 | public function offsetGet(mixed $offset): mixed 96 | { 97 | return $this->get(strval($offset)); 98 | } 99 | 100 | public function offsetSet(mixed $offset, mixed $value): void 101 | { 102 | $this->set(strval($offset), $value); 103 | } 104 | 105 | public function offsetUnset(mixed $offset): void 106 | { 107 | $this->set(strval($offset), null); 108 | } 109 | 110 | /** 111 | * @throws InvalidArgumentException 112 | */ 113 | public function checkMissingKeys(): bool 114 | { 115 | if (empty($this->requiredKeys)) { 116 | return true; 117 | } 118 | 119 | $missingKeys = []; 120 | 121 | foreach ($this->requiredKeys as $key) { 122 | if (! $this->has($key)) { 123 | $missingKeys[] = $key; 124 | } 125 | } 126 | 127 | if (! empty($missingKeys)) { 128 | throw new InvalidArgumentException(sprintf("\"%s\" cannot be empty.\r\n", implode(',', $missingKeys))); 129 | } 130 | 131 | return true; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Kernel/Contracts/AccessToken.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function toQuery(): array; 15 | } 16 | -------------------------------------------------------------------------------- /src/Kernel/Contracts/AccessTokenAwareHttpClient.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function toArray(): array; 13 | } 14 | -------------------------------------------------------------------------------- /src/Kernel/Contracts/Config.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface Config extends ArrayAccess 13 | { 14 | /** 15 | * @return array 16 | */ 17 | public function all(): array; 18 | 19 | public function has(string $key): bool; 20 | 21 | public function set(string $key, mixed $value = null): void; 22 | 23 | /** 24 | * @param array|string $key 25 | */ 26 | public function get(array|string $key, mixed $default = null): mixed; 27 | } 28 | -------------------------------------------------------------------------------- /src/Kernel/Contracts/JsApiTicket.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function configSignature(string $url, string $nonce, int $timestamp): array; 15 | } 16 | -------------------------------------------------------------------------------- /src/Kernel/Contracts/Jsonable.php: -------------------------------------------------------------------------------- 1 | appId = $appId; 76 | $this->token = $token; 77 | $this->receiveId = $receiveId; 78 | $this->aesKey = base64_decode($aesKey.'=', true) ?: ''; 79 | } 80 | 81 | public function getToken(): string 82 | { 83 | return $this->token; 84 | } 85 | 86 | /** 87 | * @throws RuntimeException 88 | * @throws Exception 89 | */ 90 | public function encrypt(string $plaintext, ?string $nonce = null, int|string|null $timestamp = null): string 91 | { 92 | return $this->encryptAsXml($plaintext, $nonce, $timestamp); 93 | } 94 | 95 | public function encryptAsXml(string $plaintext, ?string $nonce = null, int|string|null $timestamp = null): string 96 | { 97 | $encrypted = $this->encryptAsArray($plaintext, $nonce, $timestamp); 98 | 99 | $response = [ 100 | 'Encrypt' => $encrypted['ciphertext'], 101 | 'MsgSignature' => $encrypted['signature'], 102 | 'TimeStamp' => $encrypted['timestamp'], 103 | 'Nonce' => $encrypted['nonce'], 104 | ]; 105 | 106 | return Xml::build($response); 107 | } 108 | 109 | /** 110 | * @throws RuntimeException 111 | */ 112 | public function encryptAsArray(string $plaintext, ?string $nonce = null, int|string|null $timestamp = null): array 113 | { 114 | try { 115 | $plaintext = Pkcs7::padding( 116 | random_bytes(self::BLOCK_SIZE).pack('N', strlen($plaintext)).$plaintext.$this->appId, 117 | blockSize: strlen($this->aesKey) 118 | ); 119 | $ciphertext = base64_encode( 120 | openssl_encrypt( 121 | $plaintext, 122 | 'aes-256-cbc', 123 | $this->aesKey, 124 | OPENSSL_NO_PADDING, 125 | iv: substr($this->aesKey, 0, self::BLOCK_SIZE) 126 | ) ?: '' 127 | ); 128 | } catch (Throwable $e) { 129 | throw new RuntimeException($e->getMessage(), self::ERROR_ENCRYPT_AES); 130 | } 131 | 132 | $nonce ??= Str::random(); 133 | $timestamp ??= time(); 134 | 135 | return [ 136 | 'ciphertext' => $ciphertext, 137 | 'signature' => $this->createSignature($this->token, $timestamp, $nonce, $ciphertext), 138 | 'timestamp' => $timestamp, 139 | 'nonce' => $nonce, 140 | ]; 141 | } 142 | 143 | public function createSignature(mixed ...$attributes): string 144 | { 145 | sort($attributes, SORT_STRING); 146 | 147 | return sha1(implode($attributes)); 148 | } 149 | 150 | /** 151 | * @throws RuntimeException 152 | */ 153 | public function decrypt(string $ciphertext, string $msgSignature, string $nonce, int|string $timestamp): string 154 | { 155 | $signature = $this->createSignature($this->token, $timestamp, $nonce, $ciphertext); 156 | 157 | if ($signature !== $msgSignature) { 158 | throw new RuntimeException('Invalid Signature.', self::ERROR_INVALID_SIGNATURE); 159 | } 160 | 161 | $plaintext = Pkcs7::unpadding( 162 | openssl_decrypt( 163 | base64_decode($ciphertext, true) ?: '', 164 | 'aes-256-cbc', 165 | $this->aesKey, 166 | OPENSSL_NO_PADDING, 167 | iv: substr($this->aesKey, 0, self::BLOCK_SIZE) 168 | ) ?: '', 169 | blockSize: strlen($this->aesKey) 170 | ); 171 | $plaintext = substr($plaintext, self::BLOCK_SIZE); 172 | $contentLength = (unpack('N', substr($plaintext, 0, 4)) ?: [])[1]; 173 | 174 | if ($this->receiveId && trim(substr($plaintext, $contentLength + 4)) !== $this->receiveId) { 175 | throw new RuntimeException('Invalid appId.', self::ERROR_INVALID_APP_ID); 176 | } 177 | 178 | return substr($plaintext, 4, $contentLength); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Kernel/Exceptions/BadMethodCallException.php: -------------------------------------------------------------------------------- 1 | response = $response; 21 | 22 | if ($response) { 23 | $response->getBody()->rewind(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Kernel/Exceptions/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | getMimeTypes($ext)[0] ?? 'application/octet-stream'; 51 | } else { 52 | $tmp = tempnam(sys_get_temp_dir(), 'easywechat'); 53 | if (! $tmp) { 54 | throw new RuntimeException('Failed to create temporary file.'); 55 | } 56 | 57 | file_put_contents($tmp, $contents); 58 | $contentType = $mimeTypes->guessMimeType($tmp) ?? 'application/octet-stream'; 59 | $filename = md5($contents).'.'.($mimeTypes->getExtensions($contentType)[0] ?? null); 60 | } 61 | } 62 | 63 | return new self($contents, $filename, $contentType, $encoding); 64 | } 65 | 66 | /** 67 | * @throws RuntimeException 68 | * 69 | * @deprecated since EasyWeChat 7.0, use fromContents() instead 70 | */ 71 | public static function withContents( 72 | string $contents, 73 | ?string $filename = null, 74 | ?string $contentType = null, 75 | ?string $encoding = null 76 | ): DataPart { 77 | return self::fromContents($contents, $filename, $contentType, $encoding); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Kernel/Form/Form.php: -------------------------------------------------------------------------------- 1 | $fields 13 | */ 14 | public function __construct(protected array $fields) 15 | { 16 | } 17 | 18 | /** 19 | * @param array $fields 20 | */ 21 | public static function create(array $fields): Form 22 | { 23 | return new self($fields); 24 | } 25 | 26 | /** 27 | * @return array{headers:array,body:string} 28 | */ 29 | #[ArrayShape(['headers' => 'array', 'body' => 'string'])] 30 | public function toArray(): array 31 | { 32 | return $this->toOptions(); 33 | } 34 | 35 | /** 36 | * @return array{headers:array,body:string} 37 | */ 38 | #[ArrayShape(['headers' => 'array', 'body' => 'string'])] 39 | public function toOptions(): array 40 | { 41 | $formData = new FormDataPart($this->fields); 42 | 43 | return [ 44 | 'headers' => $formData->getPreparedHeaders()->toArray(), 45 | 'body' => $formData->bodyToString(), 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Kernel/HttpClient/AccessTokenAwareClient.php: -------------------------------------------------------------------------------- 1 | client = $client ?? HttpClient::create(); 40 | } 41 | 42 | public function withAccessToken(AccessTokenInterface $accessToken): static 43 | { 44 | $this->accessToken = $accessToken; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * @param array $options 51 | */ 52 | public function request(string $method, string $url, array $options = []): Response 53 | { 54 | if ($this->accessToken) { 55 | $options['query'] = array_merge((array) ($options['query'] ?? []), $this->accessToken->toQuery()); 56 | } 57 | 58 | $options = RequestUtil::formatBody($this->mergeThenResetPrepends($options)); 59 | 60 | return new Response( 61 | response: $this->client->request($method, ltrim($url, '/'), $options), 62 | failureJudge: $this->failureJudge, 63 | throw: $this->throw 64 | ); 65 | } 66 | 67 | /** 68 | * @param array $arguments 69 | */ 70 | public function __call(string $name, array $arguments): mixed 71 | { 72 | if (\str_starts_with($name, 'with')) { 73 | return $this->handleMagicWithCall($name, $arguments[0] ?? null); 74 | } 75 | 76 | return $this->client->$name(...$arguments); 77 | } 78 | 79 | public static function createMockClient(MockHttpClient $mockHttpClient): HttpClientInterface 80 | { 81 | return new self($mockHttpClient); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Kernel/HttpClient/AccessTokenExpiredRetryStrategy.php: -------------------------------------------------------------------------------- 1 | accessToken = $accessToken; 21 | 22 | return $this; 23 | } 24 | 25 | public function decideUsing(Closure $decider): static 26 | { 27 | $this->decider = $decider; 28 | 29 | return $this; 30 | } 31 | 32 | public function shouldRetry( 33 | AsyncContext $context, 34 | ?string $responseContent, 35 | ?TransportExceptionInterface $exception 36 | ): ?bool { 37 | if ($responseContent && $this->decider && ($this->decider)($context, $responseContent, $exception)) { 38 | if ($this->accessToken instanceof RefreshableAccessTokenInterface) { 39 | return (bool) $this->accessToken->refresh(); 40 | } 41 | 42 | return false; 43 | } 44 | 45 | return parent::shouldRetry($context, $responseContent, $exception); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Kernel/HttpClient/HttpClientMethods.php: -------------------------------------------------------------------------------- 1 | $options 12 | * 13 | * @throws TransportExceptionInterface 14 | */ 15 | public function get(string $url, array $options = []): Response|ResponseInterfaceAlias 16 | { 17 | return $this->request('GET', $url, RequestUtil::formatOptions($options, 'GET')); 18 | } 19 | 20 | /** 21 | * @param array $options 22 | * 23 | * @throws TransportExceptionInterface 24 | */ 25 | public function post(string $url, array $options = []): Response|ResponseInterfaceAlias 26 | { 27 | return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST')); 28 | } 29 | 30 | /** 31 | * @throws TransportExceptionInterface 32 | */ 33 | public function postJson(string $url, array $data = [], array $options = []): Response|ResponseInterfaceAlias 34 | { 35 | $options['headers']['Content-Type'] = 'application/json'; 36 | 37 | $options['json'] = $data; 38 | 39 | return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST')); 40 | } 41 | 42 | /** 43 | * @throws TransportExceptionInterface 44 | */ 45 | public function postXml(string $url, array $data = [], array $options = []): Response|ResponseInterfaceAlias 46 | { 47 | $options['headers']['Content-Type'] = 'text/xml'; 48 | 49 | if (array_key_exists('xml', $data)) { 50 | $data = $data['xml']; 51 | } 52 | 53 | $options['xml'] = $data; 54 | 55 | return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST')); 56 | } 57 | 58 | /** 59 | * @param array $options 60 | * 61 | * @throws TransportExceptionInterface 62 | */ 63 | public function patch(string $url, array $options = []): Response|ResponseInterfaceAlias 64 | { 65 | return $this->request('PATCH', $url, RequestUtil::formatOptions($options, 'PATCH')); 66 | } 67 | 68 | /** 69 | * @throws TransportExceptionInterface 70 | */ 71 | public function patchJson(string $url, array $options = []): Response|ResponseInterfaceAlias 72 | { 73 | $options['headers']['Content-Type'] = 'application/json'; 74 | 75 | return $this->request('PATCH', $url, RequestUtil::formatOptions($options, 'PATCH')); 76 | } 77 | 78 | /** 79 | * @param array $options 80 | * 81 | * @throws TransportExceptionInterface 82 | */ 83 | public function put(string $url, array $options = []): Response|ResponseInterfaceAlias 84 | { 85 | return $this->request('PUT', $url, RequestUtil::formatOptions($options, 'PUT')); 86 | } 87 | 88 | /** 89 | * @param array $options 90 | * 91 | * @throws TransportExceptionInterface 92 | */ 93 | public function delete(string $url, array $options = []): Response|ResponseInterfaceAlias 94 | { 95 | return $this->request('DELETE', $url, RequestUtil::formatOptions($options, 'DELETE')); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Kernel/HttpClient/RequestUtil.php: -------------------------------------------------------------------------------- 1 | $options 29 | * @return array 30 | */ 31 | #[ArrayShape([ 32 | 'status_codes' => 'array', 33 | 'delay' => 'int', 34 | 'max_delay' => 'int', 35 | 'max_retries' => 'int', 36 | 'multiplier' => 'float', 37 | 'jitter' => 'float', 38 | ])] 39 | public static function mergeDefaultRetryOptions(array $options): array 40 | { 41 | return \array_merge([ 42 | 'status_codes' => GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES, 43 | 'delay' => 1000, 44 | 'max_delay' => 0, 45 | 'max_retries' => 3, 46 | 'multiplier' => 2.0, 47 | 'jitter' => 0.1, 48 | ], $options); 49 | } 50 | 51 | /** 52 | * @param array $options 53 | * @return array 54 | */ 55 | public static function formatDefaultOptions(array $options): array 56 | { 57 | $defaultOptions = \array_filter( 58 | array: $options, 59 | callback: fn ($key) => array_key_exists($key, HttpClientInterface::OPTIONS_DEFAULTS), 60 | mode: ARRAY_FILTER_USE_KEY 61 | ); 62 | 63 | /** @phpstan-ignore-next-line */ 64 | if (! isset($options['headers']['User-Agent']) && ! isset($options['headers']['user-agent'])) { 65 | /** @phpstan-ignore-next-line */ 66 | $defaultOptions['headers']['User-Agent'] = UserAgent::create(); 67 | } 68 | 69 | return $defaultOptions; 70 | } 71 | 72 | public static function formatOptions(array $options, string $method): array 73 | { 74 | if (array_key_exists('query', $options) && is_array($options['query']) && empty($options['query'])) { 75 | return $options; 76 | } 77 | 78 | if (array_key_exists('body', $options) 79 | || array_key_exists('json', $options) 80 | || array_key_exists('xml', $options) 81 | ) { 82 | return $options; 83 | } 84 | 85 | $contentType = $options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null; 86 | $name = in_array($method, ['GET', 'HEAD', 'DELETE']) ? 'query' : 'body'; 87 | 88 | if ($contentType === 'application/json') { 89 | $name = 'json'; 90 | } 91 | 92 | if ($contentType === 'text/xml') { 93 | $name = 'xml'; 94 | } 95 | 96 | foreach ($options as $key => $value) { 97 | if (! array_key_exists($key, HttpClientInterface::OPTIONS_DEFAULTS)) { 98 | $options[$name][trim($key, '"')] = $value; 99 | unset($options[$key]); 100 | } 101 | } 102 | 103 | return $options; 104 | } 105 | 106 | /** 107 | * @param array{headers?:array, xml?:mixed, body?:array|string, json?:mixed} $options 108 | * @return array{headers?:array|array>, xml?:array|string, body?:array|string} 109 | * 110 | * @throws InvalidArgumentException 111 | */ 112 | public static function formatBody(array $options): array 113 | { 114 | $contentType = $options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null; 115 | 116 | if (array_key_exists('xml', $options)) { 117 | if (is_array($options['xml'])) { 118 | $options['xml'] = Xml::build($options['xml']); 119 | } 120 | 121 | if (! is_string($options['xml'])) { 122 | throw new InvalidArgumentException('The type of `xml` must be string or array.'); 123 | } 124 | 125 | if (! $contentType) { 126 | $options['headers']['Content-Type'] = 'text/xml'; 127 | } 128 | 129 | $options['body'] = $options['xml']; 130 | unset($options['xml']); 131 | } 132 | 133 | if (array_key_exists('json', $options)) { 134 | if (is_array($options['json'])) { 135 | /** XXX: 微信的 JSON 是比较奇葩的,比如菜单不能把中文 encode 为 unicode */ 136 | $options['json'] = json_encode( 137 | $options['json'], 138 | empty($options['json']) ? JSON_FORCE_OBJECT : JSON_UNESCAPED_UNICODE 139 | ); 140 | } 141 | 142 | if (! is_string($options['json'])) { 143 | throw new InvalidArgumentException('The type of `json` must be string or array.'); 144 | } 145 | 146 | if (! $contentType) { 147 | $options['headers']['Content-Type'] = 'application/json'; 148 | } 149 | 150 | $options['body'] = $options['json']; 151 | unset($options['json']); 152 | } 153 | 154 | return $options; 155 | } 156 | 157 | public static function createDefaultServerRequest(): ServerRequestInterface 158 | { 159 | $psr17Factory = new Psr17Factory; 160 | 161 | $creator = new ServerRequestCreator( 162 | serverRequestFactory: $psr17Factory, 163 | uriFactory: $psr17Factory, 164 | uploadedFileFactory: $psr17Factory, 165 | streamFactory: $psr17Factory 166 | ); 167 | 168 | return $creator->fromGlobals(); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Kernel/HttpClient/RequestWithPresets.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | protected array $prependHeaders = []; 26 | 27 | /** 28 | * @var array 29 | */ 30 | protected array $prependParts = []; 31 | 32 | /** 33 | * @var array 34 | */ 35 | protected array $presets = []; 36 | 37 | /** 38 | * @param array $presets 39 | */ 40 | public function setPresets(array $presets): static 41 | { 42 | $this->presets = $presets; 43 | 44 | return $this; 45 | } 46 | 47 | public function withHeader(string $key, string $value): static 48 | { 49 | $this->prependHeaders[$key] = $value; 50 | 51 | return $this; 52 | } 53 | 54 | public function withHeaders(array $headers): static 55 | { 56 | foreach ($headers as $key => $value) { 57 | $this->withHeader($key, $value); 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * @throws InvalidArgumentException 65 | */ 66 | public function with(string|array $key, mixed $value = null): static 67 | { 68 | if (\is_array($key)) { 69 | // $client->with(['appid', 'mchid']) 70 | // $client->with(['appid' => 'wx1234567', 'mchid']) 71 | foreach ($key as $k => $v) { 72 | if (\is_int($k) && is_string($v)) { 73 | [$k, $v] = [$v, null]; 74 | } 75 | 76 | $this->with($k, $v ?? $this->presets[$k] ?? null); 77 | } 78 | 79 | return $this; 80 | } 81 | 82 | $this->prependParts[$key] = $value ?? $this->presets[$key] ?? null; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @throws RuntimeException 89 | */ 90 | public function withFile(string $pathOrContents, string $formName = 'file', ?string $filename = null): static 91 | { 92 | $file = is_file($pathOrContents) ? File::fromPath( 93 | $pathOrContents, 94 | $filename 95 | ) : File::fromContents($pathOrContents, $filename); 96 | 97 | /** 98 | * @var array{headers: array, body: string} $options 99 | */ 100 | $options = Form::create([$formName => $file])->toOptions(); 101 | 102 | $this->withHeaders($options['headers']); 103 | 104 | return $this->withOptions([ 105 | 'body' => $options['body'], 106 | ]); 107 | } 108 | 109 | /** 110 | * @throws RuntimeException 111 | */ 112 | public function withFileContents(string $contents, string $formName = 'file', ?string $filename = null): static 113 | { 114 | return $this->withFile($contents, $formName, $filename); 115 | } 116 | 117 | /** 118 | * @throws RuntimeException 119 | */ 120 | public function withFiles(array $files): static 121 | { 122 | foreach ($files as $key => $value) { 123 | $this->withFile($value, $key); 124 | } 125 | 126 | return $this; 127 | } 128 | 129 | /** 130 | * @return array{xml?:array|string,json?:array|string,body?:array|string,query?:array,headers?:array} 131 | */ 132 | public function mergeThenResetPrepends(array $options, string $method = 'GET'): array 133 | { 134 | $name = in_array(strtoupper($method), ['GET', 'HEAD', 'DELETE']) ? 'query' : 'body'; 135 | 136 | if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'application/json' || ! empty($options['json'])) { 137 | $name = 'json'; 138 | } 139 | 140 | if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'text/xml' || ! empty($options['xml'])) { 141 | $name = 'xml'; 142 | } 143 | 144 | if (! empty($this->prependParts)) { 145 | $options[$name] = array_merge($this->prependParts, $options[$name] ?? []); 146 | } 147 | 148 | if (! empty($this->prependHeaders)) { 149 | $options['headers'] = array_merge($this->prependHeaders, $options['headers'] ?? []); 150 | } 151 | 152 | $this->prependParts = []; 153 | $this->prependHeaders = []; 154 | 155 | return $options; 156 | } 157 | 158 | /** 159 | * @throws InvalidArgumentException 160 | */ 161 | public function handleMagicWithCall(string $method, mixed $value = null): static 162 | { 163 | // $client->withAppid(); 164 | // $client->withAppid('wxf8b4f85f3a794e77'); 165 | // $client->withAppidAs('sub_appid'); 166 | if (! str_starts_with($method, 'with')) { 167 | throw new InvalidArgumentException(sprintf('The method "%s" is not supported.', $method)); 168 | } 169 | 170 | $key = Str::snakeCase(substr($method, 4)); 171 | 172 | // $client->withAppidAs('sub_appid'); 173 | if (str_ends_with($key, '_as')) { 174 | $key = substr($key, 0, -3); 175 | 176 | [$key, $value] = [is_string($value) ? $value : $key, $this->presets[$key] ?? null]; 177 | } 178 | 179 | return $this->with($key, $value); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Kernel/HttpClient/RetryableClient.php: -------------------------------------------------------------------------------- 1 | $config 14 | */ 15 | public function retry(array $config = []): static 16 | { 17 | $config = RequestUtil::mergeDefaultRetryOptions($config); 18 | 19 | $strategy = new GenericRetryStrategy( 20 | // @phpstan-ignore-next-line 21 | (array) $config['status_codes'], 22 | // @phpstan-ignore-next-line 23 | (int) $config['delay'], 24 | // @phpstan-ignore-next-line 25 | (float) $config['multiplier'], 26 | // @phpstan-ignore-next-line 27 | (int) $config['max_delay'], 28 | // @phpstan-ignore-next-line 29 | (float) $config['jitter'] 30 | ); 31 | 32 | /** @phpstan-ignore-next-line */ 33 | return $this->retryUsing($strategy, (int) $config['max_retries']); 34 | } 35 | 36 | public function retryUsing( 37 | RetryStrategyInterface $strategy, 38 | int $maxRetries = 3, 39 | ?LoggerInterface $logger = null 40 | ): static { 41 | $this->client = new RetryableHttpClient($this->client, $strategy, $maxRetries, $logger); 42 | 43 | return $this; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Kernel/HttpClient/ScopingHttpClient.php: -------------------------------------------------------------------------------- 1 | client = $client; 26 | $this->defaultOptionsByRegexp = $defaultOptionsByRegexp; 27 | } 28 | 29 | public function request(string $method, string $url, array $options = []): ResponseInterface 30 | { 31 | foreach ($this->defaultOptionsByRegexp as $regexp => $defaultOptions) { 32 | if (preg_match($regexp, $url)) { 33 | $options = self::mergeDefaultOptions($options, $defaultOptions, true); 34 | break; 35 | } 36 | } 37 | 38 | return $this->client->request($method, $url, $options); 39 | } 40 | 41 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 42 | { 43 | return $this->client->stream($responses, $timeout); 44 | } 45 | 46 | /** 47 | * @return void 48 | */ 49 | public function reset() 50 | { 51 | if ($this->client instanceof ResetInterface) { 52 | $this->client->reset(); 53 | } 54 | } 55 | 56 | public function setLogger(LoggerInterface $logger): void 57 | { 58 | if ($this->client instanceof LoggerAwareInterface) { 59 | $this->client->setLogger($logger); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Kernel/Message.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | abstract class Message implements \JsonSerializable, ArrayAccess, Jsonable 22 | { 23 | use HasAttributes; 24 | 25 | /** 26 | * @param array $attributes 27 | */ 28 | final public function __construct(array $attributes = [], protected ?string $originContent = '') 29 | { 30 | $this->attributes = $attributes; 31 | } 32 | 33 | /** 34 | * @throws BadRequestException 35 | */ 36 | public static function createFromRequest(ServerRequestInterface $request): Message 37 | { 38 | $attributes = self::format($originContent = strval($request->getBody())); 39 | 40 | return new static($attributes, $originContent); 41 | } 42 | 43 | /** 44 | * @return array 45 | * 46 | * @throws BadRequestException 47 | */ 48 | public static function format(string $originContent): array 49 | { 50 | if (stripos($originContent, '<') === 0) { 51 | $attributes = Xml::parse($originContent); 52 | } 53 | 54 | // Handle JSON format. 55 | $dataSet = json_decode($originContent, true); 56 | 57 | if (json_last_error() === JSON_ERROR_NONE && $originContent) { 58 | $attributes = $dataSet; 59 | } 60 | 61 | if (empty($attributes) || ! is_array($attributes)) { 62 | throw new BadRequestException('Failed to decode request contents.'); 63 | } 64 | 65 | return $attributes; 66 | } 67 | 68 | public function getOriginalContents(): string 69 | { 70 | return $this->originContent ?? ''; 71 | } 72 | 73 | public function __toString() 74 | { 75 | return $this->toJson() ?: ''; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Kernel/ServerResponse.php: -------------------------------------------------------------------------------- 1 | response->getBody()->rewind(); 25 | } 26 | 27 | public static function make(ResponseInterface $response): ServerResponse 28 | { 29 | if ($response instanceof ServerResponse) { 30 | return $response; 31 | } 32 | 33 | return new self($response); 34 | } 35 | 36 | public function getProtocolVersion(): string 37 | { 38 | return $this->response->getProtocolVersion(); 39 | } 40 | 41 | public function withProtocolVersion($version): ServerResponse|ResponseInterface 42 | { 43 | return $this->response->withProtocolVersion($version); 44 | } 45 | 46 | public function getHeaders(): array 47 | { 48 | return $this->response->getHeaders(); 49 | } 50 | 51 | public function hasHeader($name): bool 52 | { 53 | return $this->response->hasHeader($name); 54 | } 55 | 56 | public function getHeader($name): array 57 | { 58 | return $this->response->getHeader($name); 59 | } 60 | 61 | public function getHeaderLine($name): string 62 | { 63 | return $this->response->getHeaderLine($name); 64 | } 65 | 66 | public function withHeader($name, $value): ServerResponse|ResponseInterface 67 | { 68 | return $this->response->withHeader($name, $value); 69 | } 70 | 71 | public function withAddedHeader($name, $value): ServerResponse|ResponseInterface 72 | { 73 | return $this->response->withAddedHeader($name, $value); 74 | } 75 | 76 | public function withoutHeader($name): ServerResponse|ResponseInterface 77 | { 78 | return $this->response->withoutHeader($name); 79 | } 80 | 81 | public function getBody(): StreamInterface 82 | { 83 | return $this->response->getBody(); 84 | } 85 | 86 | public function withBody(StreamInterface $body): ServerResponse|ResponseInterface 87 | { 88 | return $this->response->withBody($body); 89 | } 90 | 91 | public function getStatusCode(): int 92 | { 93 | return $this->response->getStatusCode(); 94 | } 95 | 96 | public function withStatus($code, $reasonPhrase = ''): ServerResponse|ResponseInterface 97 | { 98 | $this->response->withStatus($code, $reasonPhrase); 99 | 100 | return $this; 101 | } 102 | 103 | public function getReasonPhrase(): string 104 | { 105 | return $this->response->getReasonPhrase(); 106 | } 107 | 108 | /** 109 | * @link https://github.com/symfony/http-foundation/blob/6.1/Response.php 110 | */ 111 | public function send(): static 112 | { 113 | $this->sendHeaders(); 114 | $this->sendContent(); 115 | 116 | if (\function_exists('fastcgi_finish_request')) { 117 | \fastcgi_finish_request(); 118 | } elseif (\function_exists('litespeed_finish_request')) { 119 | \litespeed_finish_request(); 120 | } elseif (! \in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) { 121 | static::closeOutputBuffers(0, true); 122 | } 123 | 124 | return $this; 125 | } 126 | 127 | public function sendHeaders(): static 128 | { 129 | // headers have already been sent by the developer 130 | if (\headers_sent()) { 131 | return $this; 132 | } 133 | 134 | foreach ($this->getHeaders() as $name => $values) { 135 | $replace = \strcasecmp($name, 'Content-Type') === 0; 136 | 137 | foreach ($values as $value) { 138 | header($name.': '.$value, $replace, $this->getStatusCode()); 139 | } 140 | } 141 | 142 | header( 143 | header: sprintf( 144 | 'HTTP/%s %s %s', 145 | $this->getProtocolVersion(), 146 | $this->getStatusCode(), 147 | $this->getReasonPhrase() 148 | ), 149 | replace: true, 150 | response_code: $this->getStatusCode() 151 | ); 152 | 153 | return $this; 154 | } 155 | 156 | public function sendContent(): static 157 | { 158 | echo (string) $this->getBody(); 159 | 160 | return $this; 161 | } 162 | 163 | /** 164 | * Cleans or flushes output buffers up to target level. 165 | * 166 | * Resulting level can be greater than target level if a non-removable buffer has been encountered. 167 | * 168 | * @link https://github.com/symfony/http-foundation/blob/6.1/Response.php 169 | * 170 | * @final 171 | */ 172 | public static function closeOutputBuffers(int $targetLevel, bool $flush): void 173 | { 174 | $status = ob_get_status(true); 175 | $level = count($status); 176 | $flags = PHP_OUTPUT_HANDLER_REMOVABLE | ($flush ? PHP_OUTPUT_HANDLER_FLUSHABLE : PHP_OUTPUT_HANDLER_CLEANABLE); 177 | 178 | while ($level-- > $targetLevel && ($s = $status[$level]) && (! isset($s['del']) ? ! isset($s['flags']) || ($s['flags'] & $flags) === $flags : $s['del'])) { 179 | if ($flush) { 180 | ob_end_flush(); 181 | } else { 182 | ob_end_clean(); 183 | } 184 | } 185 | } 186 | 187 | public function __toString(): string 188 | { 189 | $headers = $this->getHeaders(); 190 | $headersString = ''; 191 | 192 | if (! empty($headers)) { 193 | ksort($headers); 194 | 195 | $max = max(array_map('strlen', array_keys($headers))) + 1; 196 | 197 | foreach ($headers as $name => $values) { 198 | $name = ucwords($name, '-'); 199 | foreach ($values as $value) { 200 | $headersString .= sprintf("%-{$max}s %s\r\n", $name.':', $value); 201 | } 202 | } 203 | } 204 | 205 | return sprintf( 206 | 'HTTP/%s %s %s', 207 | $this->getProtocolVersion(), 208 | $this->getStatusCode(), 209 | $this->getReasonPhrase() 210 | )."\r\n". 211 | $headersString."\r\n". 212 | $this->getBody(); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Kernel/Support/AesCbc.php: -------------------------------------------------------------------------------- 1 | $array 41 | */ 42 | public static function exists(array $array, string|int $key): bool 43 | { 44 | return array_key_exists($key, $array); 45 | } 46 | 47 | /** 48 | * @param array $array 49 | * @return array 50 | */ 51 | public static function set(array &$array, string|int|null $key, mixed $value): array 52 | { 53 | if (! is_string($key)) { 54 | $key = (string) $key; 55 | } 56 | 57 | $keys = explode('.', $key); 58 | 59 | while (count($keys) > 1) { 60 | $key = array_shift($keys); 61 | 62 | // If the key doesn't exist at this depth, we will just create an empty array 63 | // to hold the next value, allowing us to create the arrays to hold final 64 | // values at the correct depth. Then we'll keep digging into the array. 65 | if (! isset($array[$key]) || ! is_array($array[$key])) { 66 | $array[$key] = []; 67 | } 68 | 69 | $array = &$array[$key]; 70 | } 71 | 72 | $array[array_shift($keys)] = $value; 73 | 74 | return $array; 75 | } 76 | 77 | /** 78 | * @param array $array 79 | * @return array 80 | */ 81 | public static function dot(array $array, string $prepend = ''): array 82 | { 83 | $results = []; 84 | 85 | foreach ($array as $key => $value) { 86 | if (is_array($value) && ! empty($value)) { 87 | $results = array_merge($results, static::dot($value, $prepend.$key.'.')); 88 | } else { 89 | $results[$prepend.$key] = $value; 90 | } 91 | } 92 | 93 | return $results; 94 | } 95 | 96 | /** 97 | * @param array $array 98 | * @param string|int|array|null $keys 99 | */ 100 | #[Pure] 101 | public static function has(array $array, string|int|array|null $keys): bool 102 | { 103 | if (is_null($keys)) { 104 | return false; 105 | } 106 | 107 | $keys = (array) $keys; 108 | 109 | if (empty($array)) { 110 | return false; 111 | } 112 | 113 | if ($keys === []) { 114 | return false; 115 | } 116 | 117 | foreach ($keys as $key) { 118 | $subKeyArray = $array; 119 | 120 | /** @phpstan-ignore-next-line */ 121 | if (static::exists($array, $key)) { 122 | continue; 123 | } 124 | 125 | /** @phpstan-ignore-next-line */ 126 | foreach (explode('.', (string) $key) as $segment) { 127 | /** @phpstan-ignore-next-line */ 128 | if (static::exists($subKeyArray, $segment)) { 129 | /** @phpstan-ignore-next-line */ 130 | $subKeyArray = $subKeyArray[$segment]; 131 | } else { 132 | return false; 133 | } 134 | } 135 | } 136 | 137 | return true; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Kernel/Support/Pkcs7.php: -------------------------------------------------------------------------------- 1 | 32) { 15 | throw new InvalidArgumentException('$blockSize may not be more than 32 bytes(256 bits)'); 16 | } 17 | $padding = $blockSize - (strlen($contents) % $blockSize); 18 | $pattern = chr($padding); 19 | 20 | return $contents.str_repeat($pattern, $padding); 21 | } 22 | 23 | public static function unpadding(string $contents, int $blockSize): string 24 | { 25 | $pad = ord(substr($contents, -1)); 26 | if ($pad < 1 || $pad > $blockSize) { 27 | $pad = 0; 28 | } 29 | 30 | return substr($contents, 0, (strlen($contents) - $pad)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Kernel/Support/PrivateKey.php: -------------------------------------------------------------------------------- 1 | key = "file://{$key}"; 17 | } 18 | } 19 | 20 | public function getKey(): string 21 | { 22 | if (str_starts_with($this->key, 'file://')) { 23 | return file_get_contents($this->key) ?: ''; 24 | } 25 | 26 | return $this->key; 27 | } 28 | 29 | public function getPassphrase(): ?string 30 | { 31 | return $this->passphrase; 32 | } 33 | 34 | #[Pure] 35 | public function __toString(): string 36 | { 37 | return $this->getKey(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Kernel/Support/PublicKey.php: -------------------------------------------------------------------------------- 1 | certificate = "file://{$certificate}"; 19 | } 20 | } 21 | 22 | /** 23 | * @throws InvalidConfigException 24 | */ 25 | public function getSerialNo(): string 26 | { 27 | $info = openssl_x509_parse($this->certificate); 28 | 29 | if ($info === false) { 30 | throw new InvalidConfigException('Read the $certificate failed, please check it whether or nor correct'); 31 | } 32 | 33 | return strtoupper($info['serialNumberHex']); 34 | } 35 | 36 | public function __toString(): string 37 | { 38 | if (str_starts_with($this->certificate, 'file://')) { 39 | return file_get_contents($this->certificate) ?: ''; 40 | } 41 | 42 | return $this->certificate; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Kernel/Support/Str.php: -------------------------------------------------------------------------------- 1 | $appends 24 | */ 25 | public static function create(array $appends = []): string 26 | { 27 | $value = array_map('strval', $appends); 28 | 29 | if (defined('HHVM_VERSION')) { 30 | array_unshift($value, 'HHVM/'.constant('HHVM_VERSION')); 31 | } 32 | 33 | $disabledFunctions = explode(',', ini_get('disable_functions') ?: ''); 34 | 35 | if (extension_loaded('curl') && function_exists('curl_version')) { 36 | array_unshift($value, 'curl/'.(curl_version() ?: ['version' => 'unknown'])['version']); 37 | } 38 | 39 | if (! ini_get('safe_mode') 40 | && function_exists('php_uname') 41 | && ! in_array('php_uname', $disabledFunctions, true) 42 | ) { 43 | $osName = 'OS/'.php_uname('s').'/'.php_uname('r'); 44 | array_unshift($value, $osName); 45 | } 46 | 47 | if (class_exists(InstalledVersions::class)) { 48 | array_unshift($value, 'easywechat-sdk/'.((string) InstalledVersions::getVersion('w7corp/easywechat'))); 49 | } 50 | 51 | return trim(implode(' ', $value)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Kernel/Support/Xml.php: -------------------------------------------------------------------------------- 1 | Encrypt; 24 | 25 | $this->validateSignature($encryptor->getToken(), $ciphertext, $signature, $timestamp, $nonce); 26 | 27 | $message->merge(Xml::parse( 28 | $encryptor->decrypt( 29 | ciphertext: $ciphertext, 30 | msgSignature: $signature, 31 | nonce: $nonce, 32 | timestamp: $timestamp 33 | ) 34 | ) ?? []); 35 | 36 | return $message; 37 | } 38 | 39 | /** 40 | * @throws BadRequestException 41 | */ 42 | protected function validateSignature( 43 | string $token, 44 | string $ciphertext, 45 | string $signature, 46 | int|string $timestamp, 47 | string $nonce 48 | ): void { 49 | if (empty($signature)) { 50 | throw new BadRequestException('Request signature must not be empty.'); 51 | } 52 | 53 | $params = [$token, $timestamp, $nonce, $ciphertext]; 54 | 55 | sort($params, SORT_STRING); 56 | 57 | if ($signature !== sha1(implode($params))) { 58 | throw new BadRequestException('Invalid request signature.'); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Kernel/Traits/HasAttributes.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected array $attributes = []; 17 | 18 | /** 19 | * @param array $attributes 20 | */ 21 | public function __construct(array $attributes) 22 | { 23 | $this->attributes = $attributes; 24 | } 25 | 26 | /** 27 | * @return array 28 | */ 29 | public function toArray(): array 30 | { 31 | return $this->attributes; 32 | } 33 | 34 | public function toJson(): string|false 35 | { 36 | return json_encode($this->attributes); 37 | } 38 | 39 | public function has(string $key): bool 40 | { 41 | return array_key_exists($key, $this->attributes); 42 | } 43 | 44 | /** 45 | * @param array $attributes 46 | */ 47 | public function merge(array $attributes): static 48 | { 49 | $this->attributes = array_merge($this->attributes, $attributes); 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * @return array $attributes 56 | */ 57 | public function jsonSerialize(): array 58 | { 59 | return $this->attributes; 60 | } 61 | 62 | public function __set(string $attribute, mixed $value): void 63 | { 64 | $this->attributes[$attribute] = $value; 65 | } 66 | 67 | public function __get(string $attribute): mixed 68 | { 69 | return $this->attributes[$attribute] ?? null; 70 | } 71 | 72 | public function offsetExists(mixed $offset): bool 73 | { 74 | /** @phpstan-ignore-next-line */ 75 | return array_key_exists($offset, $this->attributes); 76 | } 77 | 78 | public function offsetGet(mixed $offset): mixed 79 | { 80 | return $this->attributes[$offset]; 81 | } 82 | 83 | public function offsetSet(mixed $offset, mixed $value): void 84 | { 85 | if ($offset === null) { 86 | $this->attributes[] = $value; 87 | } else { 88 | $this->attributes[$offset] = $value; 89 | } 90 | } 91 | 92 | public function offsetUnset(mixed $offset): void 93 | { 94 | unset($this->attributes[$offset]); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Kernel/Traits/InteractWithCache.php: -------------------------------------------------------------------------------- 1 | cacheLifetime; 22 | } 23 | 24 | public function setCacheLifetime(int $cacheLifetime): void 25 | { 26 | $this->cacheLifetime = $cacheLifetime; 27 | } 28 | 29 | public function getCacheNamespace(): string 30 | { 31 | return $this->cacheNamespace; 32 | } 33 | 34 | public function setCacheNamespace(string $cacheNamespace): void 35 | { 36 | $this->cacheNamespace = $cacheNamespace; 37 | } 38 | 39 | public function setCache(CacheInterface $cache): static 40 | { 41 | $this->cache = $cache; 42 | 43 | return $this; 44 | } 45 | 46 | public function getCache(): CacheInterface 47 | { 48 | if (! $this->cache) { 49 | $this->cache = new Psr16Cache(new FilesystemAdapter($this->cacheNamespace, $this->cacheLifetime)); 50 | } 51 | 52 | return $this->cache; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Kernel/Traits/InteractWithClient.php: -------------------------------------------------------------------------------- 1 | client) { 16 | $this->client = $this->createClient(); 17 | } 18 | 19 | return $this->client; 20 | } 21 | 22 | public function setClient(AccessTokenAwareClient $client): static 23 | { 24 | $this->client = $client; 25 | 26 | return $this; 27 | } 28 | 29 | abstract public function createClient(): AccessTokenAwareClient; 30 | } 31 | -------------------------------------------------------------------------------- /src/Kernel/Traits/InteractWithConfig.php: -------------------------------------------------------------------------------- 1 | |ConfigInterface $config 18 | */ 19 | public function __construct(array|ConfigInterface $config) 20 | { 21 | $this->config = is_array($config) ? new Config($config) : $config; 22 | } 23 | 24 | public function getConfig(): ConfigInterface 25 | { 26 | return $this->config; 27 | } 28 | 29 | public function setConfig(ConfigInterface $config): static 30 | { 31 | $this->config = $config; 32 | 33 | return $this; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Kernel/Traits/InteractWithHandlers.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | protected array $handlers = []; 28 | 29 | /** 30 | * @return array 31 | */ 32 | public function getHandlers(): array 33 | { 34 | return $this->handlers; 35 | } 36 | 37 | public function with(callable|string $handler): static 38 | { 39 | return $this->withHandler($handler); 40 | } 41 | 42 | /** 43 | * @throws InvalidArgumentException 44 | */ 45 | public function withHandler(callable|string $handler): static 46 | { 47 | $this->handlers[] = $this->createHandlerItem($handler); 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * @return array{hash: string, handler: callable} 54 | */ 55 | #[ArrayShape(['hash' => 'string', 'handler' => 'callable'])] 56 | public function createHandlerItem(callable|string $handler): array 57 | { 58 | return [ 59 | 'hash' => $this->getHandlerHash($handler), 60 | 'handler' => $this->makeClosure($handler), 61 | ]; 62 | } 63 | 64 | /** 65 | * @throws InvalidArgumentException 66 | */ 67 | protected function getHandlerHash(callable|array|string $handler): string 68 | { 69 | return match (true) { 70 | is_string($handler) => $handler, 71 | is_array($handler) => is_string($handler[0]) 72 | ? $handler[0].'::'.$handler[1] 73 | : get_class($handler[0]).$handler[1], 74 | $handler instanceof Closure => spl_object_hash($handler), 75 | is_callable($handler) => spl_object_hash($handler), 76 | default => throw new InvalidArgumentException('Invalid handler: '.gettype($handler)), 77 | }; 78 | } 79 | 80 | /** 81 | * @throws InvalidArgumentException 82 | */ 83 | protected function makeClosure(callable|string $handler): callable 84 | { 85 | if (is_callable($handler)) { 86 | return $handler; 87 | } 88 | 89 | if (class_exists($handler) && method_exists($handler, '__invoke')) { 90 | /** 91 | * @psalm-suppress InvalidFunctionCall 92 | * 93 | * @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/5867 94 | */ 95 | return fn (): mixed => (new $handler)(...func_get_args()); 96 | } 97 | 98 | throw new InvalidArgumentException(sprintf('Invalid handler: %s.', $handler)); 99 | } 100 | 101 | public function prepend(callable|string $handler): static 102 | { 103 | return $this->prependHandler($handler); 104 | } 105 | 106 | public function prependHandler(callable|string $handler): static 107 | { 108 | array_unshift($this->handlers, $this->createHandlerItem($handler)); 109 | 110 | return $this; 111 | } 112 | 113 | /** 114 | * @throws InvalidArgumentException 115 | */ 116 | public function without(callable|string $handler): static 117 | { 118 | return $this->withoutHandler($handler); 119 | } 120 | 121 | /** 122 | * @throws InvalidArgumentException 123 | */ 124 | public function withoutHandler(callable|string $handler): static 125 | { 126 | $index = $this->indexOf($handler); 127 | 128 | if ($index > -1) { 129 | unset($this->handlers[$index]); 130 | } 131 | 132 | return $this; 133 | } 134 | 135 | public function indexOf(callable|string $handler): int 136 | { 137 | foreach ($this->handlers as $index => $item) { 138 | if ($item['hash'] === $this->getHandlerHash($handler)) { 139 | return $index; 140 | } 141 | } 142 | 143 | return -1; 144 | } 145 | 146 | public function when(mixed $value, callable|string $handler): static 147 | { 148 | if (is_callable($value)) { 149 | $value = call_user_func($value, $this); 150 | } 151 | 152 | if ($value) { 153 | return $this->withHandler($handler); 154 | } 155 | 156 | return $this; 157 | } 158 | 159 | public function handle(mixed $result, mixed $payload = null): mixed 160 | { 161 | $next = $result = is_callable($result) ? $result : fn (mixed $p): mixed => $result; 162 | 163 | foreach (array_reverse($this->handlers) as $item) { 164 | $next = fn (mixed $p): mixed => $item['handler']($p, $next) ?? $result($p); 165 | } 166 | 167 | return $next($payload); 168 | } 169 | 170 | public function has(callable|string $handler): bool 171 | { 172 | return $this->indexOf($handler) > -1; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Kernel/Traits/InteractWithHttpClient.php: -------------------------------------------------------------------------------- 1 | httpClient) { 24 | $this->httpClient = $this->createHttpClient(); 25 | } 26 | 27 | return $this->httpClient; 28 | } 29 | 30 | public function setHttpClient(HttpClientInterface $httpClient): static 31 | { 32 | $this->httpClient = $httpClient; 33 | 34 | if ($this instanceof LoggerAwareInterface && $httpClient instanceof LoggerAwareInterface 35 | && $this->logger instanceof LoggerInterface) { 36 | $httpClient->setLogger($this->logger); 37 | } 38 | 39 | return $this; 40 | } 41 | 42 | protected function createHttpClient(): HttpClientInterface 43 | { 44 | $options = $this->getHttpClientDefaultOptions(); 45 | 46 | $optionsByRegexp = Arr::get($options, 'options_by_regexp', []); 47 | unset($options['options_by_regexp']); 48 | 49 | $client = HttpClient::create(RequestUtil::formatDefaultOptions($options)); 50 | 51 | if (is_array($optionsByRegexp) && ! empty($optionsByRegexp)) { 52 | $client = new ScopingHttpClient($client, $optionsByRegexp); 53 | } 54 | 55 | return $client; 56 | } 57 | 58 | /** 59 | * @return array 60 | */ 61 | protected function getHttpClientDefaultOptions(): array 62 | { 63 | return []; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Kernel/Traits/InteractWithServerRequest.php: -------------------------------------------------------------------------------- 1 | request) { 20 | $this->request = RequestUtil::createDefaultServerRequest(); 21 | } 22 | 23 | return $this->request; 24 | } 25 | 26 | public function setRequest(ServerRequestInterface $request): static 27 | { 28 | $this->request = $request; 29 | 30 | return $this; 31 | } 32 | 33 | public function setRequestFromSymfonyRequest(Request $symfonyRequest): static 34 | { 35 | $psr17Factory = new Psr17Factory; 36 | $psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); 37 | 38 | $this->request = $psrHttpFactory->createRequest($symfonyRequest); 39 | 40 | return $this; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Kernel/Traits/MockableHttpClient.php: -------------------------------------------------------------------------------- 1 | $headers 21 | */ 22 | public static function mock( 23 | string $response = '', 24 | ?int $status = 200, 25 | array $headers = [], 26 | string $baseUri = 'https://example.com' 27 | ): object { 28 | $mockResponse = new MockResponse( 29 | $response, 30 | array_merge([ 31 | 'http_code' => $status, 32 | 'content_type' => 'application/json', 33 | ], $headers) 34 | ); 35 | 36 | $client = self::createMockClient(new MockHttpClient($mockResponse, $baseUri)); 37 | 38 | // @phpstan-ignore-next-line 39 | return new class($client, $mockResponse) 40 | { 41 | use DecoratorTrait; 42 | 43 | public function __construct(Mock|HttpClientInterface $client, public MockResponse $mockResponse) 44 | { 45 | $this->client = $client; 46 | } 47 | 48 | /** 49 | * @param array $arguments 50 | */ 51 | public function __call(string $name, array $arguments): mixed 52 | { 53 | return $this->client->$name(...$arguments); 54 | } 55 | 56 | #[Pure] 57 | public function getRequestMethod(): string 58 | { 59 | return $this->mockResponse->getRequestMethod(); 60 | } 61 | 62 | #[Pure] 63 | public function getRequestUrl(): string 64 | { 65 | return $this->mockResponse->getRequestUrl(); 66 | } 67 | 68 | /** 69 | * @return array 70 | */ 71 | #[Pure] 72 | public function getRequestOptions(): array 73 | { 74 | return $this->mockResponse->getRequestOptions(); 75 | } 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Kernel/Traits/RespondXmlMessage.php: -------------------------------------------------------------------------------- 1 | createXmlResponse( 32 | attributes: array_filter( 33 | array_merge( 34 | [ 35 | 'ToUserName' => $message->FromUserName, 36 | 'FromUserName' => $message->ToUserName, 37 | 'CreateTime' => time(), 38 | ], 39 | $this->normalizeResponse($response), 40 | ) 41 | ), 42 | encryptor: $encryptor 43 | ); 44 | } 45 | 46 | /** 47 | * @return array 48 | * 49 | * @throws InvalidArgumentException 50 | */ 51 | protected function normalizeResponse(mixed $response): array 52 | { 53 | if (! is_string($response) && is_callable($response)) { 54 | $response = $response(); 55 | } 56 | 57 | if (is_array($response)) { 58 | if (! isset($response['MsgType'])) { 59 | throw new InvalidArgumentException('MsgType cannot be empty.'); 60 | } 61 | 62 | return $response; 63 | } 64 | 65 | if (is_string($response) || is_numeric($response)) { 66 | return [ 67 | 'MsgType' => 'text', 68 | 'Content' => $response, 69 | ]; 70 | } 71 | 72 | throw new InvalidArgumentException( 73 | sprintf('Invalid Response type "%s".', gettype($response)) 74 | ); 75 | } 76 | 77 | /** 78 | * @param array $attributes 79 | * 80 | * @throws RuntimeException 81 | */ 82 | protected function createXmlResponse(array $attributes, ?Encryptor $encryptor = null): ResponseInterface 83 | { 84 | $xml = Xml::build($attributes); 85 | 86 | return new Response(200, ['Content-Type' => 'application/xml'], $encryptor ? $encryptor->encrypt($xml) : $xml); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/MiniApp/AccessToken.php: -------------------------------------------------------------------------------- 1 | account) { 54 | $this->account = new Account( 55 | appId: (string) $this->config->get('app_id'), /** @phpstan-ignore-line */ 56 | secret: (string) $this->config->get('secret'), /** @phpstan-ignore-line */ 57 | token: (string) $this->config->get('token'), /** @phpstan-ignore-line */ 58 | aesKey: (string) $this->config->get('aes_key'),/** @phpstan-ignore-line */ 59 | ); 60 | } 61 | 62 | return $this->account; 63 | } 64 | 65 | public function setAccount(AccountInterface $account): static 66 | { 67 | $this->account = $account; 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * @throws InvalidConfigException 74 | */ 75 | public function getEncryptor(): Encryptor 76 | { 77 | if (! $this->encryptor) { 78 | $token = $this->getAccount()->getToken(); 79 | $aesKey = $this->getAccount()->getAesKey(); 80 | 81 | if (empty($token) || empty($aesKey)) { 82 | throw new InvalidConfigException('token or aes_key cannot be empty.'); 83 | } 84 | 85 | $this->encryptor = new Encryptor( 86 | appId: $this->getAccount()->getAppId(), 87 | token: $token, 88 | aesKey: $aesKey, 89 | receiveId: $this->getAccount()->getAppId() 90 | ); 91 | } 92 | 93 | return $this->encryptor; 94 | } 95 | 96 | public function setEncryptor(Encryptor $encryptor): static 97 | { 98 | $this->encryptor = $encryptor; 99 | 100 | return $this; 101 | } 102 | 103 | public function getServer(): Server|ServerInterface 104 | { 105 | if (! $this->server) { 106 | $this->server = new Server( 107 | request: $this->getRequest(), 108 | encryptor: $this->getAccount()->getAesKey() ? $this->getEncryptor() : null 109 | ); 110 | } 111 | 112 | return $this->server; 113 | } 114 | 115 | public function setServer(ServerInterface $server): static 116 | { 117 | $this->server = $server; 118 | 119 | return $this; 120 | } 121 | 122 | public function getAccessToken(): AccessTokenInterface 123 | { 124 | if (! $this->accessToken) { 125 | $this->accessToken = new AccessToken( 126 | appId: $this->getAccount()->getAppId(), 127 | secret: $this->getAccount()->getSecret(), 128 | cache: $this->getCache(), 129 | httpClient: $this->getHttpClient(), 130 | stable: $this->config->get('use_stable_access_token', false) 131 | ); 132 | } 133 | 134 | return $this->accessToken; 135 | } 136 | 137 | public function setAccessToken(AccessTokenInterface $accessToken): static 138 | { 139 | $this->accessToken = $accessToken; 140 | 141 | return $this; 142 | } 143 | 144 | #[Pure] 145 | public function getUtils(): Utils 146 | { 147 | return new Utils($this); 148 | } 149 | 150 | public function createClient(): AccessTokenAwareClient 151 | { 152 | $httpClient = $this->getHttpClient(); 153 | 154 | if ($this->config->get('http.retry', false)) { 155 | $httpClient = new RetryableHttpClient( 156 | $httpClient, 157 | $this->getRetryStrategy(), 158 | (int) $this->config->get('http.max_retries', 2) // @phpstan-ignore-line 159 | ); 160 | } 161 | 162 | return (new AccessTokenAwareClient( 163 | client: $httpClient, 164 | accessToken: $this->getAccessToken(), 165 | failureJudge: fn ( 166 | Response $response 167 | ) => ($response->toArray()['errcode'] ?? 0) || ! is_null($response->toArray()['error'] ?? null), 168 | throw: (bool) $this->config->get('http.throw', true), 169 | ))->setPresets($this->config->all()); 170 | } 171 | 172 | public function getRetryStrategy(): AccessTokenExpiredRetryStrategy 173 | { 174 | $retryConfig = RequestUtil::mergeDefaultRetryOptions((array) $this->config->get('http.retry', [])); 175 | 176 | return (new AccessTokenExpiredRetryStrategy($retryConfig)) 177 | ->decideUsing(function (AsyncContext $context, ?string $responseContent): bool { 178 | return ! empty($responseContent) 179 | && str_contains($responseContent, '42001') 180 | && str_contains($responseContent, 'access_token expired'); 181 | }); 182 | } 183 | 184 | /** 185 | * @return array 186 | */ 187 | protected function getHttpClientDefaultOptions(): array 188 | { 189 | return array_merge( 190 | ['base_uri' => 'https://api.weixin.qq.com/'], 191 | (array) $this->config->get('http', []) 192 | ); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/MiniApp/Contracts/Account.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | * @throws DecryptException 22 | */ 23 | public static function decrypt(string $sessionKey, string $iv, string $ciphertext): array 24 | { 25 | try { 26 | $decrypted = AesCbc::decrypt( 27 | $ciphertext, 28 | base64_decode($sessionKey, false), 29 | base64_decode($iv, false) 30 | ); 31 | 32 | $decrypted = json_decode($decrypted, true); 33 | 34 | if (! $decrypted || ! is_array($decrypted)) { 35 | throw new DecryptException('The given payload is invalid.'); 36 | } 37 | } catch (Throwable $e) { 38 | throw new DecryptException(sprintf('The given payload is invalid: %s', $e->getMessage())); 39 | } 40 | 41 | return $decrypted; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/MiniApp/Server.php: -------------------------------------------------------------------------------- 1 | app->getHttpClient()->request('GET', '/sns/jscode2session', [ 19 | 'query' => [ 20 | 'appid' => $this->app->getAccount()->getAppId(), 21 | 'secret' => $this->app->getAccount()->getSecret(), 22 | 'js_code' => $code, 23 | 'grant_type' => 'authorization_code', 24 | ], 25 | ])->toArray(false); 26 | 27 | if (empty($response['openid'])) { 28 | throw new HttpException('code2Session error: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 29 | } 30 | 31 | return $response; 32 | } 33 | 34 | public function decryptSession(string $sessionKey, string $iv, string $ciphertext): array 35 | { 36 | return Decryptor::decrypt($sessionKey, $iv, $ciphertext); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/OfficialAccount/AccessToken.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://api.weixin.qq.com/']); 38 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 39 | } 40 | 41 | public function getKey(): string 42 | { 43 | return $this->key ?? $this->key = sprintf('%s.access_token.%s.%s.%s', static::CACHE_KEY_PREFIX, $this->appId, $this->secret, (int) $this->stable); 44 | } 45 | 46 | public function setKey(string $key): static 47 | { 48 | $this->key = $key; 49 | 50 | return $this; 51 | } 52 | 53 | public function getToken(): string 54 | { 55 | $token = $this->cache->get($this->getKey()); 56 | 57 | if ($token && is_string($token)) { 58 | return $token; 59 | } 60 | 61 | return $this->refresh(); 62 | } 63 | 64 | /** 65 | * @return array{access_token:string} 66 | */ 67 | #[ArrayShape(['access_token' => 'string'])] 68 | public function toQuery(): array 69 | { 70 | return ['access_token' => $this->getToken()]; 71 | } 72 | 73 | public function refresh(): string 74 | { 75 | return $this->stable ? $this->getStableAccessToken() : $this->getAccessToken(); 76 | } 77 | 78 | /** 79 | * @throws HttpException 80 | */ 81 | public function getStableAccessToken(bool $force_refresh = false): string 82 | { 83 | $response = $this->httpClient->request( 84 | 'POST', 85 | 'https://api.weixin.qq.com/cgi-bin/stable_token', 86 | [ 87 | 'json' => [ 88 | 'grant_type' => 'client_credential', 89 | 'appid' => $this->appId, 90 | 'secret' => $this->secret, 91 | 'force_refresh' => $force_refresh, 92 | ], 93 | ] 94 | )->toArray(false); 95 | 96 | if (empty($response['access_token'])) { 97 | throw new HttpException('Failed to get stable access_token: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 98 | } 99 | 100 | $this->cache->set($this->getKey(), $response['access_token'], intval($response['expires_in'])); 101 | 102 | return $response['access_token']; 103 | } 104 | 105 | /** 106 | * @throws HttpException 107 | */ 108 | public function getAccessToken(): string 109 | { 110 | $response = $this->httpClient->request( 111 | 'GET', 112 | 'cgi-bin/token', 113 | [ 114 | 'query' => [ 115 | 'grant_type' => 'client_credential', 116 | 'appid' => $this->appId, 117 | 'secret' => $this->secret, 118 | ], 119 | ] 120 | )->toArray(false); 121 | 122 | if (empty($response['access_token'])) { 123 | throw new HttpException('Failed to get access_token: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 124 | } 125 | 126 | $this->cache->set($this->getKey(), $response['access_token'], intval($response['expires_in'])); 127 | 128 | return $response['access_token']; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/OfficialAccount/Account.php: -------------------------------------------------------------------------------- 1 | appId; 23 | } 24 | 25 | /** 26 | * @throws RuntimeException 27 | */ 28 | public function getSecret(): string 29 | { 30 | if ($this->secret === null) { 31 | throw new RuntimeException('No secret configured.'); 32 | } 33 | 34 | return $this->secret; 35 | } 36 | 37 | public function getToken(): ?string 38 | { 39 | return $this->token; 40 | } 41 | 42 | public function getAesKey(): ?string 43 | { 44 | return $this->aesKey; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/OfficialAccount/Config.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected array $requiredKeys = [ 13 | 'app_id', 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /src/OfficialAccount/Contracts/Account.php: -------------------------------------------------------------------------------- 1 | getKey(); 18 | $ticket = $this->cache->get($key); 19 | 20 | if ($ticket && \is_string($ticket)) { 21 | return $ticket; 22 | } 23 | 24 | return $this->refreshTicket(); 25 | } 26 | 27 | /** 28 | * @throws HttpException 29 | */ 30 | public function refreshTicket(): string 31 | { 32 | $response = $this->httpClient->request('GET', '/cgi-bin/ticket/getticket', ['query' => ['type' => 'jsapi']]) 33 | ->toArray(false); 34 | 35 | if (empty($response['ticket'])) { 36 | throw new HttpException('Failed to get jssdk ticket: '.\json_encode($response, JSON_UNESCAPED_UNICODE)); 37 | } 38 | 39 | $this->cache->set($this->getKey(), $response['ticket'], \intval($response['expires_in'])); 40 | 41 | return $response['ticket']; 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | #[ArrayShape([ 48 | 'url' => 'string', 49 | 'nonceStr' => 'string', 50 | 'timestamp' => 'int', 51 | 'appId' => 'string', 52 | 'signature' => 'string', 53 | ])] 54 | public function configSignature(string $url, string $nonce, int $timestamp): array 55 | { 56 | return [ 57 | 'url' => $url, 58 | 'nonceStr' => $nonce, 59 | 'timestamp' => $timestamp, 60 | 'appId' => $this->appId, 61 | 'signature' => sha1(sprintf( 62 | 'jsapi_ticket=%s&noncestr=%s×tamp=%s&url=%s', 63 | $this->getTicket(), 64 | $nonce, 65 | $timestamp, 66 | $url 67 | )), 68 | ]; 69 | } 70 | 71 | public function getKey(): string 72 | { 73 | return $this->key ?? $this->key = sprintf('official_account.jsapi_ticket.%s', $this->appId); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/OfficialAccount/Message.php: -------------------------------------------------------------------------------- 1 | request = $request; 32 | } 33 | 34 | public function serve(): ResponseInterface 35 | { 36 | if ($str = $this->getRequest()->getQueryParams()['echostr'] ?? '') { 37 | return new Response(200, [], $str); 38 | } 39 | 40 | $message = $this->getRequestMessage($this->getRequest()); 41 | $query = $this->getRequest()->getQueryParams(); 42 | 43 | if ($this->encryptor && ! empty($query['msg_signature'])) { 44 | $this->prepend($this->decryptRequestMessage($query)); 45 | } 46 | 47 | $response = $this->handle(new Response(200, [], 'success'), $message); 48 | 49 | if (! ($response instanceof ResponseInterface)) { 50 | $response = $this->transformToReply($response, $message, $this->encryptor); 51 | } 52 | 53 | return ServerResponse::make($response); 54 | } 55 | 56 | /** 57 | * @throws Throwable 58 | */ 59 | public function addMessageListener(string $type, callable|string $handler): static 60 | { 61 | $handler = $this->makeClosure($handler); 62 | $this->withHandler( 63 | function (Message $message, Closure $next) use ($type, $handler): mixed { 64 | return $message->MsgType === $type ? $handler($message, $next) : $next($message); 65 | } 66 | ); 67 | 68 | return $this; 69 | } 70 | 71 | public function addEventListener(string $event, callable|string $handler): static 72 | { 73 | $handler = $this->makeClosure($handler); 74 | $this->withHandler( 75 | function (Message $message, Closure $next) use ($event, $handler): mixed { 76 | return $message->Event === $event ? $handler($message, $next) : $next($message); 77 | } 78 | ); 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * @param array $query 85 | * 86 | * @psalm-suppress PossiblyNullArgument 87 | */ 88 | protected function decryptRequestMessage(array $query): Closure 89 | { 90 | return function (Message $message, Closure $next) use ($query): mixed { 91 | if (! $this->encryptor) { 92 | return null; 93 | } 94 | 95 | $this->decryptMessage( 96 | message: $message, 97 | encryptor: $this->encryptor, 98 | signature: $query['msg_signature'] ?? '', 99 | timestamp: $query['timestamp'] ?? '', 100 | nonce: $query['nonce'] ?? '' 101 | ); 102 | 103 | return $next($message); 104 | }; 105 | } 106 | 107 | public function getRequestMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message 108 | { 109 | return Message::createFromRequest($request ?? $this->getRequest()); 110 | } 111 | 112 | public function getDecryptedMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message 113 | { 114 | $request = $request ?? $this->getRequest(); 115 | $message = $this->getRequestMessage($request); 116 | $query = $request->getQueryParams(); 117 | 118 | if (! $this->encryptor || empty($query['msg_signature'])) { 119 | return $message; 120 | } 121 | 122 | return $this->decryptMessage( 123 | message: $message, 124 | encryptor: $this->encryptor, 125 | signature: $query['msg_signature'], 126 | timestamp: $query['timestamp'] ?? '', 127 | nonce: $query['nonce'] ?? '' 128 | ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/OfficialAccount/Utils.php: -------------------------------------------------------------------------------- 1 | $jsApiList 17 | * @param array $openTagList 18 | * @return array 19 | */ 20 | public function buildJsSdkConfig( 21 | string $url, 22 | array $jsApiList = [], 23 | array $openTagList = [], 24 | bool $debug = false 25 | ): array { 26 | return array_merge( 27 | compact('jsApiList', 'openTagList', 'debug'), 28 | $this->app->getTicket()->configSignature($url, Str::random(), time()) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/OpenPlatform/Account.php: -------------------------------------------------------------------------------- 1 | appId; 22 | } 23 | 24 | public function getSecret(): string 25 | { 26 | return $this->secret; 27 | } 28 | 29 | public function getToken(): string 30 | { 31 | return $this->token; 32 | } 33 | 34 | public function getAesKey(): string 35 | { 36 | return $this->aesKey; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/OpenPlatform/Authorization.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class Authorization implements Arrayable, ArrayAccess, Jsonable 17 | { 18 | use HasAttributes; 19 | 20 | public function getAppId(): string 21 | { 22 | /** @phpstan-ignore-next-line */ 23 | return (string) $this->attributes['authorization_info']['authorizer_appid'] ?? ''; 24 | } 25 | 26 | #[Pure] 27 | public function getAccessToken(): AuthorizerAccessToken 28 | { 29 | return new AuthorizerAccessToken( 30 | /** @phpstan-ignore-next-line */ 31 | $this->attributes['authorization_info']['authorizer_appid'] ?? '', 32 | 33 | /** @phpstan-ignore-next-line */ 34 | $this->attributes['authorization_info']['authorizer_access_token'] ?? '' 35 | ); 36 | } 37 | 38 | public function getRefreshToken(): string 39 | { 40 | /** @phpstan-ignore-next-line */ 41 | return $this->attributes['authorization_info']['authorizer_refresh_token'] ?? ''; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/OpenPlatform/AuthorizerAccessToken.php: -------------------------------------------------------------------------------- 1 | appId; 21 | } 22 | 23 | public function getToken(): string 24 | { 25 | return $this->accessToken; 26 | } 27 | 28 | public function __toString() 29 | { 30 | return $this->accessToken; 31 | } 32 | 33 | /** 34 | * @return array 35 | */ 36 | #[Pure] 37 | #[ArrayShape(['access_token' => 'string'])] 38 | public function toQuery(): array 39 | { 40 | return ['access_token' => $this->getToken()]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/OpenPlatform/ComponentAccessToken.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://api.weixin.qq.com/']); 36 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 37 | } 38 | 39 | public function getKey(): string 40 | { 41 | return $this->key ?? $this->key = \sprintf('open_platform.component_access_token.%s', $this->appId); 42 | } 43 | 44 | public function setKey(string $key): static 45 | { 46 | $this->key = $key; 47 | 48 | return $this; 49 | } 50 | 51 | public function getToken(): string 52 | { 53 | $token = $this->cache->get($this->getKey()); 54 | 55 | if ($token && \is_string($token)) { 56 | return $token; 57 | } 58 | 59 | return $this->refresh(); 60 | } 61 | 62 | #[ArrayShape(['component_access_token' => 'string'])] 63 | public function toQuery(): array 64 | { 65 | return ['component_access_token' => $this->getToken()]; 66 | } 67 | 68 | /** 69 | * @throws HttpException 70 | */ 71 | public function refresh(): string 72 | { 73 | $response = $this->httpClient->request( 74 | 'POST', 75 | 'cgi-bin/component/api_component_token', 76 | [ 77 | 'json' => [ 78 | 'component_appid' => $this->appId, 79 | 'component_appsecret' => $this->secret, 80 | 'component_verify_ticket' => $this->verifyTicket->getTicket(), 81 | ], 82 | ] 83 | )->toArray(false); 84 | 85 | if (empty($response['component_access_token'])) { 86 | throw new HttpException('Failed to get component_access_token: '.json_encode( 87 | $response, 88 | JSON_UNESCAPED_UNICODE 89 | )); 90 | } 91 | 92 | $this->cache->set( 93 | $this->getKey(), 94 | $response['component_access_token'], 95 | abs(intval($response['expires_in']) - 100) 96 | ); 97 | 98 | return $response['component_access_token']; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/OpenPlatform/Config.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected array $requiredKeys = [ 13 | 'app_id', 14 | 'secret', 15 | 'aes_key', 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /src/OpenPlatform/Contracts/Account.php: -------------------------------------------------------------------------------- 1 | $config 44 | */ 45 | public function getMiniApp(AuthorizerAccessToken $authorizerAccessToken, array $config): MiniAppApplication; 46 | 47 | /** 48 | * @param array $config 49 | */ 50 | public function getOfficialAccount( 51 | AuthorizerAccessToken $authorizerAccessToken, 52 | array $config 53 | ): OfficialAccountApplication; 54 | } 55 | -------------------------------------------------------------------------------- /src/OpenPlatform/Contracts/VerifyTicket.php: -------------------------------------------------------------------------------- 1 | request = $request; 35 | } 36 | 37 | public function serve(): ResponseInterface 38 | { 39 | if ($str = $this->getRequest()->getQueryParams()['echostr'] ?? '') { 40 | return new Response(200, [], $str); 41 | } 42 | 43 | $message = $this->getRequestMessage($this->getRequest()); 44 | 45 | $this->prepend($this->decryptRequestMessage()); 46 | 47 | $response = $this->handle(new Response(200, [], 'success'), $message); 48 | 49 | if (! ($response instanceof ResponseInterface)) { 50 | $response = $this->transformToReply($response, $message, $this->encryptor); 51 | } 52 | 53 | return ServerResponse::make($response); 54 | } 55 | 56 | public function handleAuthorized(callable $handler): static 57 | { 58 | $this->with(function (Message $message, Closure $next) use ($handler): mixed { 59 | return $message->InfoType === 'authorized' ? $handler($message, $next) : $next($message); 60 | }); 61 | 62 | return $this; 63 | } 64 | 65 | public function handleUnauthorized(callable $handler): static 66 | { 67 | $this->with(function (Message $message, Closure $next) use ($handler): mixed { 68 | return $message->InfoType === 'unauthorized' ? $handler($message, $next) : $next($message); 69 | }); 70 | 71 | return $this; 72 | } 73 | 74 | public function handleAuthorizeUpdated(callable $handler): static 75 | { 76 | $this->with(function (Message $message, Closure $next) use ($handler): mixed { 77 | return $message->InfoType === 'updateauthorized' ? $handler($message, $next) : $next($message); 78 | }); 79 | 80 | return $this; 81 | } 82 | 83 | public function withDefaultVerifyTicketHandler(callable $handler): void 84 | { 85 | $this->defaultVerifyTicketHandler = fn (): mixed => $handler(...func_get_args()); 86 | $this->handleVerifyTicketRefreshed($this->defaultVerifyTicketHandler); 87 | } 88 | 89 | public function handleVerifyTicketRefreshed(callable $handler): static 90 | { 91 | if ($this->defaultVerifyTicketHandler) { 92 | $this->withoutHandler($this->defaultVerifyTicketHandler); 93 | } 94 | 95 | $this->with(function (Message $message, Closure $next) use ($handler): mixed { 96 | return $message->InfoType === 'component_verify_ticket' ? $handler($message, $next) : $next($message); 97 | }); 98 | 99 | return $this; 100 | } 101 | 102 | protected function decryptRequestMessage(): Closure 103 | { 104 | $query = $this->getRequest()->getQueryParams(); 105 | 106 | return function (Message $message, Closure $next) use ($query): mixed { 107 | $message = $this->decryptMessage( 108 | message: $message, 109 | encryptor: $this->encryptor, 110 | signature: $query['msg_signature'] ?? '', 111 | timestamp: $query['timestamp'] ?? '', 112 | nonce: $query['nonce'] ?? '' 113 | ); 114 | 115 | return $next($message); 116 | }; 117 | } 118 | 119 | public function getRequestMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message 120 | { 121 | return Message::createFromRequest($request ?? $this->getRequest()); 122 | } 123 | 124 | public function getDecryptedMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message 125 | { 126 | $request = $request ?? $this->getRequest(); 127 | $message = $this->getRequestMessage($request); 128 | $query = $request->getQueryParams(); 129 | 130 | return $this->decryptMessage( 131 | message: $message, 132 | encryptor: $this->encryptor, 133 | signature: $query['msg_signature'] ?? '', 134 | timestamp: $query['timestamp'] ?? '', 135 | nonce: $query['nonce'] ?? '' 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/OpenPlatform/VerifyTicket.php: -------------------------------------------------------------------------------- 1 | cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 26 | } 27 | 28 | public function getKey(): string 29 | { 30 | return $this->key ?? $this->key = sprintf('open_platform.verify_ticket.%s', $this->appId); 31 | } 32 | 33 | public function setKey(string $key): static 34 | { 35 | $this->key = $key; 36 | 37 | return $this; 38 | } 39 | 40 | public function setTicket(string $ticket): static 41 | { 42 | $this->cache->set($this->getKey(), $ticket, 6000); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * @throws RuntimeException 49 | */ 50 | public function getTicket(): string 51 | { 52 | $ticket = $this->cache->get($this->getKey()); 53 | 54 | if (! $ticket || ! is_string($ticket)) { 55 | throw new RuntimeException('No component_verify_ticket found.'); 56 | } 57 | 58 | return $ticket; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/OpenWork/Account.php: -------------------------------------------------------------------------------- 1 | corpId; 24 | } 25 | 26 | public function getProviderSecret(): string 27 | { 28 | return $this->providerSecret; 29 | } 30 | 31 | public function getSuiteId(): string 32 | { 33 | return $this->suiteId; 34 | } 35 | 36 | public function getSuiteSecret(): string 37 | { 38 | return $this->suiteSecret; 39 | } 40 | 41 | public function getToken(): string 42 | { 43 | return $this->token; 44 | } 45 | 46 | public function getAesKey(): string 47 | { 48 | return $this->aesKey; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/OpenWork/Authorization.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class Authorization implements Arrayable, ArrayAccess, Jsonable 16 | { 17 | use HasAttributes; 18 | 19 | public function getAppId(): string 20 | { 21 | /** @phpstan-ignore-next-line */ 22 | return (string) $this->attributes['auth_corp_info']['corpid']; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/OpenWork/AuthorizerAccessToken.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']); 33 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 34 | } 35 | 36 | public function getCorpId(): string 37 | { 38 | return $this->corpId; 39 | } 40 | 41 | public function getToken(): string 42 | { 43 | if (! isset($this->suiteAccessToken)) { 44 | return $this->permanentCodeOrAccessToken; 45 | } 46 | 47 | $token = $this->cache->get($this->getKey()); 48 | 49 | if ($token && is_string($token)) { 50 | return $token; 51 | } 52 | 53 | return $this->refresh(); 54 | } 55 | 56 | public function __toString() 57 | { 58 | return $this->getToken(); 59 | } 60 | 61 | #[ArrayShape(['access_token' => 'string'])] 62 | public function toQuery(): array 63 | { 64 | return ['access_token' => $this->getToken()]; 65 | } 66 | 67 | public function getKey(): string 68 | { 69 | return $this->key ?? $this->key = sprintf('open_work.authorizer.access_token.%s.%s', $this->corpId, $this->permanentCodeOrAccessToken); 70 | } 71 | 72 | public function setKey(string $key): static 73 | { 74 | $this->key = $key; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * @throws HttpException 81 | */ 82 | public function refresh(): string 83 | { 84 | if (! isset($this->suiteAccessToken)) { 85 | return ''; 86 | } 87 | 88 | $response = $this->httpClient->request('POST', 'cgi-bin/service/get_corp_token', [ 89 | 'query' => [ 90 | 'suite_access_token' => $this->suiteAccessToken->getToken(), 91 | ], 92 | 'json' => [ 93 | 'auth_corpid' => $this->corpId, 94 | 'permanent_code' => $this->permanentCodeOrAccessToken, 95 | ], 96 | ])->toArray(false); 97 | 98 | if (empty($response['access_token'])) { 99 | throw new HttpException('Failed to get access_token: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 100 | } 101 | 102 | $this->cache->set($this->getKey(), $response['access_token'], intval($response['expires_in'])); 103 | 104 | return $response['access_token']; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/OpenWork/Config.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected array $requiredKeys = [ 13 | 'corp_id', 14 | 'suite_id', 15 | 'provider_secret', 16 | 'suite_secret', 17 | 'token', 18 | 'aes_key', 19 | ]; 20 | } 21 | -------------------------------------------------------------------------------- /src/OpenWork/Contracts/Account.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']); 31 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 32 | } 33 | 34 | /** 35 | * @return array 36 | */ 37 | public function createConfigSignature(string $nonce, int $timestamp, string $url, array $jsApiList = [], bool $debug = false, bool $beta = true): array 38 | { 39 | return [ 40 | 'appId' => $this->corpId, 41 | 'nonceStr' => $nonce, 42 | 'timestamp' => $timestamp, 43 | 'url' => $url, 44 | 'signature' => $this->getTicketSignature($this->getTicket(), $nonce, $timestamp, $url), 45 | 'jsApiList' => $jsApiList, 46 | 'debug' => $debug, 47 | 'beta' => $beta, 48 | ]; 49 | } 50 | 51 | public function getTicketSignature(string $ticket, string $nonce, int $timestamp, string $url): string 52 | { 53 | return sha1(sprintf('jsapi_ticket=%s&noncestr=%s×tamp=%s&url=%s', $ticket, $nonce, $timestamp, $url)); 54 | } 55 | 56 | /** 57 | * @throws HttpException 58 | */ 59 | public function getTicket(): string 60 | { 61 | $key = $this->getKey(); 62 | $ticket = $this->cache->get($key); 63 | 64 | if ($ticket && is_string($ticket)) { 65 | return $ticket; 66 | } 67 | 68 | $response = $this->httpClient->request('GET', '/cgi-bin/get_jsapi_ticket')->toArray(false); 69 | 70 | if (empty($response['ticket'])) { 71 | throw new HttpException('Failed to get jssdk ticket: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 72 | } 73 | 74 | $this->cache->set($key, $response['ticket'], intval($response['expires_in'])); 75 | 76 | return $response['ticket']; 77 | } 78 | 79 | public function setKey(string $key): static 80 | { 81 | $this->key = $key; 82 | 83 | return $this; 84 | } 85 | 86 | public function getKey(): string 87 | { 88 | return $this->key ?? $this->key = sprintf('open_work.jsapi_ticket.%s', $this->corpId); 89 | } 90 | 91 | public function createAgentConfigSignature(int $agentId, string $nonce, int $timestamp, string $url, array $jsApiList = []): array 92 | { 93 | return [ 94 | 'corpid' => $this->corpId, 95 | 'agentid' => $agentId, 96 | 'nonceStr' => $nonce, 97 | 'timestamp' => $timestamp, 98 | 'signature' => $this->getTicketSignature($this->getAgentTicket($agentId), $nonce, $timestamp, $url), 99 | 'jsApiList' => $jsApiList, 100 | ]; 101 | } 102 | 103 | /** 104 | * @throws HttpException 105 | */ 106 | public function getAgentTicket(int $agentId): string 107 | { 108 | $key = $this->getAgentKey($agentId); 109 | $ticket = $this->cache->get($key); 110 | 111 | if ($ticket && is_string($ticket)) { 112 | return $ticket; 113 | } 114 | 115 | $response = $this->httpClient->request('GET', '/cgi-bin/ticket/get', ['query' => ['type' => 'agent_config']])->toArray(false); 116 | 117 | if (empty($response['ticket'])) { 118 | throw new HttpException('Failed to get jssdk agentTicket: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 119 | } 120 | 121 | $this->cache->set($key, $response['ticket'], intval($response['expires_in'])); 122 | 123 | return $response['ticket']; 124 | } 125 | 126 | public function getAgentKey(int $agentId): string 127 | { 128 | return sprintf('%s.%s', $this->getKey(), $agentId); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/OpenWork/Message.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']); 34 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 35 | } 36 | 37 | public function getKey(): string 38 | { 39 | return $this->key ?? $this->key = \sprintf('open_work.access_token.%s.%s', $this->corpId, $this->providerSecret); 40 | } 41 | 42 | public function setKey(string $key): static 43 | { 44 | $this->key = $key; 45 | 46 | return $this; 47 | } 48 | 49 | public function getToken(): string 50 | { 51 | $token = $this->cache->get($this->getKey()); 52 | 53 | if ($token && \is_string($token)) { 54 | return $token; 55 | } 56 | 57 | return $this->refresh(); 58 | } 59 | 60 | /** 61 | * @return array 62 | */ 63 | #[ArrayShape(['provider_access_token' => 'string'])] 64 | public function toQuery(): array 65 | { 66 | return ['provider_access_token' => $this->getToken()]; 67 | } 68 | 69 | /** 70 | * @throws HttpException 71 | */ 72 | public function refresh(): string 73 | { 74 | $response = $this->httpClient->request('POST', 'cgi-bin/service/get_provider_token', [ 75 | 'json' => [ 76 | 'corpid' => $this->corpId, 77 | 'provider_secret' => $this->providerSecret, 78 | ], 79 | ])->toArray(false); 80 | 81 | if (empty($response['provider_access_token'])) { 82 | throw new HttpException('Failed to get provider_access_token: '.\json_encode( 83 | $response, 84 | JSON_UNESCAPED_UNICODE 85 | )); 86 | } 87 | 88 | $this->cache->set($this->getKey(), $response['provider_access_token'], intval($response['expires_in'])); 89 | 90 | return $response['provider_access_token']; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/OpenWork/SuiteAccessToken.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']); 38 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 39 | $this->suiteTicket ??= new SuiteTicket($this->suiteId, $this->cache); 40 | } 41 | 42 | public function getKey(): string 43 | { 44 | return $this->key ?? $this->key = \sprintf('open_work.suite_access_token.%s.%s', $this->suiteId, $this->suiteSecret); 45 | } 46 | 47 | public function setKey(string $key): static 48 | { 49 | $this->key = $key; 50 | 51 | return $this; 52 | } 53 | 54 | public function getToken(): string 55 | { 56 | $token = $this->cache->get($this->getKey()); 57 | 58 | if ($token && \is_string($token)) { 59 | return $token; 60 | } 61 | 62 | return $this->refresh(); 63 | } 64 | 65 | /** 66 | * @return array 67 | */ 68 | #[ArrayShape(['suite_access_token' => 'string'])] 69 | public function toQuery(): array 70 | { 71 | return ['suite_access_token' => $this->getToken()]; 72 | } 73 | 74 | /** 75 | * @throws HttpException 76 | */ 77 | public function refresh(): string 78 | { 79 | $response = $this->httpClient->request('POST', 'cgi-bin/service/get_suite_token', [ 80 | 'json' => [ 81 | 'suite_id' => $this->suiteId, 82 | 'suite_secret' => $this->suiteSecret, 83 | 'suite_ticket' => $this->suiteTicket?->getTicket(), 84 | ], 85 | ])->toArray(false); 86 | 87 | if (empty($response['suite_access_token'])) { 88 | throw new HttpException('Failed to get suite_access_token: '.json_encode( 89 | $response, 90 | JSON_UNESCAPED_UNICODE 91 | )); 92 | } 93 | 94 | $this->cache->set( 95 | $this->getKey(), 96 | $response['suite_access_token'], 97 | abs(intval($response['expires_in']) - 100) 98 | ); 99 | 100 | return $response['suite_access_token']; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/OpenWork/SuiteEncryptor.php: -------------------------------------------------------------------------------- 1 | cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 26 | } 27 | 28 | public function getKey(): string 29 | { 30 | return $this->key ?? $this->key = sprintf('open_work.suite_ticket.%s', $this->suiteId); 31 | } 32 | 33 | public function setKey(string $key): static 34 | { 35 | $this->key = $key; 36 | 37 | return $this; 38 | } 39 | 40 | public function setTicket(string $ticket): static 41 | { 42 | $this->cache->set($this->getKey(), $ticket, 6000); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * @throws RuntimeException 49 | */ 50 | public function getTicket(): string 51 | { 52 | $ticket = $this->cache->get($this->getKey()); 53 | 54 | if (! $ticket || ! is_string($ticket)) { 55 | throw new RuntimeException('No suite_ticket found.'); 56 | } 57 | 58 | return $ticket; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Pay/Application.php: -------------------------------------------------------------------------------- 1 | getMerchant()); 36 | } 37 | 38 | public function getMerchant(): Merchant 39 | { 40 | if (! $this->merchant) { 41 | $this->merchant = new Merchant( 42 | mchId: $this->config['mch_id'], /** @phpstan-ignore-line */ 43 | privateKey: new PrivateKey((string) $this->config['private_key']), /** @phpstan-ignore-line */ 44 | certificate: new PublicKey((string) $this->config['certificate']), /** @phpstan-ignore-line */ 45 | secretKey: (string) $this->config['secret_key'], /** @phpstan-ignore-line */ 46 | v2SecretKey: (string) $this->config['v2_secret_key'], /** @phpstan-ignore-line */ 47 | platformCerts: $this->config->has('platform_certs') ? (array) $this->config['platform_certs'] : [],/** @phpstan-ignore-line */ 48 | ); 49 | } 50 | 51 | return $this->merchant; 52 | } 53 | 54 | public function getValidator(): ValidatorInterface 55 | { 56 | if (! $this->validator) { 57 | $this->validator = new Validator($this->getMerchant()); 58 | } 59 | 60 | return $this->validator; 61 | } 62 | 63 | public function setValidator(ValidatorInterface $validator): static 64 | { 65 | $this->validator = $validator; 66 | 67 | return $this; 68 | } 69 | 70 | public function getServer(): Server|ServerInterface 71 | { 72 | if (! $this->server) { 73 | $this->server = new Server( 74 | merchant: $this->getMerchant(), 75 | request: $this->getRequest(), 76 | ); 77 | } 78 | 79 | return $this->server; 80 | } 81 | 82 | public function setServer(ServerInterface $server): static 83 | { 84 | $this->server = $server; 85 | 86 | return $this; 87 | } 88 | 89 | public function setConfig(ConfigInterface $config): static 90 | { 91 | $this->config = $config; 92 | 93 | return $this; 94 | } 95 | 96 | public function getConfig(): ConfigInterface 97 | { 98 | return $this->config; 99 | } 100 | 101 | public function getClient(): Client|HttpClientInterface 102 | { 103 | return $this->client ?? $this->client = (new Client( 104 | $this->getMerchant(), 105 | $this->getHttpClient(), 106 | (array) $this->config->get('http', []) 107 | ))->setPresets($this->config->all()); 108 | } 109 | 110 | public function setClient(HttpClientInterface $client): static 111 | { 112 | $this->client = $client; 113 | 114 | return $this; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Pay/Config.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected array $requiredKeys = [ 13 | 'mch_id', 14 | 'secret_key', 15 | 'private_key', 16 | 'certificate', 17 | ]; 18 | } 19 | -------------------------------------------------------------------------------- /src/Pay/Contracts/Application.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | public function getPlatformCerts(): array; 28 | } 29 | -------------------------------------------------------------------------------- /src/Pay/Contracts/ResponseValidator.php: -------------------------------------------------------------------------------- 1 | $params 27 | * @return array 28 | * 29 | * @throws InvalidConfigException 30 | * @throws RuntimeException 31 | */ 32 | public function sign(array $params): array 33 | { 34 | $nonce = Str::random(); 35 | 36 | $params = $attributes = array_filter( 37 | \array_merge( 38 | [ 39 | 'nonce_str' => $nonce, 40 | 'sub_mch_id' => $params['sub_mch_id'] ?? null, 41 | 'sub_appid' => $params['sub_appid'] ?? null, 42 | ], 43 | $params 44 | ), 45 | static fn ($value, $key) => ! ($key === 'sign' || $value === '' || is_null($value)), 46 | ARRAY_FILTER_USE_BOTH 47 | ); 48 | 49 | ksort($attributes); 50 | 51 | $attributes['key'] = $this->merchant->getV2SecretKey(); 52 | 53 | if (empty($attributes['key'])) { 54 | throw new InvalidConfigException('Missing V2 API key.'); 55 | } 56 | 57 | if (! empty($params['sign_type']) && $params['sign_type'] === 'HMAC-SHA256') { 58 | $signType = fn (string $message): string => hash_hmac('sha256', $message, $attributes['key']); 59 | } else { 60 | $signType = 'md5'; 61 | } 62 | 63 | $sign = call_user_func_array($signType, [urldecode(http_build_query($attributes))]); 64 | 65 | if (! is_string($sign)) { 66 | throw new RuntimeException('Failed to sign the request.'); 67 | } 68 | 69 | $params['sign'] = strtoupper($sign); 70 | 71 | return $params; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Pay/Merchant.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected array $platformCerts = []; 22 | 23 | /** 24 | * @param array $platformCerts 25 | */ 26 | public function __construct( 27 | protected int|string $mchId, 28 | protected PrivateKey $privateKey, 29 | protected PublicKey $certificate, 30 | protected string $secretKey, 31 | protected ?string $v2SecretKey = null, 32 | array $platformCerts = [], 33 | ) { 34 | $this->platformCerts = $this->normalizePlatformCerts($platformCerts); 35 | } 36 | 37 | public function getMerchantId(): int 38 | { 39 | return intval($this->mchId); 40 | } 41 | 42 | public function getPrivateKey(): PrivateKey 43 | { 44 | return $this->privateKey; 45 | } 46 | 47 | public function getCertificate(): PublicKey 48 | { 49 | return $this->certificate; 50 | } 51 | 52 | public function getSecretKey(): string 53 | { 54 | return $this->secretKey; 55 | } 56 | 57 | public function getV2SecretKey(): ?string 58 | { 59 | return $this->v2SecretKey; 60 | } 61 | 62 | public function getPlatformCert(string $serial): ?PublicKey 63 | { 64 | return $this->platformCerts[$serial] ?? null; 65 | } 66 | 67 | public function getPlatformCerts(): array 68 | { 69 | return $this->platformCerts; 70 | } 71 | 72 | /** 73 | * @param array $platformCerts 74 | * @return array 75 | * 76 | * @throws InvalidArgumentException 77 | */ 78 | protected function normalizePlatformCerts(array $platformCerts): array 79 | { 80 | $certs = []; 81 | $isList = array_is_list($platformCerts); 82 | foreach ($platformCerts as $index => $publicKey) { 83 | if (is_string($publicKey)) { 84 | $publicKey = new PublicKey($publicKey); 85 | } 86 | 87 | if (! $publicKey instanceof PublicKey) { 88 | throw new InvalidArgumentException('Invalid platform certficate.'); 89 | } 90 | 91 | $certs[$isList ? $publicKey->getSerialNo() : $index] = $publicKey; 92 | } 93 | 94 | return $certs; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Pay/Message.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getOriginalAttributes(): array 20 | { 21 | $attributes = json_decode($this->getOriginalContents(), true); 22 | 23 | return is_array($attributes) ? $attributes : []; 24 | } 25 | 26 | /** 27 | * @throws RuntimeException 28 | */ 29 | public function getEventType(): ?string 30 | { 31 | $eventType = $this->getOriginalAttributes()['event_type']; 32 | 33 | if (! is_string($eventType)) { 34 | throw new RuntimeException('Invalid event type.'); 35 | } 36 | 37 | return $eventType; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Pay/ResponseValidator.php: -------------------------------------------------------------------------------- 1 | toPsrResponse(); 25 | } 26 | 27 | if ($response->getStatusCode() !== 200) { 28 | throw new BadResponseException('Request Failed'); 29 | } 30 | 31 | (new Validator($this->merchant))->validate($response); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Pay/Signature.php: -------------------------------------------------------------------------------- 1 | $options 31 | */ 32 | public function createHeader(string $method, string $url, array $options): string 33 | { 34 | $uri = new Uri($url); 35 | 36 | parse_str($uri->getQuery(), $query); 37 | $uri = $uri->withQuery(http_build_query(array_merge($query, (array) ($options['query'] ?? [])))); 38 | 39 | $body = ''; 40 | $query = $uri->getQuery(); 41 | $timestamp = time(); 42 | $nonce = Str::random(); 43 | $path = '/'.ltrim($uri->getPath().(empty($query) ? '' : '?'.$query), '/'); 44 | 45 | if (! empty($options['body']) && (is_scalar($options['body']) || $options['body'] instanceof Stringable)) { 46 | $body = strval($options['body']); 47 | } 48 | 49 | $message = strtoupper($method)."\n". 50 | $path."\n". 51 | $timestamp."\n". 52 | $nonce."\n". 53 | $body."\n"; 54 | 55 | openssl_sign($message, $signature, $this->merchant->getPrivateKey()->getKey(), 'sha256WithRSAEncryption'); 56 | 57 | return sprintf( 58 | 'WECHATPAY2-SHA256-RSA2048 %s', 59 | sprintf( 60 | 'mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"', 61 | $this->merchant->getMerchantId(), 62 | $nonce, 63 | $timestamp, 64 | $this->merchant->getCertificate()->getSerialNo(), 65 | base64_encode($signature) 66 | ) 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Pay/URLSchemeBuilder.php: -------------------------------------------------------------------------------- 1 | $appId, 22 | 'mch_id' => $this->merchant->getMerchantId(), 23 | 'time_stamp' => time(), 24 | 'nonce_str' => Str::random(), 25 | 'product_id' => $productId, 26 | ]; 27 | 28 | $params['sign'] = (new LegacySignature($this->merchant))->sign($params); 29 | 30 | return 'weixin://wxpay/bizpayurl?'.http_build_query($params); 31 | } 32 | 33 | public function forCodeUrl(string $codeUrl): string 34 | { 35 | return sprintf('weixin://wxpay/bizpayurl?sr=%s', $codeUrl); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Pay/Utils.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | #[ArrayShape([ 31 | 'appId' => 'string', 32 | 'timeStamp' => 'string', 33 | 'nonceStr' => 'string', 34 | 'package' => 'string', 35 | 'signType' => 'string', 36 | 'paySign' => 'string', 37 | ])] 38 | public function buildBridgeConfig(string $prepayId, string $appId, string $signType = 'RSA'): array 39 | { 40 | $params = [ 41 | 'appId' => $appId, 42 | 'timeStamp' => strval(time()), 43 | 'nonceStr' => Str::random(), 44 | 'package' => "prepay_id=$prepayId", 45 | 'signType' => $signType, 46 | ]; 47 | 48 | $message = $params['appId']."\n". 49 | $params['timeStamp']."\n". 50 | $params['nonceStr']."\n". 51 | $params['package']."\n"; 52 | 53 | // v2 54 | if ($signType != 'RSA') { 55 | $params['paySign'] = $this->createV2Signature($params); 56 | } else { 57 | // v3 58 | $params['paySign'] = $this->createSignature($message); 59 | } 60 | 61 | return $params; 62 | } 63 | 64 | /** 65 | * @see https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#58 66 | * 67 | * @return array 68 | */ 69 | #[ArrayShape([ 70 | 'appId' => 'string', 71 | 'nonceStr' => 'string', 72 | 'package' => 'string', 73 | 'signType' => 'string', 74 | 'paySign' => 'string', 75 | 'timestamp' => 'string', 76 | ])] 77 | public function buildSdkConfig(string $prepayId, string $appId, string $signType = 'RSA'): array 78 | { 79 | $params = $this->buildBridgeConfig($prepayId, $appId, $signType); 80 | 81 | $params['timestamp'] = $params['timeStamp']; 82 | unset($params['timeStamp']); 83 | 84 | return $params; 85 | } 86 | 87 | /** 88 | * @see https://developers.weixin.qq.com/miniprogram/dev/api/payment/wx.requestPayment.html 89 | * 90 | * @return array 91 | */ 92 | #[ArrayShape([ 93 | 'appId' => 'string', 94 | 'timeStamp' => 'string', 95 | 'nonceStr' => 'string', 96 | 'package' => 'string', 97 | 'signType' => 'string', 98 | 'paySign' => 'string', 99 | ])] 100 | public function buildMiniAppConfig(string $prepayId, string $appId, string $signType = 'RSA'): array 101 | { 102 | return $this->buildBridgeConfig($prepayId, $appId, $signType); 103 | } 104 | 105 | /** 106 | * @return array 107 | */ 108 | #[ArrayShape([ 109 | 'appid' => 'string', 110 | 'partnerid' => 'int', 111 | 'prepayid' => 'string', 112 | 'noncestr' => 'string', 113 | 'timestamp' => 'int', 114 | 'package' => 'string', 115 | 'sign' => 'string', 116 | ])] 117 | public function buildAppConfig(string $prepayId, string $appId): array 118 | { 119 | $params = [ 120 | 'appid' => $appId, 121 | 'partnerid' => $this->merchant->getMerchantId(), 122 | 'prepayid' => $prepayId, 123 | 'noncestr' => Str::random(), 124 | 'timestamp' => time(), 125 | 'package' => 'Sign=WXPay', 126 | ]; 127 | 128 | $message = $params['appid']."\n". 129 | $params['timestamp']."\n". 130 | $params['noncestr']."\n". 131 | $params['prepayid']."\n"; 132 | 133 | $params['sign'] = $this->createSignature($message); 134 | 135 | return $params; 136 | } 137 | 138 | protected function createSignature(string $message): string 139 | { 140 | openssl_sign($message, $signature, $this->merchant->getPrivateKey(), 'sha256WithRSAEncryption'); 141 | 142 | return base64_encode($signature); 143 | } 144 | 145 | /** 146 | * @link https://pay.weixin.qq.com/doc/v3/merchant/4013053257 147 | * @link https://pay.weixin.qq.com/doc/v3/partner/4013059044 148 | * 149 | * @param string $plaintext The text to be encrypted. 150 | * @param string|null $serial The serial number of the platform certificate to use for encryption. If null, the first available certificate will be used. 151 | * @return string The base64-encoded encrypted text. 152 | * 153 | * @throws InvalidConfigException If no platform certificate is found. 154 | * @throws EncryptionFailureException If the encryption process fails. 155 | */ 156 | public function encryptWithRsaPublicKey(string $plaintext, ?string $serial = null): string 157 | { 158 | $platformCerts = $this->merchant->getPlatformCerts(); 159 | /** @var string $identifier - One of the serial number of the platform certificates OR the weixin pay's public key identifier. */ 160 | $identifier = $serial ?? array_key_first($platformCerts); 161 | $platformCert = $this->merchant->getPlatformCert($identifier); 162 | 163 | if (empty($platformCert)) { 164 | throw new InvalidConfigException('Missing platform certificate.'); 165 | } 166 | 167 | if (! openssl_public_encrypt($plaintext, $encrypted, $platformCert, OPENSSL_PKCS1_OAEP_PADDING)) { 168 | throw new EncryptionFailureException('Encrypt failed.'); 169 | } 170 | 171 | return base64_encode($encrypted); 172 | } 173 | 174 | /** 175 | * @throws InvalidConfigException 176 | */ 177 | public function createV2Signature(array $params): string 178 | { 179 | $method = 'md5'; 180 | $secretKey = $this->merchant->getV2SecretKey(); 181 | 182 | if (empty($secretKey)) { 183 | throw new InvalidConfigException('Missing v2 secret key.'); 184 | } 185 | 186 | if ($params['signType'] === 'HMAC-SHA256') { 187 | $method = function ($str) use ($secretKey) { 188 | return hash_hmac('sha256', $str, $secretKey); 189 | }; 190 | } 191 | 192 | ksort($params); 193 | 194 | $params['key'] = $secretKey; 195 | 196 | // @phpstan-ignore-next-line 197 | return strtoupper((string) call_user_func_array($method, [urldecode(http_build_query($params))])); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Pay/Validator.php: -------------------------------------------------------------------------------- 1 | hasHeader($header)) { 36 | throw new InvalidSignatureException("Missing Header: {$header}"); 37 | } 38 | } 39 | 40 | [$timestamp] = $message->getHeader(self::HEADER_TIMESTAMP); 41 | [$nonce] = $message->getHeader(self::HEADER_NONCE); 42 | [$serial] = $message->getHeader(self::HEADER_SERIAL); 43 | [$signature] = $message->getHeader(self::HEADER_SIGNATURE); 44 | 45 | $body = (string) $message->getBody(); 46 | 47 | $message = "{$timestamp}\n{$nonce}\n{$body}\n"; 48 | 49 | if (\time() - \intval($timestamp) > self::MAX_ALLOWED_CLOCK_OFFSET) { 50 | throw new InvalidSignatureException('Clock Offset Exceeded'); 51 | } 52 | 53 | $publicKey = $this->merchant->getPlatformCert($serial); 54 | 55 | if (! $publicKey) { 56 | throw new InvalidConfigException( 57 | "No platform certs found for serial: {$serial}, 58 | please download from wechat pay and set it in merchant config with key `certs`." 59 | ); 60 | } 61 | 62 | if (\openssl_verify( 63 | $message, 64 | base64_decode($signature), 65 | strval($publicKey), 66 | OPENSSL_ALGO_SHA256 67 | ) !== 1) { 68 | throw new InvalidSignatureException('Invalid Signature'); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Work/AccessToken.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']); 37 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 38 | } 39 | 40 | public function getKey(): string 41 | { 42 | return $this->key ?? $this->key = sprintf('work.access_token.%s.%s', $this->corpId, $this->secret); 43 | } 44 | 45 | public function setKey(string $key): static 46 | { 47 | $this->key = $key; 48 | 49 | return $this; 50 | } 51 | 52 | public function getToken(): string 53 | { 54 | $token = $this->cache->get($this->getKey()); 55 | 56 | if ($token && is_string($token)) { 57 | return $token; 58 | } 59 | 60 | return $this->refresh(); 61 | } 62 | 63 | /** 64 | * @return array 65 | */ 66 | #[ArrayShape(['access_token' => 'string'])] 67 | public function toQuery(): array 68 | { 69 | return ['access_token' => $this->getToken()]; 70 | } 71 | 72 | /** 73 | * @throws HttpException 74 | */ 75 | public function refresh(): string 76 | { 77 | $response = $this->httpClient->request('GET', '/cgi-bin/gettoken', [ 78 | 'query' => [ 79 | 'corpid' => $this->corpId, 80 | 'corpsecret' => $this->secret, 81 | ], 82 | ])->toArray(false); 83 | 84 | if (empty($response['access_token'])) { 85 | throw new HttpException('Failed to get access_token: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 86 | } 87 | 88 | $this->cache->set($this->getKey(), $response['access_token'], intval($response['expires_in'])); 89 | 90 | return $response['access_token']; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Work/Account.php: -------------------------------------------------------------------------------- 1 | corpId; 22 | } 23 | 24 | public function getSecret(): string 25 | { 26 | return $this->secret; 27 | } 28 | 29 | public function getToken(): string 30 | { 31 | return $this->token; 32 | } 33 | 34 | public function getAesKey(): string 35 | { 36 | return $this->aesKey; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Work/Application.php: -------------------------------------------------------------------------------- 1 | account) { 46 | $this->account = new Account( 47 | corpId: (string) $this->config->get('corp_id'), /** @phpstan-ignore-line */ 48 | secret: (string) $this->config->get('secret'), /** @phpstan-ignore-line */ 49 | token: (string) $this->config->get('token'), /** @phpstan-ignore-line */ 50 | aesKey: (string) $this->config->get('aes_key'),/** @phpstan-ignore-line */ 51 | ); 52 | } 53 | 54 | return $this->account; 55 | } 56 | 57 | public function setAccount(AccountInterface $account): static 58 | { 59 | $this->account = $account; 60 | 61 | return $this; 62 | } 63 | 64 | public function getEncryptor(): Encryptor 65 | { 66 | if (! $this->encryptor) { 67 | $this->encryptor = new Encryptor( 68 | corpId: $this->getAccount()->getCorpId(), 69 | token: $this->getAccount()->getToken(), 70 | aesKey: $this->getAccount()->getAesKey(), 71 | ); 72 | } 73 | 74 | return $this->encryptor; 75 | } 76 | 77 | public function setEncryptor(Encryptor $encryptor): static 78 | { 79 | $this->encryptor = $encryptor; 80 | 81 | return $this; 82 | } 83 | 84 | public function getServer(): Server|ServerInterface 85 | { 86 | if (! $this->server) { 87 | $this->server = new Server( 88 | encryptor: $this->getEncryptor(), 89 | request: $this->getRequest() 90 | ); 91 | } 92 | 93 | return $this->server; 94 | } 95 | 96 | public function setServer(ServerInterface $server): static 97 | { 98 | $this->server = $server; 99 | 100 | return $this; 101 | } 102 | 103 | public function getAccessToken(): AccessTokenInterface 104 | { 105 | if (! $this->accessToken) { 106 | $this->accessToken = new AccessToken( 107 | corpId: $this->getAccount()->getCorpId(), 108 | secret: $this->getAccount()->getSecret(), 109 | cache: $this->getCache(), 110 | httpClient: $this->getHttpClient(), 111 | ); 112 | } 113 | 114 | return $this->accessToken; 115 | } 116 | 117 | public function setAccessToken(AccessTokenInterface $accessToken): static 118 | { 119 | $this->accessToken = $accessToken; 120 | 121 | return $this; 122 | } 123 | 124 | public function getUtils(): Utils 125 | { 126 | return new Utils($this); 127 | } 128 | 129 | public function createClient(): AccessTokenAwareClient 130 | { 131 | return (new AccessTokenAwareClient( 132 | client: $this->getHttpClient(), 133 | accessToken: $this->getAccessToken(), 134 | failureJudge: fn (Response $response) => (bool) ($response->toArray()['errcode'] ?? 0), 135 | throw: (bool) $this->config->get('http.throw', true), 136 | ))->setPresets($this->config->all()); 137 | } 138 | 139 | public function getOAuth(): SocialiteProviderInterface 140 | { 141 | $provider = new WeWork( 142 | [ 143 | 'client_id' => $this->getAccount()->getCorpId(), 144 | 'client_secret' => $this->getAccount()->getSecret(), 145 | 'redirect_url' => $this->config->get('oauth.redirect_url'), 146 | ] 147 | ); 148 | 149 | $provider->withApiAccessToken($this->getAccessToken()->getToken()); 150 | $provider->scopes((array) $this->config->get('oauth.scopes', ['snsapi_base'])); 151 | 152 | if ($this->config->has('agent_id') && \is_numeric($this->config->get('agent_id'))) { 153 | $provider->withAgentId((int) $this->config->get('agent_id')); 154 | } 155 | 156 | return $provider; 157 | } 158 | 159 | public function getTicket(): JsApiTicket 160 | { 161 | if (! $this->ticket) { 162 | $this->ticket = new JsApiTicket( 163 | corpId: $this->getAccount()->getCorpId(), 164 | cache: $this->getCache(), 165 | httpClient: $this->getClient(), 166 | ); 167 | } 168 | 169 | return $this->ticket; 170 | } 171 | 172 | public function setTicket(JsApiTicket $ticket): static 173 | { 174 | $this->ticket = $ticket; 175 | 176 | return $this; 177 | } 178 | 179 | /** 180 | * @return array 181 | */ 182 | protected function getHttpClientDefaultOptions(): array 183 | { 184 | return array_merge( 185 | ['base_uri' => 'https://qyapi.weixin.qq.com/'], 186 | (array) $this->config->get('http', []) 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Work/Config.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected array $requiredKeys = [ 13 | 'corp_id', 14 | 'secret', 15 | 'token', 16 | 'aes_key', 17 | ]; 18 | } 19 | -------------------------------------------------------------------------------- /src/Work/Contracts/Account.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?? HttpClient::create(['base_uri' => 'https://qyapi.weixin.qq.com/']); 32 | $this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter(namespace: 'easywechat', defaultLifetime: 1500)); 33 | } 34 | 35 | /** 36 | * @return array 37 | */ 38 | #[ArrayShape([ 39 | 'url' => 'string', 40 | 'nonceStr' => 'string', 41 | 'timestamp' => 'int', 42 | 'appId' => 'string', 43 | 'signature' => 'string', 44 | ])] 45 | public function createConfigSignature(string $url, string $nonce, int $timestamp): array 46 | { 47 | return [ 48 | 'appId' => $this->corpId, 49 | 'nonceStr' => $nonce, 50 | 'timestamp' => $timestamp, 51 | 'url' => $url, 52 | 'signature' => $this->getTicketSignature($this->getTicket(), $nonce, $timestamp, $url), 53 | ]; 54 | } 55 | 56 | public function getTicketSignature(string $ticket, string $nonce, int $timestamp, string $url): string 57 | { 58 | return sha1(sprintf('jsapi_ticket=%s&noncestr=%s×tamp=%s&url=%s', $ticket, $nonce, $timestamp, $url)); 59 | } 60 | 61 | /** 62 | * @throws HttpException 63 | */ 64 | public function getTicket(): string 65 | { 66 | $key = $this->getKey(); 67 | $ticket = $this->cache->get($key); 68 | 69 | if ($ticket && is_string($ticket)) { 70 | return $ticket; 71 | } 72 | 73 | $response = $this->httpClient->request('GET', '/cgi-bin/get_jsapi_ticket')->toArray(false); 74 | 75 | if (empty($response['ticket'])) { 76 | throw new HttpException('Failed to get jssdk ticket: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 77 | } 78 | 79 | $this->cache->set($key, $response['ticket'], intval($response['expires_in'])); 80 | 81 | return $response['ticket']; 82 | } 83 | 84 | public function setKey(string $key): static 85 | { 86 | $this->key = $key; 87 | 88 | return $this; 89 | } 90 | 91 | public function getKey(): string 92 | { 93 | return $this->key ?? $this->key = sprintf('work.jsapi_ticket.%s', $this->corpId); 94 | } 95 | 96 | /** 97 | * @return array 98 | */ 99 | #[ArrayShape([ 100 | 'corpid' => 'string', 101 | 'agentid' => 'int', 102 | 'nonceStr' => 'string', 103 | 'timestamp' => 'int', 104 | 'url' => 'string', 105 | 'signature' => 'string', 106 | ])] 107 | public function createAgentConfigSignature(int $agentId, string $url, string $nonce, int $timestamp): array 108 | { 109 | return [ 110 | 'corpid' => $this->corpId, 111 | 'agentid' => $agentId, 112 | 'nonceStr' => $nonce, 113 | 'timestamp' => $timestamp, 114 | 'url' => $url, 115 | 'signature' => $this->getTicketSignature($this->getAgentTicket($agentId), $nonce, $timestamp, $url), 116 | ]; 117 | } 118 | 119 | /** 120 | * @throws HttpException 121 | */ 122 | public function getAgentTicket(int $agentId): string 123 | { 124 | $key = $this->getAgentKey($agentId); 125 | $ticket = $this->cache->get($key); 126 | 127 | if ($ticket && is_string($ticket)) { 128 | return $ticket; 129 | } 130 | 131 | $response = $this->httpClient->request('GET', '/cgi-bin/ticket/get', ['query' => ['type' => 'agent_config']]) 132 | ->toArray(false); 133 | 134 | if (empty($response['ticket'])) { 135 | throw new HttpException('Failed to get jssdk agentTicket: '.json_encode($response, JSON_UNESCAPED_UNICODE)); 136 | } 137 | 138 | $this->cache->set($key, $response['ticket'], intval($response['expires_in'])); 139 | 140 | return $response['ticket']; 141 | } 142 | 143 | public function getAgentKey(int $agentId): string 144 | { 145 | return sprintf('%s.%s', $this->getKey(), $agentId); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Work/Message.php: -------------------------------------------------------------------------------- 1 | $jsApiList 17 | * @param array $openTagList 18 | * @return array 19 | */ 20 | public function buildJsSdkConfig( 21 | string $url, 22 | array $jsApiList, 23 | array $openTagList = [], 24 | bool $debug = false, 25 | bool $beta = true, 26 | ): array { 27 | return array_merge( 28 | compact('jsApiList', 'openTagList', 'debug', 'beta'), 29 | $this->app->getTicket()->createConfigSignature($url, Str::random(), time()) 30 | ); 31 | } 32 | 33 | /** 34 | * @param array $jsApiList 35 | * @param array $openTagList 36 | * @return array 37 | */ 38 | public function buildJsSdkAgentConfig( 39 | int $agentId, 40 | string $url, 41 | array $jsApiList, 42 | array $openTagList = [], 43 | bool $debug = false 44 | ): array { 45 | return array_merge( 46 | compact('jsApiList', 'openTagList', 'debug'), 47 | $this->app->getTicket()->createAgentConfigSignature($agentId, $url, Str::random(), time()) 48 | ); 49 | } 50 | } 51 | --------------------------------------------------------------------------------