APIv3的变化
相较于APIv2,新的实现,把一步下载交易帐单,接口层面拆分成了两步,即:
并且,两步下载,均是APIv3规范,即请求需要添加签名头Authorization
,第二步因为是 文件流,开发规范说明需要用第一步获取到的返回值(含文件csv
格式的sha1
签名)做验签。
分解
一步获取不到文件,第二步强依赖第一步的返回值,而且帐单文件下载地址,有效期还只有30秒,这用链式去组装,是再合适不过了。我们用自然语言来分解这个需求,如下:
- (异步)HTTP请求
v3/bill/tradebill
交易帐单下载地址接口; - 以第一步的返回值,解析获取到的
download_url
,并且一直把这一步的返回值传递给其他函数处理; - 解析
download_url
,拆分出base_uri
,pathname
及query
参数,并合并前一步的结果,传递给下一个函数处理; - 根据前俩函数的综合返回值,作为请求入参,流式及压缩模式获取帐单文件并落盘,落盘后的文件句柄,合并至前一步返回值;
- 根据上一步的落盘文件句柄,以第二步接口返回的摘要值,流式验签;
- 把上述拆解步骤,组装成一个链,使用
GuzzleHttp\Promise
来等待执行结果;
前置条件
安装软件包
composer require wechatpay/wechatpay
程序实现
<?php
// filename: bill.all.php
require_once('./vendor/autoload.php');
//帐单日期 YYYY-mm-dd 格式
$billDate = '2021-11-27';
//帐单保存文件地址
$csvFilePath = './bills/all.2021-11-27.csv.gz';
$merchantPrivateKeyFilePath = 'file:///path/to/merchant/apiclient_key.pem';
$platformCertificateFilePath = 'file:///path/to/wechatpay/cert.pem';
$privateKey = \WeChatPay\Crypto\Rsa::from($merchantPrivateKeyFilePath, \WeChatPay\Crypto\Rsa::KEY_TYPE_PRIVATE);
$publicKey = \WeChatPay\Crypto\Rsa::from($platformCertificateFilePath, \WeChatPay\Crypto\Rsa::KEY_TYPE_PUBLIC);
$platformCertificateSerial = \WeChatPay\Util\PemUtil::parseCertificateSerialNo($platformCertificateFilePath);
$instance = \WeChatPay\Builder::factory([
'mchid' => '190000****',
'serial' => '3775B6A45ACD588826D15E583A95F5DD********',
'privateKey' => $privateKey,
'certs' => [$platformCertificateSerial => $publicKey],
]);
$instance
->v3->bill->tradebill
// 1. 发起异步请求
->getAsync([
'query' => [
'bill_date' => $billDate,
'bill_type' => 'ALL',
'tar_type' => 'GZIP',
],
])
// 2. 自定义返回值并补上账单日期
->then(static function(\Psr\Http\Message\ResponseInterface $response) use ($billDate): array {
$target = (array) json_decode($response->getBody()->getContents(), true);
return $target + ['bill_date' => $billDate];
})
// 3. 转换及解析返回的URI
->then(static function(array $middle): array {
$previous = new \GuzzleHttp\Psr7\Uri($middle['download_url'] ?? '');
$baseUri = $previous->composeComponents($previous->getScheme(), $previous->getAuthority(), '/', '', '');
return $middle + [
'base_uri' => $baseUri, 'query' => $previous->getQuery(),
'pathname' => ltrim($previous->getPath(), '/')
];
})
// 4. 临时关闭内置验证器,流式下载账单
->then(static function(array $download) use ($instance, $csvFilePath): array {
$handler = clone $instance->getDriver()->select()->getConfig('handler');
$handler->remove('verifier');
$savedTo = \GuzzleHttp\Psr7\Utils::tryFopen($csvFilePath, 'w+');
$stream = \GuzzleHttp\Psr7\Utils::streamFor($savedTo);
$instance
->chain($download['pathname'])
->get([
'sink' => $stream,
'handler' => $handler,
'query' => $download['query'],
'base_uri' => $download['base_uri'],
]);
return $download + ['stream' => $stream];
})
// 5. 从第一步获取的帐单哈希签名及上一步的文件流,验证存储的文件签名是否相符
->then(static function(array $verify): ?string {
$hashAlgo = strtolower($verify['hash_type'] ?? 'sha1');
$hashValue = $verify['hash_value'] ?? null;
$stream = $verify['stream'];
$signature = \GuzzleHttp\Psr7\Utils::hash(new \GuzzleHttp\Psr7\InflateStream($stream), $hashAlgo);
if (\WeChatPay\Crypto\Hash::equals($signature, $hashValue)) {
$stream->close();
return sprintf('Verified (%s) with %s digest(%s) OK', $stream->getMetadata('uri'), $hashAlgo, $hashValue);
}
// TODO: 更多逻辑,比如验签失败,删除掉已存的文件等
$stream->close();
throw new \UnexpectedValueException('Bad digest verification');
})
// 6. 等待1-5链接的步骤执行完毕
->wait();
运行
php bill.all.php
商户如果没有交易帐单,程序则在1.
步骤即抛异常(HTTP状态码是400
),2、3、4、5步骤均不会执行;第5步是个本地验签逻辑,比较获取到的csv
文件摘要是否与第一步获取的一致,如果不一致也抛送出异常。
程序运行基本上是静默的(没有问题),获取到的文件存储路径为 ./bills/2021-11-27.csv.gz
结束
帐单下载后的解析,其实已经在软件包仓库的测试用例里已经写过了,也是流式解析,有兴趣可以翻这里看源码实现。
最后
代码折叠后的样例如下图:
加上handler之后就不再otherwise里返回了, 在then里面了, 但是then里面的response是NULL :(
<?php >then(static function($response) { echo 'okkkk'; var_dump($response); return Transformer::toArray((string)$response->getBody()); })