feat(release): harden 2.0 security and quality gates

This commit is contained in:
Anyon 2026-08-10 19:00:05 +08:00
parent fe366ae837
commit ac011388ce
66 changed files with 2640 additions and 1520 deletions

View File

@ -1,6 +1,7 @@
name: CI
on:
workflow_call:
push:
branches:
- master

View File

@ -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') }}

View File

@ -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',
],

1038
README.md

File diff suppressed because it is too large Load Diff

105
docs/alipay.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 的第二个参数作为 queryPOST 的第二个参数默认作为 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` 统一捕获。

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
/**

View File

@ -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 或正整数');
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace We\Exception;
/**
* 支付宝网关响应或业务接口失败。
*/
final class AlipayApiException extends AlipayException {}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace We\Exception;
/**
* 支付宝平台配置、协议或数据处理失败。
*/
class AlipayException extends SdkException {}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace We\Exception;
/**
* 支付宝平台响应签名验证失败。
*/
final class AlipaySignatureException extends AlipayException {}

View File

@ -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;

View 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;
}
}

View File

@ -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 {}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace We\Exception;
/**
* SDK 与外部平台通信失败。
*/
final class TransportException extends SdkException {}

View File

@ -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 {}

View File

@ -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;

View File

@ -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('支付宝响应签名节点不完整');
}
}

View File

@ -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']);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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]));
}
/**

View File

@ -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 密钥或证书');
}
}
}

View File

@ -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();

View File

@ -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],
);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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],
);
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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&notify_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&notify_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());
}
}
/**

View File

@ -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', ' ');
}

View File

@ -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 {}

View File

@ -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);

View File

@ -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);
}
}

View 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__);
}
}

View 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());
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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];
}
}

View File

@ -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;

View File

@ -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']];
}
}
/**

View File

@ -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;
}
}

View 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);
}
}

View File

@ -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;

View File

@ -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',