From ac011388cecb50bfc6f38387ba7c1bee11d834c1 Mon Sep 17 00:00:00 2001 From: Anyon Date: Mon, 10 Aug 2026 19:00:05 +0800 Subject: [PATCH] feat(release): harden 2.0 security and quality gates --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 13 +- .php-cs-fixer.php | 20 +- README.md | 1038 ++--------------- docs/alipay.md | 105 ++ docs/cache.md | 87 ++ docs/configuration.md | 151 +++ docs/design.md | 69 ++ docs/exceptions.md | 72 ++ docs/migration-2.0.md | 218 ++++ docs/payments.md | 136 +++ docs/testing.md | 72 ++ docs/wechat.md | 157 +++ src/Client.php | 124 +- src/Config/AlipayPaymentConfig.php | 6 - src/Config/AlipayPlatformConfig.php | 22 +- src/Config/WechatPaymentConfig.php | 48 +- src/Config/WechatPlatformConfig.php | 6 - src/Config/WechatServiceConfig.php | 6 - src/Config/WechatWxappConfig.php | 6 - src/Contract/ConfigInterface.php | 6 - src/Contract/StoreCacheInterface.php | 6 - src/Contract/StoreTokenInterface.php | 6 - .../Trait/WechatInteractsProtocol.php | 6 - src/Exception/AlipayApiException.php | 10 + src/Exception/AlipayException.php | 10 + src/Exception/AlipaySignatureException.php | 10 + src/Exception/ApiException.php | 6 - src/Exception/SdkException.php | 33 + src/Exception/SignatureException.php | 8 +- src/Exception/TransportException.php | 10 + src/Exception/WechatException.php | 37 +- src/Platform/Alipay/PaymentClient.php | 6 - src/Platform/Alipay/PlatformClient.php | 70 +- src/Platform/Wechat/PaymentClient.php | 103 +- src/Platform/Wechat/PlatformClient.php | 6 - src/Platform/Wechat/ServiceClient.php | 6 - src/Platform/Wechat/WxappClient.php | 6 - src/Support/CacheKey.php | 18 +- src/Support/CredentialValidator.php | 76 +- src/Support/FileCacheStore.php | 45 +- src/Support/JsonClient.php | 14 +- src/Support/MessageCrypto.php | 6 - src/Support/NullCacheStore.php | 6 - src/Support/PaymentCrypto.php | 6 - src/Support/PsrSimpleCacheStore.php | 37 +- src/Support/Signature.php | 6 - src/Support/TokenCacheKey.php | 6 - src/Support/Xml.php | 6 - tests/AccessTokenCacheTest.php | 6 - tests/AlipayPlatformClientTest.php | 159 ++- tests/CacheKeyTest.php | 22 +- tests/CacheStoreTest.php | 177 ++- tests/ClientTest.php | 48 +- tests/ConfigInterfaceTest.php | 137 ++- tests/DocumentationTest.php | 88 ++ tests/ExceptionHierarchyTest.php | 41 + tests/JsonClientTest.php | 6 - tests/MessageCryptoTest.php | 6 - tests/PaymentClientTest.php | 269 ++++- tests/PaymentCryptoTest.php | 6 - tests/ProtocolClientTest.php | 49 +- tests/TestKeys.php | 172 ++- tests/WechatPaymentResponse.php | 34 + tests/XmlTest.php | 6 - tests/bootstrap.php | 6 - 66 files changed, 2640 insertions(+), 1520 deletions(-) create mode 100644 docs/alipay.md create mode 100644 docs/cache.md create mode 100644 docs/configuration.md create mode 100644 docs/design.md create mode 100644 docs/exceptions.md create mode 100644 docs/migration-2.0.md create mode 100644 docs/payments.md create mode 100644 docs/testing.md create mode 100644 docs/wechat.md create mode 100644 src/Exception/AlipayApiException.php create mode 100644 src/Exception/AlipayException.php create mode 100644 src/Exception/AlipaySignatureException.php create mode 100644 src/Exception/SdkException.php create mode 100644 src/Exception/TransportException.php create mode 100644 tests/DocumentationTest.php create mode 100644 tests/ExceptionHierarchyTest.php create mode 100644 tests/WechatPaymentResponse.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecb5692..e79aba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: push: branches: - master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f50da2..92a5ef4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,19 @@ on: tags: - 'v*' -permissions: - contents: write +permissions: {} jobs: + quality: + permissions: + contents: read + uses: ./.github/workflows/ci.yml + release: + needs: quality runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -47,4 +54,4 @@ jobs: name: Release ${{ steps.tags.outputs.current }} body_path: RELEASE_NOTES.md draft: false - prerelease: false + prerelease: ${{ contains(steps.tags.outputs.current, '-alpha') || contains(steps.tags.outputs.current, '-beta') || contains(steps.tags.outputs.current, '-rc') }} diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 9bf8e7c..5ce66c6 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -1,23 +1,11 @@ - */ + use PhpCsFixer\Config; use PhpCsFixer\Finder; use PhpCsFixer\Runner\Parallel\ParallelConfig; -$header = <<<'EOF' -This file is part of HyperfAdmin. - -@Link https://thinkadmin.top -@Author Anyon -EOF; - $config = new Config(); $config->setRiskyAllowed(true)->setParallelConfig(new ParallelConfig(8, 24)); $finder = Finder::create() @@ -30,12 +18,6 @@ return $config->setFinder($finder)->setUsingCache(false)->setRules([ '@Symfony' => true, '@DoctrineAnnotation' => true, '@PhpCsFixer' => true, - 'header_comment' => [ - 'comment_type' => 'PHPDoc', - 'header' => $header, - 'separate' => 'none', - 'location' => 'after_declare_strict', - ], 'array_syntax' => [ 'syntax' => 'short', ], diff --git a/README.md b/README.md index 21e3a44..2982de6 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,23 @@ # WeChatDeveloper -WeChatDeveloper 是一个面向 **微信** 与 **支付宝** 的轻量 PHP SDK,根命名空间为 `We`。 +WeChatDeveloper 2.0 是面向微信与支付宝官方 API 的轻量 PHP SDK,根命名空间为 `We`。 -它只维护基础认证、通用 HTTP 调用、签名验签、回调解密和统一异常,不内置业务表结构、不绑定具体框架,也不维护海量接口别名。业务系统按官方文档传入接口 path、参数和配置即可。 +SDK 负责配置校验、访问令牌缓存、HTTP 调用、请求签名、平台响应验签、通知验签与解密,以及统一异常。业务系统继续负责订单、幂等、持久化和业务状态机。 -## 特性 +2.0 是一次有意的主版本重写:使用配置对象、带类型的客户端工厂和“官方 path + 参数数组”调用模型,不恢复 1.x 的大量业务接口类,也不提供运行时兼容层。 -- 支持微信公众平台、小程序、微信服务平台、微信支付 APIv3。 -- 支持支付宝开放平台与支付网关调用。 -- 统一入口 `We\Client`,按通道创建客户端。 -- 配置对象实现 `ConfigInterface`,构造时完成基础校验。 -- 缓存只依赖 `StoreCacheInterface`,可用于单机文件缓存或集群 Redis 适配。 -- 微信服务平台授权方 refresh token 通过 `StoreTokenInterface` 由业务系统存取。 -- 支持 JSON、原始响应、二进制下载和 multipart 上传等协议层通用能力。 -- 返回值默认是数组,失败时抛出 `WechatException` 或其子类。 +## 支持边界 -## 支持边界与域名说明 +| 平台 | 能力 | 不包含 | +|------|------|--------| +| 微信公众平台 | access token、JSON API、网页授权、消息加解密、媒体下载/上传 | 模拟 `mp.weixin.qq.com` 后台 | +| 微信小程序 | access token、JSON API、登录等无 token 调用、文件下载/上传 | 小程序业务模型 | +| 微信服务平台 | component token、授权页、授权方调用、Token 存储接缝 | 业务账号仓库 | +| 微信支付 APIv3 | 商户请求签名、平台响应验签、通知验签解密、账单下载 | 订单幂等、支付状态机 | +| 支付宝开放平台 | RSA/RSA2 请求签名、同步响应验签、通知验签、授权 | 商家中心网页自动化 | +| 支付宝支付 | 电脑网站支付、退款和通用网关调用 | 业务订单存储 | -SDK 支持的是官方协议/API 调用能力,不是对所有网页后台的 100% 自动化封装: - -| 域名/平台 | SDK 支持方式 | 边界 | -|-----------|--------------|------| -| `api.weixin.qq.com` | 公众平台、小程序、第三方平台接口,按官方 path 调用。 | 不内置每个接口别名;业务按官方文档传 path 与参数。 | -| `open.weixin.qq.com` | 生成网页授权、扫码登录等跳转 URL。 | 跳转后的用户交互和回调业务由应用处理。 | -| `mp.weixin.qq.com` | 生成第三方平台授权页 URL。 | 不模拟或爬取公众号后台网页。 | -| `api.mch.weixin.qq.com` | 微信支付 APIv3 请求签名、下载、通知验签与解密。 | 不操作 `pay.weixin.qq.com` 商户后台页面。 | -| 支付宝开放平台 | `openapi.alipay.com` 网关签名调用、`openauth.alipay.com` 授权 URL、支付/退款/通知验签。 | 不封装支付宝商家中心网页后台。 | - -如果官方新增接口但仍使用这些协议形态(GET/JSON POST/raw/download/multipart/网关表单/签名验签),通常可以直接用 `get()`、`post()`、`call()`、`raw()`、`download()` 或 `upload()` 调用,无需等待 SDK 增加新方法。 +官方新增接口只要沿用已有协议形态,通常可直接传官方 path 和参数调用,无需等待 SDK 增加别名方法。 ## 环境要求 @@ -35,8 +25,10 @@ SDK 支持的是官方协议/API 调用能力,不是对所有网页后台的 1 - `ext-json` - `ext-openssl` - `ext-simplexml` -- `guzzlehttp/guzzle` -- `psr/simple-cache` +- `guzzlehttp/guzzle ^7.0` +- `psr/simple-cache ^3.0` + +CI 覆盖 PHP 8.1 至 8.4,并验证最低依赖组合。 ## 安装 @@ -46,40 +38,12 @@ SDK 支持的是官方协议/API 调用能力,不是对所有网页后台的 1 composer require zoujingli/wechat-developer:^2.0 ``` -开发版: +跟踪 2.0 开发分支: ```bash composer require zoujingli/wechat-developer:2.0.x-dev ``` -源码开发: - -```bash -cd WeChatDeveloper -composer install -composer validate --strict -composer test -``` - -## 项目结构 - -```text -src/ -├── Client.php # SDK 根入口与通道客户端工厂 -├── Config/ # 微信、支付宝平台配置对象 -├── Contract/ # 配置、缓存、授权方 Token 存储契约 -│ └── Trait/ # 微信 JSON、raw/download/upload、Token 注入等协议层复用能力 -├── Exception/ # SDK 异常类型 -├── Platform/ -│ ├── Wechat/ # 微信公众平台、小程序、微信服务平台、微信支付 APIv3 客户端 -│ └── Alipay/ # 支付宝开放平台与支付客户端 -└── Support/ # 缓存、HTTP、签名、XML、消息/支付解密工具 - -tests/ # PHPUnit 测试用例,命名空间 We\Tests -``` - -测试入口为根目录 `tests/`,`phpunit.xml` 使用 `tests/bootstrap.php` 引导 Composer autoload。 - ## 快速开始 ```php @@ -91,7 +55,6 @@ use We\Client; use We\Config\WechatPlatformConfig; $client = new Client(); - $platform = $client->wechatPlatform(new WechatPlatformConfig( appid: 'wx_appid', appSecret: 'app_secret', @@ -102,9 +65,15 @@ $users = $platform->get('cgi-bin/user/get', [ ]); ``` -`post()`、`get()`、`call()` 的 path 与官方文档保持一致,通常不需要前导 `/`。 +微信普通接口会自动获取并附加 access token。path 使用官方相对路径,不要传绝对 URL。 + +POST 参数默认编码为 JSON: ```php +post('cgi-bin/menu/create', [ 'button' => [ [ @@ -116,923 +85,88 @@ $menu = $platform->post('cgi-bin/menu/create', [ ]); ``` +## 类型工厂 -## 调用约定 +`We\Client` 显式声明六个工厂方法,IDE 和静态分析可直接获知返回类型: -SDK 不把官方接口包装成大量固定方法,核心约定是“官方文档 path + 参数数组”: +| 工厂 | 配置 | 返回客户端 | +|------|------|------------| +| `wechatPlatform()` | `WechatPlatformConfig` | 微信公众平台 | +| `wechatWxapp()` | `WechatWxappConfig` | 微信小程序 | +| `wechatService()` | `WechatServiceConfig` | 微信服务平台 | +| `wechatPayment()` | `WechatPaymentConfig` | 微信支付 APIv3 | +| `alipayPlatform()` | `AlipayPlatformConfig` | 支付宝开放平台 | +| `alipayPayment()` | `AlipayPaymentConfig` | 支付宝支付 | + +配置驱动场景可保留动态入口: ```php -// GET:第二个参数会作为 query string。 -$result = $platform->get('cgi-bin/user/get', ['next_openid' => '']); +post('cgi-bin/message/custom/send', [ - 'touser' => 'openid', - 'msgtype' => 'text', - 'text' => ['content' => 'hello'], -]); +declare(strict_types=1); -// call:显式指定 HTTP 方法,并可透传 Guzzle options。 -$result = $platform->call('cgi-bin/menu/get', [], 'GET'); -``` - -调用时需要注意: - -| 场景 | 写法 | -|------|------| -| 接口 path | 只传相对路径,如 `cgi-bin/user/get`;SDK 会拒绝 `https://...` 或 `//...`。 | -| 微信普通接口需要 `access_token` | 默认自动附加。 | -| 微信授权、登录等不需要 `access_token` 的接口 | 传 `['with_token' => false]`。 | -| POST JSON | 默认行为,直接传 `$params`。 | -| GET query | 用 `get($path, $query)`。 | -| 自定义 query + JSON body | `post($path, $body, ['query' => [...]])`;也可显式传 `json`。 | -| 表单提交或原始 body | 透传 Guzzle 的 `form_params` 或 `body`。 | -| 二进制/非 JSON 响应 | 用 `raw()` 或 `download()` 返回 PSR-7 Response。 | -| multipart 上传 | 用 `upload($path, $multipart, $query)`,SDK 会按通道规则附加 token。 | -| 微信支付敏感字段加密 | 如需 `Wechatpay-Serial`,手动在 `headers` 传微信支付平台证书/公钥序列号。 | - -示例:小程序登录接口不需要 access token,应该关闭自动 token: - -```php -$session = $wxapp->get('sns/jscode2session', [ - 'appid' => 'wx_appid', - 'secret' => 'app_secret', - 'js_code' => 'login_code', - 'grant_type' => 'authorization_code', -], ['with_token' => false]); -``` - -图片、媒体、文件等非 JSON 响应可直接获取原始响应: - -```php -$response = $platform->download('cgi-bin/media/get', [ - 'media_id' => 'MEDIA_ID', -]); - -$binary = (string) $response->getBody(); -``` - -上传媒体或文件时传入 Guzzle multipart 结构: - -```php -$media = $platform->upload('cgi-bin/media/upload', [ - [ - 'name' => 'media', - 'contents' => fopen(__DIR__ . '/demo.jpg', 'rb'), - 'filename' => 'demo.jpg', - ], -], [ - 'type' => 'image', -]); -``` - -### 协议层能力说明 - -微信公众平台、小程序、微信服务平台共用 `WechatInteractsProtocol` 协议层能力: - -| 方法 | 说明 | -|------|------| -| `request()` | 发送 JSON 风格接口请求并解析为数组。 | -| `raw()` | 返回 PSR-7 Response,不解析 JSON,适合图片、媒体、二维码等原始响应。 | -| `download()` | 以 GET 方式获取二进制资源,并按通道规则附加 token 或签名。 | -| `upload()` | 透传 Guzzle `multipart` 上传结构,并解析平台 JSON 响应。 | - -微信支付 APIv3 的 `raw()` 与 `download()` 会先按官方规则生成 `Authorization` 签名,再返回原始响应。支付宝网关请求统一复用公共参数构造、签名和验签逻辑,电脑网站支付跳转地址也使用同一套签名参数。 - -### 接口类型示例速查 - -下面示例展示不同协议形态的写法,接口参数仍以官方文档为准。 - -**1. 微信 GET query** - -```php -$users = $platform->get('cgi-bin/user/get', [ - 'next_openid' => '', -]); -``` - -**2. 微信 POST JSON** - -```php -$message = $platform->post('cgi-bin/message/custom/send', [ - 'touser' => 'openid', - 'msgtype' => 'text', - 'text' => ['content' => 'hello'], -]); -``` - -**3. 微信无 token 接口(登录、网页授权换 token 等)** - -```php -$session = $wxapp->get('sns/jscode2session', [ - 'appid' => 'wx_appid', - 'secret' => 'app_secret', - 'js_code' => $code, - 'grant_type' => 'authorization_code', -], ['with_token' => false]); - -$oauth = $platform->get('sns/oauth2/access_token', [ - 'appid' => 'wx_appid', - 'secret' => 'app_secret', - 'code' => $code, - 'grant_type' => 'authorization_code', -], ['with_token' => false]); -``` - -**4. 自定义 query + JSON body / 表单 / 原始 body** - -```php -// query + JSON body;未显式传 json 时,$body 会作为 JSON 请求体。 -$result = $platform->post('cgi-bin/draft/add', $body, [ - 'query' => ['debug' => '1'], -]); - -// 表单提交。 -$result = $platform->call('cgi-bin/example/form', [], 'POST', [ - 'form_params' => ['name' => 'value'], -]); - -// 原始 body,适合少数非 JSON 协议接口。 -$result = $platform->call('cgi-bin/example/raw', [], 'POST', [ - 'body' => $rawBody, - 'headers' => ['Content-Type' => 'text/plain'], -]); -``` - -**5. 原始响应、下载、上传** - -```php -$response = $platform->raw('POST', 'cgi-bin/qrcode/create', [], [ - 'json' => ['expire_seconds' => 60, 'action_name' => 'QR_STR_SCENE'], -]); - -$image = $platform->download('cgi-bin/media/get', [ - 'media_id' => 'MEDIA_ID', -]); - -$upload = $platform->upload('cgi-bin/media/upload', [ - ['name' => 'media', 'contents' => fopen(__DIR__ . '/demo.jpg', 'rb'), 'filename' => 'demo.jpg'], -], [ - 'type' => 'image', -]); -``` - -**6. 微信消息安全模式解密/加密** - -```php -$plain = $platform->post('decrypt_message', [ - 'body' => $rawXml, - 'msg_signature' => $_GET['msg_signature'] ?? '', - 'timestamp' => $_GET['timestamp'] ?? '', - 'nonce' => $_GET['nonce'] ?? '', -]); - -$encrypted = $platform->post('encrypt_message', [ - 'body' => $replyXml, - 'timestamp' => (string) time(), - 'nonce' => $nonce, -]); - -echo $encrypted['xml']; -``` - -**7. 微信第三方平台:组件 token、授权 URL、代授权方调用** - -```php -$componentToken = $service->componentAccessToken($componentVerifyTicket); -$preAuth = $service->createPreAuthCode($componentToken); -$authUrl = $service->authorizationUrl((string) $preAuth['pre_auth_code'], $redirectUri); -$auth = $service->queryAuth($componentToken, $authorizationCode); - -$menu = $service->get('cgi-bin/menu/get', [], [ - 'authorizer_appid' => 'authorizer_appid', - 'component_access_token' => $componentToken, -]); -``` - -**8. 微信支付 APIv3:GET/POST/download/通知解密** - -```php -$order = $payment->post('v3/pay/transactions/jsapi', [ - 'appid' => 'wx_appid', - 'mchid' => '1900000001', - 'description' => '测试订单', - 'out_trade_no' => 'T202605080001', - 'notify_url' => 'https://example.com/wechat-pay/notify', - 'amount' => ['total' => 1, 'currency' => 'CNY'], - 'payer' => ['openid' => 'openid'], -]); - -$query = $payment->get('v3/pay/transactions/out-trade-no/T202605080001', [ - 'mchid' => '1900000001', -]); - -$bill = $payment->download('v3/bill/tradebill', [ - 'bill_date' => '2026-05-08', -]); - -// 如果接口涉及敏感信息加密,Wechatpay-Serial 应传微信支付平台证书/公钥序列号,不是商户证书序列号。 -$result = $payment->post('v3/example/with-sensitive-info', $payload, [ - 'headers' => ['Wechatpay-Serial' => 'wechatpay_platform_serial'], -]); - -$decrypted = $payment->post('decrypt_notification', [], [ - 'headers' => $headers, - 'raw_body' => $rawBody, -]); -``` - -**9. 支付宝:授权 URL、网关调用、支付、退款、通知验签、数据解密** - -```php -$authUrl = $alipay->get('auth', [ - 'redirect_uri' => 'https://example.com/alipay/callback', - 'scope' => 'auth_user', - 'state' => 'STATE', -])['url']; - -$user = $alipay->post('alipay.user.info.share', [], [ - 'auth_token' => $authToken, -]); - -$page = $alipayPayment->post('page', [ - 'out_trade_no' => 'P202605080001', - 'total_amount' => '0.01', - 'subject' => '测试订单', - 'product_code' => 'FAST_INSTANT_TRADE_PAY', -], [ - 'return_url' => 'https://example.com/alipay/return', - 'notify_url' => 'https://example.com/alipay/notify', -])['url']; - -$refund = $alipayPayment->post('refund', [ - 'out_trade_no' => 'P202605080001', - 'refund_amount' => '0.01', -]); - -$ok = $alipayPayment->verifyNotify($_POST); - -$plain = $alipay->post('decrypt', [ - 'encrypted_data' => $encryptedData, - 'session_key' => $sessionKey, - 'iv' => $iv, -]); -``` - -## 配置来源示例 - -生产项目通常从数据库或配置中心读取账号配置,再使用 `fromArray()` 构造配置对象: - -```php use We\Client; -use We\Config\WechatPlatformConfig; -use We\Support\FileCacheStore; - -$row = [ - 'appid' => 'wx_appid', - 'appsecret' => 'app_secret', - 'token' => 'messageToken123', - 'encoding_aes_key' => 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', - 'storage_scope' => 'tenant:10001:account:20002', -]; - -$client = new Client( - cache: new FileCacheStore(__DIR__ . '/runtime/wechat-cache'), - cacheKeyPrefix: 'my_project_prod', -); - -$platform = $client->wechatPlatform(WechatPlatformConfig::fromArray($row)); -``` - -`cacheKeyPrefix` 建议按项目和环境区分,例如 `mall_prod`、`mall_test`。`storage_scope` 建议按租户、账号或业务线区分,避免同一 appid 在不同业务上下文中复用缓存。 - -## 入口 Client - -```php -use GuzzleHttp\ClientInterface; -use We\Client; -use We\Contract\StoreCacheInterface; -use We\Contract\StoreTokenInterface; - -new Client( - ?StoreCacheInterface $cache = null, - ?StoreTokenInterface $authorizers = null, - ?ClientInterface $http = null, - string $cacheKeyPrefix = Client::DEFAULT_CACHE_KEY_PREFIX, -); -``` - -参数说明: - -| 参数 | 说明 | -|------|------| -| `$cache` | SDK 运行态缓存,主要保存 access token;不传时默认使用 `FileCacheStore`。 | -| `$authorizers` | 微信服务平台代调用时,读取和回写授权方 refresh token。 | -| `$http` | 可注入自定义 Guzzle HTTP 客户端,便于统一超时、代理或单元测试。 | -| `$cacheKeyPrefix` | 缓存键前缀,默认 `wechat_developer`;生产环境建议按项目设置。 | - -也可以用字符串通道创建客户端: - -```php -$platform = $client->get('wechat.platform', WechatPlatformConfig::fromArray($config)); -``` - -支持的通道: - -| 通道 | 工厂方法 | 配置对象 | -|------|----------|----------| -| `wechat.platform` | `wechatPlatform()` | `WechatPlatformConfig` | -| `wechat.wxapp` | `wechatWxapp()` | `WechatWxappConfig` | -| `wechat.service` | `wechatService()` | `WechatServiceConfig` | -| `wechat.payment` | `wechatPayment()` | `WechatPaymentConfig` | -| `alipay.platform` | `alipayPlatform()` | `AlipayPlatformConfig` | -| `alipay.payment` | `alipayPayment()` | `AlipayPaymentConfig` | - -### 初始化模式 - -默认初始化适合单机开发或简单部署: - -```php -use We\Client; - -$client = new Client(); // 默认 FileCacheStore + 官方默认 HTTP 客户端 -``` - -生产环境建议显式配置缓存前缀和共享缓存: - -```php -use We\Client; -use We\Support\FileCacheStore; - -$client = new Client( - cache: new FileCacheStore(__DIR__ . '/runtime/wechat-cache'), - cacheKeyPrefix: 'mall_prod', -); -``` - -如果要注入自定义 Guzzle 客户端,需注意微信类客户端使用相对 path,请为对应通道配置正确 `base_uri`;多通道使用不同域名时,建议分别创建根 `Client`: - -```php -use GuzzleHttp\Client as GuzzleClient; -use We\Client; - -$wechatClient = new Client( - http: new GuzzleClient([ - 'base_uri' => 'https://api.weixin.qq.com/', - 'timeout' => 10.0, - // 需要代理或中间件时可在这里继续配置 Guzzle options。 - ]), -); - -$paymentClient = new Client( - http: new GuzzleClient([ - 'base_uri' => 'https://api.mch.weixin.qq.com/', - 'timeout' => 10.0, - ]), -); -``` - -支付宝网关请求使用配置中的绝对 `gateway`,自定义 HTTP 客户端主要用于统一超时、代理、日志中间件或测试替身。 - -## 配置对象 - -所有配置对象都实现 `ConfigInterface`: - -```php -interface ConfigInterface -{ - public static function fromArray(array $data): static; - - public function validate(): void; -} -``` - -构造函数会调用 `validate()`。缺少必填字段时会抛出 `WechatException`。 - -示例: - -```php use We\Config\WechatWxappConfig; -$config = WechatWxappConfig::fromArray([ - 'appid' => 'wx_appid', - 'appsecret' => 'app_secret', - 'storage_scope' => 'tenant:1', -]); -``` - -`storage_scope` 是可选的缓存分桶标识。同一个 appid 在多租户或多业务账号下需要隔离 token 时可以设置。 - -### fromArray 完整配置示例 - -字段名兼容常见下划线写法,便于直接接数据库或配置中心: - -```php -use We\Config\AlipayPaymentConfig; -use We\Config\AlipayPlatformConfig; -use We\Config\WechatPaymentConfig; -use We\Config\WechatPlatformConfig; -use We\Config\WechatServiceConfig; -use We\Config\WechatWxappConfig; - -$wechatPlatform = WechatPlatformConfig::fromArray([ - 'appid' => 'wx_appid', - 'appsecret' => 'app_secret', - 'token' => 'messageToken123', - 'encoding_aes_key' => 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', - 'storage_scope' => 'tenant:1:official', -]); - -$wechatWxapp = WechatWxappConfig::fromArray([ - 'appid' => 'wx_appid', - 'appsecret' => 'app_secret', - 'storage_scope' => 'tenant:1:wxapp', -]); - -$wechatService = WechatServiceConfig::fromArray([ - 'component_appid' => 'wx_component_appid', - 'component_appsecret' => 'component_secret', - 'component_token' => 'componentToken123', - 'component_encoding_aes_key' => 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', - 'storage_scope' => 'tenant:1:component', -]); - -$wechatPayment = WechatPaymentConfig::fromArray([ - 'appid' => 'wx_appid', - 'mch_id' => '1900000001', - 'api_v3_key' => str_repeat('k', 32), - 'merchant_serial' => 'merchant_cert_serial', - 'merchant_private_key' => file_get_contents(__DIR__ . '/apiclient_key.pem'), - // 二选一:平台证书 PEM 或平台公钥 PEM;平台序列号用于回调头校验。 - 'platform_certificate' => file_get_contents(__DIR__ . '/wechatpay_cert.pem'), - 'platform_public_key' => '', - 'platform_serial' => 'wechatpay_platform_serial', -]); - -$alipayPlatform = AlipayPlatformConfig::fromArray([ - 'app_id' => '2021000000000000', - 'private_key' => file_get_contents(__DIR__ . '/merchant_private_key.pem'), - 'alipay_public_key' => file_get_contents(__DIR__ . '/alipay_public_key.pem'), - 'gateway' => 'https://openapi.alipay.com/gateway.do', - 'sign_type' => 'RSA2', -]); - -$alipayPayment = AlipayPaymentConfig::fromArray([ - 'app_id' => '2021000000000000', - 'private_key' => file_get_contents(__DIR__ . '/merchant_private_key.pem'), - 'alipay_public_key' => file_get_contents(__DIR__ . '/alipay_public_key.pem'), -]); -``` - -构造时会做基础格式校验: - -| 项 | 校验 | -|----|------| -| 微信消息 Token / componentToken | 3-32 位英文或数字。 | -| EncodingAESKey | 43 位字符串,Base64 解码后必须是 32 字节。 | -| 微信支付 APIv3 Key | 必须是 32 字节字符串。 | -| RSA 私钥、公钥、证书 | 必须能被 OpenSSL 解析;支付宝支持无头尾的密钥正文。 | -| 支付宝 gateway/sign_type | gateway 必须是有效 URL,sign_type 仅支持 `RSA` / `RSA2`。 | - -## 缓存与锁 - -access token 统一通过 `StoreCacheInterface` 存储: - -```php -interface StoreCacheInterface -{ - public function get(string $key, mixed $default = null): mixed; - - public function set(string $key, mixed $value, int $ttl): void; - - public function del(string $key): void; - - public function lock(string $key, int $ttl, callable $callback): mixed; -} -``` - -内置实现: - -| 类 | 说明 | -|----|------| -| `We\Support\FileCacheStore` | 默认单机文件缓存,使用 JSON 文件保存数据,刷新锁使用 `flock`。 | -| `We\Support\PsrSimpleCacheStore` | PSR-16 缓存适配器,适合 Redis、Memcached 或框架缓存。锁能力需由构造参数注入。 | -| `We\Support\NullCacheStore` | 空缓存,适合测试或明确不缓存的场景。 | - -单机文件缓存: - -```php -use We\Client; -use We\Support\FileCacheStore; - -$client = new Client( - cache: new FileCacheStore(__DIR__ . '/runtime/wechat-cache'), - cacheKeyPrefix: 'my_app', +$client = new Client(); +$wxapp = $client->get( + 'wechat.wxapp', + new WechatWxappConfig('wx_appid', 'app_secret'), ); ``` -PSR-16 缓存: +支持的动态通道是 `wechat.platform`、`wechat.wxapp`、`wechat.service`、`wechat.payment`、`alipay.platform` 和 `alipay.payment`。2.0 不再使用 `__call()` 推断工厂名称。 + +## 安全默认值 + +- 支付宝应用私钥与支付宝公钥均为必填项;同步响应与通知必须验签。 +- 微信支付必须配置平台公钥或平台证书及对应序列号;普通 JSON 响应在解析前验签。 +- 微信支付通知默认只接受时间戳偏差不超过 300 秒的已签名原始 body;配置为 `0` 才会关闭时间检查。 +- 微信账单使用 `downloadBill()` 完成“申请下载地址 + HTTPS 文件下载”两阶段流程。 +- 支付密钥必须是 RSA;可解析但算法错误的 EC 密钥会被拒绝。 +- 通用平台客户端只接受相对 path,账单专用下载器也只接受已验签响应中的 HTTPS 地址。 + +## 文档 + +- [配置与凭证](docs/configuration.md):配置对象、数组字段、RSA 和平台信任材料。 +- [缓存](docs/cache.md):缓存契约、PSR-16 适配、键格式和刷新锁。 +- [微信平台](docs/wechat.md):公众号、小程序、服务平台、下载与上传。 +- [微信支付](docs/payments.md):请求/响应验签、通知时间窗口和账单下载。 +- [支付宝](docs/alipay.md):开放平台调用、支付、响应和通知验签。 +- [异常](docs/exceptions.md):统一异常层级、上下文和捕获方式。 +- [测试与贡献](docs/testing.md):本地环境与完整质量门禁。 +- [设计](docs/design.md):模块边界、安全决策和扩展接缝。 +- [从 1.x 迁移到 2.0](docs/migration-2.0.md):破坏性变化、字段和常用调用映射。 + +## 错误处理 + +所有 SDK 故障都继承 `SdkException`;平台异常仍可精确捕获: ```php -use Psr\SimpleCache\CacheInterface; -use We\Client; -use We\Support\PsrSimpleCacheStore; +get('v3/pay/transactions/id/4200000000000000000000000000', [ + 'mchid' => '1900000001', + ]); +} catch (SignatureException $exception) { + report($exception); +} catch (SdkException $exception) { + report($exception); } ``` -注入到根 Client: +## 版本策略 -```php -$client = new Client( - cache: $cache, - authorizers: new AuthorizerTokenStore(), -); -``` +2.0 保持 PHP 8.1 最低版本。稳定发布后,1.x 的必要维护应留在独立维护分支;2.0 不通过兼容层承载旧命名空间或旧业务类。 -## 微信公众平台 +标签发布只有在 Composer 严格校验、代码风格、PHPStan、PHP 8.1 至 8.4 测试矩阵和最低依赖测试全部通过后才创建 GitHub Release。含 `-alpha`、`-beta` 或 `-rc` 的标签会标记为预发布。 -```php -use We\Config\WechatPlatformConfig; +## 许可证 -$platform = $client->wechatPlatform(new WechatPlatformConfig( - appid: 'wx_appid', - appSecret: 'app_secret', - token: 'messageToken123', - encodingAesKey: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', -)); - -$accessToken = $platform->accessToken(); - -$result = $platform->post('cgi-bin/message/custom/send', [ - 'touser' => 'openid', - 'msgtype' => 'text', - 'text' => ['content' => 'hello'], -]); -``` - -安全模式消息解密: - -```php -$plain = $platform->post('decrypt_message', [ - 'body' => $rawBody, - 'msg_signature' => $signature, - 'timestamp' => $timestamp, - 'nonce' => $nonce, -]); -``` - -## 微信小程序 - -登录换取 `openid` 与 `session_key`: - -```php -use We\Config\WechatWxappConfig; - -$wxapp = $client->wechatWxapp(new WechatWxappConfig( - appid: 'wx_appid', - appSecret: 'app_secret', -)); - -$session = $wxapp->get('sns/jscode2session', [ - 'appid' => 'wx_appid', - 'secret' => 'app_secret', - 'js_code' => 'login_code', - 'grant_type' => 'authorization_code', -], ['with_token' => false]); -``` - -获取手机号: - -```php -$phone = $wxapp->post('wxa/business/getuserphonenumber', [ - 'code' => 'phone_code_from_client', -]); -``` - -如果官方接口返回图片、文件等二进制内容,使用 `download()` 或 `raw()` 获取原始 PSR-7 Response;默认 `post()` 仍按 JSON 响应解析。 - -## 微信服务平台 - -```php -use We\Config\WechatServiceConfig; - -$componentAppid = 'component_appid'; -$service = $client->wechatService(new WechatServiceConfig( - componentAppid: $componentAppid, - componentAppSecret: 'component_secret', - componentToken: 'componentToken123', - componentEncodingAesKey: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', -)); - -$componentToken = $service->componentAccessToken($componentVerifyTicket); -$preAuth = $service->createPreAuthCode($componentToken); -$url = $service->authorizationUrl((string) $preAuth['pre_auth_code'], $redirectUri); -``` - -代授权方调用: - -```php -$result = $service->get('cgi-bin/menu/get', [], [ - 'authorizer_appid' => 'authorizer_appid', - 'component_access_token' => $componentToken, -]); -``` - -## 微信支付 APIv3 - -```php -use We\Config\WechatPaymentConfig; - -$payment = $client->wechatPayment(new WechatPaymentConfig( - appid: 'wx_appid', - mchId: 'mch_id', - apiV3Key: str_repeat('k', 32), - merchantSerial: 'merchant_serial', - merchantPrivateKey: file_get_contents(__DIR__ . '/apiclient_key.pem'), - platformPublicKey: file_get_contents(__DIR__ . '/wechatpay_public.pem'), - platformSerial: 'platform_serial', -)); - -$refund = $payment->post('v3/refund/domestic/refunds', [ - 'out_trade_no' => 'T202605020001', - 'out_refund_no' => 'R202605020001', - 'amount' => [ - 'refund' => 1, - 'total' => 1, - 'currency' => 'CNY', - ], -]); -``` - -下载账单等非 JSON 响应时使用已签名的 `download()`: - -```php -$bill = $payment->download('v3/bill/tradebill', [ - 'bill_date' => '2026-05-08', -]); -``` - -回调验签与解密: - -```php -$rawBody = file_get_contents('php://input') ?: ''; -$body = json_decode($rawBody, true) ?: []; - -$data = $payment->post('decrypt_notification', [], [ - 'headers' => $headers, - // 微信支付 APIv3 验签必须使用原始 JSON body,不要先 json_decode 后重新编码。 - 'raw_body' => $rawBody, - 'body' => $body, -]); -``` - -## 支付宝 - -```php -use We\Config\AlipayPlatformConfig; - -$alipay = $client->alipayPlatform(new AlipayPlatformConfig( - appid: 'app_id', - privateKey: file_get_contents(__DIR__ . '/merchant_private_key.pem'), - alipayPublicKey: file_get_contents(__DIR__ . '/alipay_public_key.pem'), -)); - -$auth = $alipay->get('auth', [ - 'redirect_uri' => 'https://example.com/callback', - 'scope' => 'auth_user', - 'state' => 'state', -]); - -$response = $alipay->post('alipay.user.info.share', [], [ - 'auth_token' => 'auth_token', -]); -``` - -支付宝支付: - -```php -use We\Config\AlipayPaymentConfig; - -$payment = $client->alipayPayment(AlipayPaymentConfig::fromArray([ - 'appid' => 'app_id', - 'private_key' => file_get_contents(__DIR__ . '/merchant_private_key.pem'), - 'alipay_public_key' => file_get_contents(__DIR__ . '/alipay_public_key.pem'), -])); - -$page = $payment->post('page', [ - 'out_trade_no' => 'P202605020001', - 'total_amount' => '0.01', - 'subject' => 'Test Order', - 'product_code' => 'FAST_INSTANT_TRADE_PAY', -]); -``` - - -## 常见业务案例 - -### 微信公众平台:创建菜单并发送客服消息 - -```php -$platform->post('cgi-bin/menu/create', [ - 'button' => [ - [ - 'name' => '服务', - 'sub_button' => [ - ['type' => 'view', 'name' => '官网', 'url' => 'https://example.com'], - ['type' => 'click', 'name' => '帮助', 'key' => 'HELP'], - ], - ], - ], -]); - -$platform->post('cgi-bin/message/custom/send', [ - 'touser' => 'openid', - 'msgtype' => 'text', - 'text' => ['content' => '您好,客服消息已发送。'], -]); -``` - -### 微信公众平台:网页授权 URL 与用户资料 - -```php -$redirectUri = 'https://example.com/oauth/callback'; -$authUrl = $platform->get('connect/oauth2/authorize', [ - 'redirect_uri' => $redirectUri, - 'scope' => 'snsapi_userinfo', - 'state' => 'state-value', -]); -$url = $authUrl['url']; - -$oauth = $platform->get('sns/oauth2/access_token', [ - 'appid' => 'wx_appid', - 'secret' => 'app_secret', - 'code' => $code, - 'grant_type' => 'authorization_code', -], ['with_token' => false]); - -$user = $platform->get('sns/userinfo', [ - 'access_token' => $oauth['access_token'], - 'openid' => $oauth['openid'], - 'lang' => 'zh_CN', -], ['with_token' => false]); -``` - -### 微信服务平台:授权回调后保存授权方 Token - -```php -$componentToken = $service->componentAccessToken($componentVerifyTicket); -$auth = $service->queryAuth($componentToken, $authorizationCode); - -$authorization = $auth['authorization_info'] ?? []; -$authorizerAppid = (string)($authorization['authorizer_appid'] ?? ''); - -// 业务系统应把 authorizer_refresh_token 保存到数据库,后续 StoreTokenInterface 会读取它。 -$repository->saveAuthorizerToken($authorizerAppid, $authorization); -``` - -### 微信支付:创建 JSAPI 订单 - -```php -$order = $payment->post('v3/pay/transactions/jsapi', [ - 'appid' => 'wx_appid', - 'mchid' => 'mch_id', - 'description' => '测试订单', - 'out_trade_no' => 'T202605020001', - 'notify_url' => 'https://example.com/wechat-payment/notify', - 'amount' => ['total' => 1, 'currency' => 'CNY'], - 'payer' => ['openid' => 'openid'], -]); -``` - -前端调起支付需要的 `paySign` 可由业务系统使用返回的 `prepay_id` 再按微信支付文档签名生成。SDK 只负责 APIv3 请求签名、回调验签与资源解密。 - -### 支付宝:电脑网站支付与退款 - -```php -$page = $payment->post('page', [ - 'out_trade_no' => 'P202605020001', - 'total_amount' => '0.01', - 'subject' => '测试订单', - 'product_code' => 'FAST_INSTANT_TRADE_PAY', -], [ - 'return_url' => 'https://example.com/alipay/return', - 'notify_url' => 'https://example.com/alipay/notify', -]); - -$refund = $payment->post('refund', [ - 'out_trade_no' => 'P202605020001', - 'refund_amount' => '0.01', - 'refund_reason' => '用户退款', -]); -``` - -支付宝异步通知验签: - -```php -if (!$payment->verifyNotify($_POST)) { - throw new RuntimeException('支付宝通知验签失败'); -} - -// 验签通过后再处理 trade_status、out_trade_no、trade_no 等业务字段。 -``` - -## 框架集成建议 - -- 在 Laravel、Hyperf、Symfony 等框架中,建议把 `Client` 注册为容器服务,缓存实现接入框架 Redis 或 Cache 组件。 -- 多租户系统应把租户 ID、账号 ID 放入 `storage_scope` 或 `cacheKeyPrefix`,保证 token 缓存隔离。 -- 密钥、证书、APIv3 Key、支付宝私钥应由业务系统加密保存,运行时解密后传入配置对象。 -- 日志中不要记录 app secret、access token、refresh token、私钥、证书、回调密文和支付签名。 - -## 异常处理 - -SDK 抛出的异常基类为: - -```php -We\Exception\WechatException -``` - -签名相关异常使用: - -```php -We\Exception\SignatureException -``` - -建议在业务边界统一捕获并转换成应用自己的响应格式。不要把 app secret、access token、private key、回调密文等敏感内容写入日志。 - -## 测试 - -```bash -cd WeChatDeveloper -composer cs:check -composer analyse -composer test -``` - -或在项目根目录: - -```bash -vendor/bin/phpunit -c phpunit.xml -``` - -## 设计边界 - -WeChatDeveloper 只处理协议层和 HTTP 编排: - -- 不提供账号、租户、菜单草稿、订单、授权记录等业务表。 -- 不托管密钥加密存储。 -- 不绑定 Hyperf、Laravel、Symfony 等框架。 -- 不保证覆盖每一个官方接口别名;SDK 通过官方 path、JSON、raw/download、multipart upload 等协议层能力支持接口调用。 - -这种边界使 SDK 更适合作为开源底层包,被后台系统、SaaS 平台或命令行工具组合使用。 - -## License - -MIT +[MIT](LICENSE) diff --git a/docs/alipay.md b/docs/alipay.md new file mode 100644 index 0000000..c312f97 --- /dev/null +++ b/docs/alipay.md @@ -0,0 +1,105 @@ +# 支付宝 + +支付宝客户端统一构造网关公共参数,使用应用私钥签名请求,并使用支付宝公钥验证同步响应和异步通知。 + +## 开放平台配置 + +```php +alipayPlatform($config); +``` + +`privateKey` 与 `alipayPublicKey` 都是必填安全配置。前者代表应用,后者代表支付宝平台。默认网关是 `https://openapi.alipay.com/gateway.do`。 + +完整 PEM 和无头尾的 RSA key body 都可使用;私钥支持 PKCS#1 与 PKCS#8。EC 等非 RSA 密钥会在配置阶段被拒绝。 + +## 网关调用 + +API method 与支付宝官方文档一致: + +```php +post('alipay.user.info.share', [ + 'auth_token' => 'user_auth_token', +]); + +$authUrl = $alipay->auth( + 'https://example.com/alipay/callback', + 'auth_user', + 'state-value', +); +``` + +同步响应只有在下列条件全部满足时才返回数组: + +1. JSON 可以解析。 +2. 存在与 API method 对应的响应节点,或明确的 `error_response`。 +3. 顶层存在签名,且原始响应节点通过支付宝公钥验签。 +4. 响应节点存在标量 `code`。 +5. `code` 等于 `10000`。 + +空 JSON、缺节点或缺 code 不会被当作成功,并抛出 `AlipayApiException`。缺签名或验签失败抛出 `AlipaySignatureException`。网络传输错误抛出 `TransportException`;平台业务错误的异常保留响应节点上下文。 + +## 支付客户端 + +```php +alipayPayment(new AlipayPaymentConfig( + appid: '2026000000000000', + privateKey: $applicationPrivateKey, + alipayPublicKey: $alipayPublicKey, +)); + +$pageUrl = $payment->page([ + 'out_trade_no' => 'A202608100001', + 'total_amount' => '0.01', + 'subject' => '测试订单', + 'product_code' => 'FAST_INSTANT_TRADE_PAY', +]); + +$refund = $payment->refund([ + 'out_trade_no' => 'A202608100001', + 'refund_amount' => '0.01', +]); +``` + +`page()` 返回已签名的电脑网站支付 URL;`refund()` 调用 `alipay.trade.refund`。其他支付 API 继续使用官方 method 调用 `request()`、`get()` 或 `post()`。 + +## 异步通知 + +业务处理前验证支付宝通知完整参数: + +```php +verifyNotify($_POST); +if (!$verified) { + throw new RuntimeException('Invalid Alipay notification signature.'); +} +``` + +验签会排除 `sign` 和 `sign_type`,按支付宝规则排序并拼接其他非空字段。返回 `true` 只证明参数签名有效;订单金额、商户身份、通知状态和业务幂等仍由业务系统校验。 diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..e460f9f --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,87 @@ +# 缓存 + +SDK 使用 `We\Contract\StoreCacheInterface` 缓存 access token 和 component access token。接口语义包括 TTL、删除和刷新锁: + +```php +run($key, $ttl, $callback); + }, +); + +$client = new Client( + cache: $store, + cacheKeyPrefix: 'production-tenant-a', +); +``` + +如果没有注入 `locker`,需要刷新 token 时 `PsrSimpleCacheStore::lock()` 会抛出 `SdkException`,而不是静默绕过并发保护。 + +## 缓存键 + +完整键由部署前缀、平台通道和逻辑键三段组成。每段使用 `rawurlencode()`,再用点号连接: + +```text +production-tenant-a.wechat.platform.wechat%3Aapp%3Awx_appid%3Aplatform%3Aaccess_token +``` + +该格式不会包含 PSR-16 保留字符 `{ } ( ) / \\ @ :`。部署前缀和通道仍保持稳定隔离;逻辑键的内部层次被编码在第三段内。 + +2.0 发布前的旧冒号格式不属于稳定接口。升级时不需要迁移旧 token 缓存,允许 SDK 按新键重新获取。 + +## 多租户 + +- 使用根 `Client::$cacheKeyPrefix` 隔离部署或大租户。 +- 使用微信配置中的 `storageScope` 隔离同一 appid 下的业务账号或子租户。 +- 不要在请求间随机改变这两个值,否则会失去缓存命中并放大平台 token 请求。 diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..20d7734 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,151 @@ +# 配置与凭证 + +所有平台配置实现 `We\Contract\ConfigInterface`,在构造或 `fromArray()` 时立即验证必填字段和密钥。配置无效时不会创建可调用的客户端。 + +## 根客户端 + +`We\Client` 可注入运行态缓存、微信服务平台授权方 Token 仓库和 Guzzle HTTP 客户端: + +```php + 'wx_appid', + 'app_secret' => 'app_secret', + 'storage_scope' => 'tenant-a', +]); +``` + +## 微信服务平台 + +```php +get('cgi-bin/user/get'); +} catch (SdkException $exception) { + logger()->error($exception->getMessage(), [ + 'exception' => $exception, + ]); +} +``` + +## 精确捕获和上下文 + +```php +post('alipay.trade.query', [ + 'out_trade_no' => 'A202608100001', + ]); +} catch (AlipaySignatureException $exception) { + security_log($exception->getMessage(), $exception->context()); +} catch (AlipayApiException $exception) { + application_log($exception->getMessage(), $exception->context()); +} catch (TransportException $exception) { + retryable_log($exception->getMessage(), $exception->context()); +} +``` + +异常的 `context()` 返回平台响应、签名序列号或其他诊断数据。不要把可能含敏感信息的完整上下文直接写入公开日志。 + +PHP 原生参数类型错误、调用不存在的方法等编程错误不包装为 `SdkException`;这类错误应在开发和静态分析阶段修复。 diff --git a/docs/migration-2.0.md b/docs/migration-2.0.md new file mode 100644 index 0000000..832e58b --- /dev/null +++ b/docs/migration-2.0.md @@ -0,0 +1,218 @@ +# 从 1.x 迁移到 2.0 + +WeChatDeveloper 2.0 是主版本重写,不提供兼容层。升级不是替换版本号即可完成:需要更新 PHP 运行时、命名空间、初始化、凭证、缓存、异常和每个业务调用。 + +建议先在独立分支列出实际使用的 1.x 类和方法,再按官方 API 协议逐项迁移并运行集成测试。不要在同一进程混用两套入口。 + +## 破坏性变化总览 + +| 主题 | 1.x | 2.0 | +|------|-----|-----| +| PHP | `>=5.4` | PHP 8.1 或更高 | +| 自动加载 | `WeChat\`、`WeMini\`、`WePay\`、`WePayV3\`、`AliPay\` 和 `We.php` | 单一 `We\` PSR-4 命名空间 | +| 初始化 | 各业务类 `::instance(array $config)` | `We\Client` + 配置对象 + 类型工厂 | +| API 表面 | 大量业务类和别名方法 | 官方相对 path + `get()`/`post()`/`call()` | +| HTTP | 内置 curl/tools | Guzzle 7,可注入 `ClientInterface` | +| 配置 | 松散数组、回调和文件路径 | 构造时验证的 `ConfigInterface` 对象 | +| 缓存 | `cache_path`、Tools 静态文件缓存、token callback | `StoreCacheInterface`、TTL 和刷新锁 | +| 异常 | `WeChat\Exceptions\*` | `SdkException` 统一基类和平台子类 | +| 返回 | 业务类各自返回数组/XML/字符串 | JSON API 通常返回数组,raw/download 返回 PSR-7 Response | +| 微信支付 | V2 XML/MD5/HMAC 类与独立 V3 类 | 只提供微信支付 APIv3 通用客户端 | +| 支付信任 | 部分响应未强制验签 | 支付宝和微信支付响应默认强制验签 | + +## 安装和命名空间 + +更新运行环境与依赖: + +```bash +composer require zoujingli/wechat-developer:^2.0 +``` + +移除旧代码中的 `WeChat\`、`WeMini\`、`WePay\`、`WePayV3\` 和 `AliPay\` imports。2.0 的入口统一为 `We\Client`。 + +## 初始化 + +1.x: + +```php + 'wx_appid', + 'appsecret' => 'app_secret', +]); +``` + +2.0: + +```php +wechatPlatform(new WechatPlatformConfig( + appid: 'wx_appid', + appSecret: 'app_secret', +)); +``` + +不再使用静态 `instance()` 缓存客户端。应用容器可把 `Client` 或平台客户端注册为服务,并显式注入缓存和 HTTP 适配器。 + +## 常用类映射 + +| 1.x 类族 | 2.0 入口 | 迁移方式 | +|----------|----------|----------| +| `WeChat\User`、`Menu`、`Media`、`Template` 等 | `Client::wechatPlatform()` | 查官方公众号 path,使用 `get()`/`post()`/`download()`/`upload()` | +| `WeMini\*` | `Client::wechatWxapp()` | 查官方小程序 path,按是否需要 token 调用 | +| 第三方平台自定义接入 | `Client::wechatService()` | 使用 component token、授权 URL 和授权方调用能力 | +| `WePay\Order`、`Refund`、`Bill` 等 V2 类 | `Client::wechatPayment()` | 迁移到官方 APIv3 path、JSON 字段和 RSA 凭证;不是一对一方法替换 | +| `WePayV3\*` | `Client::wechatPayment()` | 将旧 V3 path 和 payload 迁到通用 `get()`/`post()` | +| `AliPay\Trade`、`Web`、`Wap`、`Transfer` 等 | `Client::alipayPayment()` | 使用官方 method、`page()`、`refund()` 或通用网关请求 | +| 支付宝授权/用户接口 | `Client::alipayPlatform()` | 使用 `auth()` 或官方 API method | + +## 调用映射示例 + +1.x 的 `WeChat\User::getUserList()`: + +```php +get('cgi-bin/user/get', [ + 'next_openid' => '', +]); +``` + +1.x 的 `WeChat\User::updateMark()`: + +```php +post('cgi-bin/user/info/updateremark', [ + 'openid' => 'openid', + 'remark' => '新备注', +]); +``` + +1.x 支付宝 `Trade::query()`: + +```php +post('alipay.trade.query', [ + 'out_trade_no' => 'A202608100001', +]); +``` + +1.x 微信支付 V2 XML 接口不能只改 path。应先在微信支付官方文档选择对应 APIv3 接口,再迁移金额单位、字段名、通知格式、签名和证书/公钥配置。 + +## 配置字段 + +### 微信公众平台和小程序 + +- `appid` 保持不变。 +- `appsecret` 数组字段仍可由 `fromArray()` 读取;构造参数名为 `appSecret`。 +- `cache_path` 被移除,改为向根 `Client` 注入缓存。 +- `GetAccessTokenCallback` 被移除。普通 access token 由 SDK 通过缓存管理;授权方 token 使用 `StoreTokenInterface`。 + +### 微信支付 + +- `mch_id` -> `mchId`。 +- `mch_v3_key`/`api_v3_key` -> `apiV3Key`,必须正好 32 字节。 +- `cert_serial`/`merchant_serial` -> `merchantSerial`。 +- `cert_private`/`merchant_private_key` -> `merchantPrivateKey`,必须是 RSA 私钥。 +- 新增必填平台信任材料:`platform_public_key` 或 `platform_certificate`。 +- 新增必填 `platform_serial`,必须与收到的 `Wechatpay-Serial` 对应。 +- 新增 `notification_tolerance_seconds`,默认 300;`0` 才显式关闭通知时间检查。 + +旧 `cert_public` 表示商户证书/公钥,不能验证微信支付平台响应。2.0 不会把 `cert_public` 映射为 `platform_certificate`;必须从微信支付平台取得正确的 `platform_public_key` 或平台证书及其序列号。 + +### 支付宝 + +- `appid`/`app_id` -> `appid`。 +- `private_key`/`merchant_private_key` -> `privateKey`。 +- `alipay_public_key` 现在必填,用于同步响应和通知验签。 +- RSA2 仍是默认签名类型。 +- 无头尾 PKCS#1 与 PKCS#8 RSA 私钥正文可继续使用;EC 密钥会被拒绝。 + +## 缓存迁移 + +实现 `StoreCacheInterface`,或把现有 PSR-16 缓存包装为 `PsrSimpleCacheStore`。生产实现必须提供跨进程/跨节点刷新锁,不能只实现 `get()` 与 `set()`。 + +2.0 缓存键使用三段 PSR-16 安全格式。不要复用 1.x 缓存文件名或手工拼接旧 key;让 SDK 首次调用时重新获取 token。 + +## 异常迁移 + +将 `WeChat\Exceptions\InvalidArgumentException`、`InvalidResponseException` 和 `LocalCacheException` 等 catch 迁移到新层级: + +```php +get('cgi-bin/user/get'); +} catch (SignatureException $exception) { + security_log($exception); +} catch (AlipaySignatureException $exception) { + security_log($exception); +} catch (ApiException | AlipayApiException $exception) { + platform_log($exception, $exception->context()); +} catch (TransportException $exception) { + retryable_log($exception, $exception->context()); +} catch (SdkException $exception) { + sdk_log($exception); +} +``` + +`SdkException` 适合统一边界;`TransportException`、平台 API 异常和平台签名异常适合需要重试、告警或拒绝策略的代码。 + +## 返回结构 + +- JSON API 成功返回 `array`,不再返回 1.x `DataArray`。 +- `raw()` 与 `download()` 返回 PSR-7 `ResponseInterface`;通过 `(string) $response->getBody()` 获取内容。 +- 微信账单使用 `downloadBill()`,不是对申请账单 path 调用普通 `download()`。 +- 平台错误通过异常上下文提供原始字段,不要依赖 1.x 错误数组形状。 + +## 已移除能力 + +2.0 不提供兼容层,也不包含: + +- 1.x 的 `We.php` 全局入口与 helper 自动加载。 +- 旧命名空间和各业务类的 `::instance()`。 +- `WeChat\*`、`WeMini\*`、`WePay\*`、`WePayV3\*`、`AliPay\*` 的大量别名方法。 +- 微信支付 V2 XML/MD5/HMAC 业务封装。 +- `cache_path` 静态文件缓存配置和 access token callback。 +- SDK 内置的订单、退款、红包、分账等业务对象。 + +替代方式是使用 2.0 平台客户端按官方 path 调用;若某个旧功能依赖已废弃平台协议,应先迁移到平台当前协议,而不是在 2.0 中复制旧实现。 + +## 升级检查清单 + +1. 将运行环境升级到 PHP 8.1+,安装新 Composer 依赖和扩展。 +2. 清点并移除所有 1.x 命名空间、静态 `instance()` 和 helper 调用。 +3. 为每个平台建立配置对象,补齐支付宝公钥和微信支付平台信任材料。 +4. 把缓存接入 `StoreCacheInterface`,验证生产刷新锁。 +5. 按官方 path 替换业务类方法,单独处理微信支付 V2 到 APIv3 的协议迁移。 +6. 更新异常捕获和 raw/download 返回处理。 +7. 使用原始 body 验证支付通知,并在业务数据库实现幂等。 +8. 跑完应用单元测试、集成测试和支付沙箱/受控环境验证后再切换流量。 diff --git a/docs/payments.md b/docs/payments.md new file mode 100644 index 0000000..444ebef --- /dev/null +++ b/docs/payments.md @@ -0,0 +1,136 @@ +# 微信支付 APIv3 + +微信支付客户端对商户请求签名,并在信任普通 JSON 响应前校验微信支付平台签名。配置缺少平台信任材料或序列号时会立即失败。 + +## 配置 + +```php +wechatPayment($config); +``` + +商户私钥和序列号用于 `WECHATPAY2-SHA256-RSA2048` 请求签名。平台公钥或平台证书及其序列号用于响应和通知验签,两类材料不可混用。 + +## API 调用与响应信任 + +```php +post('v3/pay/transactions/jsapi', [ + 'appid' => 'wx_appid', + 'mchid' => '1900000001', + 'description' => '测试订单', + 'out_trade_no' => 'T202608100001', + 'notify_url' => 'https://example.com/wechat-pay/notify', + 'amount' => ['total' => 1, 'currency' => 'CNY'], + 'payer' => ['openid' => 'openid'], +]); + +$query = $payment->get('v3/pay/transactions/out-trade-no/T202608100001', [ + 'mchid' => '1900000001', +]); +``` + +普通 `request()`、`get()` 和 `post()` 响应必须包含: + +- `Wechatpay-Timestamp` +- `Wechatpay-Nonce` +- `Wechatpay-Signature` +- `Wechatpay-Serial` + +SDK 使用原始响应 body 验签,校验配置中的平台序列号,然后才解析 JSON。缺头、序列号不匹配或签名失败会抛出 `SignatureException`;HTTP 错误响应也先验签,再抛出带平台错误上下文的 `ApiException`。 + +`raw()` 和旧的单阶段 `download()` 只负责商户请求签名并返回原始响应,不把 body 解释为可信业务 JSON。需要业务数据时应使用 `get()`、`post()` 或 `request()`。 + +## 两阶段账单下载 + +微信支付账单不是“对申请接口直接下载文件”。应使用 `downloadBill()`: + +```php +downloadBill('v3/bill/tradebill', [ + 'bill_date' => '2026-08-09', + 'bill_type' => 'ALL', +]); + +$billContents = (string) $response->getBody(); +``` + +流程如下: + +1. SDK 对申请账单地址的 APIv3 请求生成商户签名。 +2. SDK 验证元数据响应的平台签名并读取 `download_url`。 +3. SDK 只接受有效的 HTTPS 下载地址。 +4. SDK 禁止下载响应重定向,避免已验证的 HTTPS 地址把请求降级到 HTTP 或其他目标。 +5. SDK 使用专用下载能力获取文件并返回 PSR-7 响应。 + +这不会放宽通用 JSON 客户端的相对 path 限制。元数据中的非 HTTPS、无 host 或含用户信息的 URL 会被拒绝;3xx 下载响应会作为接口错误返回,不会自动跟随。 + +## 支付通知 + +通知验签必须使用收到的原始 HTTP body,不能先 `json_decode()` 再编码。示例中的请求头数组应来自 Web 框架原始请求: + +```php + (string) ($_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? ''), + 'Wechatpay-Nonce' => (string) ($_SERVER['HTTP_WECHATPAY_NONCE'] ?? ''), + 'Wechatpay-Signature' => (string) ($_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? ''), + 'Wechatpay-Serial' => (string) ($_SERVER['HTTP_WECHATPAY_SERIAL'] ?? ''), +]; + +$resource = $payment->post('decrypt_notification', [], [ + 'headers' => $headers, + 'raw_body' => $rawBody, +]); +``` + +处理顺序是平台签名校验、时间窗口校验、JSON 解析、APIv3 resource 解密。默认允许通知时间戳与当前时间相差最多 300 秒,过去和未来都受限制。 + +可以根据部署延迟显式扩大窗口。只有 `notificationToleranceSeconds: 0` 会关闭时间检查,RSA 验签仍然执行。关闭时间检查适合受控回放,不应作为生产默认值。 + +SDK 的时间窗口只降低签名通知重放风险,不能替代业务幂等。业务系统仍应按通知 ID、商户订单号和当前订单状态做持久化去重与状态转换。 + +## 敏感信息 + +向微信支付发送使用平台公钥加密的敏感字段时,按官方文档在请求 headers 传对应平台证书/公钥序列号: + +```php +post('v3/example/with-sensitive-information', $payload, [ + 'headers' => [ + 'Wechatpay-Serial' => 'wechatpay_platform_serial', + ], +]); +``` + +这里的 `Wechatpay-Serial` 不是商户证书序列号。 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..a1a9da2 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,72 @@ +# 测试与贡献 + +项目使用 PHPUnit、PHPStan 和 PHP CS Fixer。测试不连接真实微信或支付宝账号,外部平台通过注入的 Guzzle 适配器替换。 + +## 本地准备 + +```bash +composer install +composer validate --strict +``` + +项目要求 PHP 8.1 或更高版本,以及 JSON、OpenSSL、SimpleXML 扩展。 + +macOS 使用 Homebrew OpenSSL 3 时,如果系统默认配置无法生成 RSA 测试密钥,可显式设置配置文件: + +```bash +OPENSSL_CONF=/opt/homebrew/etc/openssl@3/openssl.cnf composer test +``` + +密钥夹具生成失败会输出 OpenSSL 错误栈和当前 `OPENSSL_CONF`,用于区分环境问题与 SDK 回归。 + +## 开发循环 + +对单个行为先运行对应测试文件: + +```bash +vendor/bin/phpunit -c phpunit.xml tests/PaymentClientTest.php +composer analyse +``` + +提交前执行完整质量门禁: + +```bash +composer cs:fix +composer cs:check +composer analyse +composer validate --strict +composer test +``` + +`composer cs:fix` 会修改文件;其余命令应以零退出码完成。 + +## CI 矩阵 + +CI 包含: + +- Composer 严格元数据校验。 +- PHP CS Fixer dry-run。 +- PHPStan 静态分析。 +- PHP 8.1、8.2、8.3、8.4 完整 PHPUnit 测试。 +- PHP 8.1 最低依赖组合测试。 + +标签发布复用同一 CI 工作流。任一质量任务失败都不会创建 GitHub Release。 + +## 测试边界 + +测试优先经过调用方可见接口: + +1. 根 `Client` 和六个平台客户端。 +2. 配置对象的构造与 `fromArray()`。 +3. `StoreCacheInterface` 的可替换语义。 +4. 只在外部平台边界替换 Guzzle HTTP 客户端。 + +不要测试私有消息拼接、内部调用次数或实现细节。支付响应验签测试必须签署原始 body;通知测试必须覆盖有效窗口、过期/未来时间戳、配置窗口和显式关闭时间检查。 + +文件缓存并发回归是一个明确的低层调度例外:测试只用文件路径和 `flock` 确定旧读与新写的交错顺序,最终行为仍只通过公开 `get()`/`set()` 断言。该调度不构成缓存文件格式的公开契约。 + +文档测试会解析 README 与 `docs/` 中每个 PHP fenced code,示例必须语法完整,不要在 PHP code block 中使用省略号代替表达式。 + +## 临时文件 + +缓存测试只在系统临时目录创建隔离文件。并发回归测试依赖 POSIX `fork` 与 `flock`,不支持这些能力的环境会按测试声明处理,不应改为访问生产缓存。 diff --git a/docs/wechat.md b/docs/wechat.md new file mode 100644 index 0000000..b825371 --- /dev/null +++ b/docs/wechat.md @@ -0,0 +1,157 @@ +# 微信平台 + +微信公众平台、小程序和服务平台客户端共享“官方相对 path + 参数数组”的调用方式。SDK 管理协议和 token,不把官方接口复制成大量业务方法。 + +## 公众平台 + +```php +wechatPlatform(new WechatPlatformConfig( + appid: 'wx_appid', + appSecret: 'app_secret', +)); + +$users = $platform->get('cgi-bin/user/get', [ + 'next_openid' => '', +]); + +$result = $platform->post('cgi-bin/message/custom/send', [ + 'touser' => 'openid', + 'msgtype' => 'text', + 'text' => ['content' => 'hello'], +]); +``` + +GET 的第二个参数作为 query;POST 的第二个参数默认作为 JSON body。`options` 可透传 Guzzle 的 `headers`、`timeout`、`query`、`body` 或 `form_params`。 + +网页授权等不需要公众号 access token 的接口应显式关闭 token: + +```php +get('sns/oauth2/access_token', [ + 'appid' => 'wx_appid', + 'secret' => 'app_secret', + 'code' => 'authorization_code', + 'grant_type' => 'authorization_code', +], [ + 'with_token' => false, +]); +``` + +## 小程序 + +```php +wechatWxapp(new WechatWxappConfig( + appid: 'wx_appid', + appSecret: 'app_secret', +)); + +$session = $wxapp->get('sns/jscode2session', [ + 'appid' => 'wx_appid', + 'secret' => 'app_secret', + 'js_code' => 'login_code', + 'grant_type' => 'authorization_code', +], [ + 'with_token' => false, +]); +``` + +## 原始响应、下载和上传 + +`raw()` 与 `download()` 返回 PSR-7 `ResponseInterface`,适用于图片、媒体和其他非 JSON 数据。`upload()` 接受 Guzzle multipart 结构并解析平台 JSON 响应。 + +```php +download('cgi-bin/media/get', [ + 'media_id' => 'MEDIA_ID', +]); +$binary = (string) $image->getBody(); + +$upload = $platform->upload('cgi-bin/media/upload', [ + [ + 'name' => 'media', + 'contents' => fopen(__DIR__ . '/demo.jpg', 'rb'), + 'filename' => 'demo.jpg', + ], +], [ + 'type' => 'image', +]); +``` + +所有通用微信平台 path 必须是相对路径。`https://...`、`http://...` 和 `//host/path` 都会被拒绝,避免把平台客户端放大为任意 URL 请求器。 + +## 消息安全模式 + +公众平台配置 `token` 和 `encodingAesKey` 后,可通过特殊操作名处理消息: + +```php +post('decrypt_message', [ + 'body' => $rawXml, + 'msg_signature' => $messageSignature, + 'timestamp' => $timestamp, + 'nonce' => $nonce, +]); + +$encrypted = $platform->post('encrypt_message', [ + 'body' => $replyXml, + 'timestamp' => (string) time(), + 'nonce' => $nonce, +]); +``` + +## 微信服务平台 + +服务平台客户端管理 component access token,并可代表授权方调用官方接口。授权方 refresh token 的持久化由业务系统实现 `StoreTokenInterface`。 + +```php +wechatService(new WechatServiceConfig( + componentAppid: 'wx_component_appid', + componentAppSecret: 'component_secret', + componentToken: 'componentToken123', + componentEncodingAesKey: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', +)); + +$componentToken = $service->componentAccessToken($componentVerifyTicket); +$preAuth = $service->createPreAuthCode($componentToken); +$url = $service->authorizationUrl( + (string) $preAuth['pre_auth_code'], + 'https://example.com/wechat/component/callback', +); +``` + +通过通用 `get()`/`post()` 代表授权方调用时,在 options 中传 `authorizer_appid` 与 `component_access_token`。SDK 从 `StoreTokenInterface` 读取 refresh token,刷新后把完整 payload 回写业务存储。 + +## 返回与错误 + +JSON 调用成功时返回数组。微信平台业务错误、无效 JSON、凭证错误和传输错误抛出 `WechatException` 或 `ApiException`;签名错误抛出 `SignatureException`。它们都可由 `SdkException` 统一捕获。 diff --git a/src/Client.php b/src/Client.php index c08a6b5..fcb8bbc 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,12 +1,6 @@ - */ namespace We; @@ -20,7 +14,7 @@ use We\Config\WechatWxappConfig; use We\Contract\ConfigInterface; use We\Contract\StoreCacheInterface; use We\Contract\StoreTokenInterface; -use We\Exception\WechatException; +use We\Exception\SdkException; use We\Platform\Alipay\PaymentClient as AlipayPaymentClient; use We\Platform\Alipay\PlatformClient as AlipayPlatformClient; use We\Platform\Wechat\PaymentClient as WechatPaymentClient; @@ -33,15 +27,8 @@ use We\Support\FileCacheStore; /** * SDK 根入口:按平台与业务域创建微信、支付宝客户端,工厂方法命名与配置对象语义保持一致。 * - * access_token 等运行态数据的缓存键固定为 `{cacheKeyPrefix}:{platformChannel}:{logicalKey}`,见 {@see CacheKey::compose}。 + * access_token 等运行态数据的缓存键由 cacheKeyPrefix、platformChannel 与 logicalKey 三段编码组成,见 {@see CacheKey::compose}。 * 未注入缓存实现时使用 {@see FileCacheStore},默认目录为 {@see self::defaultCacheStoreDirectory()}。 - * - * @method WechatPlatformClient wechatPlatform(WechatPlatformConfig $config) - * @method WechatWxappClient wechatWxapp(WechatWxappConfig $config) - * @method WechatServiceClient wechatService(WechatServiceConfig $config) - * @method WechatPaymentClient wechatPayment(WechatPaymentConfig $config) - * @method AlipayPlatformClient alipayPlatform(AlipayPlatformConfig $config) - * @method AlipayPaymentClient alipayPayment(AlipayPaymentConfig $config) */ final class Client { @@ -71,7 +58,7 @@ final class Client // 缓存前缀是完整缓存键第一段,必须稳定且非空,避免不同业务实例互相覆盖 token。 $g = trim(trim($cacheKeyPrefix), ':'); if ($g === '') { - throw new WechatException('cacheKeyPrefix 不能为空'); + throw new SdkException('cacheKeyPrefix 不能为空'); } $this->cacheKeyPrefix = $g; $this->cache = $cache ?? new FileCacheStore(self::defaultCacheStoreDirectory()); @@ -79,52 +66,34 @@ final class Client $this->http = $http; } - /** - * 魔术工厂:只暴露平台前缀明确的客户端创建方法。 - * - * @param array $arguments - */ - public function __call(string $name, array $arguments): object + public function wechatPlatform(WechatPlatformConfig $config): WechatPlatformClient { - $config = $arguments[0] ?? null; - if (!$config instanceof ConfigInterface) { - throw new WechatException('通道工厂第一个参数必须为 ConfigInterface 对象'); - } + return new WechatPlatformClient($config, $this->http, $this->cache, $this->cacheKeyPrefix); + } - return match ($name) { - 'wechatPlatform' => $this->instantiate(WechatPlatformClient::class, [ - $this->ensureConfig($config, WechatPlatformConfig::class, 'wechatPlatform'), - $this->http, - $this->cache, - $this->cacheKeyPrefix, - ]), - 'wechatWxapp' => $this->instantiate(WechatWxappClient::class, [ - $this->ensureConfig($config, WechatWxappConfig::class, 'wechatWxapp'), - $this->http, - $this->cache, - $this->cacheKeyPrefix, - ]), - 'wechatService' => $this->instantiate(WechatServiceClient::class, [ - $this->ensureConfig($config, WechatServiceConfig::class, 'wechatService'), - $this->http, - $this->cache, - $this->authorizers, - $this->cacheKeyPrefix, - ]), - 'wechatPayment' => $this->instantiate(WechatPaymentClient::class, [ - $this->ensureConfig($config, WechatPaymentConfig::class, 'wechatPayment'), - $this->http, - ]), - 'alipayPlatform' => $this->instantiate(AlipayPlatformClient::class, [ - $this->ensureConfig($config, AlipayPlatformConfig::class, 'alipayPlatform'), - $this->http, - ]), - 'alipayPayment' => $this->instantiate(AlipayPaymentClient::class, [ - $this->ensureConfig($config, AlipayPaymentConfig::class, 'alipayPayment'), - $this->http, - ]), - default => throw new WechatException('不支持的通道工厂方法: ' . $name), - }; + public function wechatWxapp(WechatWxappConfig $config): WechatWxappClient + { + return new WechatWxappClient($config, $this->http, $this->cache, $this->cacheKeyPrefix); + } + + public function wechatService(WechatServiceConfig $config): WechatServiceClient + { + return new WechatServiceClient($config, $this->http, $this->cache, $this->authorizers, $this->cacheKeyPrefix); + } + + public function wechatPayment(WechatPaymentConfig $config): WechatPaymentClient + { + return new WechatPaymentClient($config, $this->http); + } + + public function alipayPlatform(AlipayPlatformConfig $config): AlipayPlatformClient + { + return new AlipayPlatformClient($config, $this->http); + } + + public function alipayPayment(AlipayPaymentConfig $config): AlipayPaymentClient + { + return new AlipayPaymentClient($config, $this->http); } /** 默认缓存落盘目录:`sys_get_temp_dir()` + 包级子目录名,供未显式传入 `cache` 时构造 {@see FileCacheStore}。 */ @@ -138,32 +107,15 @@ final class Client */ public function get(string $channel, ConfigInterface $config): object { - [$factory, $expected] = match ($channel) { - 'wechat.platform' => ['wechatPlatform', WechatPlatformConfig::class], - 'wechat.wxapp' => ['wechatWxapp', WechatWxappConfig::class], - 'wechat.service' => ['wechatService', WechatServiceConfig::class], - 'wechat.payment' => ['wechatPayment', WechatPaymentConfig::class], - 'alipay.platform' => ['alipayPlatform', AlipayPlatformConfig::class], - 'alipay.payment' => ['alipayPayment', AlipayPaymentConfig::class], - default => throw new WechatException('不支持的通道标识: ' . $channel), + return match ($channel) { + 'wechat.platform' => $this->wechatPlatform($this->ensureConfig($config, WechatPlatformConfig::class, 'wechatPlatform')), + 'wechat.wxapp' => $this->wechatWxapp($this->ensureConfig($config, WechatWxappConfig::class, 'wechatWxapp')), + 'wechat.service' => $this->wechatService($this->ensureConfig($config, WechatServiceConfig::class, 'wechatService')), + 'wechat.payment' => $this->wechatPayment($this->ensureConfig($config, WechatPaymentConfig::class, 'wechatPayment')), + 'alipay.platform' => $this->alipayPlatform($this->ensureConfig($config, AlipayPlatformConfig::class, 'alipayPlatform')), + 'alipay.payment' => $this->alipayPayment($this->ensureConfig($config, AlipayPaymentConfig::class, 'alipayPayment')), + default => throw new SdkException('不支持的通道标识: ' . $channel), }; - - return $this->__call($factory, [$this->ensureConfig($config, $expected, $factory)]); - } - - /** - * 使用反射创建具体平台客户端实例。 - * - * @param class-string $class - * @param array $args - */ - private function instantiate(string $class, array $args): object - { - try { - return (new \ReflectionClass($class))->newInstanceArgs($args); - } catch (\ReflectionException $e) { - throw new WechatException('通道客户端实例化失败: ' . $e->getMessage(), 0, $e); - } } /** @@ -176,7 +128,7 @@ final class Client private function ensureConfig(ConfigInterface $config, string $expected, string $factory): object { if (!$config instanceof $expected) { - throw new WechatException($factory . ' 需要 ' . $this->shortClass($expected)); + throw new SdkException($factory . ' 需要 ' . $this->shortClass($expected)); } return $config; diff --git a/src/Config/AlipayPaymentConfig.php b/src/Config/AlipayPaymentConfig.php index 030e6cf..95f1cd6 100644 --- a/src/Config/AlipayPaymentConfig.php +++ b/src/Config/AlipayPaymentConfig.php @@ -1,12 +1,6 @@ - */ namespace We\Config; diff --git a/src/Config/AlipayPlatformConfig.php b/src/Config/AlipayPlatformConfig.php index e594da6..d75e179 100644 --- a/src/Config/AlipayPlatformConfig.php +++ b/src/Config/AlipayPlatformConfig.php @@ -1,17 +1,11 @@ - */ namespace We\Config; use We\Contract\ConfigInterface; -use We\Exception\WechatException; +use We\Exception\AlipayException; use We\Support\CredentialValidator; /** @@ -44,19 +38,17 @@ class AlipayPlatformConfig implements ConfigInterface */ public function validate(): void { - if (trim($this->appid) === '' || trim($this->privateKey) === '') { - throw new WechatException('支付宝 appid 与 private_key 不能为空'); + if (trim($this->appid) === '' || trim($this->privateKey) === '' || trim($this->alipayPublicKey) === '') { + throw new AlipayException('支付宝 appid、private_key 与 alipay_public_key 不能为空'); } if (!in_array(strtoupper($this->signType), ['RSA', 'RSA2'], true)) { - throw new WechatException('支付宝 sign_type 仅支持 RSA 或 RSA2'); + throw new AlipayException('支付宝 sign_type 仅支持 RSA 或 RSA2'); } if (!filter_var($this->gateway, FILTER_VALIDATE_URL)) { - throw new WechatException('支付宝 gateway 必须是有效 URL'); - } - CredentialValidator::assertPrivateKey($this->privateKey, '支付宝 private_key', true); - if ($this->alipayPublicKey !== '') { - CredentialValidator::assertPublicKey($this->alipayPublicKey, '支付宝 alipay_public_key', true); + throw new AlipayException('支付宝 gateway 必须是有效 URL'); } + CredentialValidator::assertPrivateKey($this->privateKey, '支付宝 private_key', true, AlipayException::class); + CredentialValidator::assertPublicKey($this->alipayPublicKey, '支付宝 alipay_public_key', true, AlipayException::class); } /** diff --git a/src/Config/WechatPaymentConfig.php b/src/Config/WechatPaymentConfig.php index 98d1e61..91619a3 100644 --- a/src/Config/WechatPaymentConfig.php +++ b/src/Config/WechatPaymentConfig.php @@ -1,12 +1,6 @@ - */ namespace We\Config; @@ -36,6 +30,8 @@ final class WechatPaymentConfig implements ConfigInterface public string $platformPublicKey = '', /** 微信支付平台证书/公钥序列号;非空时会校验回调头 Wechatpay-Serial。 */ public string $platformSerial = '', + /** 通知时间戳允许偏差秒数;0 表示显式关闭新鲜度校验。 */ + public int $notificationToleranceSeconds = 300, ) { $this->validate(); } @@ -57,12 +53,21 @@ final class WechatPaymentConfig implements ConfigInterface } } CredentialValidator::assertApiV3Key($this->apiV3Key); - CredentialValidator::assertPrivateKey($this->merchantPrivateKey, 'merchantPrivateKey'); + CredentialValidator::assertPrivateKey($this->merchantPrivateKey, 'merchantPrivateKey', exceptionClass: WechatException::class); + if ($this->platformCertificate === '' && $this->platformPublicKey === '') { + throw new WechatException('platformCertificate 或 platformPublicKey 不能为空'); + } + if (trim($this->platformSerial) === '') { + throw new WechatException('platformSerial 不能为空'); + } + if ($this->notificationToleranceSeconds < 0) { + throw new WechatException('notificationToleranceSeconds 不能小于 0'); + } if ($this->platformCertificate !== '') { - CredentialValidator::assertPublicKey($this->platformCertificate, 'platformCertificate'); + CredentialValidator::assertPublicKey($this->platformCertificate, 'platformCertificate', exceptionClass: WechatException::class); } if ($this->platformPublicKey !== '') { - CredentialValidator::assertPublicKey($this->platformPublicKey, 'platformPublicKey'); + CredentialValidator::assertPublicKey($this->platformPublicKey, 'platformPublicKey', exceptionClass: WechatException::class); } } @@ -79,9 +84,32 @@ final class WechatPaymentConfig implements ConfigInterface (string)($data['api_v3_key'] ?? $data['mch_v3_key'] ?? ''), (string)($data['merchant_serial'] ?? $data['cert_serial'] ?? ''), (string)($data['merchant_private_key'] ?? $data['cert_private'] ?? ''), - (string)($data['platform_certificate'] ?? $data['cert_public'] ?? ''), + (string)($data['platform_certificate'] ?? ''), (string)($data['platform_public_key'] ?? ''), (string)($data['platform_serial'] ?? ''), + self::notificationTolerance($data), ); } + + /** + * @param array $data + */ + private static function notificationTolerance(array $data): int + { + if (!array_key_exists('notification_tolerance_seconds', $data)) { + return 300; + } + $value = $data['notification_tolerance_seconds']; + if (is_int($value) && $value >= 0) { + return $value; + } + if (is_string($value) && preg_match('/^(0|[1-9]\d*)$/D', $value) === 1) { + $parsed = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if (is_int($parsed)) { + return $parsed; + } + } + + throw new WechatException('notification_tolerance_seconds 必须是 0 或正整数'); + } } diff --git a/src/Config/WechatPlatformConfig.php b/src/Config/WechatPlatformConfig.php index c7ef111..d8dda7a 100644 --- a/src/Config/WechatPlatformConfig.php +++ b/src/Config/WechatPlatformConfig.php @@ -1,12 +1,6 @@ - */ namespace We\Config; diff --git a/src/Config/WechatServiceConfig.php b/src/Config/WechatServiceConfig.php index e26f495..8239684 100644 --- a/src/Config/WechatServiceConfig.php +++ b/src/Config/WechatServiceConfig.php @@ -1,12 +1,6 @@ - */ namespace We\Config; diff --git a/src/Config/WechatWxappConfig.php b/src/Config/WechatWxappConfig.php index 2c3829c..60d2b50 100644 --- a/src/Config/WechatWxappConfig.php +++ b/src/Config/WechatWxappConfig.php @@ -1,12 +1,6 @@ - */ namespace We\Config; diff --git a/src/Contract/ConfigInterface.php b/src/Contract/ConfigInterface.php index 21149a9..93d044c 100644 --- a/src/Contract/ConfigInterface.php +++ b/src/Contract/ConfigInterface.php @@ -1,12 +1,6 @@ - */ namespace We\Contract; diff --git a/src/Contract/StoreCacheInterface.php b/src/Contract/StoreCacheInterface.php index 81477f2..5a6e89e 100644 --- a/src/Contract/StoreCacheInterface.php +++ b/src/Contract/StoreCacheInterface.php @@ -1,12 +1,6 @@ - */ namespace We\Contract; diff --git a/src/Contract/StoreTokenInterface.php b/src/Contract/StoreTokenInterface.php index a33ad40..95131fd 100644 --- a/src/Contract/StoreTokenInterface.php +++ b/src/Contract/StoreTokenInterface.php @@ -1,12 +1,6 @@ - */ namespace We\Contract; diff --git a/src/Contract/Trait/WechatInteractsProtocol.php b/src/Contract/Trait/WechatInteractsProtocol.php index 3f53345..b479de4 100644 --- a/src/Contract/Trait/WechatInteractsProtocol.php +++ b/src/Contract/Trait/WechatInteractsProtocol.php @@ -1,12 +1,6 @@ - */ namespace We\Contract\Trait; diff --git a/src/Exception/AlipayApiException.php b/src/Exception/AlipayApiException.php new file mode 100644 index 0000000..0ad3796 --- /dev/null +++ b/src/Exception/AlipayApiException.php @@ -0,0 +1,10 @@ + - */ namespace We\Exception; diff --git a/src/Exception/SdkException.php b/src/Exception/SdkException.php new file mode 100644 index 0000000..50262c9 --- /dev/null +++ b/src/Exception/SdkException.php @@ -0,0 +1,33 @@ + $context + */ + public function __construct( + string $message, + int $code = 0, + ?\Throwable $previous = null, + private readonly array $context = [] + ) { + parent::__construct($message, $code, $previous); + } + + /** + * @return array + */ + public function context(): array + { + return $this->context; + } +} diff --git a/src/Exception/SignatureException.php b/src/Exception/SignatureException.php index 79957e7..51f775b 100644 --- a/src/Exception/SignatureException.php +++ b/src/Exception/SignatureException.php @@ -1,16 +1,10 @@ - */ namespace We\Exception; /** - * 微信回调、微信支付通知或支付宝通知签名验证失败时抛出的异常。 + * 微信回调、微信支付响应或通知签名验证失败时抛出的异常。 */ final class SignatureException extends WechatException {} diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php new file mode 100644 index 0000000..9739bad --- /dev/null +++ b/src/Exception/TransportException.php @@ -0,0 +1,10 @@ + - */ namespace We\Exception; /** - * SDK 基础异常。 - * - * 通过 context() 暴露平台响应、验签明细等上下文,便于业务侧记录日志和排查问题。 + * 微信平台配置、协议或数据处理失败。 */ -class WechatException extends \RuntimeException -{ - /** - * 创建 SDK 异常并保存可选上下文。 - * - * @param array $context - */ - public function __construct( - string $message, - int $code = 0, - ?\Throwable $previous = null, - private readonly array $context = [] - ) { - parent::__construct($message, $code, $previous); - } - - /** - * 获取异常附带的平台响应或验签上下文。 - * - * @return array - */ - public function context(): array - { - return $this->context; - } -} +class WechatException extends SdkException {} diff --git a/src/Platform/Alipay/PaymentClient.php b/src/Platform/Alipay/PaymentClient.php index c80918c..43c88be 100644 --- a/src/Platform/Alipay/PaymentClient.php +++ b/src/Platform/Alipay/PaymentClient.php @@ -1,12 +1,6 @@ - */ namespace We\Platform\Alipay; diff --git a/src/Platform/Alipay/PlatformClient.php b/src/Platform/Alipay/PlatformClient.php index 22becb0..1debb7c 100644 --- a/src/Platform/Alipay/PlatformClient.php +++ b/src/Platform/Alipay/PlatformClient.php @@ -1,12 +1,6 @@ - */ namespace We\Platform\Alipay; @@ -14,8 +8,10 @@ use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use We\Config\AlipayPlatformConfig; -use We\Exception\ApiException; -use We\Exception\WechatException; +use We\Exception\AlipayApiException; +use We\Exception\AlipayException; +use We\Exception\AlipaySignatureException; +use We\Exception\TransportException; use We\Support\CredentialValidator; /** @@ -51,23 +47,41 @@ class PlatformClient $response = $this->http->request('POST', $this->config->gateway, [ 'form_params' => $params, 'headers' => ['Accept' => 'application/json'], + 'http_errors' => false, ]); } catch (GuzzleException $e) { - throw new ApiException('支付宝网关请求失败: ' . $e->getMessage(), (int)$e->getCode(), $e); + throw new TransportException( + '支付宝网关请求失败: ' . $e->getMessage(), + (int)$e->getCode(), + $e, + ['platform' => 'alipay', 'method' => $apiMethod], + ); } $body = (string)$response->getBody(); $payload = json_decode($body, true); if (!is_array($payload)) { - throw new WechatException('支付宝网关响应格式无效'); + throw new AlipayApiException('支付宝网关响应格式无效', 0, null, ['body' => $body]); } $node = str_replace('.', '_', $apiMethod) . '_response'; - $responseNode = is_array($payload[$node] ?? null) ? $node : (is_array($payload['error_response'] ?? null) ? 'error_response' : $node); - if ($this->config->alipayPublicKey !== '') { - $this->assertResponseSignature($body, $responseNode, (string)($payload['sign'] ?? '')); + if (is_array($payload[$node] ?? null)) { + $responseNode = $node; + } elseif (is_array($payload['error_response'] ?? null)) { + $responseNode = 'error_response'; + } else { + throw new AlipayApiException('支付宝响应缺少节点: ' . $node, 0, null, $payload); } - $data = is_array($payload[$responseNode] ?? null) ? $payload[$responseNode] : $payload; - if (($data['code'] ?? '10000') !== '10000') { - throw new WechatException((string)($data['sub_msg'] ?? $data['msg'] ?? '支付宝接口调用失败')); + $this->assertResponseSignature($body, $responseNode, (string)($payload['sign'] ?? '')); + $data = $payload[$responseNode]; + if (!array_key_exists('code', $data) || !is_scalar($data['code'])) { + throw new AlipayApiException('支付宝响应缺少有效 code', 0, null, $data); + } + if ((string)$data['code'] !== '10000') { + throw new AlipayApiException( + (string)($data['sub_msg'] ?? $data['msg'] ?? '支付宝接口调用失败'), + (int)$data['code'], + null, + $data, + ); } return $data; @@ -95,7 +109,7 @@ class PlatformClient return false; } if ($this->config->alipayPublicKey === '') { - throw new WechatException('支付宝公钥不能为空'); + throw new AlipayException('支付宝公钥不能为空'); } return $this->verifySignature($this->buildSignContent($params, true), $sign); @@ -125,7 +139,7 @@ class PlatformClient $key = base64_decode($sessionKey, true); $ivValue = base64_decode($iv, true); if ($ciphertext === false || $key === false || $ivValue === false) { - throw new WechatException('支付宝数据解密参数 Base64 无效'); + throw new AlipayException('支付宝数据解密参数 Base64 无效'); } $plain = openssl_decrypt( $ciphertext, @@ -135,11 +149,11 @@ class PlatformClient $ivValue ); if (!is_string($plain) || $plain === '') { - throw new WechatException('支付宝数据解密失败'); + throw new AlipayException('支付宝数据解密失败'); } $data = json_decode($plain, true); if (!is_array($data)) { - throw new WechatException('支付宝解密结果无效'); + throw new AlipayException('支付宝解密结果无效'); } return $data; @@ -208,12 +222,12 @@ class PlatformClient $privateKey = $this->normalizePrivateKey($this->config->privateKey); $resource = openssl_pkey_get_private($privateKey); if ($resource === false) { - throw new WechatException('支付宝私钥无效'); + throw new AlipayException('支付宝私钥无效'); } $algo = strtoupper($this->config->signType) === 'RSA2' ? OPENSSL_ALGO_SHA256 : OPENSSL_ALGO_SHA1; $ok = openssl_sign($data, $signature, $resource, $algo); if ($ok !== true) { - throw new WechatException('支付宝签名失败'); + throw new AlipayException('支付宝签名失败'); } return base64_encode($signature); @@ -252,12 +266,12 @@ class PlatformClient private function assertResponseSignature(string $body, string $node, string $sign): void { if ($sign === '') { - throw new WechatException('支付宝响应缺少签名'); + throw new AlipaySignatureException('支付宝响应缺少签名'); } // 支付宝同步响应的验签原文是响应节点的原始 JSON 片段,不能使用 json_decode 后重新编码的数组。 if (!$this->verifySignature($this->extractJsonValue($body, $node), $sign)) { - throw new WechatException('支付宝响应验签失败'); + throw new AlipaySignatureException('支付宝响应验签失败'); } } @@ -306,7 +320,7 @@ class PlatformClient $publicKey = $this->normalizePublicKey($this->config->alipayPublicKey); $resource = openssl_pkey_get_public($publicKey); if ($resource === false) { - throw new WechatException('支付宝公钥无效'); + throw new AlipayException('支付宝公钥无效'); } $decoded = base64_decode($signature, true); if ($decoded === false) { @@ -339,7 +353,7 @@ class PlatformClient private function extractJsonValue(string $json, string $key): string { if (preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*/', $json, $match, PREG_OFFSET_CAPTURE) !== 1) { - throw new WechatException('支付宝响应缺少签名节点: ' . $key); + throw new AlipayApiException('支付宝响应缺少签名节点: ' . $key); } $start = (int)$match[0][1] + strlen((string)$match[0][0]); $length = strlen($json); @@ -349,7 +363,7 @@ class PlatformClient $first = $json[$start] ?? ''; if ($first !== '{' && $first !== '[') { - throw new WechatException('支付宝响应签名节点格式无效'); + throw new AlipayApiException('支付宝响应签名节点格式无效'); } $depth = 0; @@ -383,6 +397,6 @@ class PlatformClient } } - throw new WechatException('支付宝响应签名节点不完整'); + throw new AlipayApiException('支付宝响应签名节点不完整'); } } diff --git a/src/Platform/Wechat/PaymentClient.php b/src/Platform/Wechat/PaymentClient.php index 361d127..62cb625 100644 --- a/src/Platform/Wechat/PaymentClient.php +++ b/src/Platform/Wechat/PaymentClient.php @@ -1,21 +1,17 @@ - */ namespace We\Platform\Wechat; use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\GuzzleException; use Psr\Http\Message\ResponseInterface; use We\Config\WechatPaymentConfig; use We\Exception\ApiException; use We\Exception\SignatureException; +use We\Exception\TransportException; use We\Exception\WechatException; use We\Support\JsonClient; use We\Support\PaymentCrypto; @@ -30,6 +26,8 @@ final class PaymentClient { private JsonClient $http; + private ClientInterface $downloadHttp; + /** * 创建微信支付 APIv3 客户端并初始化商户平台 API HTTP 客户端。 */ @@ -37,7 +35,8 @@ final class PaymentClient private readonly WechatPaymentConfig $config, ?ClientInterface $http = null, ) { - $this->http = new JsonClient($http ?? new Client(['base_uri' => 'https://api.mch.weixin.qq.com/', 'timeout' => 20.0])); + $this->downloadHttp = $http ?? new Client(['base_uri' => 'https://api.mch.weixin.qq.com/', 'timeout' => 20.0]); + $this->http = new JsonClient($this->downloadHttp); } /** @@ -53,6 +52,7 @@ final class PaymentClient $response = $this->raw($method, $uri, $payload, $query, $options); $statusCode = (int)$response->getStatusCode(); $body = (string)$response->getBody(); + $this->assertPlatformSignature($response->getHeaders(), $body, '响应'); $data = $body === '' ? [] : json_decode($body, true); if (!is_array($data)) { throw new ApiException('微信支付接口响应不是有效 JSON', $statusCode, null, ['body' => $body]); @@ -97,6 +97,44 @@ final class PaymentClient return $this->raw('GET', $uri, [], $query, $options); } + /** + * 先请求并验签微信支付账单下载地址,再下载二进制账单文件。 + * + * @param array $query + * @param array $downloadOptions + */ + public function downloadBill(string $uri, array $query = [], array $downloadOptions = []): ResponseInterface + { + $metadata = $this->request('GET', $uri, [], $query); + $downloadUrl = $metadata['download_url'] ?? null; + if (!is_string($downloadUrl) || !$this->isSafeDownloadUrl($downloadUrl)) { + throw new WechatException('微信支付账单 download_url 必须是有效 HTTPS 地址'); + } + $downloadOptions['http_errors'] = $downloadOptions['http_errors'] ?? false; + $downloadOptions['allow_redirects'] = false; + try { + $response = $this->downloadHttp->request('GET', $downloadUrl, $downloadOptions); + } catch (GuzzleException $exception) { + throw new TransportException( + '微信支付账单下载失败: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception, + ['platform' => 'wechat.payment', 'url' => $downloadUrl], + ); + } + $statusCode = (int)$response->getStatusCode(); + if ($statusCode >= 300) { + throw new ApiException( + '微信支付账单下载失败', + $statusCode, + null, + ['body' => (string)$response->getBody(), 'url' => $downloadUrl], + ); + } + + return $response; + } + /** * 通用 API 调用入口;官方接口使用 API path + 参数数组,特殊路径仅用于支付通知验签与解密。 * @@ -160,7 +198,8 @@ final class PaymentClient { // 微信支付 APIv3 签名串要求使用 HTTP 原始 body;不能先 json_decode 再重新编码,否则字段顺序或转义差异会导致验签失败。 $rawBody = is_string($body) ? $body : $this->encodeJsonBody($body); - $this->assertNotificationSignature($headers, $rawBody); + $this->assertPlatformSignature($headers, $rawBody, '回调'); + $this->assertNotificationFreshness($headers); $payload = is_string($body) ? json_decode($body, true) : $body; if (!is_array($payload)) { throw new WechatException('微信支付回调 JSON 无效'); @@ -197,17 +236,17 @@ final class PaymentClient * * @param array $headers */ - private function assertNotificationSignature(array $headers, string $body): void + private function assertPlatformSignature(array $headers, string $body, string $source): void { $timestamp = $this->headerValue($headers, 'Wechatpay-Timestamp'); $nonce = $this->headerValue($headers, 'Wechatpay-Nonce'); $signature = $this->headerValue($headers, 'Wechatpay-Signature'); $serial = $this->headerValue($headers, 'Wechatpay-Serial'); if ($timestamp === '' || $nonce === '' || $signature === '' || $serial === '') { - throw new SignatureException('微信支付回调验签请求头不完整'); + throw new SignatureException('微信支付' . $source . '验签请求头不完整'); } if ($this->config->platformSerial !== '' && !hash_equals($this->config->platformSerial, $serial)) { - throw new SignatureException('微信支付平台序列号不匹配'); + throw new SignatureException('微信支付' . $source . '平台序列号不匹配'); } $message = "{$timestamp}\n{$nonce}\n{$body}\n"; $publicKey = $this->config->platformPublicKey !== '' ? $this->config->platformPublicKey : $this->config->platformCertificate; @@ -215,7 +254,12 @@ final class PaymentClient throw new SignatureException('微信支付平台公钥或证书不能为空'); } if (!Signature::verifyPaymentV3($publicKey, $message, $signature)) { - throw new SignatureException('微信支付回调验签失败'); + throw new SignatureException( + '微信支付' . $source . '验签失败', + 0, + null, + ['serial' => $serial, 'timestamp' => $timestamp], + ); } } @@ -235,6 +279,26 @@ final class PaymentClient return ''; } + /** + * @param array $headers + */ + private function assertNotificationFreshness(array $headers): void + { + $tolerance = $this->config->notificationToleranceSeconds; + if ($tolerance === 0) { + return; + } + $timestamp = $this->headerValue($headers, 'Wechatpay-Timestamp'); + if (preg_match('/^\d+$/', $timestamp) !== 1 || abs(time() - (int)$timestamp) > $tolerance) { + throw new SignatureException( + '微信支付回调时间戳已过期或超出允许偏差', + 0, + null, + ['timestamp' => $timestamp, 'tolerance' => $tolerance], + ); + } + } + /** * 合并显式 query 与 Guzzle options 中的 query,确保签名串与实际请求一致。 * @@ -349,4 +413,19 @@ final class PaymentClient return $headers; } + + private function isSafeDownloadUrl(string $url): bool + { + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + return false; + } + $parts = parse_url($url); + + return is_array($parts) + && strtolower((string)($parts['scheme'] ?? '')) === 'https' + && is_string($parts['host'] ?? null) + && $parts['host'] !== '' + && !isset($parts['user']) + && !isset($parts['pass']); + } } diff --git a/src/Platform/Wechat/PlatformClient.php b/src/Platform/Wechat/PlatformClient.php index f472d11..7353446 100644 --- a/src/Platform/Wechat/PlatformClient.php +++ b/src/Platform/Wechat/PlatformClient.php @@ -1,12 +1,6 @@ - */ namespace We\Platform\Wechat; diff --git a/src/Platform/Wechat/ServiceClient.php b/src/Platform/Wechat/ServiceClient.php index e4827c1..7b537a0 100644 --- a/src/Platform/Wechat/ServiceClient.php +++ b/src/Platform/Wechat/ServiceClient.php @@ -1,12 +1,6 @@ - */ namespace We\Platform\Wechat; diff --git a/src/Platform/Wechat/WxappClient.php b/src/Platform/Wechat/WxappClient.php index f6d8b56..a1a1186 100644 --- a/src/Platform/Wechat/WxappClient.php +++ b/src/Platform/Wechat/WxappClient.php @@ -1,12 +1,6 @@ - */ namespace We\Platform\Wechat; diff --git a/src/Support/CacheKey.php b/src/Support/CacheKey.php index e2382d8..bfbdf08 100644 --- a/src/Support/CacheKey.php +++ b/src/Support/CacheKey.php @@ -1,19 +1,13 @@ - */ namespace We\Support; -use We\Exception\WechatException; +use We\Exception\SdkException; /** - * SDK 缓存完整键生成器,固定为 `{cacheKeyPrefix}:{platformChannel}:{logicalKey}` 三段,便于按项目、通道和逻辑键隔离。 + * SDK 缓存完整键生成器,将通用前缀、平台通道和逻辑键编码为 PSR-16 安全的三段键。 */ final class CacheKey { @@ -30,16 +24,16 @@ final class CacheKey $channel = self::normalizeSegment($channel); $logical = trim($logicalKey); if ($prefix === '') { - throw new WechatException('缓存键通用前缀不能为空'); + throw new SdkException('缓存键通用前缀不能为空'); } if ($channel === '') { - throw new WechatException('缓存键通道段不能为空'); + throw new SdkException('缓存键通道段不能为空'); } if ($logical === '') { - throw new WechatException('缓存键逻辑段不能为空'); + throw new SdkException('缓存键逻辑段不能为空'); } - return $prefix . ':' . $channel . ':' . $logical; + return implode('.', array_map('rawurlencode', [$prefix, $channel, $logical])); } /** diff --git a/src/Support/CredentialValidator.php b/src/Support/CredentialValidator.php index d100ead..b8024a9 100644 --- a/src/Support/CredentialValidator.php +++ b/src/Support/CredentialValidator.php @@ -1,15 +1,10 @@ - */ namespace We\Support; +use We\Exception\SdkException; use We\Exception\WechatException; /** @@ -53,24 +48,38 @@ final class CredentialValidator /** * 校验 RSA 私钥。 + * + * @param class-string $exceptionClass */ - public static function assertPrivateKey(string $privateKey, string $field, bool $wrapRawKey = false): void - { - $resource = openssl_pkey_get_private(self::normalizePrivateKey($privateKey, $wrapRawKey)); - if ($resource === false) { - throw new WechatException($field . ' 格式无效'); - } + public static function assertPrivateKey( + string $privateKey, + string $field, + bool $wrapRawKey = false, + string $exceptionClass = WechatException::class, + ): void { + self::assertRsa( + openssl_pkey_get_private(self::normalizePrivateKey($privateKey, $wrapRawKey)), + $field, + $exceptionClass, + ); } /** * 校验 RSA 公钥或证书。 + * + * @param class-string $exceptionClass */ - public static function assertPublicKey(string $publicKey, string $field, bool $wrapRawKey = false): void - { - $resource = openssl_pkey_get_public(self::normalizePublicKey($publicKey, $wrapRawKey)); - if ($resource === false) { - throw new WechatException($field . ' 格式无效'); - } + public static function assertPublicKey( + string $publicKey, + string $field, + bool $wrapRawKey = false, + string $exceptionClass = WechatException::class, + ): void { + self::assertRsa( + openssl_pkey_get_public(self::normalizePublicKey($publicKey, $wrapRawKey)), + $field, + $exceptionClass, + ); } /** @@ -86,7 +95,14 @@ final class CredentialValidator return $key; } - return "-----BEGIN PRIVATE KEY-----\n" . chunk_split($key, 64, "\n") . '-----END PRIVATE KEY-----'; + foreach (['PRIVATE KEY', 'RSA PRIVATE KEY'] as $label) { + $candidate = self::wrapPem($key, $label); + if (openssl_pkey_get_private($candidate) !== false) { + return $candidate; + } + } + + return self::wrapPem($key, 'PRIVATE KEY'); } /** @@ -102,6 +118,26 @@ final class CredentialValidator return $key; } - return "-----BEGIN PUBLIC KEY-----\n" . chunk_split($key, 64, "\n") . '-----END PUBLIC KEY-----'; + return self::wrapPem($key, 'PUBLIC KEY'); + } + + private static function wrapPem(string $body, string $label): string + { + return "-----BEGIN {$label}-----\n" . chunk_split($body, 64, "\n") . "-----END {$label}-----"; + } + + /** + * @param false|\OpenSSLAsymmetricKey|resource $resource + * @param class-string $exceptionClass + */ + private static function assertRsa(mixed $resource, string $field, string $exceptionClass): void + { + if ($resource === false) { + throw new $exceptionClass($field . ' 格式无效'); + } + $details = openssl_pkey_get_details($resource); + if (!is_array($details) || ($details['type'] ?? null) !== OPENSSL_KEYTYPE_RSA) { + throw new $exceptionClass($field . ' 必须是 RSA 密钥或证书'); + } } } diff --git a/src/Support/FileCacheStore.php b/src/Support/FileCacheStore.php index 578e1f5..f2be303 100644 --- a/src/Support/FileCacheStore.php +++ b/src/Support/FileCacheStore.php @@ -1,17 +1,11 @@ - */ namespace We\Support; use We\Contract\StoreCacheInterface; -use We\Exception\WechatException; +use We\Exception\SdkException; /** * 本地文件缓存实现:缓存值以 JSON 保存,并使用 flock 提供单机多进程刷新锁。 @@ -24,13 +18,13 @@ final class FileCacheStore implements StoreCacheInterface public function __construct(private readonly string $directory) { if ($this->directory === '') { - throw new WechatException('FileCacheStore 目录不能为空'); + throw new SdkException('FileCacheStore 目录不能为空'); } if (!is_dir($this->directory) && @mkdir($this->directory, 0775, true) !== true) { - throw new WechatException('FileCacheStore 无法创建目录: ' . $this->directory); + throw new SdkException('FileCacheStore 无法创建目录: ' . $this->directory); } if (!is_writable($this->directory)) { - throw new WechatException('FileCacheStore 目录不可写: ' . $this->directory); + throw new SdkException('FileCacheStore 目录不可写: ' . $this->directory); } } @@ -68,8 +62,6 @@ final class FileCacheStore implements StoreCacheInterface return $default; } if ((int)$payload['expires_at'] <= time()) { - $this->del($key); - return $default; } @@ -84,23 +76,32 @@ final class FileCacheStore implements StoreCacheInterface $path = $this->pathFor($key); $dir = dirname($path); if (!is_dir($dir) && @mkdir($dir, 0775, true) !== true) { - throw new WechatException('FileCacheStore 无法创建子目录: ' . $dir); + throw new SdkException('FileCacheStore 无法创建子目录: ' . $dir); } - $body = json_encode( - ['expires_at' => time() + max(1, $ttl), 'value' => $value], - JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, - ); + try { + $body = json_encode( + ['expires_at' => time() + max(1, $ttl), 'value' => $value], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + ); + } catch (\JsonException $exception) { + throw new SdkException( + 'FileCacheStore 缓存值无法 JSON 编码', + 0, + $exception, + ['key' => $key], + ); + } $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; if (@file_put_contents($tmp, $body, LOCK_EX) === false) { @unlink($tmp); - throw new WechatException('FileCacheStore 写入失败: ' . $tmp); + throw new SdkException('FileCacheStore 写入失败: ' . $tmp); } if (!@rename($tmp, $path)) { @unlink($path); if (!@rename($tmp, $path)) { @unlink($tmp); - throw new WechatException('FileCacheStore 提交失败: ' . $path); + throw new SdkException('FileCacheStore 提交失败: ' . $path); } } } @@ -124,17 +125,17 @@ final class FileCacheStore implements StoreCacheInterface $path = $this->pathFor('lock:' . $key) . '.lock'; $dir = dirname($path); if (!is_dir($dir) && @mkdir($dir, 0775, true) !== true) { - throw new WechatException('FileCacheStore 无法创建锁目录: ' . $dir); + throw new SdkException('FileCacheStore 无法创建锁目录: ' . $dir); } $handle = @fopen($path, 'c'); if ($handle === false) { - throw new WechatException('FileCacheStore 无法创建锁文件: ' . $path); + throw new SdkException('FileCacheStore 无法创建锁文件: ' . $path); } try { if (!flock($handle, LOCK_EX)) { - throw new WechatException('FileCacheStore 获取锁失败: ' . $key); + throw new SdkException('FileCacheStore 获取锁失败: ' . $key); } return $callback(); diff --git a/src/Support/JsonClient.php b/src/Support/JsonClient.php index b5d4535..f4f581c 100644 --- a/src/Support/JsonClient.php +++ b/src/Support/JsonClient.php @@ -1,12 +1,6 @@ - */ namespace We\Support; @@ -15,6 +9,7 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use Psr\Http\Message\ResponseInterface; use We\Exception\ApiException; +use We\Exception\TransportException; /** * JSON HTTP 客户端封装。 @@ -82,7 +77,12 @@ final class JsonClient try { return $this->http->request($method, $uri, $options); } catch (GuzzleException $exception) { - throw new ApiException($exception->getMessage(), (int)$exception->getCode(), $exception); + throw new TransportException( + $exception->getMessage(), + (int)$exception->getCode(), + $exception, + ['platform' => 'wechat', 'method' => $method, 'uri' => $uri], + ); } } diff --git a/src/Support/MessageCrypto.php b/src/Support/MessageCrypto.php index 69f422e..f013278 100644 --- a/src/Support/MessageCrypto.php +++ b/src/Support/MessageCrypto.php @@ -1,12 +1,6 @@ - */ namespace We\Support; diff --git a/src/Support/NullCacheStore.php b/src/Support/NullCacheStore.php index 3698ec9..8b889fa 100644 --- a/src/Support/NullCacheStore.php +++ b/src/Support/NullCacheStore.php @@ -1,12 +1,6 @@ - */ namespace We\Support; diff --git a/src/Support/PaymentCrypto.php b/src/Support/PaymentCrypto.php index cba11cb..0353113 100644 --- a/src/Support/PaymentCrypto.php +++ b/src/Support/PaymentCrypto.php @@ -1,12 +1,6 @@ - */ namespace We\Support; diff --git a/src/Support/PsrSimpleCacheStore.php b/src/Support/PsrSimpleCacheStore.php index 811986b..2e2e2af 100644 --- a/src/Support/PsrSimpleCacheStore.php +++ b/src/Support/PsrSimpleCacheStore.php @@ -1,18 +1,13 @@ - */ namespace We\Support; +use Psr\SimpleCache\CacheException; use Psr\SimpleCache\CacheInterface; use We\Contract\StoreCacheInterface; -use We\Exception\WechatException; +use We\Exception\SdkException; /** * PSR-16 缓存适配器;缓存能力委托给 PSR Simple Cache,实现侧需注入原子锁回调以支持刷新锁。 @@ -34,7 +29,7 @@ final class PsrSimpleCacheStore implements StoreCacheInterface */ public function get(string $key, mixed $default = null): mixed { - return $this->cache->get($key, $default); + return $this->execute('get', $key, fn (): mixed => $this->cache->get($key, $default)); } /** @@ -42,7 +37,10 @@ final class PsrSimpleCacheStore implements StoreCacheInterface */ public function set(string $key, mixed $value, int $ttl): void { - $this->cache->set($key, $value, max(1, $ttl)); + $success = $this->execute('set', $key, fn (): bool => $this->cache->set($key, $value, max(1, $ttl))); + if ($success !== true) { + throw new SdkException('PsrSimpleCacheStore 写入失败', 0, null, ['key' => $key, 'operation' => 'set']); + } } /** @@ -50,7 +48,10 @@ final class PsrSimpleCacheStore implements StoreCacheInterface */ public function del(string $key): void { - $this->cache->delete($key); + $success = $this->execute('delete', $key, fn (): bool => $this->cache->delete($key)); + if ($success !== true) { + throw new SdkException('PsrSimpleCacheStore 删除失败', 0, null, ['key' => $key, 'operation' => 'delete']); + } } /** @@ -59,9 +60,23 @@ final class PsrSimpleCacheStore implements StoreCacheInterface public function lock(string $key, int $ttl, callable $callback): mixed { if (!is_callable($this->locker)) { - throw new WechatException('PsrSimpleCacheStore 未配置锁能力'); + throw new SdkException('PsrSimpleCacheStore 未配置锁能力'); } return ($this->locker)($key, max(1, $ttl), $callback); } + + private function execute(string $operation, string $key, callable $callback): mixed + { + try { + return $callback(); + } catch (CacheException $exception) { + throw new SdkException( + 'PsrSimpleCacheStore 后端操作失败', + 0, + $exception, + ['key' => $key, 'operation' => $operation], + ); + } + } } diff --git a/src/Support/Signature.php b/src/Support/Signature.php index fe5a67a..62be925 100644 --- a/src/Support/Signature.php +++ b/src/Support/Signature.php @@ -1,12 +1,6 @@ - */ namespace We\Support; diff --git a/src/Support/TokenCacheKey.php b/src/Support/TokenCacheKey.php index b8136bf..801e936 100644 --- a/src/Support/TokenCacheKey.php +++ b/src/Support/TokenCacheKey.php @@ -1,12 +1,6 @@ - */ namespace We\Support; diff --git a/src/Support/Xml.php b/src/Support/Xml.php index 65d6d29..7478c93 100644 --- a/src/Support/Xml.php +++ b/src/Support/Xml.php @@ -1,12 +1,6 @@ - */ namespace We\Support; diff --git a/tests/AccessTokenCacheTest.php b/tests/AccessTokenCacheTest.php index 930777b..19c05b8 100644 --- a/tests/AccessTokenCacheTest.php +++ b/tests/AccessTokenCacheTest.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; diff --git a/tests/AlipayPlatformClientTest.php b/tests/AlipayPlatformClientTest.php index 519f6d8..49c5301 100644 --- a/tests/AlipayPlatformClientTest.php +++ b/tests/AlipayPlatformClientTest.php @@ -1,24 +1,26 @@ - */ namespace We\Tests; +use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use We\Config\AlipayPlatformConfig; +use We\Exception\AlipayApiException; +use We\Exception\AlipaySignatureException; +use We\Exception\TransportException; use We\Platform\Alipay\PlatformClient as AlipayPlatformClient; /** @@ -33,11 +35,11 @@ final class AlipayPlatformClientTest extends TestCase */ public function testRequestVerifiesSignedResponseWhenPublicKeyConfigured(): void { - [$privateKey, $publicKey] = self::keyPair(); + [$alipayPrivateKey, $alipayPublicKey] = TestKeys::platformKeyPair(); $responseNode = '{"code":"10000","msg":"Success","trade_no":"TRADE202605040001"}'; - $body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $privateKey) . '"}'; + $body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $alipayPrivateKey) . '"}'; $client = new AlipayPlatformClient( - new AlipayPlatformConfig('ali_app', $privateKey, $publicKey), + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), new AlipayFakeHttpClient($body), ); @@ -51,7 +53,7 @@ final class AlipayPlatformClientTest extends TestCase */ public function testVerifyNotify(): void { - [$privateKey, $publicKey] = self::keyPair(); + [$alipayPrivateKey, $alipayPublicKey] = TestKeys::platformKeyPair(); $params = [ 'notify_time' => '2026-05-04 12:00:00', 'app_id' => 'ali_app', @@ -59,26 +61,135 @@ final class AlipayPlatformClientTest extends TestCase 'out_trade_no' => 'P202605040001', 'sign_type' => 'RSA2', ]; - $params['sign'] = self::sign('app_id=ali_app¬ify_time=2026-05-04 12:00:00&out_trade_no=P202605040001&trade_status=TRADE_SUCCESS', $privateKey); - $client = new AlipayPlatformClient(new AlipayPlatformConfig('ali_app', $privateKey, $publicKey)); + $params['sign'] = self::sign('app_id=ali_app¬ify_time=2026-05-04 12:00:00&out_trade_no=P202605040001&trade_status=TRADE_SUCCESS', $alipayPrivateKey); + $client = new AlipayPlatformClient(new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey)); self::assertTrue($client->verifyNotify($params)); } - /** - * 生成测试使用的 RSA 密钥对。 - * - * @return array{0:string,1:string} - */ - private static function keyPair(): array + public function testRequestRejectsSignedResponseWithoutBusinessCode(): void { - $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); - self::assertNotFalse($resource); - openssl_pkey_export($resource, $privateKey); - $details = openssl_pkey_get_details($resource); - self::assertIsArray($details); + [$alipayPrivateKey, $alipayPublicKey] = TestKeys::platformKeyPair(); + $responseNode = '{"msg":"Success","trade_no":"TRADE_WITHOUT_CODE"}'; + $body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $alipayPrivateKey) . '"}'; + $client = new AlipayPlatformClient( + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), + new AlipayFakeHttpClient($body), + ); - return [$privateKey, (string)$details['key']]; + $this->expectException(AlipayApiException::class); + $this->expectExceptionMessage('code'); + + $client->request('alipay.trade.query'); + } + + public function testRequestExposesSignedPlatformErrorContext(): void + { + [$alipayPrivateKey, $alipayPublicKey] = TestKeys::platformKeyPair(); + $responseNode = '{"code":"40004","msg":"Business Failed","sub_code":"ACQ.TRADE_NOT_EXIST","sub_msg":"交易不存在"}'; + $body = '{"error_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $alipayPrivateKey) . '"}'; + $client = new AlipayPlatformClient( + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), + new AlipayFakeHttpClient($body), + ); + + try { + $client->request('alipay.trade.query'); + self::fail('Expected a signed Alipay platform error.'); + } catch (AlipayApiException $exception) { + self::assertSame(40004, $exception->getCode()); + self::assertSame('交易不存在', $exception->getMessage()); + self::assertSame('ACQ.TRADE_NOT_EXIST', $exception->context()['sub_code']); + } + } + + public function testRequestRejectsInvalidPlatformSignatureWithDistinctType(): void + { + [, $alipayPublicKey] = TestKeys::platformKeyPair(); + $responseNode = '{"code":"10000","msg":"Success"}'; + $body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' + . base64_encode('invalid-signature') . '"}'; + $client = new AlipayPlatformClient( + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), + new AlipayFakeHttpClient($body), + ); + + $this->expectException(AlipaySignatureException::class); + + $client->request('alipay.trade.query'); + } + + public function testRequestRejectsMissingResponseNode(): void + { + [, $alipayPublicKey] = TestKeys::platformKeyPair(); + $client = new AlipayPlatformClient( + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), + new AlipayFakeHttpClient('{}'), + ); + + $this->expectException(AlipayApiException::class); + $this->expectExceptionMessage('节点'); + + $client->request('alipay.trade.query'); + } + + public function testRequestRejectsNonJsonResponse(): void + { + [, $alipayPublicKey] = TestKeys::platformKeyPair(); + $client = new AlipayPlatformClient( + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), + new AlipayFakeHttpClient('not-json'), + ); + + $this->expectException(AlipayApiException::class); + $this->expectExceptionMessage('格式'); + + $client->request('alipay.trade.query'); + } + + public function testRequestExposesTransportFailureType(): void + { + [, $alipayPublicKey] = TestKeys::platformKeyPair(); + $failure = new ConnectException( + 'connection failed', + new Request('POST', 'https://openapi.alipay.com/gateway.do'), + ); + $http = new Client([ + 'handler' => HandlerStack::create(new MockHandler([$failure])), + ]); + $client = new AlipayPlatformClient( + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), + $http, + ); + + $this->expectException(TransportException::class); + + $client->request('alipay.trade.query'); + } + + public function testSignedHttpErrorRemainsAPlatformApiFailure(): void + { + [$alipayPrivateKey, $alipayPublicKey] = TestKeys::platformKeyPair(); + $responseNode = '{"code":"40004","msg":"Business Failed","sub_msg":"交易不存在"}'; + $body = '{"error_response":' . $responseNode . ',"sign":"' + . self::sign($responseNode, $alipayPrivateKey) . '"}'; + $http = new Client([ + 'handler' => HandlerStack::create(new MockHandler([ + new Response(400, ['Content-Type' => 'application/json'], $body), + ])), + ]); + $client = new AlipayPlatformClient( + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), $alipayPublicKey), + $http, + ); + + try { + $client->request('alipay.trade.query'); + self::fail('Expected a signed Alipay platform error.'); + } catch (AlipayApiException $exception) { + self::assertSame(40004, $exception->getCode()); + self::assertSame('交易不存在', $exception->getMessage()); + } } /** diff --git a/tests/CacheKeyTest.php b/tests/CacheKeyTest.php index ecddf45..dfa65f0 100644 --- a/tests/CacheKeyTest.php +++ b/tests/CacheKeyTest.php @@ -1,18 +1,12 @@ - */ namespace We\Tests; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use We\Exception\WechatException; +use We\Exception\SdkException; use We\Support\CacheKey; use We\Support\TokenCacheKey; @@ -30,7 +24,8 @@ final class CacheKeyTest extends TestCase { $logical = TokenCacheKey::wechatPlatformAccessToken('wx_demo', ''); $full = CacheKey::compose('myapp', 'wechat.platform', $logical); - $this->assertSame('myapp:wechat.platform:' . $logical, $full); + $this->assertSame('myapp.wechat.platform.' . rawurlencode($logical), $full); + $this->assertDoesNotMatchRegularExpression('/[{}()\/\\\@:]/', $full); } /** @@ -39,7 +34,10 @@ final class CacheKeyTest extends TestCase public function testComposeTrimsColonNoiseOnSegments(): void { $logical = TokenCacheKey::wechatServiceComponentAccessToken('wx_service'); - $this->assertSame('ns:wechat.service:' . $logical, CacheKey::compose('::ns::', ':::wechat.service::', $logical)); + $this->assertSame( + 'ns.wechat.service.' . rawurlencode($logical), + CacheKey::compose('::ns::', ':::wechat.service::', $logical), + ); } /** @@ -70,7 +68,7 @@ final class CacheKeyTest extends TestCase */ public function testComposeThrowsWhenPrefixEmpty(): void { - $this->expectException(WechatException::class); + $this->expectException(SdkException::class); $this->expectExceptionMessage('通用前缀'); CacheKey::compose('', 'wechat.wxapp', 'wechat:app:x:wxapp:access_token'); } @@ -80,7 +78,7 @@ final class CacheKeyTest extends TestCase */ public function testComposeThrowsWhenChannelEmpty(): void { - $this->expectException(WechatException::class); + $this->expectException(SdkException::class); $this->expectExceptionMessage('通道段'); CacheKey::compose('app', '', 'wechat:app:x:wxapp:access_token'); } @@ -90,7 +88,7 @@ final class CacheKeyTest extends TestCase */ public function testComposeThrowsWhenLogicalEmpty(): void { - $this->expectException(WechatException::class); + $this->expectException(SdkException::class); $this->expectExceptionMessage('逻辑段'); CacheKey::compose('app', 'wechat.platform', ' '); } diff --git a/tests/CacheStoreTest.php b/tests/CacheStoreTest.php index a84c9db..9168a6e 100644 --- a/tests/CacheStoreTest.php +++ b/tests/CacheStoreTest.php @@ -1,22 +1,19 @@ - */ namespace We\Tests; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Psr\SimpleCache\CacheException; use Psr\SimpleCache\CacheInterface; -use We\Exception\WechatException; +use We\Exception\SdkException; +use We\Support\CacheKey; use We\Support\FileCacheStore; use We\Support\NullCacheStore; use We\Support\PsrSimpleCacheStore; +use We\Support\TokenCacheKey; /** * SDK 缓存存储实现测试用例。 @@ -62,6 +59,72 @@ final class CacheStoreTest extends TestCase $this->removeDir($dir); } + public function testFileCacheStoreWrapsJsonEncodingFailureAsSdkException(): void + { + $dir = $this->tempDir(); + $store = new FileCacheStore($dir); + $recursive = []; + $recursive['self'] = &$recursive; + + try { + $store->set('recursive', $recursive, 60); + self::fail('Expected the recursive value to be rejected.'); + } catch (SdkException $exception) { + self::assertInstanceOf(\JsonException::class, $exception->getPrevious()); + } finally { + $this->removeDir($dir); + } + } + + public function testExpiredReadCannotDeleteConcurrentFreshWrite(): void + { + if (!function_exists('pcntl_fork') || !function_exists('stream_socket_pair')) { + self::markTestSkipped('This concurrency regression requires pcntl and local sockets.'); + } + $dir = $this->tempDir(); + $store = new FileCacheStore($dir); + $store->set('race-key', str_repeat('x', 16 * 1024 * 1024), 1); + sleep(2); + $files = iterator_to_array(new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + )); + $cacheFile = array_values(array_filter( + $files, + static fn (\SplFileInfo $file): bool => $file->isFile() && str_ends_with($file->getFilename(), '.json'), + ))[0] ?? null; + self::assertInstanceOf(\SplFileInfo::class, $cacheFile); + $lock = fopen($cacheFile->getPathname(), 'rb'); + self::assertIsResource($lock); + self::assertTrue(flock($lock, LOCK_EX)); + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + self::assertIsArray($sockets); + + $pid = pcntl_fork(); + self::assertGreaterThanOrEqual(0, $pid); + if ($pid === 0) { + fclose($sockets[0]); + fwrite($sockets[1], 'ready'); + $store->get('race-key', 'expired'); + fclose($sockets[1]); + exit(0); + } + + fclose($sockets[1]); + self::assertSame('ready', fread($sockets[0], 5)); + flock($lock, LOCK_UN); + usleep(1000); + self::assertTrue(flock($lock, LOCK_EX)); + $store->set('race-key', 'fresh', 3600); + flock($lock, LOCK_UN); + fclose($lock); + fclose($sockets[0]); + pcntl_waitpid($pid, $status); + + self::assertSame(0, pcntl_wexitstatus($status)); + self::assertSame('fresh', $store->get('race-key', 'missing')); + $this->removeDir($dir); + } + /** * 测试空缓存锁会直接执行回调。 */ @@ -79,7 +142,7 @@ final class CacheStoreTest extends TestCase { $store = new PsrSimpleCacheStore(new ArraySimpleCache()); - $this->expectException(WechatException::class); + $this->expectException(SdkException::class); $this->expectExceptionMessage('锁能力'); $store->lock('k', 10, static fn (): string => 'never'); @@ -101,6 +164,67 @@ final class CacheStoreTest extends TestCase $this->assertSame([['k', 10]], $calls); } + public function testPsrSimpleCacheStoreAcceptsGeneratedTokenKey(): void + { + $cache = new ArraySimpleCache(); + $store = new PsrSimpleCacheStore($cache); + $key = CacheKey::compose( + 'tenant@example', + 'wechat.platform', + TokenCacheKey::wechatPlatformAccessToken('wx/app:1'), + ); + + $store->set($key, 'token', 3600); + + self::assertSame('token', $store->get($key)); + self::assertDoesNotMatchRegularExpression('/[{}()\/\\\@:]/', $key); + } + + public function testPsrSimpleCacheStoreWrapsBackendFailure(): void + { + $failure = new TestCacheException('backend unavailable'); + $store = new PsrSimpleCacheStore(new ArraySimpleCache(failure: $failure)); + + try { + $store->get('token'); + self::fail('Expected the backend failure to be wrapped.'); + } catch (SdkException $exception) { + self::assertSame($failure, $exception->getPrevious()); + self::assertSame('get', $exception->context()['operation']); + } + } + + public function testPsrSimpleCacheStoreDoesNotMaskProgrammingErrors(): void + { + $store = new PsrSimpleCacheStore(new ArraySimpleCache( + failure: new \TypeError('backend programming error'), + )); + + $this->expectException(\TypeError::class); + + $store->get('token'); + } + + public function testPsrSimpleCacheStoreRejectsUnsuccessfulWrite(): void + { + $store = new PsrSimpleCacheStore(new ArraySimpleCache(writeResult: false)); + + $this->expectException(SdkException::class); + $this->expectExceptionMessage('写入失败'); + + $store->set('token', 'value', 60); + } + + public function testPsrSimpleCacheStoreRejectsUnsuccessfulDelete(): void + { + $store = new PsrSimpleCacheStore(new ArraySimpleCache(deleteResult: false)); + + $this->expectException(SdkException::class); + $this->expectExceptionMessage('删除失败'); + + $store->del('token'); + } + /** * 创建本次测试使用的临时目录路径。 */ @@ -137,11 +261,20 @@ final class ArraySimpleCache implements CacheInterface /** @var array */ private array $values = []; + public function __construct( + private readonly ?\Throwable $failure = null, + private readonly bool $writeResult = true, + private readonly bool $deleteResult = true, + ) {} + /** * 读取测试缓存值。 */ public function get(string $key, mixed $default = null): mixed { + $this->failWhenConfigured(); + $this->assertValidKey($key); + return $this->values[$key] ?? $default; } @@ -150,6 +283,11 @@ final class ArraySimpleCache implements CacheInterface */ public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool { + $this->failWhenConfigured(); + $this->assertValidKey($key); + if (!$this->writeResult) { + return false; + } $this->values[$key] = $value; return true; @@ -160,6 +298,11 @@ final class ArraySimpleCache implements CacheInterface */ public function delete(string $key): bool { + $this->failWhenConfigured(); + $this->assertValidKey($key); + if (!$this->deleteResult) { + return false; + } unset($this->values[$key]); return true; @@ -214,6 +357,24 @@ final class ArraySimpleCache implements CacheInterface */ public function has(string $key): bool { + $this->assertValidKey($key); + return array_key_exists($key, $this->values); } + + private function assertValidKey(string $key): void + { + if ($key === '' || preg_match('/[{}()\/\\\@:]/', $key) === 1) { + throw new \InvalidArgumentException('Invalid PSR-16 key: ' . $key); + } + } + + private function failWhenConfigured(): void + { + if ($this->failure !== null) { + throw $this->failure; + } + } } + +final class TestCacheException extends \RuntimeException implements CacheException {} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 9f4dfda..204b31f 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; @@ -16,10 +10,13 @@ use We\Client; use We\Config\AlipayPlatformConfig; use We\Config\WechatPlatformConfig; use We\Config\WechatServiceConfig; -use We\Exception\WechatException; +use We\Exception\SdkException; +use We\Platform\Alipay\PaymentClient as AlipayPaymentClient; use We\Platform\Alipay\PlatformClient as AlipayPlatformClient; +use We\Platform\Wechat\PaymentClient as WechatPaymentClient; use We\Platform\Wechat\PlatformClient as WechatPlatformClient; use We\Platform\Wechat\ServiceClient as WechatServiceClient; +use We\Platform\Wechat\WxappClient as WechatWxappClient; /** * SDK 根入口通道工厂测试用例。 @@ -28,12 +25,31 @@ use We\Platform\Wechat\ServiceClient as WechatServiceClient; #[CoversClass(Client::class)] final class ClientTest extends TestCase { + public function testPlatformFactoriesExposeConcreteReturnTypes(): void + { + $factories = [ + 'wechatPlatform' => WechatPlatformClient::class, + 'wechatWxapp' => WechatWxappClient::class, + 'wechatService' => WechatServiceClient::class, + 'wechatPayment' => WechatPaymentClient::class, + 'alipayPlatform' => AlipayPlatformClient::class, + 'alipayPayment' => AlipayPaymentClient::class, + ]; + $client = new \ReflectionClass(Client::class); + + foreach ($factories as $method => $returnType) { + self::assertTrue($client->hasMethod($method), $method . ' must be a declared method'); + self::assertSame($returnType, (string)$client->getMethod($method)->getReturnType()); + } + self::assertFalse($client->hasMethod('__call')); + } + /** * 测试根客户端缓存前缀为空时抛出异常。 */ public function testConstructorThrowsWhenCacheKeyPrefixEmpty(): void { - $this->expectException(WechatException::class); + $this->expectException(SdkException::class); $this->expectExceptionMessage('cacheKeyPrefix'); new Client(cacheKeyPrefix: ' '); } @@ -55,7 +71,7 @@ final class ClientTest extends TestCase { $client = new Client(); - $this->expectException(WechatException::class); + $this->expectException(SdkException::class); $this->expectExceptionMessage('不支持的通道标识'); $client->get('unknown.channel', new WechatPlatformConfig('wx_x', 'sec')); } @@ -67,7 +83,7 @@ final class ClientTest extends TestCase { $client = new Client(); - $this->expectException(WechatException::class); + $this->expectException(SdkException::class); $this->expectExceptionMessage('WechatPlatformConfig'); $client->get('wechat.platform', new WechatServiceConfig('app', 'sec', 'token', TestKeys::encodingAesKey())); } @@ -129,7 +145,11 @@ final class ClientTest extends TestCase public function testAlipayPlatformCallReturnsAuthorizationUrl(): void { $client = new Client(); - $alipay = $client->alipayPlatform(new AlipayPlatformConfig('202605010001', TestKeys::privateKey())); + $alipay = $client->alipayPlatform(new AlipayPlatformConfig( + '202605010001', + TestKeys::privateKey(), + TestKeys::publicKey(), + )); $result = $alipay->get('auth', [ 'redirect_uri' => 'https://example.com/alipay/callback', 'scope' => 'auth_user', @@ -146,7 +166,11 @@ final class ClientTest extends TestCase public function testGetCanReturnSpecificChannelClient(): void { $client = new Client(); - $channelClient = $client->get('alipay.platform', new AlipayPlatformConfig('202605010001', TestKeys::privateKey())); + $channelClient = $client->get('alipay.platform', new AlipayPlatformConfig( + '202605010001', + TestKeys::privateKey(), + TestKeys::publicKey(), + )); $this->assertInstanceOf(AlipayPlatformClient::class, $channelClient); $this->assertNotInstanceOf(WechatServiceClient::class, $channelClient); diff --git a/tests/ConfigInterfaceTest.php b/tests/ConfigInterfaceTest.php index 4697bed..c2897f8 100644 --- a/tests/ConfigInterfaceTest.php +++ b/tests/ConfigInterfaceTest.php @@ -1,16 +1,11 @@ - */ namespace We\Tests; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use We\Config\AlipayPaymentConfig; use We\Config\AlipayPlatformConfig; @@ -19,6 +14,7 @@ use We\Config\WechatPlatformConfig; use We\Config\WechatServiceConfig; use We\Config\WechatWxappConfig; use We\Contract\ConfigInterface; +use We\Exception\AlipayException; use We\Exception\WechatException; /** @@ -37,9 +33,17 @@ final class ConfigInterfaceTest extends TestCase new WechatPlatformConfig('wx_app', 'secret'), new WechatWxappConfig('wx_wxapp', 'secret'), new WechatServiceConfig('wx_component', 'secret', 'token', TestKeys::encodingAesKey()), - new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', TestKeys::privateKey()), - new AlipayPlatformConfig('ali_app', TestKeys::privateKey()), - new AlipayPaymentConfig('ali_pay', TestKeys::privateKey()), + new WechatPaymentConfig( + 'wx_app', + 'mch', + str_repeat('k', 32), + 'serial', + TestKeys::privateKey(), + platformPublicKey: TestKeys::publicKey(), + platformSerial: 'platform-serial', + ), + new AlipayPlatformConfig('ali_app', TestKeys::privateKey(), TestKeys::publicKey()), + new AlipayPaymentConfig('ali_pay', TestKeys::privateKey(), TestKeys::publicKey()), ] as $config) { $this->assertInstanceOf(ConfigInterface::class, $config); } @@ -108,8 +112,123 @@ final class ConfigInterfaceTest extends TestCase $config = AlipayPaymentConfig::fromArray([ 'appid' => 'ali_pay', 'private_key' => TestKeys::privateKey(), + 'alipay_public_key' => TestKeys::publicKey(), ]); $this->assertInstanceOf(AlipayPaymentConfig::class, $config); } + + public function testPaymentConfigsRejectNonRsaKeys(): void + { + $this->expectException(WechatException::class); + $this->expectExceptionMessage('RSA'); + + new WechatPaymentConfig( + 'wx_app', + 'mch', + str_repeat('k', 32), + 'merchant-serial', + TestKeys::ecPrivateKey(), + platformPublicKey: TestKeys::publicKey(), + platformSerial: 'platform-serial', + ); + } + + public function testAlipayConfigRequiresPlatformPublicKey(): void + { + $this->expectException(AlipayException::class); + $this->expectExceptionMessage('alipay_public_key'); + + new AlipayPlatformConfig('ali_app', TestKeys::privateKey()); + } + + public function testWechatPaymentConfigRequiresPlatformVerificationMaterial(): void + { + $this->expectException(WechatException::class); + $this->expectExceptionMessage('platform'); + + new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'merchant-serial', TestKeys::privateKey()); + } + + public function testAlipayAcceptsHeaderlessPkcs8AndPkcs1PrivateKeys(): void + { + $pkcs8 = new AlipayPlatformConfig( + 'ali_pkcs8', + TestKeys::privateKeyBody(), + TestKeys::publicKeyBody(), + ); + $pkcs1 = new AlipayPlatformConfig( + 'ali_pkcs1', + TestKeys::pkcs1PrivateKeyBody(), + TestKeys::publicKeyBody(), + ); + + self::assertSame('ali_pkcs8', $pkcs8->appid); + self::assertSame('ali_pkcs1', $pkcs1->appid); + } + + public function testLegacyMerchantCertificateIsNotMappedToPlatformCertificate(): void + { + $config = WechatPaymentConfig::fromArray([ + 'appid' => 'wx_app', + 'mch_id' => 'mch', + 'api_v3_key' => str_repeat('k', 32), + 'merchant_serial' => 'merchant-serial', + 'merchant_private_key' => TestKeys::privateKey(), + 'cert_public' => TestKeys::publicKey(), + 'platform_public_key' => TestKeys::publicKey(), + 'platform_serial' => 'platform-serial', + ]); + + self::assertSame('', $config->platformCertificate); + self::assertSame(TestKeys::publicKey(), $config->platformPublicKey); + } + + #[DataProvider('invalidNotificationToleranceValues')] + public function testWechatPaymentFromArrayRejectsImplicitlyDisabledFreshness(mixed $value): void + { + $this->expectException(WechatException::class); + $this->expectExceptionMessage('notification_tolerance_seconds'); + + WechatPaymentConfig::fromArray(self::wechatPaymentData([ + 'notification_tolerance_seconds' => $value, + ])); + } + + /** + * @return iterable + */ + public static function invalidNotificationToleranceValues(): iterable + { + yield 'empty string' => ['']; + yield 'boolean false' => [false]; + yield 'word' => ['disabled']; + yield 'float' => [1.5]; + } + + public function testWechatPaymentFromArrayAcceptsExplicitNumericZeroFreshness(): void + { + $config = WechatPaymentConfig::fromArray(self::wechatPaymentData([ + 'notification_tolerance_seconds' => '0', + ])); + + self::assertSame(0, $config->notificationToleranceSeconds); + } + + /** + * @param array $overrides + * @return array + */ + private static function wechatPaymentData(array $overrides): array + { + return array_merge([ + 'appid' => 'wx_app', + 'mch_id' => 'mch', + 'api_v3_key' => str_repeat('k', 32), + 'merchant_serial' => 'merchant-serial', + 'merchant_private_key' => TestKeys::privateKey(), + 'platform_public_key' => TestKeys::platformKeyPair()[1], + 'platform_serial' => 'platform-serial', + ], $overrides); + } } diff --git a/tests/DocumentationTest.php b/tests/DocumentationTest.php new file mode 100644 index 0000000..d84fd1a --- /dev/null +++ b/tests/DocumentationTest.php @@ -0,0 +1,88 @@ + */ + private const DOCUMENTS = [ + 'docs/configuration.md', + 'docs/cache.md', + 'docs/wechat.md', + 'docs/payments.md', + 'docs/alipay.md', + 'docs/exceptions.md', + 'docs/testing.md', + 'docs/design.md', + 'docs/migration-2.0.md', + ]; + + public function testReadmeLinksEveryTopicDocument(): void + { + $readme = self::read('README.md'); + + foreach (self::DOCUMENTS as $document) { + self::assertFileExists(self::root() . '/' . $document); + self::assertStringContainsString('(' . $document . ')', $readme); + } + } + + public function testMigrationGuideCoversRequiredBreakingChanges(): void + { + $migration = self::read('docs/migration-2.0.md'); + + foreach ([ + 'PHP 8.1', + 'We\Client', + 'alipay_public_key', + 'platform_public_key', + 'cert_public', + 'SdkException', + 'StoreCacheInterface', + '不提供兼容层', + ] as $requiredText) { + self::assertStringContainsString($requiredText, $migration); + } + } + + public function testPhpExamplesAreSyntacticallyValid(): void + { + $documents = array_merge(['README.md'], self::DOCUMENTS); + $blockCount = 0; + + foreach ($documents as $document) { + $markdown = self::read($document); + preg_match_all('/```php\s*\R(.*?)```/s', $markdown, $matches); + foreach ($matches[1] as $snippet) { + $source = (string)preg_replace('/^\s*<\?php\s*/', '', $snippet); + $tokens = token_get_all(" 'INVALID']); + + self::assertSame(7, $exception->getCode()); + self::assertSame(['platform_code' => 'INVALID'], $exception->context()); + } +} diff --git a/tests/JsonClientTest.php b/tests/JsonClientTest.php index d986eb2..6668f5f 100644 --- a/tests/JsonClientTest.php +++ b/tests/JsonClientTest.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; diff --git a/tests/MessageCryptoTest.php b/tests/MessageCryptoTest.php index 1b2ff23..f49eaea 100644 --- a/tests/MessageCryptoTest.php +++ b/tests/MessageCryptoTest.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; diff --git a/tests/PaymentClientTest.php b/tests/PaymentClientTest.php index 8ca931e..49a5be2 100644 --- a/tests/PaymentClientTest.php +++ b/tests/PaymentClientTest.php @@ -1,19 +1,22 @@ - */ namespace We\Tests; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use We\Config\WechatPaymentConfig; +use We\Exception\ApiException; use We\Exception\SignatureException; +use We\Exception\TransportException; use We\Exception\WechatException; use We\Platform\Wechat\PaymentClient as WechatPaymentClient; use We\Support\Signature; @@ -25,12 +28,189 @@ use We\Support\Signature; #[CoversClass(WechatPaymentClient::class)] final class PaymentClientTest extends TestCase { + public function testRequestReturnsOnlySignedPlatformResponse(): void + { + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $body = "{\n \"prepay_id\": \"wx-prepay-1\"\n}"; + $response = WechatPaymentResponse::signed(200, $body, $platformPrivateKey); + $client = self::paymentClient($response, $platformPublicKey); + + $data = $client->post('v3/pay/transactions/jsapi', ['description' => 'signed response']); + + self::assertSame('wx-prepay-1', $data['prepay_id']); + } + + public function testRequestRejectsUnsignedPlatformResponse(): void + { + $client = self::paymentClient( + new Response(200, [], '{"prepay_id":"unsigned"}'), + TestKeys::platformKeyPair()[1], + ); + + $this->expectException(SignatureException::class); + $this->expectExceptionMessage('响应'); + + $client->post('v3/pay/transactions/jsapi', ['description' => 'unsigned response']); + } + + public function testRequestRejectsInvalidPlatformSignature(): void + { + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $response = WechatPaymentResponse::signed(200, '{"prepay_id":"tampered"}', $platformPrivateKey) + ->withHeader('Wechatpay-Signature', base64_encode('invalid-signature')); + $client = self::paymentClient($response, $platformPublicKey); + + $this->expectException(SignatureException::class); + + $client->post('v3/pay/transactions/jsapi'); + } + + public function testRequestRejectsSignedNonJsonResponse(): void + { + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $client = self::paymentClient( + WechatPaymentResponse::signed(200, 'not-json', $platformPrivateKey), + $platformPublicKey, + ); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('JSON'); + + $client->get('v3/certificates'); + } + + public function testRequestExposesTransportFailureType(): void + { + $failure = new ConnectException( + 'connection failed', + new Request('GET', 'https://api.mch.weixin.qq.com/v3/certificates'), + ); + $client = self::paymentClient($failure, TestKeys::platformKeyPair()[1]); + + $this->expectException(TransportException::class); + + $client->get('v3/certificates'); + } + + public function testNotificationRejectsTimestampOutsideDefaultWindow(): void + { + [$client, $headers, $body] = self::notificationFixture(time() - 301); + + $this->expectException(SignatureException::class); + $this->expectExceptionMessage('时间戳'); + + $client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]); + } + + public function testNotificationRejectsFutureTimestampOutsideDefaultWindow(): void + { + [$client, $headers, $body] = self::notificationFixture(time() + 301); + + $this->expectException(SignatureException::class); + $this->expectExceptionMessage('时间戳'); + + $client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]); + } + + public function testNotificationAcceptsConfiguredFreshnessWindow(): void + { + [$client, $headers, $body] = self::notificationFixture(time() - 600, 900); + + $data = $client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]); + + self::assertSame('T-FRESHNESS', $data['out_trade_no']); + } + + public function testNotificationFreshnessCanBeExplicitlyDisabled(): void + { + [$client, $headers, $body] = self::notificationFixture(1, 0); + + $data = $client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]); + + self::assertSame('T-FRESHNESS', $data['out_trade_no']); + } + + public function testDownloadBillCompletesVerifiedTwoStageFlow(): void + { + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $metadataBody = '{"download_url":"https://download.example.com/bill.csv?token=signed"}'; + $history = []; + $stack = HandlerStack::create(new MockHandler([ + WechatPaymentResponse::signed(200, $metadataBody, $platformPrivateKey), + new Response(200, ['Content-Type' => 'text/csv'], "trade_no,amount\nT1,1\n"), + ])); + $stack->push(Middleware::history($history)); + $http = new Client(['handler' => $stack, 'base_uri' => 'https://api.mch.weixin.qq.com/']); + $client = new WechatPaymentClient(new WechatPaymentConfig( + 'wx_app', + 'mch_id', + str_repeat('k', 32), + 'merchant-serial', + TestKeys::privateKey(), + platformPublicKey: $platformPublicKey, + platformSerial: 'platform-serial', + ), $http); + + $response = $client->downloadBill('v3/bill/tradebill', ['bill_date' => '2026-08-10']); + + self::assertSame("trade_no,amount\nT1,1\n", (string)$response->getBody()); + self::assertCount(2, $history); + self::assertSame('api.mch.weixin.qq.com', $history[0]['request']->getUri()->getHost()); + self::assertSame('download.example.com', $history[1]['request']->getUri()->getHost()); + } + + public function testDownloadBillRejectsNonHttpsDownloadUrl(): void + { + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $metadataBody = '{"download_url":"http://127.0.0.1/internal"}'; + $client = self::paymentClient( + WechatPaymentResponse::signed(200, $metadataBody, $platformPrivateKey), + $platformPublicKey, + ); + + $this->expectException(WechatException::class); + $this->expectExceptionMessage('HTTPS'); + + $client->downloadBill('v3/bill/tradebill', ['bill_date' => '2026-08-10']); + } + + public function testDownloadBillDoesNotFollowRedirects(): void + { + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $metadataBody = '{"download_url":"https://download.example.com/bill.csv"}'; + $history = []; + $stack = HandlerStack::create(new MockHandler([ + WechatPaymentResponse::signed(200, $metadataBody, $platformPrivateKey), + new Response(302, ['Location' => 'http://127.0.0.1/internal']), + new Response(200, [], 'unsafe redirect result'), + ])); + $stack->push(Middleware::history($history)); + $http = new Client(['handler' => $stack, 'base_uri' => 'https://api.mch.weixin.qq.com/']); + $client = new WechatPaymentClient(new WechatPaymentConfig( + 'wx_app', + 'mch_id', + str_repeat('k', 32), + 'merchant-serial', + TestKeys::privateKey(), + platformPublicKey: $platformPublicKey, + platformSerial: 'platform-serial', + ), $http); + + try { + $client->downloadBill('v3/bill/tradebill'); + self::fail('Expected redirect response to be rejected.'); + } catch (ApiException $exception) { + self::assertSame(302, $exception->getCode()); + self::assertCount(2, $history); + } + } + /** * 测试微信支付回调验签使用原始请求体。 */ public function testDecryptNotificationUsesRawBodyForSignature(): void { - [$platformPrivateKey, $platformPublicKey] = self::keyPair(); + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); $apiV3Key = str_repeat('k', 32); $nonce = '123456789012'; $aad = 'transaction'; @@ -39,7 +219,7 @@ final class PaymentClientTest extends TestCase self::assertIsString($cipher); $rawBody = "{\n \"id\": \"notify-id\",\n \"resource\": {\n \"ciphertext\": \"" . base64_encode($cipher . $tag) . "\",\n \"nonce\": \"{$nonce}\",\n \"associated_data\": \"{$aad}\"\n }\n}"; - $timestamp = '1777600000'; + $timestamp = (string)time(); $notifyNonce = 'notify-nonce'; $headers = [ 'Wechatpay-Timestamp' => $timestamp, @@ -72,9 +252,9 @@ final class PaymentClientTest extends TestCase */ public function testDecryptNotificationRejectsPlatformSerialMismatch(): void { - [$platformPrivateKey, $platformPublicKey] = self::keyPair(); + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); $rawBody = '{"resource":{"ciphertext":"invalid","nonce":"nonce"}}'; - $timestamp = '1777600000'; + $timestamp = (string)time(); $notifyNonce = 'notify-nonce'; $headers = [ 'Wechatpay-Timestamp' => $timestamp, @@ -107,9 +287,9 @@ final class PaymentClientTest extends TestCase */ public function testDecryptNotificationRejectsInvalidResourceShape(): void { - [$platformPrivateKey, $platformPublicKey] = self::keyPair(); + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); $rawBody = '{"resource":"invalid"}'; - $timestamp = '1777600000'; + $timestamp = (string)time(); $notifyNonce = 'notify-nonce'; $headers = [ 'Wechatpay-Timestamp' => $timestamp, @@ -137,19 +317,60 @@ final class PaymentClientTest extends TestCase ]); } - /** - * 生成测试使用的 RSA 密钥对。 - * - * @return array{0:string,1:string} - */ - private static function keyPair(): array + private static function paymentClient(Response|\Throwable $result, string $platformPublicKey): WechatPaymentClient { - $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); - self::assertNotFalse($resource); - openssl_pkey_export($resource, $privateKey); - $details = openssl_pkey_get_details($resource); - self::assertIsArray($details); + $http = new Client([ + 'handler' => HandlerStack::create(new MockHandler([$result])), + 'base_uri' => 'https://api.mch.weixin.qq.com/', + ]); - return [$privateKey, (string)$details['key']]; + return new WechatPaymentClient(new WechatPaymentConfig( + 'wx_app', + 'mch_id', + str_repeat('k', 32), + 'merchant-serial', + TestKeys::privateKey(), + platformPublicKey: $platformPublicKey, + platformSerial: 'platform-serial', + ), $http); + } + + /** + * @return array{0:WechatPaymentClient,1:array,2:string} + */ + private static function notificationFixture(int $timestamp, int $freshnessWindow = 300): array + { + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $apiV3Key = str_repeat('k', 32); + $nonce = '123456789012'; + $aad = 'transaction'; + $plain = '{"out_trade_no":"T-FRESHNESS"}'; + $cipher = openssl_encrypt($plain, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad); + self::assertIsString($cipher); + $body = '{"resource":{"ciphertext":"' . base64_encode($cipher . $tag) + . '","nonce":"' . $nonce . '","associated_data":"' . $aad . '"}}'; + $timestampValue = (string)$timestamp; + $notifyNonce = 'freshness-nonce'; + $headers = [ + 'Wechatpay-Timestamp' => $timestampValue, + 'Wechatpay-Nonce' => $notifyNonce, + 'Wechatpay-Serial' => 'platform-serial', + 'Wechatpay-Signature' => Signature::paymentV3Sign( + $platformPrivateKey, + "{$timestampValue}\n{$notifyNonce}\n{$body}\n", + ), + ]; + $client = new WechatPaymentClient(new WechatPaymentConfig( + 'wx_app', + 'mch_id', + $apiV3Key, + 'merchant-serial', + TestKeys::privateKey(), + platformPublicKey: $platformPublicKey, + platformSerial: 'platform-serial', + notificationToleranceSeconds: $freshnessWindow, + )); + + return [$client, $headers, $body]; } } diff --git a/tests/PaymentCryptoTest.php b/tests/PaymentCryptoTest.php index 92acdac..0e5bd96 100644 --- a/tests/PaymentCryptoTest.php +++ b/tests/PaymentCryptoTest.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; diff --git a/tests/ProtocolClientTest.php b/tests/ProtocolClientTest.php index 3c039fa..fd34cf4 100644 --- a/tests/ProtocolClientTest.php +++ b/tests/ProtocolClientTest.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; @@ -107,7 +101,8 @@ final class ProtocolClientTest extends TestCase */ public function testWechatPaymentDownloadSignsRequestAndReturnsRawResponse(): void { - [$merchantPrivateKey] = self::keyPair(); + $merchantPrivateKey = TestKeys::privateKey(); + [, $platformPublicKey] = TestKeys::platformKeyPair(); $http = new ProtocolHttpClient([new Response(200, ['Content-Type' => 'text/plain'], 'BILL-DATA')]); $payment = new WechatPaymentClient(new WechatPaymentConfig( 'wx_app', @@ -115,6 +110,8 @@ final class ProtocolClientTest extends TestCase str_repeat('k', 32), 'merchant-serial', $merchantPrivateKey, + platformPublicKey: $platformPublicKey, + platformSerial: 'platform-serial', ), $http); $response = $payment->download('v3/bill/tradebill', ['bill_date' => '2026-05-08']); @@ -131,14 +128,19 @@ final class ProtocolClientTest extends TestCase */ public function testWechatPaymentCallPassesGuzzleOptions(): void { - [$merchantPrivateKey] = self::keyPair(); - $http = new ProtocolHttpClient([new Response(200, [], '{"ok":true}')]); + $merchantPrivateKey = TestKeys::privateKey(); + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $http = new ProtocolHttpClient([ + WechatPaymentResponse::signed(200, '{"ok":true}', $platformPrivateKey), + ]); $payment = new WechatPaymentClient(new WechatPaymentConfig( 'wx_app', 'mch_id', str_repeat('k', 32), 'merchant-serial', $merchantPrivateKey, + platformPublicKey: $platformPublicKey, + platformSerial: 'platform-serial', ), $http); $data = $payment->post('v3/custom/request', ['ignored' => 'payload'], [ @@ -162,14 +164,23 @@ final class ProtocolClientTest extends TestCase */ public function testWechatPaymentRequestThrowsOnHttpErrorPayload(): void { - [$merchantPrivateKey] = self::keyPair(); - $http = new ProtocolHttpClient([new Response(400, [], '{"code":"PARAM_ERROR","message":"参数错误"}')]); + $merchantPrivateKey = TestKeys::privateKey(); + [$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair(); + $http = new ProtocolHttpClient([ + WechatPaymentResponse::signed( + 400, + '{"code":"PARAM_ERROR","message":"参数错误"}', + $platformPrivateKey, + ), + ]); $payment = new WechatPaymentClient(new WechatPaymentConfig( 'wx_app', 'mch_id', str_repeat('k', 32), 'merchant-serial', $merchantPrivateKey, + platformPublicKey: $platformPublicKey, + platformSerial: 'platform-serial', ), $http); try { @@ -207,22 +218,6 @@ final class ProtocolClientTest extends TestCase $this->assertSame('NEXT', $http->requests[0]['options']['query']['next_openid']); $this->assertSame('authorizer-token', $http->requests[0]['options']['query']['access_token']); } - - /** - * 生成测试使用的 RSA 密钥对。 - * - * @return array{0:string,1:string} - */ - private static function keyPair(): array - { - $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); - self::assertNotFalse($resource); - openssl_pkey_export($resource, $privateKey); - $details = openssl_pkey_get_details($resource); - self::assertIsArray($details); - - return [$privateKey, (string)$details['key']]; - } } /** diff --git a/tests/TestKeys.php b/tests/TestKeys.php index d88e356..e63860b 100644 --- a/tests/TestKeys.php +++ b/tests/TestKeys.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; @@ -18,6 +12,13 @@ final class TestKeys /** @var null|array{0:string,1:string} */ private static ?array $keyPair = null; + /** @var null|array{0:string,1:string} */ + private static ?array $platformKeyPair = null; + + private static ?string $ecPrivateKey = null; + + private static ?string $pkcs1PrivateKey = null; + /** * 返回测试用 EncodingAESKey。 */ @@ -42,6 +43,62 @@ final class TestKeys return self::keyPair()[1]; } + public static function privateKeyBody(): string + { + return self::pemBody(self::privateKey()); + } + + public static function publicKeyBody(): string + { + return self::pemBody(self::publicKey()); + } + + public static function pkcs1PrivateKeyBody(): string + { + if (self::$pkcs1PrivateKey !== null) { + return self::pemBody(self::$pkcs1PrivateKey); + } + $resource = openssl_pkey_get_private(self::privateKey()); + $details = $resource === false ? false : openssl_pkey_get_details($resource); + $rsa = is_array($details) && is_array($details['rsa'] ?? null) ? $details['rsa'] : null; + if (!is_array($rsa)) { + throw self::openSslFailure('Unable to read test RSA private-key details.'); + } + $der = self::asn1Sequence( + self::asn1Integer("\0"), + self::asn1Integer((string)$rsa['n']), + self::asn1Integer((string)$rsa['e']), + self::asn1Integer((string)$rsa['d']), + self::asn1Integer((string)$rsa['p']), + self::asn1Integer((string)$rsa['q']), + self::asn1Integer((string)$rsa['dmp1']), + self::asn1Integer((string)$rsa['dmq1']), + self::asn1Integer((string)$rsa['iqmp']), + ); + self::$pkcs1PrivateKey = "-----BEGIN RSA PRIVATE KEY-----\n" + . chunk_split(base64_encode($der), 64, "\n") + . '-----END RSA PRIVATE KEY-----'; + + return self::pemBody(self::$pkcs1PrivateKey); + } + + public static function ecPrivateKey(): string + { + if (self::$ecPrivateKey !== null) { + return self::$ecPrivateKey; + } + self::clearOpenSslErrors(); + $resource = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + if ($resource === false || !openssl_pkey_export($resource, $privateKey)) { + throw self::openSslFailure('Unable to create test EC private key.'); + } + + return self::$ecPrivateKey = $privateKey; + } + /** * 生成并缓存测试使用的 RSA 密钥对。 * @@ -53,16 +110,101 @@ final class TestKeys return self::$keyPair; } - $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); - if ($resource === false) { - throw new \RuntimeException('Unable to create test RSA key pair.'); - } - openssl_pkey_export($resource, $privateKey); - $details = openssl_pkey_get_details($resource); - if (!is_array($details)) { - throw new \RuntimeException('Unable to read test RSA public key.'); + return self::$keyPair = self::generateKeyPair(); + } + + /** + * 返回与商户/应用密钥相互独立的平台测试 RSA 密钥对。 + * + * @return array{0:string,1:string} + */ + public static function platformKeyPair(): array + { + if (self::$platformKeyPair !== null) { + return self::$platformKeyPair; } - return self::$keyPair = [$privateKey, (string)$details['key']]; + return self::$platformKeyPair = self::generateKeyPair(); + } + + /** + * @return array{0:string,1:string} + */ + private static function generateKeyPair(): array + { + self::clearOpenSslErrors(); + $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + if ($resource === false) { + throw self::openSslFailure('Unable to create test RSA key pair.'); + } + if (!openssl_pkey_export($resource, $privateKey)) { + throw self::openSslFailure('Unable to export test RSA private key.'); + } + $details = openssl_pkey_get_details($resource); + if (!is_array($details) || !is_string($details['key'] ?? null)) { + throw self::openSslFailure('Unable to read test RSA public key.'); + } + + return [$privateKey, $details['key']]; + } + + private static function clearOpenSslErrors(): void + { + while (openssl_error_string() !== false); + } + + private static function openSslFailure(string $message): \RuntimeException + { + $errors = []; + while (($error = openssl_error_string()) !== false) { + $errors[] = $error; + } + $details = $errors === [] ? 'OpenSSL returned no diagnostic details.' : implode(' | ', $errors); + $config = getenv('OPENSSL_CONF'); + + return new \RuntimeException(sprintf( + '%s %s OPENSSL_CONF=%s', + $message, + $details, + is_string($config) && $config !== '' ? $config : '(not set)', + )); + } + + private static function pemBody(string $pem): string + { + return (string)preg_replace('/-----BEGIN [^-]+-----|-----END [^-]+-----|\s+/', '', $pem); + } + + private static function asn1Integer(string $value): string + { + $value = ltrim($value, "\0"); + if ($value === '') { + $value = "\0"; + } elseif ((ord($value[0]) & 0x80) !== 0) { + $value = "\0" . $value; + } + + return "\x02" . self::asn1Length(strlen($value)) . $value; + } + + private static function asn1Sequence(string ...$values): string + { + $value = implode('', $values); + + return "\x30" . self::asn1Length(strlen($value)) . $value; + } + + private static function asn1Length(int $length): string + { + if ($length < 0x80) { + return chr($length); + } + $bytes = ''; + while ($length > 0) { + $bytes = chr($length & 0xFF) . $bytes; + $length >>= 8; + } + + return chr(0x80 | strlen($bytes)) . $bytes; } } diff --git a/tests/WechatPaymentResponse.php b/tests/WechatPaymentResponse.php new file mode 100644 index 0000000..6193e61 --- /dev/null +++ b/tests/WechatPaymentResponse.php @@ -0,0 +1,34 @@ + $timestamp, + 'Wechatpay-Nonce' => $nonce, + 'Wechatpay-Serial' => $serial, + 'Wechatpay-Signature' => Signature::paymentV3Sign( + $platformPrivateKey, + "{$timestamp}\n{$nonce}\n{$body}\n", + ), + ], $body); + } +} diff --git a/tests/XmlTest.php b/tests/XmlTest.php index c5e38b5..f95581a 100644 --- a/tests/XmlTest.php +++ b/tests/XmlTest.php @@ -1,12 +1,6 @@ - */ namespace We\Tests; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d791133..dfd5468 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,12 +1,6 @@ - */ $autoloadCandidates = [ dirname(__DIR__) . '/vendor/autoload.php', dirname(__DIR__, 2) . '/vendor/autoload.php',