├── 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 | [](https://github.com/w7corp/easywechat/actions)
6 | [](https://github.com/w7corp/easywechat/actions)
7 | [](https://packagist.org/packages/w7corp/easywechat)
8 | [](https://packagist.org/packages/w7corp/easywechat)
9 | [](https://packagist.org/packages/w7corp/easywechat)
10 | [](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://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 |
--------------------------------------------------------------------------------