├── CHANGELOG.md
├── LICENSE
├── README.md
├── README_APIv2.md
├── SECURITY.md
├── UPGRADING.md
├── bin
├── CertificateDownloader.php
└── README.md
├── composer.json
├── phpstan.v8.4.neon
└── src
├── Builder.php
├── BuilderChainable.php
├── BuilderTrait.php
├── ClientDecorator.php
├── ClientDecoratorInterface.php
├── ClientJsonTrait.php
├── ClientXmlTrait.php
├── Crypto
├── AesEcb.php
├── AesGcm.php
├── AesInterface.php
├── Hash.php
└── Rsa.php
├── Exception
├── InvalidArgumentException.php
└── WeChatPayException.php
├── Formatter.php
├── Transformer.php
└── Util
├── MediaUtil.php
└── PemUtil.php
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 变更历史
2 |
3 | ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27
4 |
5 | - 修正`APIv2`特殊`GET`请求抛异常问题,相关记录见[这里](https://github.com/wechatpay-apiv3/wechatpay-php/issues/146)。
6 |
7 | ## [1.4.11](../../compare/v1.4.10...v1.4.11) - 2024-12-27
8 |
9 | - 对`APIv2`服务端返回值做精细判断,对于`return_code`(返回状态码)及/或`result_code`(业务结果)有key且值不为`SUCCESS`的情形,抛出客户端`RejectionException`异常,并加入[AuthcodetoopenidTest.php](./tests/OpenAPI/V2/Tools/AuthcodetoopenidTest.php)异常处理示例。
10 |
11 | ## [1.4.10](../../compare/v1.4.9...v1.4.10) - 2024-09-19
12 |
13 | - 客户端在`RSA`非对称加解密方案上,不再支持`OPENSSL_PKCS1_PADDING`填充模式,相关记录见[这里](https://github.com/wechatpay-apiv3/wechatpay-php/issues/133);
14 | - 增加[`#[\SensitiveParameter]`](https://www.php.net/manual/zh/class.sensitiveparameter.php)参数注解,加强信息安全;
15 | - 支持PHP8.4运行时;
16 |
17 | ## [1.4.9](../../compare/v1.4.8...v1.4.9) - 2023-11-21
18 |
19 | - 支持PHP8.3运行时
20 |
21 | ## [1.4.8](../../compare/v1.4.7...v1.4.8) - 2023-01-05
22 |
23 | - 新增海外账单下载`/v3/global/statements`应答特殊处理逻辑;
24 |
25 | ## [1.4.7](../../compare/v1.4.6...v1.4.7) - 2022-12-06
26 |
27 | - 对PHP8.2的官方支持,如下PHP8.2的特性需要被提及:
28 | - ext-openssl 有若干调整,已知在 `OpenSSL3.0` 上,常量 `RSA_SSLV23_PADDING` 被删除(详细可阅读 openssl/openssl#14216, openssl/openssl#14283),PHP做了兼容处理,如果扩展依赖的是`OpenSSL3.0`,则对应的`OPENSSL_SSLV23_PADDING`常量将不存在,进而影响到了「非对称加解密混合填充模式的测试用例」的覆盖(详情可阅读 shivammathur/setup-php#658)。本类库并不支持此填充模式,删除对`OPENSSL_SSLV23_PADDING`的测试断言,向前兼容;
29 | - 对象动态属性的废弃提示([Deprecate dynamic properties](https://wiki.php.net/rfc/deprecate_dynamic_properties)),本类库实例构造的是`ArrayIterator`的一个“伪”动态属性结构体,对象属性访问实则访问的是`ArrayObject`内置`__storage`属性,形似动态属性实则不是;此废弃提示对本类库本身无影响;
30 |
31 | ## [1.4.6](../../compare/v1.4.5...v1.4.6) - 2022-08-19
32 |
33 | - 取消 `APIv2` 上的`trigger_error`提醒,以消除不必要的恐慌;
34 | - 优化 `Transformer::walk` 方法,以支持 [Stringable](https://www.php.net/manual/zh/class.stringable.php) 对象的值转换;
35 |
36 | ## [1.4.5](../../compare/v1.4.4...v1.4.5) - 2022-05-21
37 |
38 | - 新增`APIv3`请求/响应特殊验签逻辑,国内两个下载接口自动忽略验签,海外商户账单下载仅验RSA签名,详见 [#94](https://github.com/wechatpay-apiv3/wechatpay-php/issues/94);
39 | - 新增`APIv3`[海外商户账单下载](https://pay.weixin.qq.com/wiki/doc/api/wxpay/ch/fusion_wallet_ch/QuickPay/chapter8_5.shtml)测试用例,示例说明如何验证流`SHA1`摘要;
40 |
41 | ## [1.4.4](../../compare/v1.4.3...v1.4.4) - 2022-05-19
42 |
43 | - 新增`APIv3`[客诉图片下载](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter10_2_18.shtml)测试用例,示例说明如何避免[double pct-encoded](https://github.com/guzzle/uri-template/issues/18)问题;
44 | - PHP内置函数`hash`方法在`PHP8`变更了返回值逻辑,代之为抛送`ValueError`异常,优化`MediaUtilTest`测试用例,以兼容`PHP7`;
45 | - 新增`APIv2`请求/响应白名单`URL`及调整验签逻辑,对于白名单内的请求,已知无`sign`返回,应用侧自动忽略验签,详见 [#92](https://github.com/wechatpay-apiv3/wechatpay-php/issues/92);
46 |
47 | ## [1.4.3](../../compare/v1.4.2...v1.4.3) - 2022-01-04
48 |
49 | - 优化,严格限定初始化时`mchid`为字符串;
50 | - 优化,严格限定`chain`接口函数入参为字符串;
51 | - 优化`README`,增加`常见问题`示例说明`URL template`用法;
52 |
53 | ## [1.4.2](../../compare/v1.4.1...v1.4.2) - 2021-12-02
54 |
55 | - 优化`Rsa::parse`代码逻辑,去除`is_resource`/`is_object`检测;
56 | - 调整`Rsa::from[Pkcs8|Pkcs1|Spki]`加载语法糖实现,以`Rsa::from`为统一入口;
57 | - 优化`ClientDecorator::request[Async]`处理逻辑,优先替换`URI Template`变量,可支持短链模式调用接口;
58 |
59 | ## [1.4.1](../../compare/v1.4.0...v1.4.1) - 2021-11-03
60 |
61 | - 新增`phpstan/phpstan:^1.0`支持;
62 | - 优化代码,消除函数内部不安全的`Unsafe call to private method|property ... through static::`调用隐患;
63 |
64 | ## [1.4.0](../../compare/v1.3.2...v1.4.0) - 2021-10-24
65 |
66 | - 调整`Guzzle`最低版本支持至v6.5.0,相应降低PHP版本要求至7.1.2,相关见[#71519](http://bugs.php.net/71519);
67 | - 调整`PHPUnit`最低版本至v7.5.0||v8.5.16||v9.3.5,相关问题见[#4663](https://github.com/sebastianbergmann/phpunit/issues/4663);
68 |
69 | 详细说明可见[1.3至1.4升级指南](UPGRADING.md)
70 |
71 | ## [1.3.2](../../compare/v1.3.1...v1.3.2) - 2021-09-30
72 |
73 | - 增加`MediaUtil::setMeta`函数,以支持特殊场景(API)下`meta`数据结构的特殊需求;
74 |
75 | ## [1.3.1](../../compare/v1.3.0...v1.3.1) - 2021-09-22
76 |
77 | - 修正`APIv2`上,合单支付产品`xml`入参是`combine_mch_id`引发的不适问题;
78 |
79 | ## [1.3.0](../../compare/v1.2.2...v1.3.0) - 2021-09-18
80 |
81 | - 增加IDE提示`OpenAPI\V2`&`OpenAPI\V3`的两个入口,接口描述文件拆分为单独的包发行,生产环境无需安装(没必要),仅面向开发环境;
82 | - 优化`userAgent`方法,使拼接`User-Agent`字典清晰可读;
83 | - 优化`README`,增加`V3`通知验签注释说明,增加`v2`链式`otherwise`处理逻辑说明;
84 |
85 | ## [1.2.2](../../compare/v1.2.1...v1.2.2) - 2021-09-09
86 |
87 | - 以`at sign`形式,温和提示`APIv2`的`DEP_XML_PROTOCOL_IS_REACHABLE_EOL`,相关[#38](https://github.com/wechatpay-apiv3/wechatpay-php/issues/38);
88 | - 优化`Transformer::toArray`函数,对入参`xml`非法时,返回空`array`,并把最后一条错误信息温和地打入`E_USER_NOTICE`通道;
89 | - 修正`Formatter::ksort`排列键值时兼容问题,使用`字典序(dictionary order)`排序,相关[#41](https://github.com/wechatpay-apiv3/wechatpay-php/issues/41), 感谢 @suiaiyun 报告此问题;
90 |
91 | ## [1.2.1](../../compare/v1.2.0...v1.2.1) - 2021-09-06
92 |
93 | - 增加`加密RSA私钥`的测试用例覆盖;
94 | - 优化文档样例及升级指南,修正错别字;
95 | - 优化内部`withDefaults`函数,使用变长参数合并初始化参数;
96 | - 优化`Rsa::encrypt`及`Rsa::decrpt`方法,增加第三可选参数,以支持`OPENSSL_PKCS1_PADDING`填充模式的加解密;
97 |
98 | ## [1.2.0](../../compare/v1.1.4...v1.2.0) - 2021-09-02
99 |
100 | - 新增`Rsa::from`统一加载函数,以接替`PemUtil::loadPrivateKey`函数功能;
101 | - 新增`Rsa::fromPkcs1`, `Rsa::fromPkcs8`, `Rsa::fromSpki`语法糖,以支持从云端加载RSA公/私钥;
102 | - 新增RSA公钥`Rsa::pkcs1ToSpki`格式转换函数,入参是`base64`字符串;
103 | - 标记 `PemUtil::loadPrivateKey`及`PemUtil::loadPrivateKeyFromString`为`不推荐用法`;
104 | - 详细变化可见[1.1至1.2升级指南](UPGRADING.md)
105 |
106 | ## [1.1.4](../../compare/v1.1.3...v1.1.4) - 2021-08-26
107 |
108 | - 优化`平台证书下载工具`使用说明,增加`composer exec`执行方法说明;
109 | - 优化了一点点代码结构,使逻辑更清晰了一些;
110 |
111 | ## [1.1.3](../../compare/v1.1.2...v1.1.3) - 2021-08-22
112 |
113 | - 优化`README`,增加`回调通知`处理说明及样本代码;
114 | - 优化测试用例,使用`严格限定名称`方式引用系统内置函数;
115 | - 优化`Makefile`,在生成模拟证书时,避免产生`0x00`开头的证书序列号;
116 | - 调整`composer.json`,新增`guzzlehttp/uri-template:^1.0`支持;
117 |
118 | ## [1.1.2](../../compare/V1.1.1...v1.1.2) - 2021-08-19
119 |
120 | - 优化`README`,`密钥`、`证书`等相关术语保持一致;
121 | - 优化`UPGRADING`,增加从`php_sdk_v3.0.10`迁移指南;
122 | - 优化测试用例,完整覆盖`PHP7.2/7.3/7.4/8.0 + Linux/macOS/Windows`运行时;
123 | - 调整`composer.json`,去除`test`, `phpstan`命令,面向生产环境可用;
124 |
125 | ## [1.1.1](../../compare/v1.1.0...V1.1.1) - 2021-08-13
126 |
127 | - 优化内部中间件始终从`\GuzzleHttp\Psr7\Stream::__toString`取值,并在取值后,判断如果影响了`Stream`指针,则回滚至开始位;
128 | - 增加`APIv2`上一些特殊用法示例,增加`数据签名`样例;
129 | - 增加`APIv2`文档提示说明`DEP_XML_PROTOCOL_IS_REACHABLE_EOL`;
130 | - 修正`APIv2`上,转账至用户零钱接口,`xml`入参是`mchid`引发的不适问题;
131 | - 增加`APIv2`上转账至用户零钱接口测试用例,样例说明如何进行异常捕获;
132 |
133 | ## [1.1.0](../../compare/v1.0.9...v1.1.0) - 2021-08-07
134 |
135 | - 调整内部中间件栈顺序,并对`APIv3`的正常返回内容(`20X`)做精细判断,逻辑异常时使用`\GuzzleHttp\Exception\RequestException`抛出,应用端可捕获源返回内容;
136 | - 对于`30X`及`4XX`,`5XX`返回,`Guzzle`基础中间件默认已处理,具体用法及使用,可参考`\GuzzleHttp\RedirectMiddleware`及`\GuzzleHttp\Middleware::httpErrors`说明;
137 | - 详细变化可见[1.0至1.1升级指南](UPGRADING.md)
138 |
139 | ## [1.0.9](../../compare/v1.0.8...v1.0.9) - 2021-08-05
140 |
141 | - 优化平台证书下载器`CertificateDownloader`异常处理逻辑部分,详见[#22](https://github.com/wechatpay-apiv3/wechatpay-php/issues/22);
142 | - 优化`README`使用示例的异常处理部分;
143 |
144 | ## [1.0.8](../../compare/v1.0.7...v1.0.8) - 2021-07-26
145 |
146 | - 增加`WeChatPay\Crypto\Hash::equals`方法,用于比较`APIv2`哈希签名值是否相等;
147 | - 建议使用`APIv2`的商户,在回调通知场景中,使用此方法来验签,相关说明见PHP[hash_equals](https://www.php.net/manual/zh/function.hash-equals.php)说明;
148 |
149 | ## [1.0.7](../../compare/v1.0.6...v1.0.7) - 2021-07-22
150 |
151 | - 完善`APIv3`及`APIv2`工厂方法初始化说明,推荐优先使用`APIv3`;
152 |
153 | ## [1.0.6](../../compare/v1.0.5...v1.0.6) - 2021-07-21
154 |
155 | - 调整 `Formatter::nonce` 算法,使用密码学安全的`random_bytes`生产`BASE62`随机字符串;
156 |
157 | ## [1.0.5](../../compare/v1.0.4...v1.0.5) - 2021-07-08
158 |
159 | - 核心代码全部转入严格类型`declare(strict_types=1)`校验模式;
160 | - 调整 `Authorization` 头格式顺序,debug时优先展示关键信息;
161 | - 调整 媒体文件`MediaUtil`类读取文件时,严格二进制读,避免跨平台干扰问题;
162 | - 增加 测试用例覆盖`APIv2`版用法;
163 |
164 | ## [1.0.4](../../compare/v1.0.3...v1.0.4) - 2021-07-05
165 |
166 | - 修正 `segments` 首字符大写时异常问题;
167 | - 调整 初始入参如果有提供`handler`,透传给了下游客户端问题;
168 | - 增加 `PHP`最低版本说明,相关问题 [#10](https://github.com/wechatpay-apiv3/wechatpay-php/issues/10);
169 | - 增加 测试用例已基本全覆盖`APIv3`版用法;
170 |
171 | ## [1.0.3](../../compare/v1.0.2...v1.0.3) - 2021-06-28
172 |
173 | - 初始化`jsonBased`入参判断,`平台证书及序列号`结构体内不能含`商户序列号`,相关问题 [#8](https://github.com/wechatpay-apiv3/wechatpay-php/issues/8);
174 | - 修复文档错误,相关 [#7](https://github.com/wechatpay-apiv3/wechatpay-php/issues/7);
175 | - 优化 `github actions`,针对PHP7.2单独缓存依赖(`PHP7.2`下只能跑`PHPUnit8`,`PHP7.3`以上均可跑`PHPUnit9`);
176 | - 增加 `composer test` 命令并集成进 `CI` 内(测试用例持续增加中);
177 | - 修复 `PHPStan` 所有遗留问题;
178 |
179 | ## [1.0.2](../../compare/v1.0.1...v1.0.2) - 2021-06-24
180 |
181 | - 优化了一些性能;
182 | - 增加 `github actions` 覆盖 PHP7.2/7.3/7.4/8.0 + Linux/macOS/Windows环境;
183 | - 提升 `phpstan` 至 `level8` 最严谨级别,并修复大量遗留问题;
184 | - 优化 `\WeChatPay\Exception\WeChatPayException` 异常类接口;
185 | - 完善文档及平台证书下载器用法说明;
186 |
187 | ## [1.0.1](../../compare/v1.0.0...v1.0.1) - 2021-06-21
188 |
189 | - 优化了一些性能;
190 | - 修复了大量 `phpstan level6` 静态分析遗留问题;
191 | - 新增`\WeChatPay\Exception\WeChatPayException`异常类接口;
192 | - 完善文档及方法类型签名;
193 |
194 | ## [1.0.0](../../compare/6782ac3..v1.0.0) - 2021-06-18
195 |
196 | 源自 `wechatpay-guzzle-middleware`,不兼容源版,顾自 `v1.0.0` 开始。
197 |
198 | - `APIv2` & `APIv3` 同质化调用SDK,默认为 `APIv3` 版;
199 | - 标记 `APIv2` 为不推荐调用,预期 `v2.0` 会移除掉;
200 | - 支持 `同步(sync)`(默认)及 `异步(async)` 请求服务端接口;
201 | - 支持 `链式(chain)` 请求服务端接口;
202 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 微信支付 WeChatPay OpenAPI SDK
2 |
3 | [A]Sync Chainable WeChatPay v2&v3's OpenAPI SDK for PHP
4 |
5 | [](https://github.com/wechatpay-apiv3/wechatpay-php/actions)
6 | [](https://packagist.org/packages/wechatpay/wechatpay)
7 | [](https://packagist.org/packages/wechatpay/wechatpay)
8 | [](https://packagist.org/packages/wechatpay/wechatpay)
9 | [](https://packagist.org/packages/wechatpay/wechatpay)
10 | [](https://packagist.org/packages/wechatpay/wechatpay)
11 |
12 | ## 概览
13 |
14 | 基于 [Guzzle HTTP Client](http://docs.guzzlephp.org/) 的微信支付 PHP 开发库。
15 |
16 | ### 功能介绍
17 |
18 | 1. 微信支付 APIv2 和 APIv3 的 Guzzle HTTP 客户端,支持 [同步](#同步请求) 或 [异步](#异步请求) 发送请求,并自动进行请求签名和应答验签
19 |
20 | 1. [链式实现的 URI Template](#链式-uri-template)
21 |
22 | 1. [敏感信息加解密](#敏感信息加解密)
23 |
24 | 1. [回调通知](#回调通知)的验签和解密
25 |
26 | ## 项目状态
27 |
28 | 当前版本为 `1.4.12` 版。
29 | 项目版本遵循 [语义化版本号](https://semver.org/lang/zh-CN/)。
30 | 如果你使用的版本 `<=v1.3.2`,升级前请参考 [升级指南](UPGRADING.md)。
31 |
32 | ## 环境要求
33 |
34 | 项目支持的环境如下:
35 |
36 | + Guzzle 7.0,PHP >= 7.2.5
37 | + Guzzle 6.5,PHP >= 7.1.2
38 |
39 | 我们推荐使用目前处于 [Active Support](https://www.php.net/supported-versions.php) 阶段的 PHP 8 和 Guzzle 7。
40 |
41 | ## 安装
42 |
43 | 推荐使用 PHP 包管理工具 [Composer](https://getcomposer.org/) 安装 SDK:
44 |
45 | ```shell
46 | composer require wechatpay/wechatpay
47 | ```
48 |
49 | ## 开始
50 |
51 | :information_source: 以下是 [微信支付 API v3](https://pay.weixin.qq.com/docs/merchant/development/interface-rules/introduction.html) 的指引。如果你是 API v2 的使用者,请看 [README_APIv2](README_APIv2.md)。
52 |
53 | ### 概念
54 |
55 | + **商户 API 证书**,是用来证实商户身份的。证书中包含商户号、证书序列号、证书有效期等信息,由证书授权机构(Certificate Authority ,简称 CA)签发,以防证书被伪造或篡改。详情见 [什么是商户API证书?如何获取商户API证书?](https://kf.qq.com/faq/161222NneAJf161222U7fARv.html) 。
56 |
57 | + **商户 API 私钥**。你申请商户 API 证书时,会生成商户私钥,并保存在本地证书文件夹的文件 apiclient_key.pem 中。为了证明 API 请求是由你发送的,你应使用商户 API 私钥对请求进行签名。
58 |
59 | > :key: 不要把私钥文件暴露在公共场合,如上传到 Github,写在 App 代码中等。
60 |
61 | + **微信支付平台证书**。微信支付平台证书是指:由微信支付负责申请,包含微信支付平台标识、公钥信息的证书。你需使用微信支付平台证书中的公钥验证 API 应答和回调通知的签名。
62 |
63 | > :bookmark: 通用的 composer 命令,像安装依赖包一样 [下载平台证书](#如何下载平台证书) 文件,供SDK初始化使用。
64 |
65 | + **证书序列号**。每个证书都有一个由 CA 颁发的唯一编号,即证书序列号。
66 |
67 | + **微信支付公钥**,用于应答及回调通知的数据签名,可在 [微信支付商户平台](https://pay.weixin.qq.com) -> 账户中心 -> API安全 直接下载。
68 |
69 | + **微信支付公钥ID**,是微信支付公钥的唯一标识,可在 [微信支付商户平台](https://pay.weixin.qq.com) -> 账户中心 -> API安全 直接查看。
70 |
71 | ### 初始化一个APIv3客户端
72 |
73 | ```php
74 | 账户中心 -> API安全 查询到
99 | $platformCertificateSerial = '7132D72A03E93CDDF8C03BBD1F37EEDF********';
100 |
101 | // 从本地文件中加载「微信支付公钥」,用来验证微信支付应答的签名
102 | $platformPublicKeyFilePath = 'file:///path/to/wechatpay/publickey.pem';
103 | $twoPlatformPublicKeyInstance = Rsa::from($platformPublicKeyFilePath, Rsa::KEY_TYPE_PUBLIC);
104 |
105 | // 「微信支付公钥」的「微信支付公钥ID」
106 | // 需要在 商户平台 -> 账户中心 -> API安全 查询
107 | $platformPublicKeyId = 'PUB_KEY_ID_01142321349124100000000000********';
108 |
109 | // 构造一个 APIv3 客户端实例
110 | $instance = Builder::factory([
111 | 'mchid' => $merchantId,
112 | 'serial' => $merchantCertificateSerial,
113 | 'privateKey' => $merchantPrivateKeyInstance,
114 | 'certs' => [
115 | $platformCertificateSerial => $onePlatformPublicKeyInstance,
116 | $platformPublicKeyId => $twoPlatformPublicKeyInstance,
117 | ],
118 | ]);
119 | ```
120 |
121 | ### 示例,第一个请求:查询「微信支付平台证书」
122 |
123 | ```php
124 | // 发送请求
125 | try {
126 | $resp = $instance->chain('v3/certificates')->get(
127 | /** @see https://docs.guzzlephp.org/en/stable/request-options.html#debug */
128 | // ['debug' => true] // 调试模式
129 | );
130 | echo (string) $resp->getBody(), PHP_EOL;
131 | } catch(\Exception $e) {
132 | // 进行异常捕获并进行错误判断处理
133 | echo $e->getMessage(), PHP_EOL;
134 | if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
135 | $r = $e->getResponse();
136 | echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL;
137 | echo (string) $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL;
138 | }
139 | echo $e->getTraceAsString(), PHP_EOL;
140 | }
141 | ```
142 |
143 | 当程序进入「异常捕获」逻辑,输出形如:
144 |
145 | ```json
146 | {
147 | "code": "RESOURCE_NOT_EXISTS",
148 | "message": "无可用的平台证书,请在商户平台-API安全申请使用微信支付公钥。可查看指引https://pay.weixin.qq.com/docs/merchant/products/platform-certificate/wxp-pub-key-guide.html"
149 | }
150 | ```
151 |
152 | 即表示商户仅能运行在「微信支付公钥」模式,初始化即无需读取及配置`$platformCertificateSerial`及`$onePlatformPublicKeyInstance`等信息。
153 |
154 | ## 文档
155 |
156 | ### 同步请求
157 |
158 | 使用客户端提供的 `get`、`put`、`post`、`patch` 或 `delete` 方法发送同步请求。以 [Native支付下单](https://pay.weixin.qq.com/docs/merchant/apis/native-payment/direct-jsons/native-prepay.html) 为例。
159 |
160 | ```php
161 | try {
162 | $resp = $instance
163 | ->chain('v3/pay/transactions/native')
164 | ->post(['json' => [
165 | 'mchid' => '1900006XXX',
166 | 'out_trade_no' => 'native12177525012014070332333',
167 | 'appid' => 'wxdace645e0bc2cXXX',
168 | 'description' => 'Image形象店-深圳腾大-QQ公仔',
169 | 'notify_url' => 'https://weixin.qq.com/',
170 | 'amount' => [
171 | 'total' => 1,
172 | 'currency' => 'CNY'
173 | ],
174 | ]]);
175 |
176 | echo $resp->getStatusCode(), PHP_EOL;
177 | echo (string) $resp->getBody(), PHP_EOL;
178 | } catch (\Exception $e) {
179 | // 进行错误处理
180 | echo $e->getMessage(), PHP_EOL;
181 | if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
182 | $r = $e->getResponse();
183 | echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL;
184 | echo (string) $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL;
185 | }
186 | echo $e->getTraceAsString(), PHP_EOL;
187 | }
188 | ```
189 |
190 | 请求成功后,你会获得一个 `GuzzleHttp\Psr7\Response` 的应答对象。
191 | 阅读 Guzzle 文档 [Using Response](https://docs.guzzlephp.org/en/stable/quickstart.html#using-responses) 进一步了解如何访问应答内的信息。
192 |
193 | ### 异步请求
194 |
195 | 使用客户端提供的 `getAsync`、`putAsync`、`postAsync`、`patchAsync` 或 `deleteAsync` 方法发送异步请求。以 [退款申请](https://pay.weixin.qq.com/docs/merchant/apis/native-payment/create.html) 为例。
196 |
197 | ```php
198 | $promise = $instance
199 | ->chain('v3/refund/domestic/refunds')
200 | ->postAsync([
201 | 'json' => [
202 | 'transaction_id' => '1217752501201407033233368018',
203 | 'out_refund_no' => '1217752501201407033233368018',
204 | 'amount' => [
205 | 'refund' => 888,
206 | 'total' => 888,
207 | 'currency' => 'CNY',
208 | ],
209 | ],
210 | ])
211 | ->then(static function($response) {
212 | // 正常逻辑回调处理
213 | echo (string) $response->getBody(), PHP_EOL;
214 | return $response;
215 | })
216 | ->otherwise(static function($e) {
217 | // 异常错误处理
218 | echo $e->getMessage(), PHP_EOL;
219 | if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
220 | $r = $e->getResponse();
221 | echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL;
222 | echo (string) $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL;
223 | }
224 | echo $e->getTraceAsString(), PHP_EOL;
225 | });
226 | // 同步等待
227 | $promise->wait();
228 |
229 | ```
230 |
231 | `[get|post|put|patch|delete]Async` 返回的是 [Guzzle Promises](https://github.com/guzzle/promises)。你可以做两件事:
232 |
233 | + 成功时使用 `then()` 处理得到的 `Psr\Http\Message\ResponseInterface`,(可选地)将它传给下一个 `then()`
234 | + 失败时使用 `otherwise()` 处理异常
235 |
236 | 最后使用 `wait()` 等待请求执行完成。
237 |
238 | ### 同步还是异步
239 |
240 | 对于大部分开发者,我们建议使用同步的模式,因为它更加易于理解。
241 |
242 | 如果你是具有异步编程基础的开发者,在某些连续调用 API 的场景,将多个操作通过 `then()` 流式串联起来会是一种优雅的实现方式。例如 [以函数链的形式流式下载交易帐单](https://developers.weixin.qq.com/community/pay/article/doc/000ec4521086b85fb81d6472a51013)。
243 |
244 | ## 链式 URI Template
245 |
246 | [URI Template](https://www.rfc-editor.org/rfc/rfc6570.html) 是表达 URI 中变量的一种方式。微信支付 API 使用这种方式表示 URL Path 中的单号或者 ID。
247 |
248 | ```
249 | # 使用微信支付订单号查询订单
250 | GET /v3/pay/transactions/id/{transaction_id}
251 |
252 | # 使用商户订单号查询订单
253 | GET /v3/pay/transactions/out-trade-no/{out_trade_no}
254 | ```
255 |
256 | 使用 [链式](https://en.wikipedia.org/wiki/Method_chaining) URI Template,你能像书写代码一样流畅地书写 URL,轻松地输入路径并传递 URL 参数。配置接口描述包后还能开启 [IDE提示](https://github.com/TheNorthMemory/wechatpay-openapi)。
257 |
258 | 链式串联的基本单元是 URI Path 中的 [segments](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.3),`segments` 之间以 `->` 连接。连接的规则如下:
259 |
260 | + 普通 segment
261 | + 直接书写。例如 `v3->pay->transactions->native`
262 | + 使用 `chain()`。例如 `chain('v3/pay/transactions/native')`
263 | + 包含连字号(-)的 segment
264 | + 使用驼峰 camelCase 风格书写。例如 `merchant-service` 可写成 `merchantService`
265 | + 使用 `{'foo-bar'}` 方式书写。例如 `{'merchant-service'}`
266 | + Path 变量。URL 中的 Path 变量应使用这种写法,避免自行组装或者使用 `chain()`,导致大小写处理错误
267 | + **推荐使用** `_variable_name_` 方式书写,支持 IDE 提示。例如 `v3->pay->transactions->id->_transaction_id_`。
268 | + 使用 `{'{variable_name}'}` 方式书写。例如 `v3->pay->transactions->id->{'{transaction_id}'}`
269 | + 请求的 `HTTP METHOD` 作为链式最后的执行方法。例如 `v3->pay->transactions->native->post([ ... ])`
270 | + Path 变量的值,以同名参数传入执行方法
271 | + Query 参数,以名为 `query` 的参数传入执行方法
272 |
273 | 以 [查询订单](https://pay.weixin.qq.com/docs/merchant/apis/native-payment/query-by-wx-trade-no.html) `GET` 方法为例:
274 |
275 | ```php
276 | $promise = $instance
277 | ->v3->pay->transactions->id->_transaction_id_
278 | ->getAsync([
279 | // Query 参数
280 | 'query' => ['mchid' => '1230000109'],
281 | // 变量名 => 变量值
282 | 'transaction_id' => '1217752501201407033233368018',
283 | ]);
284 | ```
285 |
286 | 以 [关闭订单](https://pay.weixin.qq.com/docs/merchant/apis/native-payment/close-order.html) `POST` 方法为例:
287 |
288 | ```php
289 | $promise = $instance
290 | ->v3->pay->transactions->outTradeNo->_out_trade_no_->close
291 | ->postAsync([
292 | // 请求消息
293 | 'json' => ['mchid' => '1230000109'],
294 | // 变量名 => 变量值
295 | 'out_trade_no' => '1217752501201407033233368018',
296 | ]);
297 | ```
298 |
299 | ## 更多例子
300 |
301 | ### [视频文件上传](https://pay.weixin.qq.com/docs/partner/apis/contracted-merchant-application/video-upload.html)
302 |
303 | ```php
304 | // 参考上述指引说明,并引入 `MediaUtil` 正常初始化,无额外条件
305 | use WeChatPay\Util\MediaUtil;
306 | // 实例化一个媒体文件流,注意文件后缀名需符合接口要求
307 | $media = new MediaUtil('/your/file/path/video.mp4');
308 |
309 | $resp = $instance-
310 | >chain('v3/merchant/media/video_upload')
311 | ->post([
312 | 'body' => $media->getStream(),
313 | 'headers' => [
314 | 'content-type' => $media->getContentType(),
315 | ]
316 | ]);
317 | ```
318 |
319 | ### [营销图片上传](https://pay.weixin.qq.com/docs/partner/apis/cash-coupons/upload-image.html)
320 |
321 | ```php
322 | use WeChatPay\Util\MediaUtil;
323 | $media = new MediaUtil('/your/file/path/image.jpg');
324 | $resp = $instance
325 | ->v3->marketing->favor->media->imageUpload
326 | ->post([
327 | 'body' => $media->getStream(),
328 | 'headers' => [
329 | 'Content-Type' => $media->getContentType(),
330 | ]
331 | ]);
332 | ```
333 |
334 | ## 敏感信息加/解密
335 |
336 | 为了保证通信过程中敏感信息字段(如用户的住址、银行卡号、手机号码等)的机密性,
337 |
338 | + 微信支付要求加密上送的敏感信息
339 | + 微信支付会加密下行的敏感信息
340 |
341 | 下面以 [特约商户进件](https://pay.weixin.qq.com/docs/partner/apis/contracted-merchant-application/applyment/submit.html) 为例,演示如何进行 [敏感信息加解密](https://pay.weixin.qq.com/docs/partner/development/interface-rules/sensitive-data-encryption.html)。
342 |
343 | ```php
344 | use WeChatPay\Crypto\Rsa;
345 | // 做一个匿名方法,供后续方便使用,$platformPublicKeyInstance 见初始化章节
346 | $encryptor = static function(string $msg) use ($platformPublicKeyInstance): string {
347 | return Rsa::encrypt($msg, $platformPublicKeyInstance);
348 | };
349 |
350 | $resp = $instance
351 | ->chain('v3/applyment4sub/applyment/')
352 | ->post([
353 | 'json' => [
354 | 'business_code' => 'APL_98761234',
355 | 'contact_info' => [
356 | 'contact_name' => $encryptor('张三'),
357 | 'contact_id_number' => $encryptor('110102YYMMDD888X'),
358 | 'mobile_phone' => $encryptor('13000000000'),
359 | 'contact_email' => $encryptor('abc123@example.com'),
360 | ],
361 | //...
362 | ],
363 | 'headers' => [
364 | // $platformCertificateSerialOrPublicKeyId 见初始化章节
365 | 'Wechatpay-Serial' => $platformCertificateSerialOrPublicKeyId,
366 | ],
367 | ]);
368 | ```
369 |
370 | ## 签名
371 |
372 | 你可以使用 `Rsa::sign()` 计算调起支付时所需参数签名。以 [JSAPI支付](https://pay.weixin.qq.com/docs/merchant/apis/jsapi-payment/jsapi-transfer-payment.html) 为例。
373 |
374 | ```php
375 | use WeChatPay\Formatter;
376 | use WeChatPay\Crypto\Rsa;
377 |
378 | $merchantPrivateKeyFilePath = 'file:///path/to/merchant/apiclient_key.pem';
379 | $merchantPrivateKeyInstance = Rsa::from($merchantPrivateKeyFilePath);
380 |
381 | $params = [
382 | 'appId' => 'wx8888888888888888',
383 | 'timeStamp' => (string)Formatter::timestamp(),
384 | 'nonceStr' => Formatter::nonce(),
385 | 'package' => 'prepay_id=wx201410272009395522657a690389285100',
386 | ];
387 | $params += ['paySign' => Rsa::sign(
388 | Formatter::joinedByLineFeed(...array_values($params)),
389 | $merchantPrivateKeyInstance
390 | ), 'signType' => 'RSA'];
391 |
392 | echo json_encode($params);
393 | ```
394 |
395 | ## 回调通知
396 |
397 | 回调通知受限于开发者/商户所使用的`WebServer`有很大差异,这里只给出开发指导步骤,供参考实现。
398 |
399 | 1. 从请求头部`Headers`,拿到`Wechatpay-Signature`、`Wechatpay-Nonce`、`Wechatpay-Timestamp`、`Wechatpay-Serial`及`Request-ID`,商户侧`Web`解决方案可能有差异,请求头可能大小写不敏感,请根据自身应用来定;
400 | 2. 获取请求`body`体的`JSON`纯文本;
401 | 3. 检查通知消息头标记的`Wechatpay-Timestamp`偏移量是否在5分钟之内;
402 | 4. 调用`SDK`内置方法,[构造验签名串](https://pay.weixin.qq.com/docs/merchant/development/verify-signature-overview/overview-signature-and-verification.html) 然后经`Rsa::verfify`验签;
403 | 5. 消息体需要解密的,调用`SDK`内置方法解密;
404 | 6. 如遇到问题,请拿`Request-ID`点击[这里](https://support.pay.weixin.qq.com/online-service?utm_source=github&utm_medium=wechatpay-php&utm_content=apiv3),联系官方在线技术支持;
405 |
406 | 样例代码如下:
407 |
408 | ```php
409 | use WeChatPay\Crypto\Rsa;
410 | use WeChatPay\Crypto\AesGcm;
411 | use WeChatPay\Formatter;
412 |
413 | $inWechatpaySignature = '';// 请根据实际情况获取
414 | $inWechatpayTimestamp = '';// 请根据实际情况获取
415 | $inWechatpaySerial = '';// 请根据实际情况获取
416 | $inWechatpayNonce = '';// 请根据实际情况获取
417 | $inBody = '';// 请根据实际情况获取,例如: file_get_contents('php://input');
418 |
419 | $apiv3Key = '';// 在商户平台上设置的APIv3密钥
420 |
421 | // 根据通知的平台证书序列号,查询本地平台证书文件,
422 | // 假定为 `/path/to/wechatpay/inWechatpaySerial.pem`
423 | $platformPublicKeyInstance = Rsa::from('file:///path/to/wechatpay/inWechatpaySerial.pem', Rsa::KEY_TYPE_PUBLIC);
424 |
425 | // 检查通知时间偏移量,允许5分钟之内的偏移
426 | $timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp);
427 | $verifiedStatus = Rsa::verify(
428 | // 构造验签名串
429 | Formatter::joinedByLineFeed($inWechatpayTimestamp, $inWechatpayNonce, $inBody),
430 | $inWechatpaySignature,
431 | $platformPublicKeyInstance
432 | );
433 | if ($timeOffsetStatus && $verifiedStatus) {
434 | // 转换通知的JSON文本消息为PHP Array数组
435 | $inBodyArray = (array)json_decode($inBody, true);
436 | // 使用PHP7的数据解构语法,从Array中解构并赋值变量
437 | ['resource' => [
438 | 'ciphertext' => $ciphertext,
439 | 'nonce' => $nonce,
440 | 'associated_data' => $aad
441 | ]] = $inBodyArray;
442 | // 加密文本消息解密
443 | $inBodyResource = AesGcm::decrypt($ciphertext, $apiv3Key, $nonce, $aad);
444 | // 把解密后的文本转换为PHP Array数组
445 | $inBodyResourceArray = (array)json_decode($inBodyResource, true);
446 | // print_r($inBodyResourceArray);// 打印解密后的结果
447 | }
448 | ```
449 |
450 | ## 异常处理
451 |
452 | `Guzzle` 默认已提供基础中间件`\GuzzleHttp\Middleware::httpErrors`来处理异常,文档可见[这里](https://docs.guzzlephp.org/en/stable/quickstart.html#exceptions)。
453 | 本SDK自`v1.1`对异常处理做了微调,各场景抛送出的异常如下:
454 |
455 | + `HTTP`网络错误,如网络连接超时、DNS解析失败等,送出`\GuzzleHttp\Exception\RequestException`;
456 | + 服务器端返回了 `5xx HTTP` 状态码,送出`\GuzzleHttp\Exception\ServerException`;
457 | + 服务器端返回了 `4xx HTTP` 状态码,送出`\GuzzleHttp\Exception\ClientException`;
458 | + 服务器端返回了 `30x HTTP` 状态码,如超出SDK客户端重定向设置阈值,送出`\GuzzleHttp\Exception\TooManyRedirectsException`;
459 | + 服务器端返回了 `20x HTTP` 状态码,如SDK客户端逻辑处理失败,例如应答签名验证失败,送出`\GuzzleHttp\Exception\RequestException`;
460 | + 请求签名准备阶段,`HTTP`请求未发生之前,如PHP环境异常、商户私钥异常等,送出`\UnexpectedValueException`;
461 | + 初始化时,如把`商户证书序列号`配置成`平台证书序列号`,送出`\InvalidArgumentException`;
462 |
463 | 以上示例代码,均含有`catch`及`otherwise`错误处理场景示例,测试用例也覆盖了[5xx/4xx/20x异常](tests/ClientDecoratorTest.php),开发者可参考这些代码逻辑进行错误处理。
464 |
465 | ## 定制
466 |
467 | 当默认的本地签名和验签方式不适合你的系统时,你可以通过实现`signer`或者`verifier`中间件来定制签名和验签,比如,你的系统把商户私钥集中存储,业务系统需通过远程调用进行签名。
468 | 以下示例用来演示如何替换SDK内置中间件,来实现远程`请求签名`及`结果验签`,供商户参考实现。
469 |
470 |
471 | 例:内网集中签名/验签解决方案
472 |
473 | ```php
474 | use GuzzleHttp\Client;
475 | use GuzzleHttp\Middleware;
476 | use GuzzleHttp\Exception\RequestException;
477 | use Psr\Http\Message\RequestInterface;
478 | use Psr\Http\Message\ResponseInterface;
479 |
480 | // 假设集中管理服务器接入点为内网`http://192.168.169.170:8080/`地址,并提供两个URI供签名及验签
481 | // - `/wechatpay-merchant-request-signature` 为请求签名
482 | // - `/wechatpay-response-merchant-validation` 为响应验签
483 | $client = new Client(['base_uri' => 'http://192.168.169.170:8080/']);
484 |
485 | // 请求参数签名,返回字符串形如`\WeChatPay\Formatter::authorization`返回的字符串
486 | $remoteSigner = function (RequestInterface $request) use ($client, $merchantId): string {
487 | return (string)$client->post('/wechatpay-merchant-request-signature', ['json' => [
488 | 'mchid' => $merchantId,
489 | 'verb' => $request->getMethod(),
490 | 'uri' => $request->getRequestTarget(),
491 | 'body' => (string)$request->getBody(),
492 | ]])->getBody();
493 | };
494 |
495 | // 返回结果验签,返回可以是4xx,5xx,与远程验签应用约定返回字符串'OK'为验签通过
496 | $remoteVerifier = function (ResponseInterface $response) use ($client, $merchantId): string {
497 | [$nonce] = $response->getHeader('Wechatpay-Nonce');
498 | [$serial] = $response->getHeader('Wechatpay-Serial');
499 | [$signature] = $response->getHeader('Wechatpay-Signature');
500 | [$timestamp] = $response->getHeader('Wechatpay-Timestamp');
501 | return (string)$client->post('/wechatpay-response-merchant-validation', ['json' => [
502 | 'mchid' => $merchantId,
503 | 'nonce' => $nonce,
504 | 'serial' => $serial,
505 | 'signature' => $signature,
506 | 'timestamp' => $timestamp,
507 | 'body' => (string)$response->getBody(),
508 | ]])->getBody();
509 | };
510 |
511 | $stack = $instance->getDriver()->select()->getConfig('handler');
512 | // 卸载SDK内置签名中间件
513 | $stack->remove('signer');
514 | // 注册内网远程请求签名中间件
515 | $stack->before('prepare_body', Middleware::mapRequest(
516 | static function (RequestInterface $request) use ($remoteSigner): RequestInterface {
517 | return $request->withHeader('Authorization', $remoteSigner($request));
518 | }
519 | ), 'signer');
520 | // 卸载SDK内置验签中间件
521 | $stack->remove('verifier');
522 | // 注册内网远程请求验签中间件
523 | $stack->before('http_errors', static function (callable $handler) use ($remoteVerifier): callable {
524 | return static function (RequestInterface $request, array $options = []) use ($remoteVerifier, $handler) {
525 | return $handler($request, $options)->then(
526 | static function(ResponseInterface $response) use ($remoteVerifier, $request): ResponseInterface {
527 | $verified = '';
528 | try {
529 | $verified = $remoteVerifier($response);
530 | } catch (\Throwable $exception) {}
531 | if ($verified === 'OK') { //远程验签约定,返回字符串`OK`作为验签通过
532 | throw new RequestException('签名验签失败', $request, $response, $exception ?? null);
533 | }
534 | return $response;
535 | }
536 | );
537 | };
538 | }, 'verifier');
539 |
540 | // 链式/同步/异步请求APIv3即可,例如:
541 | $instance->v3->certificates->getAsync()->then(static function($res) { return $res->getBody(); })->wait();
542 | ```
543 |
544 |
545 |
546 | ## 常见问题
547 |
548 | ### 如何下载平台证书?
549 |
550 | 使用内置的[微信支付平台证书下载器](bin/README.md)。
551 |
552 | ```bash
553 | composer exec CertificateDownloader.php -- -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
554 | ```
555 |
556 | 微信支付平台证书下载后,下载器会用获得的`平台证书`对返回的消息进行验签。下载器同时开启了 `Guzzle` 的 `debug => true` 参数,方便查询请求/响应消息的基础调试信息。
557 |
558 | ℹ️ [什么是APIv3密钥?如何设置?](https://kf.qq.com/faq/180830E36vyQ180830AZFZvu.html)
559 |
560 | ### 证书和回调解密需要的AesGcm解密在哪里?
561 |
562 | 请参考[AesGcm.php](src/Crypto/AesGcm.php),例如内置的`平台证书`下载工具解密代码如下:
563 |
564 | ```php
565 | AesGcm::decrypt($cert->ciphertext, $apiv3Key, $cert->nonce, $cert->associated_data);
566 | ```
567 |
568 | ### 配合swoole使用时,上传文件接口报错
569 |
570 | 建议升级至swoole 4.6+,swoole在 4.6.0 中增加了native-curl([swoole/swoole-src#3863](https://github.com/swoole/swoole-src/pull/3863))支持,我们测试能正常使用了。
571 | 更详细的信息,请参考[#36](https://github.com/wechatpay-apiv3/wechatpay-guzzle-middleware/issues/36)。
572 |
573 | ### 如何加载公/私钥和证书
574 |
575 | `v1.2`提供了统一的加载函数 `Rsa::from($thing, $type)`。
576 |
577 | - `Rsa::from($thing, $type)` 支持从文件/字符串加载公/私钥和证书,使用方法可参考 [RsaTest.php](tests/Crypto/RsaTest.php)
578 | - `Rsa::fromPkcs1`是个语法糖,支持加载 `PKCS#1` 格式的公/私钥,入参是 `base64` 字符串
579 | - `Rsa::fromPkcs8`是个语法糖,支持加载 `PKCS#8` 格式的私钥,入参是 `base64` 字符串
580 | - `Rsa::fromSpki`是个语法糖,支持加载 `SPKI` 格式的公钥,入参是 `base64` 字符串
581 | - `Rsa::pkcs1ToSpki`是个 `RSA公钥` 格式转换函数,入参是 `base64` 字符串
582 |
583 | ### 如何计算商家券发券 API 的签名
584 |
585 | 使用 `Hash::sign()`计算 APIv2 的签名,示例请参考 APIv2 文档的 [数据签名](README_APIv2.md#数据签名)。
586 |
587 | ### 为什么 URL 上的变量 OpenID,请求时被替换成小写了?
588 |
589 | 本 SDK 把 URL 中的大写视为包含连字号的 segment。请求时, `camelCase` 会替换为 `camel-case`。相关 issue 可参考 [#56](https://github.com/wechatpay-apiv3/wechatpay-php/issues/56)、 [#69](https://github.com/wechatpay-apiv3/wechatpay-php/issues/69)。
590 |
591 | 为了避免大小写错乱,URL 中存在变量时的正确做法是:使用 [链式 URI Template](#%E9%93%BE%E5%BC%8F-uri-template) 的 Path 变量。比如:
592 |
593 | - **推荐写法** `->v3->marketing->favor->users->_openid_->coupons->post(['openid' => 'AbcdEF12345'])`
594 | - `->v3->marketing->favor->users->{'{openid}'}->coupons->post(['openid' => 'AbcdEF12345'])`
595 | - `->chain('{+myurl}')->post(['myurl' => 'v3/marketing/favor/users/AbcdEF12345/coupons'])`
596 | - `->{'{+myurl}'}->post(['myurl' => 'v3/marketing/favor/users/AbcdEF12345/coupons'])`
597 |
598 | ## 联系我们
599 |
600 | 如果你发现了**BUG**或者有任何疑问、建议,请通过issue进行反馈。
601 |
602 | 也欢迎访问我们的[开发者社区](https://developers.weixin.qq.com/community/pay)。
603 |
604 | ## 链接
605 |
606 | + [GuzzleHttp官方版本支持](https://docs.guzzlephp.org/en/stable/overview.html#requirements)
607 | + [PHP官方版本支持](https://www.php.net/supported-versions.php)
608 | + [变更历史](CHANGELOG.md)
609 | + [升级指南](UPGRADING.md)
610 | + [RFC3986](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.3)
611 | > section-3.3 `segments`: A path consists of a sequence of path segments separated by a slash ("/") character.
612 | + [RFC6570](https://www.rfc-editor.org/rfc/rfc6570.html)
613 | + [PHP密钥/证书参数 相关说明](https://www.php.net/manual/zh/openssl.certparams.php)
614 |
615 | ## License
616 |
617 | [Apache-2.0 License](LICENSE)
618 |
--------------------------------------------------------------------------------
/README_APIv2.md:
--------------------------------------------------------------------------------
1 | # API v2
2 |
3 | 本类库可单独用于`APIv2`的开发,希望能给商户提供一个过渡,可先平滑迁移至本类库以承接`APIv2`对接,然后再按需替换升级至`APIv3`上。
4 |
5 | 以下代码以单独使用展开示例,供商户参考。关于链式 `->`,请先阅读 [链式 URI Template](README.md#链式-uri-template)。
6 |
7 | ## V2初始化
8 |
9 | ```php
10 | use WeChatPay\Builder;
11 |
12 | // 商户号,假定为`1000100`
13 | $merchantId = '1000100';
14 | // APIv2密钥(32字节) 假定为`exposed_your_key_here_have_risks`,使用请替换为实际值
15 | $apiv2Key = 'exposed_your_key_here_have_risks';
16 | // 商户私钥,文件路径假定为 `/path/to/merchant/apiclient_key.pem`
17 | $merchantPrivateKeyFilePath = '/path/to/merchant/apiclient_key.pem';
18 | // 商户证书,文件路径假定为 `/path/to/merchant/apiclient_cert.pem`
19 | $merchantCertificateFilePath = '/path/to/merchant/apiclient_cert.pem';
20 |
21 | // 工厂方法构造一个实例
22 | $instance = Builder::factory([
23 | 'mchid' => $merchantId,
24 | 'serial' => 'nop',
25 | 'privateKey' => 'any',
26 | 'certs' => ['any' => null],
27 | 'secret' => $apiv2Key,
28 | 'merchant' => [
29 | 'cert' => $merchantCertificateFilePath,
30 | 'key' => $merchantPrivateKeyFilePath,
31 | ],
32 | ]);
33 | ```
34 |
35 | 初始化字典说明如下:
36 |
37 | - `mchid` 为你的`商户号`,一般是10字节纯数字
38 | - `serial` 为你的`商户证书序列号`,不使用APIv3可填任意值
39 | - `privateKey` 为你的`商户API私钥`,不使用APIv3可填任意值
40 | - `certs[$serial_number => #resource]` 不使用APIv3可填任意值, `$serial_number` 注意不要与商户证书序列号`serial`相同
41 | - `secret` 为APIv2版的`密钥`,商户平台上设置的32字节字符串
42 | - `merchant[cert => $path]` 为你的`商户证书`,一般是文件名为`apiclient_cert.pem`文件路径,接受`[$path, $passphrase]` 格式,其中`$passphrase`为证书密码
43 | - `merchant[key => $path]` 为你的`商户API私钥`,一般是通过官方证书生成工具生成的文件名是`apiclient_key.pem`文件路径,接受`[$path, $passphrase]` 格式,其中`$passphrase`为私钥密码
44 |
45 | **注:** `APIv3`, `APIv2` 以及 `GuzzleHttp\Client` 的 `$config = []` 初始化参数,均融合在一个型参上。
46 |
47 | ## 企业付款到零钱
48 |
49 | [官方开发文档地址](https://pay.weixin.qq.com/wiki/doc/api/tools/mch_pay.php?chapter=14_2)
50 |
51 | ```php
52 | use WeChatPay\Transformer;
53 | $res = $instance
54 | ->v2->mmpaymkttransfers->promotion->transfers
55 | ->postAsync([
56 | 'xml' => [
57 | 'mch_appid' => 'wx8888888888888888',
58 | 'mchid' => '1900000109',// 注意这个商户号,key是`mchid`非`mch_id`
59 | 'partner_trade_no' => '10000098201411111234567890',
60 | 'openid' => 'oxTWIuGaIt6gTKsQRLau2M0yL16E',
61 | 'check_name' => 'FORCE_CHECK',
62 | 're_user_name' => '王小王',
63 | 'amount' => '10099',
64 | 'desc' => '理赔',
65 | 'spbill_create_ip' => '192.168.0.1',
66 | ],
67 | 'security' => true, //请求需要双向证书
68 | 'debug' => true //开启调试模式
69 | ])
70 | ->then(static function($response) {
71 | return Transformer::toArray((string)$response->getBody());
72 | })
73 | ->otherwise(static function($e) {
74 | // 更多`$e`异常类型判断是必须的,这里仅列出一种可能情况,请根据实际对接过程调整并增加
75 | if ($e instanceof \GuzzleHttp\Promise\RejectionException) {
76 | return Transformer::toArray((string)$e->getReason()->getBody());
77 | }
78 | return [];
79 | })
80 | ->wait();
81 | print_r($res);
82 | ```
83 |
84 | `APIv2`末尾驱动的 `HTTP METHOD(POST)` 方法入参 `array $options`,可接受类库定义的两个参数,释义如下:
85 |
86 | - `$options['nonceless']` - 标量 `scalar` 任意值,语义上即,本次请求不用自动添加`nonce_str`参数,推荐 `boolean(True)`
87 | - `$options['security']` - 布尔量`True`,语义上即,本次请求需要加载ssl证书,对应的是初始化 `array $config['merchant']` 结构体
88 |
89 | ## 企业付款到银行卡-获取RSA公钥
90 |
91 | [官方开发文档地址](https://pay.weixin.qq.com/wiki/doc/api/tools/mch_pay_yhk.php?chapter=24_7&index=4)
92 |
93 | ```php
94 | use WeChatPay\Transformer;
95 | $res = $instance
96 | ->v2->risk->getpublickey
97 | ->postAsync([
98 | 'xml' => [
99 | 'mch_id' => '1900000109',
100 | 'sign_type' => 'MD5',
101 | ],
102 | 'security' => true, //请求需要双向证书
103 | // 特殊接入点,仅对本次请求有效
104 | 'base_uri' => 'https://fraud.mch.weixin.qq.com/',
105 | ])
106 | ->then(static function($response) {
107 | return Transformer::toArray((string)$response->getBody());
108 | })
109 | ->wait();
110 | print_r($res);
111 | ```
112 |
113 | ## 付款到银行卡
114 |
115 | [官方开发文档地址](https://pay.weixin.qq.com/wiki/doc/api/tools/mch_pay_yhk.php?chapter=24_2)
116 |
117 | ```php
118 | use WeChatPay\Transformer;
119 | use WeChatPay\Crypto\Rsa;
120 | // 做一个匿名方法,供后续方便使用,$rsaPubKeyString 是`risk/getpublickey` 的返回值'pub_key'字符串
121 | $rsaPublicKeyInstance = Rsa::from($rsaPubKeyString, Rsa::KEY_TYPE_PUBLIC);
122 | $encryptor = static function(string $msg) use ($rsaPublicKeyInstance): string {
123 | return Rsa::encrypt($msg, $rsaPublicKeyInstance);
124 | };
125 | $res = $instance
126 | ->v2->mmpaysptrans->pay_bank
127 | ->postAsync([
128 | 'xml' => [
129 | 'mch_id' => '1900000109',
130 | 'partner_trade_no' => '1212121221227',
131 | 'enc_bank_no' => $encryptor('6225............'),
132 | 'enc_true_name' => $encryptor('张三'),
133 | 'bank_code' => '1001',
134 | 'amount' => '100000',
135 | 'desc' => '理财',
136 | ],
137 | 'security' => true, //请求需要双向证书
138 | ])
139 | ->then(static function($response) {
140 | return Transformer::toArray((string)$response->getBody());
141 | })
142 | ->wait();
143 | print_r($res);
144 | ```
145 |
146 | SDK自v1.4.6调整了XML转换函数,支持了[Stringable](https://www.php.net/manual/zh/class.stringable.php) 值的转换,对于 [Scalar](https://www.php.net/manual/zh/function.is-scalar.php) 标量值及实现了 [__toString](https://www.php.net/manual/zh/language.types.string.php#language.types.string.casting) 方法的对象,均支持直接转换,详细可参考 [TransformerTest.php](tests/TransformerTest.php) 的用例用法示例。
147 |
148 | ## 刷脸支付-人脸识别-获取调用凭证
149 |
150 | [官方开发文档地址](https://pay.weixin.qq.com/wiki/doc/wxfacepay/develop/android/faceuser.html)
151 |
152 | ```php
153 | use WeChatPay\Formatter;
154 | use WeChatPay\Transformer;
155 |
156 | $res = $instance
157 | ->v2->face->get_wxpayface_authinfo
158 | ->postAsync([
159 | 'xml' => [
160 | 'store_id' => '1234567',
161 | 'store_name' => '云店(广州白云机场店)',
162 | 'device_id' => 'abcdef',
163 | 'rawdata' => '从客户端`getWxpayfaceRawdata`方法取得的数据',
164 | 'appid' => 'wx8888888888888888',
165 | 'mch_id' => '1900000109',
166 | 'now' => (string)Formatter::timestamp(),
167 | 'version' => '1',
168 | 'sign_type' => 'HMAC-SHA256',
169 | ],
170 | // 特殊接入点,仅对本次请求有效
171 | 'base_uri' => 'https://payapp.weixin.qq.com/',
172 | ])
173 | ->then(static function($response) {
174 | return Transformer::toArray((string)$response->getBody());
175 | })
176 | ->wait();
177 | print_r($res);
178 | ```
179 |
180 | ## v2沙箱环境-获取验签密钥API
181 |
182 | [官方开发文档地址](https://pay.weixin.qq.com/wiki/doc/api/tools/sp_coupon.php?chapter=23_1&index=2)
183 |
184 | ```php
185 | use WeChatPay\Transformer;
186 | $res = $instance
187 | ->v2->xdc->apiv2getsignkey->sign->getsignkey
188 | ->postAsync([
189 | 'xml' => [
190 | 'mch_id' => '1900000109',
191 | ],
192 | // 通知SDK不接受沙箱环境重定向,仅对本次请求有效
193 | 'allow_redirects' => false,
194 | ])
195 | ->then(static function($response) {
196 | return Transformer::toArray((string)$response->getBody());
197 | })
198 | ->wait();
199 | print_r($res);
200 | ```
201 |
202 | ## v2通知应答
203 |
204 | ```php
205 | use WeChatPay\Transformer;
206 |
207 | $xml = Transformer::toXml([
208 | 'return_code' => 'SUCCESS',
209 | 'return_msg' => 'OK',
210 | ]);
211 |
212 | echo $xml;
213 | ```
214 |
215 | ## 数据签名
216 |
217 | ### 商家券-小程序发券APIv2密钥签名
218 |
219 | [官方开发文档地址](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter9_3_1.shtml)
220 |
221 | ```php
222 | use WeChatPay\Formatter;
223 | use WeChatPay\Crypto\Hash;
224 |
225 | $apiv2Key = 'exposed_your_key_here_have_risks';
226 |
227 | $busiFavorFlat = static function (array $params): array {
228 | $result = ['send_coupon_merchant' => $params['send_coupon_merchant']];
229 | foreach ($params['send_coupon_params'] as $index => $item) {
230 | foreach ($item as $key => $value) {
231 | $result["{$key}{$index}"] = $value;
232 | }
233 | }
234 | return $result;
235 | };
236 |
237 | // 发券小程序所需数据结构
238 | $busiFavor = [
239 | 'send_coupon_params' => [
240 | ['out_request_no' => '1234567', 'stock_id' => 'abc123'],
241 | ['out_request_no' => '7654321', 'stock_id' => '321cba'],
242 | ],
243 | 'send_coupon_merchant' => '10016226'
244 | ];
245 |
246 | $busiFavor += ['sign' => Hash::sign(
247 | Hash::ALGO_HMAC_SHA256,
248 | Formatter::queryStringLike(Formatter::ksort($busiFavorFlat($busiFavor))),
249 | $apiv2Key
250 | )];
251 |
252 | echo json_encode($busiFavor);
253 | ```
254 |
255 | ### 商家券-H5发券APIv2密钥签名
256 |
257 | [官方开发文档地址](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter9_4_1.shtml)
258 |
259 | ```php
260 | use WeChatPay\Formatter;
261 | use WeChatPay\Crypto\Hash;
262 |
263 | $apiv2Key = 'exposed_your_key_here_have_risks';
264 |
265 | $params = [
266 | 'stock_id' => '12111100000001',
267 | 'out_request_no' => '20191204550002',
268 | 'send_coupon_merchant' => '10016226',
269 | 'open_id' => 'oVvBvwEurkeUJpBzX90-6MfCHbec',
270 | 'coupon_code' => '75345199',
271 | ];
272 |
273 | $params += ['sign' => Hash::sign(
274 | Hash::ALGO_HMAC_SHA256,
275 | Formatter::queryStringLike(Formatter::ksort($params)),
276 | $apiv2Key
277 | )];
278 |
279 | echo json_encode($params);
280 | ```
281 |
282 | ## v2回调通知
283 |
284 | 回调通知受限于开发者/商户所使用的`WebServer`有很大差异,这里只给出开发指导步骤,供参考实现。
285 |
286 | 1. 从请求头`Headers`获取`Request-ID`,商户侧`Web`解决方案可能有差异,请求头的`Request-ID`可能大小写不敏感,请根据自身应用来定;
287 | 2. 获取请求`body`体的`XML`纯文本;
288 | 3. 调用`SDK`内置方法,根据[签名算法](https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3)做本地数据签名计算,然后与通知文本的`sign`做`Hash::equals`对比验签;
289 | 4. 消息体需要解密的,调用`SDK`内置方法解密;
290 | 5. 如遇到问题,请拿`Request-ID`点击[这里](https://support.pay.weixin.qq.com/online-service?utm_source=github&utm_medium=wechatpay-php&utm_content=apiv2),联系官方在线技术支持;
291 |
292 | 样例代码如下:
293 |
294 | ```php
295 | use WeChatPay\Transformer;
296 | use WeChatPay\Crypto\Hash;
297 | use WeChatPay\Crypto\AesEcb;
298 | use WeChatPay\Formatter;
299 |
300 | $inBody = '';// 请根据实际情况获取,例如: file_get_contents('php://input');
301 |
302 | $apiv2Key = '';// 在商户平台上设置的APIv2密钥
303 |
304 | $inBodyArray = Transformer::toArray($inBody);
305 |
306 | // 部分通知体无`sign_type`,部分`sign_type`默认为`MD5`,部分`sign_type`默认为`HMAC-SHA256`
307 | // 部分通知无`sign`字典
308 | // 请根据官方开发文档确定
309 | ['sign_type' => $signType, 'sign' => $sign] = $inBodyArray;
310 |
311 | $calculated = Hash::sign(
312 | $signType ?? Hash::ALGO_MD5,// 如没获取到`sign_type`,假定默认为`MD5`
313 | Formatter::queryStringLike(Formatter::ksort($inBodyArray)),
314 | $apiv2Key
315 | );
316 |
317 | $signatureStatus = Hash::equals($calculated, $sign);
318 |
319 | if ($signatureStatus) {
320 | // 如需要解密的
321 | ['req_info' => $reqInfo] = $inBodyArray;
322 | $inBodyReqInfoXml = AesEcb::decrypt($reqInfo, Hash::md5($apiv2Key));
323 | $inBodyReqInfoArray = Transformer::toArray($inBodyReqInfoXml);
324 | // print_r($inBodyReqInfoArray);// 打印解密后的结果
325 | }
326 | ```
327 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | | Version | Supported |
6 | | ------- | ------------------ |
7 | | 1.x | :white_check_mark: |
8 |
9 | ## Reporting a Vulnerability
10 |
11 | Please do not open GitHub issues or pull requests - this makes the problem immediately visible to everyone, including malicious actors.
12 |
13 | Security issues in this open source project can be safely reported to [TSRC](https://security.tencent.com).
14 |
15 | ## 报告漏洞
16 |
17 | 请不要使用 GitHub issues 或 pull request —— 这会让漏洞立即暴露给所有人,包括恶意人员。
18 |
19 | 请将本开源项目的安全问题报告给 [腾讯安全应急响应中心](https://security.tencent.com).
20 |
21 | ---
22 |
23 | 另外,你可能需要关注影响本SDK运行时行为的主要的PHP扩展缺陷列表:
24 |
25 | + [OpenSSL](https://www.openssl.org/news/vulnerabilities.html)
26 | + [libxml2](https://gitlab.gnome.org/GNOME/libxml2/-/blob/master/NEWS)
27 | + [curl](https://curl.se/docs/security.html)
28 |
29 | 当你准备在报告安全问题时,请先对照如上列表,确认是否存在已知运行时环境安全问题。
30 | 当你尝试升级主要扩展至最新版本之后,如若问题依旧存在,请将本开源项目的安全问题报告给 [TSRC腾讯安全应急响应中心](https://security.tencent.com),致谢。
31 |
--------------------------------------------------------------------------------
/UPGRADING.md:
--------------------------------------------------------------------------------
1 | # 升级指南
2 |
3 | ## 从 1.3 升级至 1.4
4 |
5 | v1.4版,对`Guzzle6`提供了**有限**兼容支持,最低可兼容至**v6.5.0**,原因是测试依赖的前向兼容`GuzzleHttp\Handler\MockHandler::reset()`方法,在这个版本上才可用,相关见 [Guzzle#2143](https://github.com/guzzle/guzzle/pull/2143);
6 |
7 | `Guzzle6`的PHP版本要求是 **>=5.5**,而本类库前向兼容时,读取RSA证书序列号用到了PHP的[#7151 serialNumberHex support](http://bugs.php.net/71519)功能,顾PHP的最低版本可降级至**7.1.2**这个版本;
8 |
9 | 为**有限**兼容**Guzzle6**,类库放弃使用`Guzzle7`上的`\GuzzleHttp\Utils::jsonEncode`及`\GuzzleHttp\Utils::jsonDecode`封装方法,取而代之为PHP原生`json_encode`/`json_decode`方法,极端情况下(`meta`数据非法)可能会在`APIv3媒体文件上传`的几个接口上,本该抛送客户端异常而代之为返回服务端异常;这种场景下,会对调试带来部分困难,评估下来可控,遂放弃使用`\GuzzleHttp\Utils`的封装,待`Guzzle6 EOL`时,再择机回滚至使用这两个封装方法。
10 |
11 | **警告**:PHP7.1已于*1 Dec 2019*完成其**PHP官方支持**生命周期,本类库在PHP7.1环境上也仅有限支持可用,请**商户/开发者**自行评估继续使用PHP7.1的风险。
12 |
13 | 同时,测试用例依赖的`PHPUnit8`调整最低版本至**v8.5.16**,原因是本类库的前向用例覆盖用到了`TestCase::expectError`方法,其在PHP7.4/8.0上有[bug #4663](https://github.com/sebastianbergmann/phpunit/issues/4663),顾调整至这个版本。
14 |
15 | Guzzle7+PHP7.2/7.3/7.4/8.0环境下,本次版本升级不受影响。
16 |
17 | ## 从 1.2 升级到 1.3
18 |
19 | v1.3主要更新内容是为IDE增加`接口`及`参数`描述提示,以单独的安装包发行,建议仅在`composer --dev`即(`Add requirement to require-dev.`),生产运行时环境完全无需。
20 |
21 | ## 从 1.1 升级至 1.2
22 |
23 | v1.2 对 `RSA公/私钥`加载做了加强,释放出 `Rsa::from` 统一加载函数,以接替`PemUtil::loadPrivateKey`,同时释放出`Rsa::fromPkcs1`, `Rsa::fromPkcs8`, `Rsa::fromSpki`及`Rsa::pkcs1ToSpki`方法,在不丢失精度的前提下,支持`不落盘`从云端(如`公/私钥`存储在数据库/NoSQL等媒介中)加载。
24 |
25 | - `Rsa::from` 支持从文件/字符串/完整RSA公私钥字符串/X509证书加载,对应的测试用例覆盖见[这里](tests/Crypto/RsaTest.php);
26 | - `Rsa::fromPkcs1`是个语法糖,支持加载`PKCS#1`格式的公/私钥,入参是`base64`字符串;
27 | - `Rsa::fromPkcs8`是个语法糖,支持加载`PKCS#8`格式的私钥,入参是`base64`字符串;
28 | - `Rsa::fromSpki`是个语法糖,支持加载`SPKI`格式的公钥,入参是`base64`字符串;
29 | - `Rsa::pkcs1ToSpki`是个`RSA公钥`格式转换函数,入参是`base64`字符串;
30 |
31 | 特别地,对于`APIv2` 付款到银行卡功能,现在可直接支持`加密敏感信息`了,即从[获取RSA加密公钥](https://pay.weixin.qq.com/wiki/doc/api/tools/mch_pay_yhk.php?chapter=24_7&index=4)接口获取的`pub_key`字符串,经`Rsa::from($pub_key, Rsa::KEY_TYPE_PUBLIC)`加载,用于`Rsa::encrypt`加密,详细用法见README示例;
32 |
33 | 标记 `PemUtil::loadPrivateKey`及`PemUtil::loadPrivateKeyFromString`为`不推荐用法`,当前向下兼容v1.1及v1.0版本用法,预期在v2.0大版本上会移除这两个方法;
34 |
35 | 推荐升级加载`RSA公/私钥`为以下形式:
36 |
37 | 从文件加载「商户RSA私钥」,变化如下:
38 |
39 | ```diff
40 | +use WeChatPay\Crypto\Rsa;
41 |
42 | -$merchantPrivateKeyFilePath = '/path/to/merchant/apiclient_key.pem';
43 | -$merchantPrivateKeyInstance = PemUtil::loadPrivateKey($merchantPrivateKeyFilePath);
44 | +$merchantPrivateKeyFilePath = 'file:///path/to/merchant/apiclient_key.pem';// 注意 `file://` 开头协议不能少
45 | +$merchantPrivateKeyInstance = Rsa::from($merchantPrivateKeyFilePath, Rsa::KEY_TYPE_PRIVATE);
46 | ```
47 |
48 | 从文件加载「平台证书」,变化如下:
49 |
50 | ```diff
51 | -$platformCertificateFilePath = '/path/to/wechatpay/cert.pem';
52 | -$platformCertificateInstance = PemUtil::loadCertificate($platformCertificateFilePath);
53 | -// 解析平台证书序列号
54 | -$platformCertificateSerial = PemUtil::parseCertificateSerialNo($platformCertificateInstance);
55 | +$platformCertificateFilePath = 'file:///path/to/wechatpay/cert.pem';// 注意 `file://` 开头协议不能少
56 | +$platformPublicKeyInstance = Rsa::from($platformCertificateFilePath, Rsa::KEY_TYPE_PUBLIC);
57 | +// 解析「平台证书」序列号,「平台证书」当前五年一换,缓存后就是个常量
58 | +$platformCertificateSerial = PemUtil::parseCertificateSerialNo($platformCertificateFilePath);
59 | ```
60 |
61 | 相对应地初始化工厂方法,平台证书相关入参初始化变化如下:
62 |
63 | ```diff
64 | 'certs' => [
65 | - $platformCertificateSerial => $platformCertificateInstance,
66 | + $platformCertificateSerial => $platformPublicKeyInstance,
67 | ],
68 | ```
69 |
70 | APIv3相关「RSA数据签名」,变化如下:
71 |
72 | ```diff
73 | -use WeChatPay\Util\PemUtil;
74 | -$merchantPrivateKeyFilePath = '/path/to/merchant/apiclient_key.pem';
75 | -$merchantPrivateKeyInstance = PemUtil::loadPrivateKey($merchantPrivateKeyFilePath);
76 | +$merchantPrivateKeyFilePath = 'file:///path/to/merchant/apiclient_key.pem';
77 | +$merchantPrivateKeyInstance = Rsa::from($merchantPrivateKeyFilePath);
78 | ```
79 |
80 | APIv3回调通知「验签」,变化如下:
81 |
82 | ```diff
83 | -use WeChatPay\Util\PemUtil;
84 | // 根据通知的平台证书序列号,查询本地平台证书文件,
85 | // 假定为 `/path/to/wechatpay/inWechatpaySerial.pem`
86 | -$certInstance = PemUtil::loadCertificate('/path/to/wechatpay/inWechatpaySerial.pem');
87 | +$platformPublicKeyInstance = Rsa::from('file:///path/to/wechatpay/inWechatpaySerial.pem', Rsa::KEY_TYPE_PUBLIC);
88 |
89 | // 检查通知时间偏移量,允许5分钟之内的偏移
90 | $timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp);
91 | $verifiedStatus = Rsa::verify(
92 | // 构造验签名串
93 | Formatter::joinedByLineFeed($inWechatpayTimestamp, $inWechatpayNonce, $inBody),
94 | $inWechatpaySignature,
95 | - $certInstance
96 | + $platformPublicKeyInstance
97 | );
98 | ```
99 |
100 | 更高级的加载`RSA公/私钥`方式,如从`Rsa::fromPkcs1`, `Rsa::fromPkcs8`, `Rsa::fromSpki`等语法糖加载,可查询参考测试用例[RsaTest.php](tests/Crypto/RsaTest.php)做法,请按需自行拓展使用。
101 |
102 | ## 从 1.0 升级至 1.1
103 |
104 | v1.1 版本对内部中间件实现做了微调,对`APIv3的异常`做了部分调整,调整内容如下:
105 |
106 | 1. 对中间件栈顺序,做了微调,从原先的栈顶调整至必要位置,即:
107 | 1. 请求签名中间件 `signer` 从栈顶调整至 `prepare_body` 之前,`请求签名`仅须发生在请求发送体准备阶段之前,这个顺序调整对应用端无感知;
108 | 2. 返回验签中间件 `verifier` 从栈顶调整至 `http_errors` 之前(默认实际仍旧在栈顶),对异常(HTTP 4XX, 5XX)返回交由`Guzzle`内置的`\GuzzleHttp\Middleware::httpErrors`进行处理,`返回验签`仅对正常(HTTP 20X)结果验签;
109 | 2. 重构了 `verifier` 实现,调整内容如下:
110 | 1. 异常类型从 `\UnexpectedValueException` 调整成 `\GuzzleHttp\Exception\RequestException`;因由是,请求/响应已经完成,响应内容有(HTTP 20X)结果,调整后,SDK客户端异常时,可以从`RequestException::getResponse()`获取到这个响应对象,进而可甄别出`返回体`具体内容;
111 | 2. 正常响应结果在验签时,有可能从 `\WeChatPay\Crypto\Rsa::verify` 内部抛出`UnexpectedValueException`异常,调整后,一并把这个异常交由`RequestException`抛出,应用侧可以从`RequestException::getPrevious()`获取到这个异常实例;
112 |
113 | 以上调整,对于正常业务逻辑(HTTP 20X)无影响,对于应用侧异常捕获,需要做如下适配调整:
114 |
115 | 同步模型,建议从捕获`UnexpectedValueException`调整为`\GuzzleHttp\Exception\RequestException`,如下:
116 |
117 | ```diff
118 | try {
119 | $instance
120 | ->v3->pay->transactions->native
121 | ->post(['json' => []]);
122 | - } catch (\UnexpectedValueException $e) {
123 | + } catch (\GuzzleHttp\Exception\RequestException $e) {
124 | // do something
125 | }
126 | ```
127 |
128 | 异步模型,建议始终判断当前异常是否实例于`\GuzzleHttp\Exception\RequestException`,判断方法见[README](README.md)示例代码。
129 |
130 | ## 从 wechatpay-guzzle-middleware 0.2 迁移至 1.0
131 |
132 | 如 [变更历史](CHANGELOG.md) 所述,本类库自1.0不兼容`wechatpay/wechatpay-guzzle-middleware:~0.2`,原因如下:
133 |
134 | 1. 升级`Guzzle`大版本至`7`, `Guzzle7`做了许多不兼容更新,相关讨论可见[Laravel8依赖变更](https://github.com/wechatpay-apiv3/wechatpay-guzzle-middleware/issues/54);`Guzzle7`要求PHP最低版本为`7.2.5`,重要特性是加入了`函数参数类型签名`以及`函数返回值类型签名`功能,从开发语言层面,使类库健壮性有了显著提升;
135 | 2. 重构并修正了原[敏感信息加解密](https://github.com/wechatpay-apiv3/wechatpay-guzzle-middleware/issues/25)过度设计问题;
136 | 3. 重新设计了类库函数及方案,以提供[回调通知签名](https://github.com/wechatpay-apiv3/wechatpay-guzzle-middleware/issues/42)所需方法;
137 | 4. 调整`composer.json`移动`guzzlehttp/guzzle`从`require-dev`弱依赖至`require`强依赖,开发者无须再手动添加;
138 | 5. 缩减初始化手动拼接客户端参数至`Builder::factory`,统一由SDK来构建客户端;
139 | 6. 新增链式调用封装器,原生提供对`APIv3`的链式调用;
140 | 7. 新增`APIv2`支持,推荐商户可以先升级至本类库支持的`APIv2`能力,然后再按需升级至相对应的`APIv3`能力;
141 | 8. 增加类库单元测试覆盖`Linux`,`macOS`及`Windows`运行时;
142 | 9. 调整命名空间`namespace`为`WeChatPay`;
143 |
144 | ### 迁移指南
145 |
146 | PHP版本最低要求为`7.2.5`,请商户的技术开发人员**先评估**运行时环境是否支持**再决定**按如下步骤迁移。
147 | ### composer.json 调整
148 |
149 | 依赖调整
150 |
151 | ```diff
152 | "require": {
153 | - "guzzlehttp/guzzle": "^6.3",
154 | - "wechatpay/wechatpay-guzzle-middleware": "^0.2.0"
155 | + "wechatpay/wechatpay": "^1.0"
156 | }
157 | ```
158 |
159 | ### 初始化方法调整
160 |
161 | ```diff
162 | use GuzzleHttp\Exception\RequestException;
163 | - use WechatPay\GuzzleMiddleware\WechatPayMiddleware;
164 | + use WeChatPay\Builder;
165 | - use WechatPay\GuzzleMiddleware\Util\PemUtil;
166 | + use WeChatPay\Util\PemUtil;
167 |
168 | $merchantId = '1000100';
169 | $merchantSerialNumber = 'XXXXXXXXXX';
170 | $merchantPrivateKey = PemUtil::loadPrivateKey('/path/to/mch/private/key.pem');
171 | $wechatpayCertificate = PemUtil::loadCertificate('/path/to/wechatpay/cert.pem');
172 | +$wechatpayCertificateSerialNumber = PemUtil::parseCertificateSerialNo($wechatpayCertificate);
173 |
174 | - $wechatpayMiddleware = WechatPayMiddleware::builder()
175 | - ->withMerchant($merchantId, $merchantSerialNumber, $merchantPrivateKey)
176 | - ->withWechatPay([ $wechatpayCertificate ])
177 | - ->build();
178 | - $stack = GuzzleHttp\HandlerStack::create();
179 | - $stack->push($wechatpayMiddleware, 'wechatpay');
180 | - $client = new GuzzleHttp\Client(['handler' => $stack]);
181 | + $instance = Builder::factory([
182 | + 'mchid' => $merchantId,
183 | + 'serial' => $merchantSerialNumber,
184 | + 'privateKey' => $merchantPrivateKey,
185 | + 'certs' => [$wechatpayCertificateSerialNumber => $wechatpayCertificate],
186 | + ]);
187 | ```
188 |
189 | ### 调用方法调整
190 |
191 | #### **GET**请求
192 |
193 | 可以使用本SDK提供的语法糖,缩减请求代码结构如下:
194 |
195 | ```diff
196 | try {
197 | - $resp = $client->request('GET', 'https://api.mch.weixin.qq.com/v3/...', [
198 | + $resp = $instance->chain('v3/...')->get([
199 | - 'headers' => [ 'Accept' => 'application/json' ]
200 | ]);
201 | } catch (RequestException $e) {
202 | //do something
203 | }
204 | ```
205 |
206 | #### **POST**请求
207 |
208 | 缩减请求代码如下:
209 |
210 | ```diff
211 | try {
212 | - $resp = $client->request('POST', 'https://api.mch.weixin.qq.com/v3/...', [
213 | + $resp = $instance->chain('v3/...')->post([
214 | 'json' => [ // JSON请求体
215 | 'field1' => 'value1',
216 | 'field2' => 'value2'
217 | ],
218 | - 'headers' => [ 'Accept' => 'application/json' ]
219 | ]);
220 | } catch (RequestException $e) {
221 | //do something
222 | }
223 | ```
224 |
225 | #### 上传媒体文件
226 |
227 | ```diff
228 | - use WechatPay\GuzzleMiddleware\Util\MediaUtil;
229 | + use WeChatPay\Util\MediaUtil;
230 | $media = new MediaUtil('/your/file/path/with.extension');
231 | try {
232 | - $resp = $client->request('POST', 'https://api.mch.weixin.qq.com/v3/[merchant/media/video_upload|marketing/favor/media/image-upload]', [
233 | + $resp = $instance->chain('v3/marketing/favor/media/image-upload')->post([
234 | 'body' => $media->getStream(),
235 | 'headers' => [
236 | - 'Accept' => 'application/json',
237 | 'content-type' => $media->getContentType(),
238 | ]
239 | ]);
240 | } catch (Exception $e) {
241 | // do something
242 | }
243 | ```
244 |
245 | ```diff
246 | try {
247 | - $resp = $client->post('merchant/media/upload', [
248 | + $resp = $instance->chain('v3/merchant/media/upload')->post([
249 | 'body' => $media->getStream(),
250 | 'headers' => [
251 | - 'Accept' => 'application/json',
252 | 'content-type' => $media->getContentType(),
253 | ]
254 | ]);
255 | } catch (Exception $e) {
256 | // do something
257 | }
258 | ```
259 |
260 | #### 敏感信息加/解密
261 |
262 | ```diff
263 | - use WechatPay\GuzzleMiddleware\Util\SensitiveInfoCrypto;
264 | + use WeChatPay\Crypto\Rsa;
265 | - $encryptor = new SensitiveInfoCrypto(PemUtil::loadCertificate('/path/to/wechatpay/cert.pem'));
266 | + $encryptor = function($msg) use ($wechatpayCertificate) { return Rsa::encrypt($msg, $wechatpayCertificate); };
267 |
268 | try {
269 | - $resp = $client->post('/v3/applyment4sub/applyment/', [
270 | + $resp = $instance->chain('v3/applyment4sub/applyment/')->post([
271 | 'json' => [
272 | 'business_code' => 'APL_98761234',
273 | 'contact_info' => [
274 | 'contact_name' => $encryptor('value of `contact_name`'),
275 | 'contact_id_number' => $encryptor('value of `contact_id_number'),
276 | 'mobile_phone' => $encryptor('value of `mobile_phone`'),
277 | 'contact_email' => $encryptor('value of `contact_email`'),
278 | ],
279 | //...
280 | ],
281 | 'headers' => [
282 | - 'Wechatpay-Serial' => 'must be the serial number via the downloaded pem file of `/v3/certificates`',
283 | + 'Wechatpay-Serial' => $wechatpayCertificateSerialNumber,
284 | - 'Accept' => 'application/json',
285 | ],
286 | ]);
287 | } catch (Exception $e) {
288 | // do something
289 | }
290 | ```
291 |
292 | #### 平台证书下载工具
293 |
294 | 在第一次下载平台证书时,本类库充分利用了`\GuzzleHttp\HandlerStack`中间件管理器能力,按照栈执行顺序,在返回结果验签中间件`verifier`之前注册`certsInjector`,之后注册`certsRecorder`来 **"解开"** "死循环"问题。
295 | 本类库提供的下载工具**未改变** `返回结果验签` 逻辑,完整实现可参考[bin/CertificateDownloader.php](bin/CertificateDownloader.php)。
296 |
297 | #### AesGcm平台证书解密
298 |
299 | ```diff
300 | - use WechatPay\GuzzleMiddleware\Util\AesUtil;
301 | + use WeChatPay\Crypto\AesGcm;
302 | - $decrypter = new AesUtil($opts['key']);
303 | - $plain = $decrypter->decryptToString($encCert['associated_data'], $encCert['nonce'], $encCert['ciphertext']);
304 | + $plain = AesGcm::decrypt($encCert['ciphertext'], $opts['key'], $encCert['nonce'], $encCert['associated_data']);
305 | ```
306 |
307 | ## 从 php_sdk_v3.0.10 迁移至 1.0
308 |
309 | 这个`php_sdk_v3.0.10`版的SDK,是在`APIv2`版的文档上有下载,这里提供一份迁移指南,抛砖引玉如何迁移。
310 | ### 初始化
311 |
312 | 从手动文件模式调整参数,变更为实例初始化方式:
313 |
314 | ```diff
315 | - // ③、修改lib/WxPay.Config.php为自己申请的商户号的信息(配置详见说明)
316 | + use WeChatPay/Builder;
317 | + $instance = new Builder([
318 | + 'mchid' => $mchid,
319 | + 'serial' => 'nop',
320 | + 'privateKey' => 'any',
321 | + 'secret' => $apiv2Key,
322 | + 'certs' => ['any' => null],
323 | + 'merchant' => ['key' => '/path/to/cert/apiclient_key.pem', 'cert' => '/path/to/cert/apiclient_cert.pem'],
324 | + ]);
325 | ```
326 |
327 | ### 统一下单-JSAPI下单及数据二次签名
328 |
329 | ```diff
330 | - require_once "../lib/WxPay.Api.php";
331 | - require_once "WxPay.JsApiPay.php";
332 | - require_once "WxPay.Config.php";
333 |
334 | - $tools = new JsApiPay();
335 | - $openId = $tools->GetOpenid();
336 | - $input = new WxPayUnifiedOrder();
337 | - $input->SetBody("test");
338 | - $input->SetAttach("test");
339 | - $input->SetOut_trade_no("sdkphp".date("YmdHis"));
340 | - $input->SetTotal_fee("1");
341 | - $input->SetTime_start(date("YmdHis"));
342 | - $input->SetTime_expire(date("YmdHis", time() + 600));
343 | - $input->SetGoods_tag("test");
344 | - $input->SetNotify_url("http://paysdk.weixin.qq.com/notify.php");
345 | - $input->SetTrade_type("JSAPI");
346 | - $input->SetOpenid($openId);
347 | - $config = new WxPayConfig();
348 | - $order = WxPayApi::unifiedOrder($config, $input);
349 | - printf_info($order);
350 | - // 数据签名
351 | - $jsapi = new WxPayJsApiPay();
352 | - $jsapi->SetAppid($order["appid"]);
353 | - $timeStamp = time();
354 | - $jsapi->SetTimeStamp("$timeStamp");
355 | - $jsapi->SetNonceStr(WxPayApi::getNonceStr());
356 | - $jsapi->SetPackage("prepay_id=" . $order['prepay_id']);
357 | - $config = new WxPayConfig();
358 | - $jsapi->SetPaySign($jsapi->MakeSign($config));
359 | - $parameters = json_encode($jsapi->GetValues());
360 | + use WeChatPay\Formatter;
361 | + use WeChatPay\Transformer;
362 | + use WeChatPay\Crypto\Hash;
363 | + // 直接构造请求数组参数
364 | + $input = [
365 | + 'appid' => $appid, // 从config拿到当前请求参数上
366 | + 'mch_id' => $mchid, // 从config拿到当前请求参数上
367 | + 'body' => 'test',
368 | + 'attach' => 'test',
369 | + 'out_trade_no' => 'sdkphp' . date('YmdHis'),
370 | + 'total_fee' => '1',
371 | + 'time_start' => date('YmdHis'),
372 | + 'time_expire' => date('YmdHis, time() + 600),
373 | + 'goods_tag' => 'test',
374 | + 'notify_url' => 'http://paysdk.weixin.qq.com/notify.php',
375 | + 'trade_type' => 'JSAPI',
376 | + 'openid' => $openId, // 有太多优秀解决方案能够获取到这个值,这里假定已经有了
377 | + 'sign_type' => Hash::ALGO_HMAC_SHA256, // 以下二次数据签名「签名类型」需与预下单数据「签名类型」一致
378 | + ];
379 | + // 发起请求并取得结果,抑制`E_USER_DEPRECATED`提示
380 | + $resp = @$instance->chain('v2/pay/unifiedorder')->post(['xml' => $input]);
381 | + $order = Transformer::toArray((string)$resp->getBody());
382 | + // print_r($order);
383 | + // 数据签名
384 | + $params = [
385 | + 'appId' => $appid,
386 | + 'timeStamp' => (string)Formatter::timestamp(),
387 | + 'nonceStr' => Formatter::nonce(),
388 | + 'package' => 'prepay_id=' . $order['prepay_id'],
389 | + 'signType' => Hash::ALGO_HMAC_SHA256,
390 | + ];
391 | + // 二次数据签名「签名类型」需与预下单数据「签名类型」一致
392 | + $params['paySign'] = Hash::sign(Hash::ALGO_HMAC_SHA256, Formatter::queryStringLike(Formatter::ksort($parameters)), $apiv2Key);
393 | + $parameters = json_encode($params);
394 | ```
395 |
396 | ### 付款码支付
397 |
398 | ```diff
399 | - require_once "../lib/WxPay.Api.php";
400 | - require_once "WxPay.MicroPay.php";
401 | -
402 | - $auth_code = $_REQUEST["auth_code"];
403 | - $input = new WxPayMicroPay();
404 | - $input->SetAuth_code($auth_code);
405 | - $input->SetBody("刷卡测试样例-支付");
406 | - $input->SetTotal_fee("1");
407 | - $input->SetOut_trade_no("sdkphp".date("YmdHis"));
408 | -
409 | - $microPay = new MicroPay();
410 | - printf_info($microPay->pay($input));
411 | + use WeChatPay\Formatter;
412 | + use WeChatPay\Transformer;
413 | + // 直接构造请求数组参数
414 | + $input = [
415 | + 'appid' => $appid, // 从config拿到当前请求参数上
416 | + 'mch_id' => $mchid, // 从config拿到当前请求参数上
417 | + 'auth_code' => $auth_code,
418 | + 'body' => '刷卡测试样例-支付',
419 | + 'total_fee' => '1',
420 | + 'out_trade_no' => 'sdkphp' . date('YmdHis'),
421 | + 'spbill_create_ip' => $mechineIp,
422 | + ];
423 | + // 发起请求并取得结果,抑制`E_USER_DEPRECATED`提示
424 | + $resp = @$instance->chain('v2/pay/micropay')->post(['xml' => $input]);
425 | + $order = Transformer::toArray((string)$resp->getBody());
426 | + // print_r($order);
427 | ```
428 |
429 | ### 撤销订单
430 |
431 | ```diff
432 | + $input = [
433 | + 'appid' => $appid, // 从config拿到当前请求参数上
434 | + 'mch_id' => $mchid, // 从config拿到当前请求参数上
435 | + 'out_trade_no' => $outTradeNo,
436 | + ];
437 | + // 发起请求并取得结果,抑制`E_USER_DEPRECATED`提示
438 | + $resp = @$instance->chain('v2/secapi/pay/reverse')->postAsync(['xml' => $input, 'security' => true])->wait();
439 | + $result = Transformer::toArray((string)$resp->getBody());
440 | + // print_r($result);
441 | ```
442 |
443 | 其他`APIv2`迁移及接口请求类似如上,示例仅做了正常返回样例,**程序缜密性,需要加入`try catch`/`otherwise`结构捕获异常情况**。
444 |
445 | 至此,迁移后,`Chainable`、`PromiseA+`以及强劲的`PHP8`运行时,均可愉快地调用微信支付官方接口了。
446 |
--------------------------------------------------------------------------------
/bin/CertificateDownloader.php:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | parseOpts();
37 |
38 | if (!$opts || isset($opts['help'])) {
39 | $this->printHelp();
40 | return;
41 | }
42 | if (isset($opts['version'])) {
43 | self::prompt(ClientDecoratorInterface::VERSION);
44 | return;
45 | }
46 | $this->job($opts);
47 | }
48 |
49 | /**
50 | * Before `verifier` executing, decrypt and put the platform certificate(s) into the `$certs` reference.
51 | *
52 | * @param string $apiv3Key
53 | * @param array $certs
54 | *
55 | * @return callable(ResponseInterface)
56 | */
57 | private static function certsInjector(string $apiv3Key, array &$certs): callable {
58 | return static function(ResponseInterface $response) use ($apiv3Key, &$certs): ResponseInterface {
59 | $body = (string) $response->getBody();
60 | $json = \json_decode($body);
61 | $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
62 | \array_map(static function($row) use ($apiv3Key, &$certs) {
63 | $cert = $row->encrypt_certificate;
64 | $certs[$row->serial_no] = AesGcm::decrypt($cert->ciphertext, $apiv3Key, $cert->nonce, $cert->associated_data);
65 | }, $data);
66 |
67 | return $response;
68 | };
69 | }
70 |
71 | /**
72 | * @param array $opts
73 | *
74 | * @return void
75 | */
76 | private function job(array $opts): void
77 | {
78 | static $certs = ['any' => null];
79 |
80 | $outputDir = $opts['output'] ?? \sys_get_temp_dir();
81 | $apiv3Key = (string) $opts['key'];
82 |
83 | $instance = Builder::factory([
84 | 'mchid' => $opts['mchid'],
85 | 'serial' => $opts['serialno'],
86 | 'privateKey' => \file_get_contents((string)$opts['privatekey']),
87 | 'certs' => &$certs,
88 | 'base_uri' => (string)($opts['baseuri'] ?? self::DEFAULT_BASE_URI),
89 | ]);
90 |
91 | /** @var \GuzzleHttp\HandlerStack $stack */
92 | $stack = $instance->getDriver()->select(ClientDecoratorInterface::JSON_BASED)->getConfig('handler');
93 | // The response middle stacks were executed one by one on `FILO` order.
94 | $stack->after('verifier', Middleware::mapResponse(self::certsInjector($apiv3Key, $certs)), 'injector');
95 | $stack->before('verifier', Middleware::mapResponse(self::certsRecorder((string) $outputDir, $certs)), 'recorder');
96 |
97 | $instance->chain('v3/certificates')->getAsync(
98 | ['debug' => true]
99 | )->otherwise(static function($exception) {
100 | self::prompt($exception->getMessage());
101 | if ($exception instanceof RequestException && $exception->hasResponse()) {
102 | /** @var ResponseInterface $response */
103 | $response = $exception->getResponse();
104 | self::prompt((string) $response->getBody(), '', '');
105 | }
106 | self::prompt($exception->getTraceAsString());
107 | })->wait();
108 | }
109 |
110 | /**
111 | * After `verifier` executed, wrote the platform certificate(s) onto disk.
112 | *
113 | * @param string $outputDir
114 | * @param array $certs
115 | *
116 | * @return callable(ResponseInterface)
117 | */
118 | private static function certsRecorder(string $outputDir, array &$certs): callable {
119 | return static function(ResponseInterface $response) use ($outputDir, &$certs): ResponseInterface {
120 | $body = (string) $response->getBody();
121 | $json = \json_decode($body);
122 | $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
123 | \array_walk($data, static function($row, $index, $certs) use ($outputDir) {
124 | $serialNo = $row->serial_no;
125 | $outpath = $outputDir . \DIRECTORY_SEPARATOR . 'wechatpay_' . $serialNo . '.pem';
126 |
127 | self::prompt(
128 | 'Certificate #' . $index . ' {',
129 | ' Serial Number: ' . self::highlight($serialNo),
130 | ' Not Before: ' . (new \DateTime($row->effective_time))->format(\DateTime::W3C),
131 | ' Not After: ' . (new \DateTime($row->expire_time))->format(\DateTime::W3C),
132 | ' Saved to: ' . self::highlight($outpath),
133 | ' You may confirm the above infos again even if this library already did(by Crypto\Rsa::verify):',
134 | ' ' . self::highlight(\sprintf('openssl x509 -in %s -noout -serial -dates', $outpath)),
135 | ' Content: ', '', $certs[$serialNo] ?? '', '',
136 | '}'
137 | );
138 |
139 | \file_put_contents($outpath, $certs[$serialNo]);
140 | }, $certs);
141 |
142 | return $response;
143 | };
144 | }
145 |
146 | /**
147 | * @param string $thing
148 | */
149 | private static function highlight(string $thing): string
150 | {
151 | return \sprintf("\x1B[1;32m%s\x1B[0m", $thing);
152 | }
153 |
154 | /**
155 | * @param string $messages
156 | */
157 | private static function prompt(...$messages): void
158 | {
159 | \array_walk($messages, static function (string $message): void { \printf('%s%s', $message, \PHP_EOL); });
160 | }
161 |
162 | /**
163 | * @return ?array
164 | */
165 | private function parseOpts(): ?array
166 | {
167 | $opts = [
168 | [ 'key', 'k', true ],
169 | [ 'mchid', 'm', true ],
170 | [ 'privatekey', 'f', true ],
171 | [ 'serialno', 's', true ],
172 | [ 'output', 'o', false ],
173 | // baseuri can be one of 'https://api2.mch.weixin.qq.com/', 'https://apihk.mch.weixin.qq.com/'
174 | [ 'baseuri', 'u', false ],
175 | ];
176 |
177 | $shortopts = 'hV';
178 | $longopts = [ 'help', 'version' ];
179 | foreach ($opts as $opt) {
180 | [$key, $alias] = $opt;
181 | $shortopts .= $alias . ':';
182 | $longopts[] = $key . ':';
183 | }
184 | $parsed = \getopt($shortopts, $longopts);
185 |
186 | if (!$parsed) {
187 | return null;
188 | }
189 |
190 | $args = [];
191 | foreach ($opts as $opt) {
192 | [$key, $alias, $mandatory] = $opt;
193 | if (isset($parsed[$key]) || isset($parsed[$alias])) {
194 | /** @var string|string[] $possible */
195 | $possible = $parsed[$key] ?? $parsed[$alias] ?? '';
196 | $args[$key] = \is_array($possible) ? $possible[0] : $possible;
197 | } elseif ($mandatory) {
198 | return null;
199 | }
200 | }
201 |
202 | if (isset($parsed['h']) || isset($parsed['help'])) {
203 | $args['help'] = true;
204 | }
205 | if (isset($parsed['V']) || isset($parsed['version'])) {
206 | $args['version'] = true;
207 | }
208 | return $args;
209 | }
210 |
211 | private function printHelp(): void
212 | {
213 | self::prompt(
214 | 'Usage: 微信支付平台证书下载工具 [-hV]',
215 | ' -f= -k= -m=',
216 | ' -s= -o=[outputFilePath] -u=[baseUri]',
217 | 'Options:',
218 | ' -m, --mchid= 商户号',
219 | ' -s, --serialno= 商户证书的序列号',
220 | ' -f, --privatekey=',
221 | ' 商户的私钥文件',
222 | ' -k, --key= APIv3密钥',
223 | ' -o, --output=[outputFilePath]',
224 | ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹',
225 | ' -u, --baseuri=[baseUri] 接入点,可选,默认为 ' . self::DEFAULT_BASE_URI,
226 | ' -V, --version Print version information and exit.',
227 | ' -h, --help Show this help message and exit.', ''
228 | );
229 | }
230 | }
231 |
232 | // main
233 | (new CertificateDownloader())->run();
234 |
--------------------------------------------------------------------------------
/bin/README.md:
--------------------------------------------------------------------------------
1 | # Certificate Downloader
2 |
3 | Certificate Downloader 是 PHP版 微信支付 APIv3 平台证书的命令行下载工具。该工具可从 `https://api.mch.weixin.qq.com/v3/certificates` 接口获取商户可用证书,并使用 [APIv3 密钥](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/api-v3-mi-yao) 和 AES_256_GCM 算法进行解密,并把解密后证书下载到指定位置。
4 |
5 | ## 使用
6 | 使用方法与 [Java版Certificate Downloader](https://github.com/wechatpay-apiv3/CertificateDownloader) 一致,参数与常见问题请参考[其文档](https://github.com/wechatpay-apiv3/CertificateDownloader/blob/master/README.md)。
7 |
8 | ```shell
9 | > bin/CertificateDownloader.php
10 |
11 | Usage: 微信支付平台证书下载工具 [-hV]
12 | -f= -k= -m=
13 | -s= -o=[outputFilePath] -u=[baseUri]
14 | Options:
15 | -m, --mchid= 商户号
16 | -s, --serialno= 商户证书的序列号
17 | -f, --privatekey=
18 | 商户的私钥文件
19 | -k, --key= ApiV3Key
20 | -o, --output=[outputFilePath]
21 | 下载成功后保存证书的路径,可选参数,默认为临时文件目录夹
22 | -u, --baseuri=[baseUri] 接入点,默认为 https://api.mch.weixin.qq.com/
23 | -V, --version Print version information and exit.
24 | -h, --help Show this help message and exit.
25 | ```
26 |
27 | 完整命令示例:
28 |
29 | ```shell
30 | ./bin/CertificateDownloader.php -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
31 | ```
32 |
33 | 或
34 |
35 | ```shell
36 | php -f ./bin/CertificateDownloader.php -- -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
37 | ```
38 |
39 | 或
40 |
41 | ```shell
42 | php ./bin/CertificateDownloader.php -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
43 | ```
44 |
45 | 使用`composer`安装的软件包,可以通过如下命令下载:
46 |
47 | ```shell
48 | vendor/bin/CertificateDownloader.php -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
49 | ```
50 |
51 | 或
52 |
53 | ```shell
54 | composer exec CertificateDownloader.php -- -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
55 | ```
56 |
57 | 使用源码克隆版本,也可以使用`composer`通过以下命令下载:
58 |
59 | ```shell
60 | composer v3-certificates -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
61 | ```
62 |
63 | 支持从海外接入点下载,命令如下:
64 |
65 | ```shell
66 | composer v3-certificates -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath} -u https://apihk.mch.weixin.qq.com/
67 | ```
68 |
69 | **注:** 示例命令行上的`${}`是变量表达方法,运行时请替换(包括`${}`)为对应的实际值。
70 |
71 | ## 常见问题
72 |
73 | ### 如何保证证书正确
74 | 请参见CertificateDownloader文档中[关于如何保证证书正确的说明](https://github.com/wechatpay-apiv3/CertificateDownloader#%E5%A6%82%E4%BD%95%E4%BF%9D%E8%AF%81%E8%AF%81%E4%B9%A6%E6%AD%A3%E7%A1%AE)。
75 |
76 | ### 如何使用信任链验证平台证书
77 | 请参见CertificateDownloader文档中[关于如何使用信任链验证平台证书的说明](https://github.com/wechatpay-apiv3/CertificateDownloader#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8%E4%BF%A1%E4%BB%BB%E9%93%BE%E9%AA%8C%E8%AF%81%E5%B9%B3%E5%8F%B0%E8%AF%81%E4%B9%A6)。
78 |
79 | ### 第一次下载证书
80 |
81 | 请参见CertificateDownloader文档中[相关说明](https://github.com/wechatpay-apiv3/CertificateDownloader#%E7%AC%AC%E4%B8%80%E6%AC%A1%E4%B8%8B%E8%BD%BD%E8%AF%81%E4%B9%A6)。
82 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wechatpay/wechatpay",
3 | "version": "1.4.12",
4 | "description": "[A]Sync Chainable WeChatPay v2&v3's OpenAPI SDK for PHP",
5 | "type": "library",
6 | "keywords": [
7 | "wechatpay",
8 | "openapi-chainable",
9 | "xml-parser",
10 | "xml-builder",
11 | "aes-ecb",
12 | "aes-gcm",
13 | "rsa-oaep"
14 | ],
15 | "authors": [
16 | {
17 | "name": "James ZHANG",
18 | "homepage": "https://github.com/TheNorthMemory"
19 | },
20 | {
21 | "name": "WeChatPay Community",
22 | "homepage": "https://developers.weixin.qq.com/community/pay"
23 | }
24 | ],
25 | "homepage": "https://pay.weixin.qq.com/",
26 | "license": "Apache-2.0",
27 | "require": {
28 | "php": ">=7.1.2",
29 | "ext-curl": "*",
30 | "ext-libxml": "*",
31 | "ext-simplexml": "*",
32 | "ext-openssl": "*",
33 | "guzzlehttp/uri-template": "^0.2 || ^1.0",
34 | "guzzlehttp/guzzle": "^6.5 || ^7.0"
35 | },
36 | "require-dev": {
37 | "phpunit/phpunit": "^7.5 || ^8.5.16 || ^9.3.5",
38 | "phpstan/phpstan": "^0.12.89 || ^1.0"
39 | },
40 | "autoload": {
41 | "psr-4": { "WeChatPay\\" : "src/" }
42 | },
43 | "autoload-dev": {
44 | "psr-4": { "WeChatPay\\Tests\\" : "tests/" }
45 | },
46 | "bin": [
47 | "bin/CertificateDownloader.php"
48 | ],
49 | "scripts": {
50 | "v3-certificates": "bin/CertificateDownloader.php"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/phpstan.v8.4.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - phpstan.v8.2.neon
3 | parameters:
4 | ignoreErrors:
5 | -
6 | message: "#^(?:Left|Right) side of && is always true#"
7 | path: src/Crypto/Hash.php
8 |
--------------------------------------------------------------------------------
/src/Builder.php:
--------------------------------------------------------------------------------
1 | - The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
25 | * - secret?: string - The secret key string (optional)
26 | * - merchant?: array{key?: string, cert?: string} - The merchant private key and certificate array. (optional)
27 | * - merchant - The merchant private key(file path string). (optional)
28 | * - merchant - The merchant certificate(file path string). (optional)
29 | *
30 | * ```php
31 | * // usage samples
32 | * $instance = Builder::factory([]);
33 | * $res = $instance->chain('v3/merchantService/complaintsV2')->get(['debug' => true]);
34 | * $res = $instance->chain('v3/merchant-service/complaint-notifications')->get(['debug' => true]);
35 | * $instance->v3->merchantService->ComplaintNotifications->postAsync([])->wait();
36 | * $instance->v3->certificates->getAsync()->then(function() {})->otherwise(function() {})->wait();
37 | * ```
38 | *
39 | * @param array $config - `\GuzzleHttp\Client`, `APIv3` and `APIv2` configuration settings.
40 | */
41 | public static function factory(array $config = []): BuilderChainable
42 | {
43 | return new class([], new ClientDecorator($config)) extends ArrayIterator implements BuilderChainable
44 | {
45 | use BuilderTrait;
46 |
47 | /**
48 | * Compose the chainable `ClientDecorator` instance, most starter with the tree root point
49 | * @param string[] $input
50 | * @param ?ClientDecoratorInterface $instance
51 | */
52 | public function __construct(array $input = [], ?ClientDecoratorInterface $instance = null) {
53 | parent::__construct($input, self::STD_PROP_LIST | self::ARRAY_AS_PROPS);
54 |
55 | $this->setDriver($instance);
56 | }
57 |
58 | /**
59 | * @var ClientDecoratorInterface $driver - The `ClientDecorator` instance
60 | */
61 | protected $driver;
62 |
63 | /**
64 | * `$driver` setter
65 | * @param ClientDecoratorInterface $instance - The `ClientDecorator` instance
66 | */
67 | public function setDriver(ClientDecoratorInterface &$instance): BuilderChainable
68 | {
69 | $this->driver = $instance;
70 |
71 | return $this;
72 | }
73 |
74 | /**
75 | * @inheritDoc
76 | */
77 | public function getDriver(): ClientDecoratorInterface
78 | {
79 | return $this->driver;
80 | }
81 |
82 | /**
83 | * Normalize the `$thing` by the rules: `PascalCase` -> `camelCase`
84 | * & `camelCase` -> `kebab-case`
85 | * & `_placeholder_` -> `{placeholder}`
86 | *
87 | * @param string $thing - The string waiting for normalization
88 | *
89 | * @return string
90 | */
91 | protected function normalize(string $thing = ''): string
92 | {
93 | return preg_replace_callback_array([
94 | '#^[A-Z]#' => static function(array $piece): string { return strtolower($piece[0]); },
95 | '#[A-Z]#' => static function(array $piece): string { return '-' . strtolower($piece[0]); },
96 | '#^_(.*)_$#' => static function(array $piece): string { return '{' . $piece[1] . '}'; },
97 | ], $thing) ?? $thing;
98 | }
99 |
100 | /**
101 | * URI pathname
102 | *
103 | * @param string $seperator - The URI seperator, default is slash(`/`) character
104 | *
105 | * @return string - The URI string
106 | */
107 | protected function pathname(string $seperator = '/'): string
108 | {
109 | return implode($seperator, $this->simplized());
110 | }
111 |
112 | /**
113 | * Only retrieve a copy array of the URI segments
114 | *
115 | * @return string[] - The URI segments array
116 | */
117 | protected function simplized(): array
118 | {
119 | return array_filter($this->getArrayCopy(), static function($v) { return !($v instanceof BuilderChainable); });
120 | }
121 |
122 | /**
123 | * @inheritDoc
124 | */
125 | public function offsetGet($key): BuilderChainable
126 | {
127 | if (!$this->offsetExists($key)) {
128 | $indices = $this->simplized();
129 | $indices[] = $this->normalize($key);
130 | $this->offsetSet($key, new self($indices, $this->getDriver()));
131 | }
132 |
133 | return parent::offsetGet($key);
134 | }
135 |
136 | /**
137 | * @inheritDoc
138 | */
139 | public function chain(string $segment): BuilderChainable
140 | {
141 | return $this->offsetGet($segment);
142 | }
143 | };
144 | }
145 |
146 | private function __construct()
147 | {
148 | // cannot be instantiated
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/BuilderChainable.php:
--------------------------------------------------------------------------------
1 | $options Request options to apply.
31 | */
32 | public function get(array $options = []): ResponseInterface;
33 |
34 | /**
35 | * Create and send an HTTP PUT request.
36 | *
37 | * @param array $options Request options to apply.
38 | */
39 | public function put(array $options = []): ResponseInterface;
40 |
41 | /**
42 | * Create and send an HTTP POST request.
43 | *
44 | * @param array $options Request options to apply.
45 | */
46 | public function post(array $options = []): ResponseInterface;
47 |
48 | /**
49 | * Create and send an HTTP PATCH request.
50 | *
51 | * @param array $options Request options to apply.
52 | */
53 | public function patch(array $options = []): ResponseInterface;
54 |
55 | /**
56 | * Create and send an HTTP DELETE request.
57 | *
58 | * @param array $options Request options to apply.
59 | */
60 | public function delete(array $options = []): ResponseInterface;
61 |
62 | /**
63 | * Create and send an asynchronous HTTP GET request.
64 | *
65 | * @param array $options Request options to apply.
66 | */
67 | public function getAsync(array $options = []): PromiseInterface;
68 |
69 | /**
70 | * Create and send an asynchronous HTTP PUT request.
71 | *
72 | * @param array $options Request options to apply.
73 | */
74 | public function putAsync(array $options = []): PromiseInterface;
75 |
76 | /**
77 | * Create and send an asynchronous HTTP POST request.
78 | *
79 | * @param array $options Request options to apply.
80 | */
81 | public function postAsync(array $options = []): PromiseInterface;
82 |
83 | /**
84 | * Create and send an asynchronous HTTP PATCH request.
85 | *
86 | * @param array $options Request options to apply.
87 | */
88 | public function patchAsync(array $options = []): PromiseInterface;
89 |
90 | /**
91 | * Create and send an asynchronous HTTP DELETE request.
92 | *
93 | * @param array $options Request options to apply.
94 | */
95 | public function deleteAsync(array $options = []): PromiseInterface;
96 | }
97 |
--------------------------------------------------------------------------------
/src/BuilderTrait.php:
--------------------------------------------------------------------------------
1 | getDriver()->request('GET', $this->pathname(), $options);
30 | }
31 |
32 | /**
33 | * @inheritDoc
34 | */
35 | public function put(array $options = []): ResponseInterface
36 | {
37 | return $this->getDriver()->request('PUT', $this->pathname(), $options);
38 | }
39 |
40 | /**
41 | * @inheritDoc
42 | */
43 | public function post(array $options = []): ResponseInterface
44 | {
45 | return $this->getDriver()->request('POST', $this->pathname(), $options);
46 | }
47 |
48 | /**
49 | * @inheritDoc
50 | */
51 | public function patch(array $options = []): ResponseInterface
52 | {
53 | return $this->getDriver()->request('PATCH', $this->pathname(), $options);
54 | }
55 |
56 | /**
57 | * @inheritDoc
58 | */
59 | public function delete(array $options = []): ResponseInterface
60 | {
61 | return $this->getDriver()->request('DELETE', $this->pathname(), $options);
62 | }
63 |
64 | /**
65 | * @inheritDoc
66 | */
67 | public function getAsync(array $options = []): PromiseInterface
68 | {
69 | return $this->getDriver()->requestAsync('GET', $this->pathname(), $options);
70 | }
71 |
72 | /**
73 | * @inheritDoc
74 | */
75 | public function putAsync(array $options = []): PromiseInterface
76 | {
77 | return $this->getDriver()->requestAsync('PUT', $this->pathname(), $options);
78 | }
79 |
80 | /**
81 | * @inheritDoc
82 | */
83 | public function postAsync(array $options = []): PromiseInterface
84 | {
85 | return $this->getDriver()->requestAsync('POST', $this->pathname(), $options);
86 | }
87 |
88 | /**
89 | * @inheritDoc
90 | */
91 | public function patchAsync(array $options = []): PromiseInterface
92 | {
93 | return $this->getDriver()->requestAsync('PATCH', $this->pathname(), $options);
94 | }
95 |
96 | /**
97 | * @inheritDoc
98 | */
99 | public function deleteAsync(array $options = []): PromiseInterface
100 | {
101 | return $this->getDriver()->requestAsync('DELETE', $this->pathname(), $options);
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/ClientDecorator.php:
--------------------------------------------------------------------------------
1 | $config - The configuration.
47 | *
48 | * @return array - With the built-in configuration.
49 | */
50 | protected static function withDefaults(array ...$config): array
51 | {
52 | return array_replace_recursive(static::$defaults, ['headers' => static::userAgent()], ...$config);
53 | }
54 |
55 | /**
56 | * Prepare the `User-Agent` value key/value pair
57 | *
58 | * @return array
59 | */
60 | protected static function userAgent(): array
61 | {
62 | return ['User-Agent' => implode(' ', [
63 | sprintf('wechatpay-php/%s', static::VERSION),
64 | sprintf('GuzzleHttp/%s', constant(ClientInterface::class . (defined(ClientInterface::class . '::VERSION') ? '::VERSION' : '::MAJOR_VERSION'))),
65 | sprintf('curl/%s', ((array)call_user_func('\curl_version'))['version'] ?? 'unknown'),
66 | sprintf('(%s/%s)', PHP_OS, php_uname('r')),
67 | sprintf('PHP/%s', PHP_VERSION),
68 | ])];
69 | }
70 |
71 | /**
72 | * Taken body string
73 | *
74 | * @param MessageInterface $message - The message
75 | */
76 | protected static function body(MessageInterface $message): string
77 | {
78 | $stream = $message->getBody();
79 | $content = (string) $stream;
80 |
81 | $stream->tell() && $stream->rewind();
82 |
83 | return $content;
84 | }
85 |
86 | /**
87 | * Decorate the `GuzzleHttp\Client` factory
88 | *
89 | * Acceptable \$config parameters stucture
90 | * - mchid: string - The merchant ID
91 | * - serial: string - The serial number of the merchant certificate
92 | * - privateKey: \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string - The merchant private key.
93 | * - certs: array - The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
94 | * - secret?: string - The secret key string (optional)
95 | * - merchant?: array{key?: string, cert?: string} - The merchant private key and certificate array. (optional)
96 | * - merchant - The merchant private key(file path string). (optional)
97 | * - merchant - The merchant certificate(file path string). (optional)
98 | *
99 | * @param array $config - `\GuzzleHttp\Client`, `APIv3` and `APIv2` configuration settings.
100 | */
101 | public function __construct(array $config = [])
102 | {
103 | $this->{static::XML_BASED} = static::xmlBased($config);
104 | $this->{static::JSON_BASED} = static::jsonBased($config);
105 | }
106 |
107 | /**
108 | * Identify the `protocol` and `uri`
109 | *
110 | * @param string $uri - The uri string.
111 | *
112 | * @return string[] - the first element is the API version aka `protocol`, the second is the real `uri`
113 | */
114 | private static function prepare(string $uri): array
115 | {
116 | return $uri && 0 === strncasecmp(static::XML_BASED . '/', $uri, 3)
117 | ? [static::XML_BASED, substr($uri, 3)]
118 | : [static::JSON_BASED, $uri];
119 | }
120 |
121 | /**
122 | * @inheritDoc
123 | */
124 | public function select(?string $protocol = null): ClientInterface
125 | {
126 | return $protocol && 0 === strcasecmp(static::XML_BASED, $protocol)
127 | ? $this->{static::XML_BASED}
128 | : $this->{static::JSON_BASED};
129 | }
130 |
131 | /**
132 | * @inheritDoc
133 | */
134 | public function request(string $method, string $uri, array $options = []): ResponseInterface
135 | {
136 | [$protocol, $pathname] = self::prepare(UriTemplate::expand($uri, $options));
137 |
138 | return $this->select($protocol)->request($method, $pathname, $options);
139 | }
140 |
141 | /**
142 | * @inheritDoc
143 | */
144 | public function requestAsync(string $method, string $uri, array $options = []): PromiseInterface
145 | {
146 | [$protocol, $pathname] = self::prepare(UriTemplate::expand($uri, $options));
147 |
148 | return $this->select($protocol)->requestAsync($method, $pathname, $options);
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/ClientDecoratorInterface.php:
--------------------------------------------------------------------------------
1 | $options - The options.
44 | *
45 | * @return ResponseInterface - The `Psr\Http\Message\ResponseInterface` instance
46 | */
47 | public function request(string $method, string $uri, array $options = []): ResponseInterface;
48 |
49 | /**
50 | * Async request the remote `$uri` by a HTTP `$method` verb
51 | *
52 | * @param string $uri - The uri string.
53 | * @param string $method - The method string.
54 | * @param array $options - The options.
55 | *
56 | * @return PromiseInterface - The `GuzzleHttp\Promise\PromiseInterface` instance
57 | */
58 | public function requestAsync(string $method, string $uri, array $options = []): PromiseInterface;
59 | }
60 |
--------------------------------------------------------------------------------
/src/ClientJsonTrait.php:
--------------------------------------------------------------------------------
1 | > - The defaults configuration whose pased in `GuzzleHttp\Client`.
44 | */
45 | protected static $defaults = [
46 | 'base_uri' => 'https://api.mch.weixin.qq.com/',
47 | 'headers' => [
48 | 'Accept' => 'application/json, text/plain, application/x-gzip, application/pdf, image/png, image/*;q=0.5',
49 | 'Content-Type' => 'application/json; charset=utf-8',
50 | ],
51 | ];
52 |
53 | abstract protected static function body(MessageInterface $message): string;
54 |
55 | abstract protected static function withDefaults(array ...$config): array;
56 |
57 | /**
58 | * APIv3's signer middleware stack
59 | *
60 | * @param string $mchid - The merchant ID
61 | * @param string $serial - The serial number of the merchant certificate
62 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string $privateKey - The merchant private key.
63 | *
64 | * @return callable(RequestInterface)
65 | */
66 | public static function signer(
67 | string $mchid,
68 | string $serial,
69 | #[\SensitiveParameter]
70 | $privateKey
71 | ): callable
72 | {
73 | return static function (RequestInterface $request) use ($mchid, $serial, $privateKey): RequestInterface {
74 | $nonce = Formatter::nonce();
75 | $timestamp = (string) Formatter::timestamp();
76 | $signature = Crypto\Rsa::sign(Formatter::request(
77 | $request->getMethod(), $request->getRequestTarget(), $timestamp, $nonce, static::body($request)
78 | ), $privateKey);
79 |
80 | return $request->withHeader('Authorization', Formatter::authorization(
81 | $mchid, $nonce, $signature, $timestamp, $serial
82 | ));
83 | };
84 | }
85 |
86 | /**
87 | * Assert the HTTP `20X` responses fit for the business logic, otherwise thrown a `\GuzzleHttp\Exception\RequestException`.
88 | *
89 | * The `30X` responses were handled by `\GuzzleHttp\RedirectMiddleware`.
90 | * The `4XX, 5XX` responses were handled by `\GuzzleHttp\Middleware::httpErrors`.
91 | *
92 | * @param array $certs The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
93 | * @return callable(ResponseInterface,RequestInterface)
94 | * @throws RequestException
95 | */
96 | protected static function assertSuccessfulResponse(array &$certs): callable
97 | {
98 | return static function (ResponseInterface $response, RequestInterface $request) use(&$certs): ResponseInterface {
99 | if (
100 | 0 === strcasecmp($url = $request->getUri()->getPath(), '/v3/billdownload/file')
101 | || (0 === strncasecmp($url, '/v3/merchant-service/images/', 28) && 0 !== strcasecmp($url, '/v3/merchant-service/images/upload'))
102 | ) {
103 | return $response;
104 | }
105 |
106 | if (!($response->hasHeader(WechatpayNonce) && $response->hasHeader(WechatpaySerial)
107 | && $response->hasHeader(WechatpaySignature) && $response->hasHeader(WechatpayTimestamp))) {
108 | throw new RequestException(sprintf(
109 | Exception\WeChatPayException::EV3_RES_HEADERS_INCOMPLETE,
110 | WechatpayNonce, WechatpaySerial, WechatpaySignature, WechatpayTimestamp
111 | ), $request, $response);
112 | }
113 |
114 | [$nonce] = $response->getHeader(WechatpayNonce);
115 | [$serial] = $response->getHeader(WechatpaySerial);
116 | [$signature] = $response->getHeader(WechatpaySignature);
117 | [$timestamp] = $response->getHeader(WechatpayTimestamp);
118 |
119 | $localTimestamp = Formatter::timestamp();
120 |
121 | if (abs($localTimestamp - intval($timestamp)) > MAXIMUM_CLOCK_OFFSET) {
122 | throw new RequestException(sprintf(
123 | Exception\WeChatPayException::EV3_RES_HEADER_TIMESTAMP_OFFSET,
124 | MAXIMUM_CLOCK_OFFSET, $timestamp, $localTimestamp
125 | ), $request, $response);
126 | }
127 |
128 | if (!array_key_exists($serial, $certs)) {
129 | throw new RequestException(sprintf(
130 | Exception\WeChatPayException::EV3_RES_HEADER_PLATFORM_SERIAL,
131 | $serial, WechatpaySerial, implode(',', array_keys($certs))
132 | ), $request, $response);
133 | }
134 |
135 | $isOverseas = (0 === strcasecmp($url, '/hk/v3/statements') || 0 === strcasecmp($url, '/v3/global/statements')) && $response->hasHeader(WechatpayStatementSha1);
136 |
137 | $verified = false;
138 | try {
139 | $verified = Crypto\Rsa::verify(
140 | Formatter::response(
141 | $timestamp,
142 | $nonce,
143 | $isOverseas ? static::digestBody($response) : static::body($response)
144 | ),
145 | $signature, $certs[$serial]
146 | );
147 | } catch (\Exception $exception) {}
148 | if ($verified === false) {
149 | throw new RequestException(sprintf(
150 | Exception\WeChatPayException::EV3_RES_HEADER_SIGNATURE_DIGEST,
151 | $timestamp, $nonce, $signature, $serial
152 | ), $request, $response, $exception ?? null);
153 | }
154 |
155 | return $response;
156 | };
157 | }
158 |
159 | /**
160 | * Downloading the reconciliation was required the client to format the `WechatpayStatementSha1` digest string as `JSON`.
161 | *
162 | * There was also sugguestion that to validate the response streaming's `SHA1` digest whether or nor equals to `WechatpayStatementSha1`.
163 | * Here may contains with or without `gzip` parameter. Both of them are validating the plain `CSV` stream.
164 | * Keep the same logic with the mainland's one(without `SHA1` validation).
165 | * If someone needs this feature built-in, contrubiting is welcome.
166 | *
167 | * @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/ch/fusion_wallet_ch/QuickPay/chapter8_5.shtml
168 | * @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/en/fusion_wallet/QuickPay/chapter8_5.shtml
169 | * @see https://pay.weixin.qq.com/wiki/doc/api_external/ch/apis/chapter3_1_6.shtml
170 | * @see https://pay.weixin.qq.com/wiki/doc/api_external/en/apis/chapter3_1_6.shtml
171 | *
172 | * @param ResponseInterface $response - The response instance
173 | *
174 | * @return string - The JSON string
175 | */
176 | protected static function digestBody(ResponseInterface $response): string
177 | {
178 | return sprintf('{"sha1":"%s"}', $response->getHeader(WechatpayStatementSha1)[0]);
179 | }
180 |
181 | /**
182 | * APIv3's verifier middleware stack
183 | *
184 | * @param array $certs The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
185 | * @return callable(callable(RequestInterface,array))
186 | */
187 | public static function verifier(array &$certs): callable
188 | {
189 | $assert = static::assertSuccessfulResponse($certs);
190 | return static function (callable $handler) use ($assert): callable {
191 | return static function (RequestInterface $request, array $options = []) use ($assert, $handler): PromiseInterface {
192 | return $handler($request, $options)->then(static function(ResponseInterface $response) use ($assert, $request): ResponseInterface {
193 | return $assert($response, $request);
194 | });
195 | };
196 | };
197 | }
198 |
199 | /**
200 | * Create an APIv3's client
201 | *
202 | * Mandatory \$config array paramters
203 | * - mchid: string - The merchant ID
204 | * - serial: string - The serial number of the merchant certificate
205 | * - privateKey: \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string - The merchant private key.
206 | * - certs: array{string, \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string} - The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
207 | *
208 | * @param array $config - The configuration
209 | * @throws \WeChatPay\Exception\InvalidArgumentException
210 | */
211 | public static function jsonBased(array $config = []): Client
212 | {
213 | if (!(
214 | isset($config['mchid']) && is_string($config['mchid'])
215 | )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_MCHID_IS_MANDATORY); }
216 |
217 | if (!(
218 | isset($config['serial']) && is_string($config['serial'])
219 | )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_SERIAL_IS_MANDATORY); }
220 |
221 | if (!(
222 | isset($config['privateKey']) && (is_string($config['privateKey']) || is_resource($config['privateKey']) || is_object($config['privateKey']))
223 | )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_PRIVATEKEY_IS_MANDATORY); }
224 |
225 | if (!(
226 | isset($config['certs']) && is_array($config['certs']) && count($config['certs'])
227 | )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_CERTS_IS_MANDATORY); }
228 |
229 | if (array_key_exists($config['serial'], $config['certs'])) {
230 | throw new Exception\InvalidArgumentException(sprintf(
231 | Exception\ERR_INIT_CERTS_EXCLUDE_MCHSERIAL, implode(',', array_keys($config['certs'])), $config['serial']
232 | ));
233 | }
234 |
235 | /** @var HandlerStack $stack */
236 | $stack = isset($config['handler']) && ($config['handler'] instanceof HandlerStack) ? (clone $config['handler']) : HandlerStack::create();
237 | $stack->before('prepare_body', Middleware::mapRequest(static::signer((string)$config['mchid'], $config['serial'], $config['privateKey'])), 'signer');
238 | $stack->before('http_errors', static::verifier($config['certs']), 'verifier');
239 | $config['handler'] = $stack;
240 |
241 | unset($config['mchid'], $config['serial'], $config['privateKey'], $config['certs'], $config['secret'], $config['merchant']);
242 |
243 | return new Client(static::withDefaults($config));
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/src/ClientXmlTrait.php:
--------------------------------------------------------------------------------
1 | - The default headers whose passed in `GuzzleHttp\Client`.
27 | */
28 | protected static $headers = [
29 | 'Accept' => 'text/xml, text/plain, application/x-gzip',
30 | 'Content-Type' => 'text/xml; charset=utf-8',
31 | ];
32 |
33 | /**
34 | * @var string[] - Special URLs whose were designed that none signature respond.
35 | */
36 | protected static $noneSignatureRespond = [
37 | '/mchrisk/querymchrisk',
38 | '/mchrisk/setmchriskcallback',
39 | '/mchrisk/syncmchriskresult',
40 | '/mmpaymkttransfers/gethbinfo',
41 | '/mmpaymkttransfers/gettransferinfo',
42 | '/mmpaymkttransfers/pay_bank',
43 | '/mmpaymkttransfers/promotion/paywwsptrans2pocket',
44 | '/mmpaymkttransfers/promotion/querywwsptrans2pocket',
45 | '/mmpaymkttransfers/promotion/transfers',
46 | '/mmpaymkttransfers/query_bank',
47 | '/mmpaymkttransfers/sendgroupredpack',
48 | '/mmpaymkttransfers/sendminiprogramhb',
49 | '/mmpaymkttransfers/sendredpack',
50 | '/papay/entrustweb',
51 | '/papay/h5entrustweb',
52 | '/papay/partner/entrustweb',
53 | '/papay/partner/h5entrustweb',
54 | '/pay/downloadbill',
55 | '/pay/downloadfundflow',
56 | '/payitil/report',
57 | '/risk/getpublickey',
58 | '/risk/getviolation',
59 | '/secapi/mch/submchmanage',
60 | '/xdc/apiv2getsignkey/sign/getsignkey',
61 | ];
62 |
63 | abstract protected static function body(MessageInterface $message): string;
64 |
65 | abstract protected static function withDefaults(array ...$config): array;
66 |
67 | /**
68 | * APIv2's transformRequest, did the `datasign` and `array2xml` together
69 | *
70 | * @param ?string $mchid - The merchant ID
71 | * @param string $secret - The secret key string (optional)
72 | * @param array{cert?: ?string, key?: ?string} $merchant - The merchant private key and certificate array. (optional)
73 | *
74 | * @return callable(callable(RequestInterface, array))
75 | * @throws \WeChatPay\Exception\InvalidArgumentException
76 | */
77 | public static function transformRequest(
78 | ?string $mchid = null,
79 | #[\SensitiveParameter]
80 | string $secret = '',
81 | ?array $merchant = null
82 | ): callable
83 | {
84 | return static function (callable $handler) use ($mchid, $secret, $merchant): callable {
85 | return static function (RequestInterface $request, array $options = []) use ($handler, $mchid, $secret, $merchant): PromiseInterface {
86 | $methodIsGet = $request->getMethod() === 'GET';
87 |
88 | if ($methodIsGet) {
89 | $queryParams = Query::parse($request->getUri()->getQuery());
90 | }
91 |
92 | $data = $options['xml'] ?? ($queryParams ?? []);
93 |
94 | if ($mchid && $mchid !== ($inputMchId = $data['mch_id'] ?? $data['mchid'] ?? $data['combine_mch_id'] ?? null)) {
95 | throw new Exception\InvalidArgumentException(sprintf(Exception\EV2_REQ_XML_NOTMATCHED_MCHID, $inputMchId ?? '', $mchid));
96 | }
97 |
98 | $type = $data['sign_type'] ?? Crypto\Hash::ALGO_MD5;
99 |
100 | isset($options['nonceless']) || $data['nonce_str'] = $data['nonce_str'] ?? Formatter::nonce();
101 |
102 | $data['sign'] = Crypto\Hash::sign($type, Formatter::queryStringLike(Formatter::ksort($data)), $secret);
103 |
104 | $modify = $methodIsGet ? ['query' => Query::build($data)] : ['body' => Transformer::toXml($data)];
105 |
106 | // for security request, it was required the merchant's private_key and certificate
107 | if (isset($options['security']) && true === $options['security']) {
108 | $options['ssl_key'] = $merchant['key'] ?? null;
109 | $options['cert'] = $merchant['cert'] ?? null;
110 | }
111 |
112 | unset($options['xml'], $options['nonceless'], $options['security']);
113 |
114 | return $handler(Utils::modifyRequest($request, $modify), $options);
115 | };
116 | };
117 | }
118 |
119 | /**
120 | * APIv2's transformResponse, doing the `xml2array` then `verify` the signature job only
121 | *
122 | * @param string $secret - The secret key string (optional)
123 | *
124 | * @return callable(callable(RequestInterface, array))
125 | */
126 | public static function transformResponse(
127 | #[\SensitiveParameter]
128 | string $secret = ''
129 | ): callable
130 | {
131 | return static function (callable $handler) use ($secret): callable {
132 | return static function (RequestInterface $request, array $options = []) use ($secret, $handler): PromiseInterface {
133 | if (in_array($request->getUri()->getPath(), static::$noneSignatureRespond)) {
134 | return $handler($request, $options);
135 | }
136 |
137 | return $handler($request, $options)->then(static function(ResponseInterface $response) use ($secret) {
138 | $result = Transformer::toArray(static::body($response));
139 |
140 | if (!(array_key_exists('return_code', $result) && Crypto\Hash::equals('SUCCESS', $result['return_code']))) {
141 | return Create::rejectionFor($response);
142 | }
143 |
144 | if (array_key_exists('result_code', $result) && !Crypto\Hash::equals('SUCCESS', $result['result_code'])) {
145 | return Create::rejectionFor($response);
146 | }
147 |
148 | /** @var ?string $sign */
149 | $sign = $result['sign'] ?? null;
150 | $type = $sign && strlen($sign) === 64 ? Crypto\Hash::ALGO_HMAC_SHA256 : Crypto\Hash::ALGO_MD5;
151 | /** @var string $calc - calculated digest string, it's naver `null` here because of \$type known. */
152 | $calc = Crypto\Hash::sign($type, Formatter::queryStringLike(Formatter::ksort($result)), $secret);
153 |
154 | return Crypto\Hash::equals($calc, $sign) ? $response : Create::rejectionFor($response);
155 | });
156 | };
157 | };
158 | }
159 |
160 | /**
161 | * Create an APIv2's client
162 | *
163 | * @deprecated 1.0 - @see \WeChatPay\Exception\WeChatPayException::DEP_XML_PROTOCOL_IS_REACHABLE_EOL
164 | *
165 | * Optional acceptable \$config parameters
166 | * - mchid?: ?string - The merchant ID
167 | * - secret?: ?string - The secret key string
168 | * - merchant?: array{key?: string, cert?: string} - The merchant private key and certificate array. (optional)
169 | * - merchant - The merchant private key(file path string). (optional)
170 | * - merchant - The merchant certificate(file path string). (optional)
171 | *
172 | * @param array $config - The configuration
173 | */
174 | public static function xmlBased(array $config = []): Client
175 | {
176 | /** @var HandlerStack $stack */
177 | $stack = isset($config['handler']) && ($config['handler'] instanceof HandlerStack) ? (clone $config['handler']) : HandlerStack::create();
178 | $stack->before('prepare_body', static::transformRequest($config['mchid'] ?? null, $config['secret'] ?? '', $config['merchant'] ?? []), 'transform_request');
179 | $stack->before('http_errors', static::transformResponse($config['secret'] ?? ''), 'transform_response');
180 | $config['handler'] = $stack;
181 |
182 | unset($config['mchid'], $config['serial'], $config['privateKey'], $config['certs'], $config['secret'], $config['merchant']);
183 |
184 | return new Client(static::withDefaults(['headers' => static::$headers], $config));
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/src/Crypto/AesEcb.php:
--------------------------------------------------------------------------------
1 | static::BLOCK_SIZE || ($tagLength < 12 && $tagLength !== 8 && $tagLength !== 4)) {
93 | throw new RuntimeException('The inputs `$ciphertext` incomplete, the bytes length must be one of 16, 15, 14, 13, 12, 8 or 4.');
94 | }
95 |
96 | $plaintext = openssl_decrypt(substr($ciphertext, 0, $tailLength), static::ALGO_AES_256_GCM, $key, OPENSSL_RAW_DATA, $iv, $authTag, $aad);
97 |
98 | if (false === $plaintext) {
99 | throw new UnexpectedValueException('Decrypting the input $ciphertext failed, please checking your $key and $iv whether or nor correct.');
100 | }
101 |
102 | return $plaintext;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/Crypto/AesInterface.php:
--------------------------------------------------------------------------------
1 | 'hmac', ALGO_MD5 => 'md5'];
18 |
19 | /**
20 | * Crypto hash functions utils.
21 | * [Specification]{@link https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3}
22 | */
23 | class Hash
24 | {
25 | /** @var string - hashing `MD5` algorithm */
26 | public const ALGO_MD5 = ALGO_MD5;
27 |
28 | /** @var string - hashing `HMAC-SHA256` algorithm */
29 | public const ALGO_HMAC_SHA256 = ALGO_HMAC_SHA256;
30 |
31 | /**
32 | * Calculate the input string with an optional secret `key` in MD5,
33 | * when the `key` is Falsey, this method works as normal `MD5`.
34 | *
35 | * @param string $thing - The input string.
36 | * @param string $key - The secret key string.
37 | * @param boolean|int|string $agency - The secret **key** is from work.weixin.qq.com, default is `false`,
38 | * placed with `true` or better of the `AgentId` value.
39 | * [spec]{@link https://work.weixin.qq.com/api/doc/90000/90135/90281}
40 | *
41 | * @return string - The data signature
42 | */
43 | public static function md5(
44 | string $thing,
45 | #[\SensitiveParameter]
46 | string $key = '',
47 | $agency = false
48 | ): string
49 | {
50 | $ctx = hash_init(ALGO_MD5);
51 |
52 | hash_update($ctx, $thing) && $key && hash_update($ctx, $agency ? '&secret=' : '&key=') && hash_update($ctx, $key);
53 |
54 | return hash_final($ctx);
55 | }
56 |
57 | /**
58 | * Calculate the input string with a secret `key` as of `algorithm` string which is one of the 'sha256', 'sha512' etc.
59 | *
60 | * @param string $thing - The input string.
61 | * @param string $key - The secret key string.
62 | * @param string $algorithm - The algorithm string, default is `sha256`.
63 | *
64 | * @return string - The data signature
65 | */
66 | public static function hmac(
67 | string $thing,
68 | #[\SensitiveParameter]
69 | string $key,
70 | string $algorithm = 'sha256'
71 | ): string
72 | {
73 | $ctx = hash_init($algorithm, HASH_HMAC, $key);
74 |
75 | hash_update($ctx, $thing) && hash_update($ctx, '&key=') && hash_update($ctx, $key);
76 |
77 | return hash_final($ctx);
78 | }
79 |
80 | /**
81 | * Wrapping the builtins `hash_equals` function.
82 | *
83 | * @param string $known_string - The string of known length to compare against.
84 | * @param ?string $user_string - The user-supplied string.
85 | *
86 | * @return bool - Returns true when the two are equal, false otherwise.
87 | */
88 | public static function equals(
89 | #[\SensitiveParameter]
90 | string $known_string,
91 | #[\SensitiveParameter]
92 | ?string $user_string = null
93 | ): bool
94 | {
95 | return is_null($user_string) ? false : hash_equals($known_string, $user_string);
96 | }
97 |
98 | /**
99 | * Utils of the data signature calculation.
100 | *
101 | * @param string $type - The sign type, one of the `MD5` or `HMAC-SHA256`.
102 | * @param string $data - The input data.
103 | * @param string $key - The secret key string.
104 | *
105 | * @return ?string - The data signature in UPPERCASE.
106 | */
107 | public static function sign(
108 | string $type,
109 | string $data,
110 | #[\SensitiveParameter]
111 | string $key
112 | ): ?string
113 | {
114 | return array_key_exists($type, ALGO_DICTONARIES) ? strtoupper(static::{ALGO_DICTONARIES[$type]}($data, $key)) : null;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/Crypto/Rsa.php:
--------------------------------------------------------------------------------
1 | - Supported loading rules */
54 | private const RULES = [
55 | 'private.pkcs1' => [self::PKEY_PEM_FORMAT, 'RSA PRIVATE', 16],
56 | 'private.pkcs8' => [self::PKEY_PEM_FORMAT, 'PRIVATE', 16],
57 | 'public.pkcs1' => [self::PKEY_PEM_FORMAT, 'RSA PUBLIC', 15],
58 | 'public.spki' => [self::PKEY_PEM_FORMAT, 'PUBLIC', 14],
59 | ];
60 |
61 | /**
62 | * @var string - Equal to `sequence(oid(1.2.840.113549.1.1.1), null))`
63 | * @link https://datatracker.ietf.org/doc/html/rfc3447#appendix-A.2
64 | */
65 | private const ASN1_OID_RSAENCRYPTION = '300d06092a864886f70d0101010500';
66 | private const ASN1_SEQUENCE = 48;
67 | private const CHR_NUL = "\0";
68 | private const CHR_ETX = "\3";
69 |
70 | /**
71 | * Translate the \$thing strlen from `X690` style to the `ASN.1` 128bit hexadecimal length string
72 | *
73 | * @param string $thing - The string
74 | *
75 | * @return string The `ASN.1` 128bit hexadecimal length string
76 | */
77 | private static function encodeLength(string $thing): string
78 | {
79 | $num = strlen($thing);
80 | if ($num <= 0x7F) {
81 | return sprintf('%c', $num);
82 | }
83 |
84 | $tmp = ltrim(pack('N', $num), self::CHR_NUL);
85 | return pack('Ca*', strlen($tmp) | 0x80, $tmp);
86 | }
87 |
88 | /**
89 | * Convert the `PKCS#1` format RSA Public Key to `SPKI` format
90 | *
91 | * @param string $thing - The base64-encoded string, without evelope style
92 | *
93 | * @return string The `SPKI` style public key without evelope string
94 | */
95 | public static function pkcs1ToSpki(string $thing): string
96 | {
97 | $raw = self::CHR_NUL . base64_decode($thing);
98 | $new = pack('H*', self::ASN1_OID_RSAENCRYPTION) . self::CHR_ETX . self::encodeLength($raw) . $raw;
99 |
100 | return base64_encode(pack('Ca*a*', self::ASN1_SEQUENCE, self::encodeLength($new), $new));
101 | }
102 |
103 | /**
104 | * Sugar for loading input `privateKey` string, pure `base64-encoded-string` without LF and evelope.
105 | *
106 | * @param string $thing - The string in `PKCS#8` format.
107 | * @return \OpenSSLAsymmetricKey|resource|mixed
108 | * @throws UnexpectedValueException
109 | */
110 | public static function fromPkcs8(
111 | #[\SensitiveParameter]
112 | string $thing
113 | )
114 | {
115 | return static::from(sprintf('private.pkcs8://%s', $thing), static::KEY_TYPE_PRIVATE);
116 | }
117 |
118 | /**
119 | * Sugar for loading input `privateKey/publicKey` string, pure `base64-encoded-string` without LF and evelope.
120 | *
121 | * @param string $thing - The string in `PKCS#1` format.
122 | * @param string $type - Either `self::KEY_TYPE_PUBLIC` or `self::KEY_TYPE_PRIVATE` string, default is `self::KEY_TYPE_PRIVATE`.
123 | * @return \OpenSSLAsymmetricKey|resource|mixed
124 | * @throws UnexpectedValueException
125 | */
126 | public static function fromPkcs1(
127 | #[\SensitiveParameter]
128 | string $thing,
129 | string $type = self::KEY_TYPE_PRIVATE
130 | )
131 | {
132 | return static::from(sprintf('%s://%s', $type === static::KEY_TYPE_PUBLIC ? 'public.pkcs1' : 'private.pkcs1', $thing), $type);
133 | }
134 |
135 | /**
136 | * Sugar for loading input `publicKey` string, pure `base64-encoded-string` without LF and evelope.
137 | *
138 | * @param string $thing - The string in `SKPI` format.
139 | * @return \OpenSSLAsymmetricKey|resource|mixed
140 | * @throws UnexpectedValueException
141 | */
142 | public static function fromSpki(string $thing)
143 | {
144 | return static::from(sprintf('public.spki://%s', $thing), static::KEY_TYPE_PUBLIC);
145 | }
146 |
147 | /**
148 | * Loading the privateKey/publicKey.
149 | *
150 | * The `\$thing` can be one of the following:
151 | * - `file://` protocol `PKCS#1/PKCS#8 privateKey`/`SPKI publicKey`/`x509 certificate(for publicKey)` string.
152 | * - `public.spki://`, `public.pkcs1://`, `private.pkcs1://`, `private.pkcs8://` protocols string.
153 | * - full `PEM` in `PKCS#1/PKCS#8` format `privateKey`/`publicKey`/`x509 certificate(for publicKey)` string.
154 | * - `\OpenSSLAsymmetricKey` (PHP8) or `resource#pkey` (PHP7).
155 | * - `\OpenSSLCertificate` (PHP8) or `resource#X509` (PHP7) for publicKey.
156 | * - `Array` of `[privateKeyString,passphrase]` for encrypted privateKey.
157 | *
158 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|array{string,string}|string|mixed $thing - The thing.
159 | * @param string $type - Either `self::KEY_TYPE_PUBLIC` or `self::KEY_TYPE_PRIVATE` string, default is `self::KEY_TYPE_PRIVATE`.
160 | *
161 | * @return \OpenSSLAsymmetricKey|resource|mixed
162 | * @throws UnexpectedValueException
163 | */
164 | public static function from(
165 | #[\SensitiveParameter]
166 | $thing,
167 | string $type = self::KEY_TYPE_PRIVATE
168 | )
169 | {
170 | $pkey = ($isPublic = $type === static::KEY_TYPE_PUBLIC)
171 | ? openssl_pkey_get_public(self::parse($thing, $type))
172 | : openssl_pkey_get_private(self::parse($thing));
173 |
174 | if (false === $pkey) {
175 | throw new UnexpectedValueException(sprintf(
176 | 'Cannot load %s from(%s), please take care about the \$thing input.',
177 | $isPublic ? 'publicKey' : 'privateKey',
178 | gettype($thing)
179 | ));
180 | }
181 |
182 | return $pkey;
183 | }
184 |
185 | /**
186 | * Parse the `\$thing` for the `openssl_pkey_get_public`/`openssl_pkey_get_private` function.
187 | *
188 | * The `\$thing` can be the `file://` protocol privateKey/publicKey string, eg:
189 | * - `file:///my/path/to/private.pkcs1.key`
190 | * - `file:///my/path/to/private.pkcs8.key`
191 | * - `file:///my/path/to/public.spki.pem`
192 | * - `file:///my/path/to/x509.crt` (for publicKey)
193 | *
194 | * The `\$thing` can be the `public.spki://`, `public.pkcs1://`, `private.pkcs1://`, `private.pkcs8://` protocols string, eg:
195 | * - `public.spki://MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCg...`
196 | * - `public.pkcs1://MIIBCgKCAQEAgYxTW5Yj...`
197 | * - `private.pkcs1://MIIEpAIBAAKCAQEApdXuft3as2x...`
198 | * - `private.pkcs8://MIIEpAIBAAKCAQEApdXuft3as2x...`
199 | *
200 | * The `\$thing` can be the string with PEM `evelope`, eg:
201 | * - `-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----`
202 | * - `-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----`
203 | * - `-----BEGIN RSA PUBLIC KEY-----...-----END RSA PUBLIC KEY-----`
204 | * - `-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----`
205 | * - `-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----` (for publicKey)
206 | *
207 | * The `\$thing` can be the \OpenSSLAsymmetricKey/\OpenSSLCertificate/resouce, eg:
208 | * - `\OpenSSLAsymmetricKey` (PHP8) or `resource#pkey` (PHP7) for publicKey/privateKey.
209 | * - `\OpenSSLCertificate` (PHP8) or `resource#X509` (PHP7) for publicKey.
210 | *
211 | * The `\$thing` can be the Array{$privateKey,$passphrase} style for loading privateKey, eg:
212 | * - [`file:///my/path/to/encrypted.private.pkcs8.key`, 'your_pass_phrase']
213 | * - [`-----BEGIN ENCRYPTED PRIVATE KEY-----...-----END ENCRYPTED PRIVATE KEY-----`, 'your_pass_phrase']
214 | *
215 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|array{string,string}|string|mixed $thing - The thing.
216 | * @param string $type - Either `self::KEY_TYPE_PUBLIC` or `self::KEY_TYPE_PRIVATE` string, default is `self::KEY_TYPE_PRIVATE`.
217 | * @return \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|array{string,string}|string|mixed
218 | */
219 | private static function parse(
220 | #[\SensitiveParameter]
221 | $thing,
222 | string $type = self::KEY_TYPE_PRIVATE
223 | )
224 | {
225 | $src = $thing;
226 |
227 | if (is_string($src) && is_int(strpos($src, self::PKEY_PEM_NEEDLE))
228 | && $type === static::KEY_TYPE_PUBLIC && preg_match(self::PKEY_PEM_FORMAT_PATTERN, $src, $matches)) {
229 | [, $kind, $base64] = $matches;
230 | $mapRules = (array)array_combine(array_column(self::RULES, 1/*column*/), array_keys(self::RULES));
231 | $protocol = $mapRules[$kind] ?? '';
232 | if ('public.pkcs1' === $protocol) {
233 | $src = sprintf('%s://%s', $protocol, str_replace([self::CHR_CR, self::CHR_LF], '', $base64));
234 | }
235 | }
236 |
237 | if (is_string($src) && is_bool(strpos($src, self::LOCAL_FILE_PROTOCOL)) && is_int(strpos($src, '://'))) {
238 | $protocol = parse_url($src, PHP_URL_SCHEME);
239 | [$format, $kind, $offset] = self::RULES[$protocol] ?? [null, null, null];
240 | if ($format && $kind && $offset) {
241 | $src = substr($src, $offset);
242 | if ('public.pkcs1' === $protocol) {
243 | $src = static::pkcs1ToSpki($src);
244 | [$format, $kind] = self::RULES['public.spki'];
245 | }
246 | return sprintf($format, $kind, wordwrap($src, 64, self::CHR_LF, true));
247 | }
248 | }
249 |
250 | return $src;
251 | }
252 |
253 | /**
254 | * Check the padding mode whether or nor supported.
255 | *
256 | * @param int $padding - The padding mode, only support `OPENSSL_PKCS1_PADDING`, otherwise thrown `\UnexpectedValueException`.
257 | *
258 | * @throws UnexpectedValueException
259 | */
260 | private static function paddingModeLimitedCheck(int $padding): void
261 | {
262 | if ($padding !== OPENSSL_PKCS1_OAEP_PADDING) {
263 | throw new UnexpectedValueException(sprintf('Here\'s only support the OPENSSL_PKCS1_OAEP_PADDING(4) mode, yours(%d).', $padding));
264 | }
265 | }
266 |
267 | /**
268 | * Encrypts text by the given `$publicKey` in the `$padding`(default is `OPENSSL_PKCS1_OAEP_PADDING`) mode.
269 | *
270 | * @param string $plaintext - Cleartext to encode.
271 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|mixed $publicKey - The public key.
272 | * @param int $padding - default is `OPENSSL_PKCS1_OAEP_PADDING`.
273 | *
274 | * @return string - The base64-encoded ciphertext.
275 | * @throws UnexpectedValueException
276 | */
277 | public static function encrypt(
278 | #[\SensitiveParameter]
279 | string $plaintext,
280 | $publicKey,
281 | int $padding = OPENSSL_PKCS1_OAEP_PADDING
282 | ): string
283 | {
284 | self::paddingModeLimitedCheck($padding);
285 |
286 | if (!openssl_public_encrypt($plaintext, $encrypted, $publicKey, $padding)) {
287 | throw new UnexpectedValueException('Encrypting the input $plaintext failed, please checking your $publicKey whether or nor correct.');
288 | }
289 |
290 | return base64_encode($encrypted);
291 | }
292 |
293 | /**
294 | * Verifying the `message` with given `signature` string that uses `OPENSSL_ALGO_SHA256`.
295 | *
296 | * @param string $message - Content will be `openssl_verify`.
297 | * @param string $signature - The base64-encoded ciphertext.
298 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|mixed $publicKey - The public key.
299 | *
300 | * @return boolean - True is passed, false is failed.
301 | * @throws UnexpectedValueException
302 | */
303 | public static function verify(string $message, string $signature, $publicKey): bool
304 | {
305 | if (($result = openssl_verify($message, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256)) === false) {
306 | throw new UnexpectedValueException('Verified the input $message failed, please checking your $publicKey whether or nor correct.');
307 | }
308 |
309 | return $result === 1;
310 | }
311 |
312 | /**
313 | * Creates and returns a `base64_encode` string that uses `OPENSSL_ALGO_SHA256`.
314 | *
315 | * @param string $message - Content will be `openssl_sign`.
316 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|mixed $privateKey - The private key.
317 | *
318 | * @return string - The base64-encoded signature.
319 | * @throws UnexpectedValueException
320 | */
321 | public static function sign(
322 | string $message,
323 | #[\SensitiveParameter]
324 | $privateKey
325 | ): string
326 | {
327 | if (!openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
328 | throw new UnexpectedValueException('Signing the input $message failed, please checking your $privateKey whether or nor correct.');
329 | }
330 |
331 | return base64_encode($signature);
332 | }
333 |
334 | /**
335 | * Decrypts base64 encoded string with `$privateKey` in the `$padding`(default is `OPENSSL_PKCS1_OAEP_PADDING`) mode.
336 | *
337 | * @param string $ciphertext - Was previously encrypted string using the corresponding public key.
338 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|array{string,string}|mixed $privateKey - The private key.
339 | * @param int $padding - default is `OPENSSL_PKCS1_OAEP_PADDING`.
340 | *
341 | * @return string - The utf-8 plaintext.
342 | * @throws UnexpectedValueException
343 | */
344 | public static function decrypt(
345 | string $ciphertext,
346 | #[\SensitiveParameter]
347 | $privateKey,
348 | int $padding = OPENSSL_PKCS1_OAEP_PADDING
349 | ): string
350 | {
351 | self::paddingModeLimitedCheck($padding);
352 |
353 | if (!openssl_private_decrypt(base64_decode($ciphertext), $decrypted, $privateKey, $padding)) {
354 | throw new UnexpectedValueException('Decrypting the input $ciphertext failed, please checking your $privateKey whether or nor correct.');
355 | }
356 |
357 | return $decrypted;
358 | }
359 | }
360 |
--------------------------------------------------------------------------------
/src/Exception/InvalidArgumentException.php:
--------------------------------------------------------------------------------
1 | $certificate]`.';
11 | const ERR_INIT_CERTS_EXCLUDE_MCHSERIAL = 'The `certs(%1$s)` contains the merchant\'s certificate serial number(%2$s) which is not allowed here.';
12 |
13 | const EV2_REQ_XML_NOTMATCHED_MCHID = 'The xml\'s structure[mch_id(%1$s)] doesn\'t matched the init one mchid(%2$s).';
14 |
15 | const EV3_RES_HEADERS_INCOMPLETE = 'The response\'s Headers incomplete, must have(`%1$s`, `%2$s`, `%3$s` and `%4$s`).';
16 | const EV3_RES_HEADER_TIMESTAMP_OFFSET = 'It\'s allowed time offset in ± %1$s seconds, the response was on %2$s, your\'s localtime on %3$s.';
17 | const EV3_RES_HEADER_PLATFORM_SERIAL = 'Cannot found the serial(`%1$s`)\'s configuration, which\'s from the response(header:%2$s), your\'s %3$s.';
18 | const EV3_RES_HEADER_SIGNATURE_DIGEST = 'Verify the response\'s data with: timestamp=%1$s, nonce=%2$s, signature=%3$s, cert=[%4$s => ...] failed.';
19 |
20 | interface WeChatPayException
21 | {
22 | const DEP_XML_PROTOCOL_IS_REACHABLE_EOL = DEP_XML_PROTOCOL_IS_REACHABLE_EOL;
23 | const EV3_RES_HEADERS_INCOMPLETE = EV3_RES_HEADERS_INCOMPLETE;
24 | const EV3_RES_HEADER_TIMESTAMP_OFFSET = EV3_RES_HEADER_TIMESTAMP_OFFSET;
25 | const EV3_RES_HEADER_PLATFORM_SERIAL = EV3_RES_HEADER_PLATFORM_SERIAL;
26 | const EV3_RES_HEADER_SIGNATURE_DIGEST = EV3_RES_HEADER_SIGNATURE_DIGEST;
27 | }
28 |
--------------------------------------------------------------------------------
/src/Formatter.php:
--------------------------------------------------------------------------------
1 | $thing - The input array.
118 | *
119 | * @return array - The sorted array.
120 | */
121 | public static function ksort(array $thing = []): array
122 | {
123 | ksort($thing, SORT_STRING);
124 |
125 | return $thing;
126 | }
127 |
128 | /**
129 | * Like `queryString` does but without the `sign` and `empty value` entities.
130 | *
131 | * @param array $thing - The input array.
132 | *
133 | * @return string - The `key=value` pair string whose joined by `&` char.
134 | */
135 | public static function queryStringLike(array $thing = []): string
136 | {
137 | $data = [];
138 |
139 | foreach ($thing as $key => $value) {
140 | if ($key === 'sign' || is_null($value) || $value === '') {
141 | continue;
142 | }
143 | $data[] = implode('=', [$key, $value]);
144 | }
145 |
146 | return implode('&', $data);
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/Transformer.php:
--------------------------------------------------------------------------------
1 | ` string
44 | *
45 | * @return array
46 | */
47 | public static function toArray(string $xml = ''): array
48 | {
49 | LIBXML_VERSION < 20900 && $previous = libxml_disable_entity_loader(true);
50 |
51 | libxml_use_internal_errors(true);
52 | $el = simplexml_load_string(static::sanitize($xml), SimpleXMLElement::class, LIBXML_NONET | LIBXML_COMPACT | LIBXML_NOCDATA | LIBXML_NOBLANKS);
53 |
54 | LIBXML_VERSION < 20900 && isset($previous) && libxml_disable_entity_loader($previous);
55 |
56 | if (false === $el) {
57 | // while parsing failed, let's clean the internal buffer and
58 | // only leave the last error message which still can be fetched by the `error_get_last()` function.
59 | if (false !== ($err = libxml_get_last_error())) {
60 | libxml_clear_errors();
61 | @trigger_error(sprintf(
62 | 'Parsing the $xml failed with the last error(level=%d,code=%d,message=%s).',
63 | $err->level, $err->code, $err->message
64 | ));
65 | }
66 |
67 | return [];
68 | }
69 |
70 | return static::cast($el);
71 | }
72 |
73 | /**
74 | * Recursive cast the $thing as array data structure.
75 | *
76 | * @param array|object|\SimpleXMLElement $thing - The thing
77 | *
78 | * @return array
79 | */
80 | protected static function cast($thing): array
81 | {
82 | $data = (array) $thing;
83 | array_walk($data, static function(&$value) { static::value($value); });
84 |
85 | return $data;
86 | }
87 |
88 | /**
89 | * Cast the value $thing, specially doing the `array`, `object`, `SimpleXMLElement` to `array`
90 | *
91 | * @param string|array|object|\SimpleXMLElement $thing - The value thing reference
92 | */
93 | protected static function value(&$thing): void
94 | {
95 | is_array($thing) && $thing = static::cast($thing);
96 | if (is_object($thing) && $thing instanceof SimpleXMLElement) {
97 | $thing = $thing->count() ? static::cast($thing) : (string) $thing;
98 | }
99 | }
100 |
101 | /**
102 | * Trim invalid characters from the $xml string
103 | *
104 | * @see https://github.com/w7corp/easywechat/pull/1419
105 | * @license https://github.com/w7corp/easywechat/blob/4.x/LICENSE
106 | *
107 | * @param string $xml - The xml string
108 | */
109 | public static function sanitize(string $xml): string
110 | {
111 | return preg_replace('#[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]+#u', '', $xml) ?? '';
112 | }
113 |
114 | /**
115 | * Transform the given $data array as of an XML string.
116 | *
117 | * @param array $data - The data array
118 | * @param boolean $headless - The headless flag, default `true` means without the `` doctype
119 | * @param boolean $indent - Toggle indentation on/off, default is `false` off
120 | * @param string $root - The root node label, default is `xml` string
121 | * @param string $item - The nest array identify text, default is `item` string
122 | *
123 | * @return string - The xml string
124 | */
125 | public static function toXml(array $data, bool $headless = true, bool $indent = false, string $root = 'xml', string $item = 'item'): string
126 | {
127 | $writer = new XMLWriter();
128 | $writer->openMemory();
129 | $writer->setIndent($indent);
130 | $headless || $writer->startDocument('1.0', 'utf-8');
131 | $writer->startElement($root);
132 | static::walk($writer, $data, $item);
133 | $writer->endElement();
134 | $headless || $writer->endDocument();
135 | $xml = $writer->outputMemory();
136 | $writer = null;
137 |
138 | return $xml;
139 | }
140 |
141 | /**
142 | * Walk the given data array by the `XMLWriter` instance.
143 | *
144 | * @param \XMLWriter $writer - The `XMLWriter` instance reference
145 | * @param array $data - The data array
146 | * @param string $item - The nest array identify tag text
147 | */
148 | protected static function walk(XMLWriter &$writer, array $data, string $item): void
149 | {
150 | foreach ($data as $key => $value) {
151 | $tag = is_string($key) && static::isElementNameValid($key) ? $key : $item;
152 | $writer->startElement($tag);
153 | if (is_array($value) || (is_object($value) && $value instanceof Traversable)) {
154 | static::walk($writer, (array) $value, $item);
155 | } else {
156 | static::content($writer, (string) $value);
157 | }
158 | $writer->endElement();
159 | }
160 | }
161 |
162 | /**
163 | * Write content text.
164 | *
165 | * The content text includes the characters `<`, `>`, `&` and `"` are written as CDATA references.
166 | * All others including `'` are written literally.
167 | *
168 | * @param \XMLWriter $writer - The `XMLWriter` instance reference
169 | * @param string $thing - The content text
170 | */
171 | protected static function content(XMLWriter &$writer, string $thing = ''): void
172 | {
173 | static::needsCdataWrapping($thing) && $writer->writeCdata($thing) || $writer->text($thing);
174 | }
175 |
176 | /**
177 | * Checks the name is a valid xml element name.
178 | *
179 | * @see \Symfony\Component\Serializer\Encoder\XmlEncoder::isElementNameValid
180 | * @license https://github.com/symfony/serializer/blob/5.3/LICENSE
181 | *
182 | * @param string $name - The name
183 | *
184 | * @return boolean - True means valid
185 | */
186 | protected static function isElementNameValid(string $name = ''): bool
187 | {
188 | return $name && false === strpos($name, ' ') && preg_match('#^[\pL_][\pL0-9._:-]*$#ui', $name);
189 | }
190 |
191 | /**
192 | * Checks if a value contains any characters which would require CDATA wrapping.
193 | *
194 | * Notes here: the `XMLWriter` shall been wrapped the `"` string as `"` symbol string,
195 | * it's strictly following the `XMLWriter` specification here.
196 | *
197 | * @see \Symfony\Component\Serializer\Encoder\XmlEncoder::needsCdataWrapping
198 | * @license https://github.com/symfony/serializer/blob/5.3/LICENSE
199 | *
200 | * @param string $value - The value
201 | *
202 | * @return boolean - True means need
203 | */
204 | protected static function needsCdataWrapping(string $value = ''): bool
205 | {
206 | return $value && 0 < preg_match('#[>&"<]#', $value);
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/src/Util/MediaUtil.php:
--------------------------------------------------------------------------------
1 | filepath = $filepath;
64 | $this->fileStream = $fileStream;
65 | $this->composeStream();
66 | }
67 |
68 | /**
69 | * Compose the GuzzleHttp\Psr7\FnStream
70 | */
71 | private function composeStream(): void
72 | {
73 | $basename = basename($this->filepath);
74 | $stream = $this->fileStream ?? new LazyOpenStream($this->filepath, 'rb');
75 | if ($stream instanceof StreamInterface && !($stream->isSeekable())) {
76 | $stream = new CachingStream($stream);
77 | }
78 | if (!($stream instanceof StreamInterface)) {
79 | throw new UnexpectedValueException(sprintf('Cannot open or caching the file: `%s`', $this->filepath));
80 | }
81 |
82 | $buffer = new BufferStream();
83 | $metaStream = FnStream::decorate($buffer, [
84 | 'getSize' => static function () { return null; },
85 | // The `BufferStream` doen't have `uri` metadata(`null` returned),
86 | // but the `MultipartStream` did checked this prop with the `substr` method, which method described
87 | // the first paramter must be the string on the `strict_types` mode.
88 | // Decorate the `getMetadata` for this case.
89 | 'getMetadata' => static function($key = null) use ($buffer) {
90 | if ('uri' === $key) { return 'php://temp'; }
91 | return $buffer->getMetadata($key);
92 | },
93 | ]);
94 |
95 | $this->fileStream = $this->fileStream ?? $stream;
96 | $this->metaStream = $metaStream;
97 |
98 | $this->setMeta();
99 |
100 | $multipart = new MultipartStream([
101 | [
102 | 'name' => 'meta',
103 | 'contents' => $this->metaStream,
104 | 'headers' => [
105 | 'Content-Type' => 'application/json',
106 | ],
107 | ],
108 | [
109 | 'name' => 'file',
110 | 'filename' => $basename,
111 | 'contents' => $this->fileStream,
112 | ],
113 | ]);
114 | $this->multipart = $multipart;
115 |
116 | $this->stream = FnStream::decorate($multipart, [
117 | '__toString' => function () { return $this->getMeta(); },
118 | 'getSize' => static function () { return null; },
119 | ]);
120 | }
121 |
122 | /**
123 | * Set the `meta` part of the `multipart/form-data` stream
124 | *
125 | * Note: The `meta` weren't be the `media file`'s `meta data` anymore.
126 | *
127 | * Previous whose were designed as `{filename,sha256}`,
128 | * but another API was described asof `{bank_type,filename,sha256}`.
129 | *
130 | * Exposed the ability of setting the `meta` for the `new` data structure.
131 | *
132 | * @param ?string $json - The `meta` string
133 | * @since v1.3.2
134 | */
135 | public function setMeta(?string $json = null): int
136 | {
137 | $content = $json ?? (string)json_encode([
138 | 'filename' => basename($this->filepath),
139 | 'sha256' => $this->fileStream ? Utils::hash($this->fileStream, 'sha256') : '',
140 | ]);
141 | // clean the metaStream's buffer string
142 | $this->metaStream->getContents();
143 |
144 | return $this->metaStream->write($content);
145 | }
146 |
147 | /**
148 | * Get the `meta` string
149 | */
150 | public function getMeta(): string
151 | {
152 | $json = (string)$this->metaStream;
153 | $this->setMeta($json);
154 |
155 | return $json;
156 | }
157 |
158 | /**
159 | * Get the `FnStream` which is the `MultipartStream` decorator
160 | */
161 | public function getStream(): StreamInterface
162 | {
163 | return $this->stream;
164 | }
165 |
166 | /**
167 | * Get the `Content-Type` value from the `{$this->multipart}` instance
168 | */
169 | public function getContentType(): string
170 | {
171 | return 'multipart/form-data; boundary=' . $this->multipart->getBoundary();
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/Util/PemUtil.php:
--------------------------------------------------------------------------------
1 |