mirror of
https://gitee.com/zoujingli/WeChatDeveloper.git
synced 2026-09-05 07:27:50 +08:00
feat(release): harden 2.0 security and quality gates
This commit is contained in:
parent
fe366ae837
commit
ac011388ce
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -1,6 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@ -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') }}
|
||||
|
||||
@ -1,23 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<zoujingli@qq.com>
|
||||
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',
|
||||
],
|
||||
|
||||
105
docs/alipay.md
Normal file
105
docs/alipay.md
Normal file
@ -0,0 +1,105 @@
|
||||
# 支付宝
|
||||
|
||||
支付宝客户端统一构造网关公共参数,使用应用私钥签名请求,并使用支付宝公钥验证同步响应和异步通知。
|
||||
|
||||
## 开放平台配置
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Config\AlipayPlatformConfig;
|
||||
|
||||
$config = new AlipayPlatformConfig(
|
||||
appid: '2026000000000000',
|
||||
privateKey: $applicationPrivateKey,
|
||||
alipayPublicKey: $alipayPublicKey,
|
||||
signType: 'RSA2',
|
||||
);
|
||||
|
||||
$alipay = (new Client())->alipayPlatform($config);
|
||||
```
|
||||
|
||||
`privateKey` 与 `alipayPublicKey` 都是必填安全配置。前者代表应用,后者代表支付宝平台。默认网关是 `https://openapi.alipay.com/gateway.do`。
|
||||
|
||||
完整 PEM 和无头尾的 RSA key body 都可使用;私钥支持 PKCS#1 与 PKCS#8。EC 等非 RSA 密钥会在配置阶段被拒绝。
|
||||
|
||||
## 网关调用
|
||||
|
||||
API method 与支付宝官方文档一致:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$result = $alipay->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Config\AlipayPaymentConfig;
|
||||
|
||||
$payment = (new Client())->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$verified = $alipay->verifyNotify($_POST);
|
||||
if (!$verified) {
|
||||
throw new RuntimeException('Invalid Alipay notification signature.');
|
||||
}
|
||||
```
|
||||
|
||||
验签会排除 `sign` 和 `sign_type`,按支付宝规则排序并拼接其他非空字段。返回 `true` 只证明参数签名有效;订单金额、商户身份、通知状态和业务幂等仍由业务系统校验。
|
||||
87
docs/cache.md
Normal file
87
docs/cache.md
Normal file
@ -0,0 +1,87 @@
|
||||
# 缓存
|
||||
|
||||
SDK 使用 `We\Contract\StoreCacheInterface` 缓存 access token 和 component access token。接口语义包括 TTL、删除和刷新锁:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Contract\StoreCacheInterface;
|
||||
|
||||
final class ApplicationCache implements StoreCacheInterface
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $default;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return $callback();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
生产适配器的 `lock()` 必须在多进程或多节点之间互斥执行回调,避免多个请求同时刷新同一平台凭据。上例只展示接口形状,不具备生产锁语义。
|
||||
|
||||
## 内置实现
|
||||
|
||||
| 实现 | 用途 | 锁语义 |
|
||||
|------|------|--------|
|
||||
| `FileCacheStore` | 单机、本地开发、小规模部署 | `flock` 进程锁 |
|
||||
| `PsrSimpleCacheStore` | Redis 等 PSR-16 实现 | 由调用方注入分布式锁回调 |
|
||||
| `NullCacheStore` | 测试或显式禁用缓存 | 直接执行回调,不互斥 |
|
||||
|
||||
文件缓存使用临时文件和原子重命名提交新值。读取过期文件只返回默认值,不会按旧路径删除,因此不会误删并发写入的新值。
|
||||
|
||||
## PSR-16 适配
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Support\PsrSimpleCacheStore;
|
||||
|
||||
$store = new PsrSimpleCacheStore(
|
||||
cache: $psr16Cache,
|
||||
locker: static function (string $key, int $ttl, callable $callback) use ($distributedLock): mixed {
|
||||
return $distributedLock->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 请求。
|
||||
151
docs/configuration.md
Normal file
151
docs/configuration.md
Normal file
@ -0,0 +1,151 @@
|
||||
# 配置与凭证
|
||||
|
||||
所有平台配置实现 `We\Contract\ConfigInterface`,在构造或 `fromArray()` 时立即验证必填字段和密钥。配置无效时不会创建可调用的客户端。
|
||||
|
||||
## 根客户端
|
||||
|
||||
`We\Client` 可注入运行态缓存、微信服务平台授权方 Token 仓库和 Guzzle HTTP 客户端:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Support\FileCacheStore;
|
||||
|
||||
$client = new Client(
|
||||
cache: new FileCacheStore(__DIR__ . '/runtime/wechat-cache'),
|
||||
cacheKeyPrefix: 'production-tenant-a',
|
||||
);
|
||||
```
|
||||
|
||||
`cacheKeyPrefix` 必须非空,用于隔离部署或租户。未注入缓存时使用系统临时目录中的文件缓存。
|
||||
|
||||
## 微信公众平台
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Config\WechatPlatformConfig;
|
||||
|
||||
$config = new WechatPlatformConfig(
|
||||
appid: 'wx_appid',
|
||||
appSecret: 'app_secret',
|
||||
token: 'callbackToken123',
|
||||
encodingAesKey: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
|
||||
storageScope: 'tenant-a',
|
||||
);
|
||||
```
|
||||
|
||||
数组字段:`appid`、`appsecret`/`app_secret`、`token`、`encodingaeskey`/`encoding_aes_key`、`storage_scope`。
|
||||
|
||||
`token` 与 `encodingAesKey` 只有消息签名或安全模式加解密场景需要;`appid` 与 `appSecret` 始终必填。
|
||||
|
||||
## 微信小程序
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Config\WechatWxappConfig;
|
||||
|
||||
$config = WechatWxappConfig::fromArray([
|
||||
'appid' => 'wx_appid',
|
||||
'app_secret' => 'app_secret',
|
||||
'storage_scope' => 'tenant-a',
|
||||
]);
|
||||
```
|
||||
|
||||
## 微信服务平台
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Config\WechatServiceConfig;
|
||||
|
||||
$config = new WechatServiceConfig(
|
||||
componentAppid: 'wx_component_appid',
|
||||
componentAppSecret: 'component_secret',
|
||||
componentToken: 'componentToken123',
|
||||
componentEncodingAesKey: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
|
||||
storageScope: 'tenant-a',
|
||||
);
|
||||
```
|
||||
|
||||
数组字段:`component_appid`、`component_appsecret`/`component_app_secret`、`component_token`、`component_encodingaeskey`/`component_encoding_aes_key`、`storage_scope`。
|
||||
|
||||
## 微信支付 APIv3
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Config\WechatPaymentConfig;
|
||||
|
||||
$config = new WechatPaymentConfig(
|
||||
appid: 'wx_appid',
|
||||
mchId: '1900000001',
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
merchantSerial: 'merchant_certificate_serial',
|
||||
merchantPrivateKey: $merchantPrivateKeyPem,
|
||||
platformPublicKey: $wechatPayPlatformPublicKeyPem,
|
||||
platformSerial: 'wechatpay_platform_key_or_certificate_serial',
|
||||
notificationToleranceSeconds: 300,
|
||||
);
|
||||
```
|
||||
|
||||
商户私钥用于请求签名。`platformPublicKey` 或 `platformCertificate` 至少配置一个,并与 `platformSerial` 一起用于普通响应和通知验签。平台公钥优先于平台证书。
|
||||
|
||||
数组字段:
|
||||
|
||||
| 构造参数 | `fromArray()` 字段 |
|
||||
|----------|---------------------|
|
||||
| `appid` | `appid` |
|
||||
| `mchId` | `mch_id` / `mchid` |
|
||||
| `apiV3Key` | `api_v3_key` / `mch_v3_key` |
|
||||
| `merchantSerial` | `merchant_serial` / `cert_serial` |
|
||||
| `merchantPrivateKey` | `merchant_private_key` / `cert_private` |
|
||||
| `platformCertificate` | `platform_certificate` |
|
||||
| `platformPublicKey` | `platform_public_key` |
|
||||
| `platformSerial` | `platform_serial` |
|
||||
| `notificationToleranceSeconds` | `notification_tolerance_seconds`,默认 `300` |
|
||||
|
||||
旧字段 `cert_public` 是商户证书,不会映射到微信支付平台证书。2.0 必须显式提供平台信任材料。
|
||||
|
||||
`notificationToleranceSeconds` 不得小于 0。`fromArray()` 只接受非负整数或仅含数字的字符串,不会把空字符串、布尔值或任意文字转换成 `0`。`0` 表示调用方明确关闭通知时间检查;它不会关闭 RSA 验签。
|
||||
|
||||
## 支付宝
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Config\AlipayPaymentConfig;
|
||||
|
||||
$config = new AlipayPaymentConfig(
|
||||
appid: '2026000000000000',
|
||||
privateKey: $applicationPrivateKey,
|
||||
alipayPublicKey: $alipayPublicKey,
|
||||
signType: 'RSA2',
|
||||
);
|
||||
```
|
||||
|
||||
应用私钥和 `alipayPublicKey` 均为必填项。数组字段是 `appid`/`app_id`、`private_key`/`merchant_private_key`、`alipay_public_key`、`gateway`、`charset`、`sign_type`、`format`、`version`。
|
||||
|
||||
`AlipayPlatformConfig` 与 `AlipayPaymentConfig` 使用同一套网关和密钥字段,后者创建支付客户端。
|
||||
|
||||
## RSA 规则
|
||||
|
||||
- 微信支付商户私钥、微信支付平台公钥/证书、支付宝应用私钥和支付宝公钥必须是 RSA。
|
||||
- EC 或其他可被 OpenSSL 解析但算法不匹配的密钥会被拒绝。
|
||||
- 支付宝私钥支持完整 PEM,也支持无头尾的 PKCS#1 或 PKCS#8 Base64 正文。
|
||||
- 支付宝公钥支持完整 PEM 或无头尾公钥正文。
|
||||
- 不要把商户证书、公钥和平台公钥混用;它们代表不同信任主体。
|
||||
69
docs/design.md
Normal file
69
docs/design.md
Normal file
@ -0,0 +1,69 @@
|
||||
# 设计
|
||||
|
||||
## 定位
|
||||
|
||||
2.0 把 SDK 收敛为协议层模块:少量稳定入口承载认证、HTTP、签名、验签和加解密复杂度,业务接口名称和字段继续以平台官方文档为准。
|
||||
|
||||
```text
|
||||
Application
|
||||
|
|
||||
v
|
||||
We\Client -- ConfigInterface
|
||||
|
|
||||
+-- WeChat platform / wxapp / service clients
|
||||
+-- WeChat Pay APIv3 client
|
||||
+-- Alipay platform / payment clients
|
||||
|
|
||||
+-- StoreCacheInterface / StoreTokenInterface
|
||||
+-- injected Guzzle ClientInterface
|
||||
```
|
||||
|
||||
`Client` 提供六个显式类型工厂,也保留动态 `get()` 供配置驱动系统使用。删除魔术 `__call()` 后,错误工厂名称能在开发阶段更早暴露。
|
||||
|
||||
## 模块边界
|
||||
|
||||
| 模块 | 负责 | 不负责 |
|
||||
|------|------|--------|
|
||||
| `Config` | 必填字段、URL、RSA 和平台信任材料校验 | 读取环境变量、密钥轮换 |
|
||||
| 平台客户端 | token、请求协议、响应解析、平台签名 | 业务实体和数据库 |
|
||||
| `Support` | 密钥规范化、缓存、签名、XML、加解密 | 业务流程编排 |
|
||||
| 缓存契约 | token TTL 和刷新互斥 | 通用应用缓存 API |
|
||||
| Token 契约 | 授权方 refresh token 读写 | 账号数据模型 |
|
||||
| 异常层级 | 统一捕获和平台诊断上下文 | 日志与告警策略 |
|
||||
|
||||
## 安全决策
|
||||
|
||||
支付数据采用强安全默认值:
|
||||
|
||||
- 支付宝响应必须存在预期节点、标量 code 和有效签名。
|
||||
- 微信支付普通 JSON 响应必须包含完整平台签名头,并以原始 body 验签。
|
||||
- 微信支付通知先验签,再检查默认 300 秒时间窗口,最后解密 resource。
|
||||
- 缺少信任材料在配置阶段失败,不允许“未配置即跳过验签”。
|
||||
- 支付密钥不仅要求 OpenSSL 可解析,还要求算法确为 RSA。
|
||||
|
||||
通知幂等保留在业务层。SDK 不知道订单聚合、合法状态转换或事务边界,因此不能可靠代替业务持久化去重。
|
||||
|
||||
## URL 边界
|
||||
|
||||
通用微信平台和 JSON 客户端只接受相对 path,避免调用方参数把受信客户端变成任意 URL 请求器。
|
||||
|
||||
微信账单的绝对 URL 是官方协议要求,因此封装在专用 `downloadBill()` 内:只有先通过已签名 API 响应取得的有效 HTTPS 地址才会交给下载 HTTP 适配器。该能力不会泄漏到通用客户端。
|
||||
|
||||
## 缓存设计
|
||||
|
||||
缓存键由部署前缀、平台通道和逻辑用途组成。三段独立编码并使用点号连接,既保留隔离语义,又满足 PSR-16 对保留字符的限制。
|
||||
|
||||
`FileCacheStore` 通过临时文件和原子重命名发布新值。过期读不做按路径删除,避免旧读者在新值重命名后误删新文件。
|
||||
|
||||
## 扩展接缝
|
||||
|
||||
- 框架缓存实现 `StoreCacheInterface`,或用 `PsrSimpleCacheStore` 适配 PSR-16。
|
||||
- 微信服务平台账号仓库实现 `StoreTokenInterface`。
|
||||
- 测试或特殊传输策略向根 `Client` 注入 Guzzle `ClientInterface`。
|
||||
- 新官方 API 优先通过现有平台客户端的 `get()`、`post()`、`call()`、`raw()`、`download()` 或 `upload()` 表达。
|
||||
|
||||
只有当新协议形态无法由现有公开接口安全表达时,才增加专用方法,例如两阶段账单下载。
|
||||
|
||||
## 版本边界
|
||||
|
||||
2.0 不移植 1.x 上百个业务接口类,不保留旧命名空间、静态 `instance()` 或全局 helper。主版本断代使协议层接口保持可发现、可测试和较小的维护面。
|
||||
72
docs/exceptions.md
Normal file
72
docs/exceptions.md
Normal file
@ -0,0 +1,72 @@
|
||||
# 异常
|
||||
|
||||
2.0 使用 `We\Exception\SdkException` 作为所有 SDK 故障的统一基类,同时保留平台和故障类别。
|
||||
|
||||
```text
|
||||
RuntimeException
|
||||
└── SdkException
|
||||
├── TransportException
|
||||
├── WechatException
|
||||
│ ├── ApiException
|
||||
│ └── SignatureException
|
||||
└── AlipayException
|
||||
├── AlipayApiException
|
||||
└── AlipaySignatureException
|
||||
```
|
||||
|
||||
| 类型 | 典型场景 |
|
||||
|------|----------|
|
||||
| `SdkException` | 根客户端、缓存适配或通用 SDK 配置错误 |
|
||||
| `TransportException` | 与微信或支付宝建立连接、发送请求或下载文件失败 |
|
||||
| `WechatException` | 微信配置、协议、加解密或请求故障 |
|
||||
| `ApiException` | 微信平台/支付 API 返回错误或无效响应 |
|
||||
| `SignatureException` | 微信消息、支付响应或通知验签失败 |
|
||||
| `AlipayException` | 支付宝配置、密钥、签名或解密故障 |
|
||||
| `AlipayApiException` | 支付宝网关响应结构或业务错误 |
|
||||
| `AlipaySignatureException` | 支付宝同步响应缺少签名或验签失败 |
|
||||
|
||||
## 统一捕获
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Exception\SdkException;
|
||||
|
||||
try {
|
||||
$result = $platform->get('cgi-bin/user/get');
|
||||
} catch (SdkException $exception) {
|
||||
logger()->error($exception->getMessage(), [
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
## 精确捕获和上下文
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Exception\AlipayApiException;
|
||||
use We\Exception\AlipaySignatureException;
|
||||
use We\Exception\TransportException;
|
||||
|
||||
try {
|
||||
$result = $alipay->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`;这类错误应在开发和静态分析阶段修复。
|
||||
218
docs/migration-2.0.md
Normal file
218
docs/migration-2.0.md
Normal file
@ -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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use WeChat\User;
|
||||
|
||||
$user = User::instance([
|
||||
'appid' => 'wx_appid',
|
||||
'appsecret' => 'app_secret',
|
||||
]);
|
||||
```
|
||||
|
||||
2.0:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Config\WechatPlatformConfig;
|
||||
|
||||
$platform = (new Client())->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$users = $platform->get('cgi-bin/user/get', [
|
||||
'next_openid' => '',
|
||||
]);
|
||||
```
|
||||
|
||||
1.x 的 `WeChat\User::updateMark()`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$result = $platform->post('cgi-bin/user/info/updateremark', [
|
||||
'openid' => 'openid',
|
||||
'remark' => '新备注',
|
||||
]);
|
||||
```
|
||||
|
||||
1.x 支付宝 `Trade::query()`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$trade = $alipayPayment->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Exception\AlipayApiException;
|
||||
use We\Exception\AlipaySignatureException;
|
||||
use We\Exception\ApiException;
|
||||
use We\Exception\SdkException;
|
||||
use We\Exception\SignatureException;
|
||||
use We\Exception\TransportException;
|
||||
|
||||
try {
|
||||
$result = $platform->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<string,mixed>`,不再返回 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. 跑完应用单元测试、集成测试和支付沙箱/受控环境验证后再切换流量。
|
||||
136
docs/payments.md
Normal file
136
docs/payments.md
Normal file
@ -0,0 +1,136 @@
|
||||
# 微信支付 APIv3
|
||||
|
||||
微信支付客户端对商户请求签名,并在信任普通 JSON 响应前校验微信支付平台签名。配置缺少平台信任材料或序列号时会立即失败。
|
||||
|
||||
## 配置
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Config\WechatPaymentConfig;
|
||||
|
||||
$config = new WechatPaymentConfig(
|
||||
appid: 'wx_appid',
|
||||
mchId: '1900000001',
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
merchantSerial: 'merchant_certificate_serial',
|
||||
merchantPrivateKey: $merchantPrivateKeyPem,
|
||||
platformPublicKey: $wechatPayPlatformPublicKeyPem,
|
||||
platformSerial: 'wechatpay_platform_serial',
|
||||
notificationToleranceSeconds: 300,
|
||||
);
|
||||
|
||||
$payment = (new Client())->wechatPayment($config);
|
||||
```
|
||||
|
||||
商户私钥和序列号用于 `WECHATPAY2-SHA256-RSA2048` 请求签名。平台公钥或平台证书及其序列号用于响应和通知验签,两类材料不可混用。
|
||||
|
||||
## API 调用与响应信任
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$order = $payment->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$response = $payment->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$rawBody = (string) file_get_contents('php://input');
|
||||
$headers = [
|
||||
'Wechatpay-Timestamp' => (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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$result = $payment->post('v3/example/with-sensitive-information', $payload, [
|
||||
'headers' => [
|
||||
'Wechatpay-Serial' => 'wechatpay_platform_serial',
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
这里的 `Wechatpay-Serial` 不是商户证书序列号。
|
||||
72
docs/testing.md
Normal file
72
docs/testing.md
Normal file
@ -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`,不支持这些能力的环境会按测试声明处理,不应改为访问生产缓存。
|
||||
157
docs/wechat.md
Normal file
157
docs/wechat.md
Normal file
@ -0,0 +1,157 @@
|
||||
# 微信平台
|
||||
|
||||
微信公众平台、小程序和服务平台客户端共享“官方相对 path + 参数数组”的调用方式。SDK 管理协议和 token,不把官方接口复制成大量业务方法。
|
||||
|
||||
## 公众平台
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Config\WechatPlatformConfig;
|
||||
|
||||
$platform = (new Client())->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$oauth = $platform->get('sns/oauth2/access_token', [
|
||||
'appid' => 'wx_appid',
|
||||
'secret' => 'app_secret',
|
||||
'code' => 'authorization_code',
|
||||
'grant_type' => 'authorization_code',
|
||||
], [
|
||||
'with_token' => false,
|
||||
]);
|
||||
```
|
||||
|
||||
## 小程序
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Config\WechatWxappConfig;
|
||||
|
||||
$wxapp = (new 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,
|
||||
]);
|
||||
```
|
||||
|
||||
## 原始响应、下载和上传
|
||||
|
||||
`raw()` 与 `download()` 返回 PSR-7 `ResponseInterface`,适用于图片、媒体和其他非 JSON 数据。`upload()` 接受 Guzzle multipart 结构并解析平台 JSON 响应。
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$image = $platform->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$plain = $platform->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use We\Client;
|
||||
use We\Config\WechatServiceConfig;
|
||||
|
||||
$client = new Client(authorizers: $authorizerTokenStore);
|
||||
$service = $client->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` 统一捕获。
|
||||
124
src/Client.php
124
src/Client.php
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<int,mixed> $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<int, mixed> $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;
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Config;
|
||||
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<string,mixed> $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 或正整数');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Config;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Config;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Config;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Contract;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Contract;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Contract;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Contract\Trait;
|
||||
|
||||
|
||||
10
src/Exception/AlipayApiException.php
Normal file
10
src/Exception/AlipayApiException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
/**
|
||||
* 支付宝网关响应或业务接口失败。
|
||||
*/
|
||||
final class AlipayApiException extends AlipayException {}
|
||||
10
src/Exception/AlipayException.php
Normal file
10
src/Exception/AlipayException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
/**
|
||||
* 支付宝平台配置、协议或数据处理失败。
|
||||
*/
|
||||
class AlipayException extends SdkException {}
|
||||
10
src/Exception/AlipaySignatureException.php
Normal file
10
src/Exception/AlipaySignatureException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
/**
|
||||
* 支付宝平台响应签名验证失败。
|
||||
*/
|
||||
final class AlipaySignatureException extends AlipayException {}
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
|
||||
33
src/Exception/SdkException.php
Normal file
33
src/Exception/SdkException.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
/**
|
||||
* SDK 基础异常。
|
||||
*
|
||||
* 通过 context() 暴露平台响应、验签明细等上下文,便于业务侧记录日志和排查问题。
|
||||
*/
|
||||
class SdkException extends \RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
string $message,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null,
|
||||
private readonly array $context = []
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function context(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
/**
|
||||
* 微信回调、微信支付通知或支付宝通知签名验证失败时抛出的异常。
|
||||
* 微信回调、微信支付响应或通知签名验证失败时抛出的异常。
|
||||
*/
|
||||
final class SignatureException extends WechatException {}
|
||||
|
||||
10
src/Exception/TransportException.php
Normal file
10
src/Exception/TransportException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
/**
|
||||
* SDK 与外部平台通信失败。
|
||||
*/
|
||||
final class TransportException extends SdkException {}
|
||||
@ -1,43 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Exception;
|
||||
|
||||
/**
|
||||
* SDK 基础异常。
|
||||
*
|
||||
* 通过 context() 暴露平台响应、验签明细等上下文,便于业务侧记录日志和排查问题。
|
||||
* 微信平台配置、协议或数据处理失败。
|
||||
*/
|
||||
class WechatException extends \RuntimeException
|
||||
{
|
||||
/**
|
||||
* 创建 SDK 异常并保存可选上下文。
|
||||
*
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
string $message,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null,
|
||||
private readonly array $context = []
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取异常附带的平台响应或验签上下文。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function context(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
class WechatException extends SdkException {}
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Platform\Alipay;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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('支付宝响应签名节点不完整');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<string,mixed> $query
|
||||
* @param array<string,mixed> $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<string,mixed> $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<string,mixed> $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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Platform\Wechat;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Platform\Wechat;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Platform\Wechat;
|
||||
|
||||
|
||||
@ -1,19 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Support;
|
||||
|
||||
use We\Exception\SdkException;
|
||||
use We\Exception\WechatException;
|
||||
|
||||
/**
|
||||
@ -53,24 +48,38 @@ final class CredentialValidator
|
||||
|
||||
/**
|
||||
* 校验 RSA 私钥。
|
||||
*
|
||||
* @param class-string<SdkException> $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<SdkException> $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<SdkException> $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 密钥或证书');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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();
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Support;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Support;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Support;
|
||||
|
||||
|
||||
@ -1,18 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Support;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Support;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Support;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
|
||||
@ -1,24 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,18 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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', ' ');
|
||||
}
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<string,mixed> */
|
||||
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 {}
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<string,array{mixed}>
|
||||
*/
|
||||
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<string,mixed> $overrides
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
88
tests/DocumentationTest.php
Normal file
88
tests/DocumentationTest.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 面向使用者的 Markdown 文档契约测试。
|
||||
* @internal
|
||||
* @coversNothing
|
||||
*/
|
||||
final class DocumentationTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
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("<?php\n" . $source, TOKEN_PARSE);
|
||||
self::assertNotEmpty($tokens, 'Unable to tokenize PHP example in ' . $document);
|
||||
++$blockCount;
|
||||
}
|
||||
}
|
||||
|
||||
self::assertGreaterThan(0, $blockCount);
|
||||
}
|
||||
|
||||
private static function read(string $path): string
|
||||
{
|
||||
$contents = file_get_contents(self::root() . '/' . $path);
|
||||
self::assertIsString($contents, 'Unable to read ' . $path);
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
private static function root(): string
|
||||
{
|
||||
return dirname(__DIR__);
|
||||
}
|
||||
}
|
||||
41
tests/ExceptionHierarchyTest.php
Normal file
41
tests/ExceptionHierarchyTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use We\Exception\AlipayApiException;
|
||||
use We\Exception\AlipayException;
|
||||
use We\Exception\AlipaySignatureException;
|
||||
use We\Exception\ApiException;
|
||||
use We\Exception\SdkException;
|
||||
use We\Exception\SignatureException;
|
||||
use We\Exception\TransportException;
|
||||
use We\Exception\WechatException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @coversNothing
|
||||
*/
|
||||
final class ExceptionHierarchyTest extends TestCase
|
||||
{
|
||||
public function testAllPlatformFailuresCanBeCaughtAsSdkFailures(): void
|
||||
{
|
||||
self::assertInstanceOf(SdkException::class, new WechatException('wechat'));
|
||||
self::assertInstanceOf(SdkException::class, new AlipayException('alipay'));
|
||||
self::assertInstanceOf(WechatException::class, new ApiException('wechat api'));
|
||||
self::assertInstanceOf(WechatException::class, new SignatureException('wechat signature'));
|
||||
self::assertInstanceOf(AlipayException::class, new AlipayApiException('alipay api'));
|
||||
self::assertInstanceOf(AlipayException::class, new AlipaySignatureException('alipay signature'));
|
||||
self::assertInstanceOf(SdkException::class, new TransportException('transport'));
|
||||
}
|
||||
|
||||
public function testSdkFailureExposesDiagnosticContext(): void
|
||||
{
|
||||
$exception = new SdkException('failure', 7, null, ['platform_code' => 'INVALID']);
|
||||
|
||||
self::assertSame(7, $exception->getCode());
|
||||
self::assertSame(['platform_code' => 'INVALID'], $exception->context());
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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<string,string>,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];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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']];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
34
tests/WechatPaymentResponse.php
Normal file
34
tests/WechatPaymentResponse.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use We\Support\Signature;
|
||||
|
||||
/**
|
||||
* 微信支付平台签名响应测试夹具。
|
||||
*/
|
||||
final class WechatPaymentResponse
|
||||
{
|
||||
public static function signed(
|
||||
int $status,
|
||||
string $body,
|
||||
string $platformPrivateKey,
|
||||
string $serial = 'platform-serial',
|
||||
): Response {
|
||||
$timestamp = '1778200000';
|
||||
$nonce = 'response-nonce';
|
||||
|
||||
return new Response($status, [
|
||||
'Wechatpay-Timestamp' => $timestamp,
|
||||
'Wechatpay-Nonce' => $nonce,
|
||||
'Wechatpay-Serial' => $serial,
|
||||
'Wechatpay-Signature' => Signature::paymentV3Sign(
|
||||
$platformPrivateKey,
|
||||
"{$timestamp}\n{$nonce}\n{$body}\n",
|
||||
),
|
||||
], $body);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
|
||||
namespace We\Tests;
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of HyperfAdmin.
|
||||
*
|
||||
* @Link https://thinkadmin.top
|
||||
* @Author Anyon<zoujingli@qq.com>
|
||||
*/
|
||||
$autoloadCandidates = [
|
||||
dirname(__DIR__) . '/vendor/autoload.php',
|
||||
dirname(__DIR__, 2) . '/vendor/autoload.php',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user