├── 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 | [![GitHub actions](https://github.com/wechatpay-apiv3/wechatpay-php/workflows/CI/badge.svg)](https://github.com/wechatpay-apiv3/wechatpay-php/actions) 6 | [![Packagist Stars](https://img.shields.io/packagist/stars/wechatpay/wechatpay)](https://packagist.org/packages/wechatpay/wechatpay) 7 | [![Packagist Downloads](https://img.shields.io/packagist/dm/wechatpay/wechatpay)](https://packagist.org/packages/wechatpay/wechatpay) 8 | [![Packagist Version](https://img.shields.io/packagist/v/wechatpay/wechatpay)](https://packagist.org/packages/wechatpay/wechatpay) 9 | [![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/wechatpay/wechatpay)](https://packagist.org/packages/wechatpay/wechatpay) 10 | [![Packagist License](https://img.shields.io/packagist/l/wechatpay/wechatpay)](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 |