评论

使用wechatpay-php,以函数链的形式,从APIv3流式下载交易帐单

代码分享,如何使用 wechatpay/wechatpay 软件包,两步结合stream+gzip流式及压缩模式下载交易帐单,低内存消耗,使用相同逻辑可链接更多处理逻辑。

APIv3的变化

相较于APIv2,新的实现,把一步下载交易帐单,接口层面拆分成了两步,即:

  1. 申请交易帐单下载地址
  2. 从申请到的下载地址,下载交易帐单

并且,两步下载,均是APIv3规范,即请求需要添加签名头Authorization,第二步因为是 文件流,开发规范说明需要用第一步获取到的返回值(含文件csv格式的sha1签名)做验签。

分解

一步获取不到文件,第二步强依赖第一步的返回值,而且帐单文件下载地址,有效期还只有30秒,这用链式去组装,是再合适不过了。我们用自然语言来分解这个需求,如下:

  1. (异步)HTTP请求 v3/bill/tradebill 交易帐单下载地址接口;
  2. 以第一步的返回值,解析获取到的 download_url,并且一直把这一步的返回值传递给其他函数处理;
  3. 解析download_url,拆分出 base_uri, pathnamequery 参数,并合并前一步的结果,传递给下一个函数处理;
  4. 根据前俩函数的综合返回值,作为请求入参,流式及压缩模式获取帐单文件并落盘,落盘后的文件句柄,合并至前一步返回值;
  5. 根据上一步的落盘文件句柄,以第二步接口返回的摘要值,流式验签;
  6. 把上述拆解步骤,组装成一个链,使用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

结束

帐单下载后的解析,其实已经在软件包仓库的测试用例里已经写过了,也是流式解析,有兴趣可以翻这里看源码实现。

最后

代码折叠后的样例如下图:

最后一次编辑于  2021-11-29  
点赞 7
收藏
评论

3 个评论

  • 肚叽
    肚叽
    2021-12-20

    加上handler之后就不再otherwise里返回了, 在then里面了, 但是then里面的response是NULL :(

    <?php
    >then(static function($response) {
        echo 'okkkk';
        var_dump($response);
        return Transformer::toArray((string)$response->getBody());
    })
    
    2021-12-20
    赞同 1
    回复 1
    • 北望沣渭
      北望沣渭
      2021-12-20
      这个流式下载帐单,已经在我们生产环境用着了,稳定运行2个多月了;你代码上的`then` callback型参没值是因为你没有给对'handler'的值,参考我在之前你的提问里的回复
      2021-12-20
      回复
  • 冯侃尉
    冯侃尉
    2022-05-24

    老师,我下载的账单是这样的,下载对了吗

    2022-05-24
    赞同
    回复 1
    • 北望沣渭
      北望沣渭
      2022-05-25
      是对的
      2022-05-25
      回复
  • 冯侃尉
    冯侃尉
    2022-05-23

    什么时候计算的签名,设置的请求头?代码里没看出来

    2022-05-23
    赞同
    回复 6
    • 北望沣渭
      北望沣渭
      2022-05-23
      ClientJsonTrait.php::signer 方法
      2022-05-23
      回复
    • 冯侃尉
      冯侃尉
      2022-05-24回复北望沣渭
      你好,我的意思是你代码里没看到有计算签名的地方,不理解的地方在于$previous,能从一个字符串的uri中设置好签名请求头吗?第四步中下载账单的base_uri是什么?
      2022-05-24
      回复
    • 北望沣渭
      北望沣渭
      2022-05-24
      本文是个具体应用,签名计算是在SDK中(自动);$previous是指账单文件下载,是分两步,1是获取下载文件地址,2是获取文件内容;$previous就是1。

      这里官方文档没细说的是,参与签名的是URI中的URL(pathname), base_uri 是不参与签名的。

      第四步的base_uri是第三步解析出来的,从服务端返回的,官方他们吐回什么,就是什么,有可能是api.mch,也可能是api2.mch。
      2022-05-24
      回复
    • 冯侃尉
      冯侃尉
      2022-05-24回复北望沣渭
      谢谢,账单下载成功了,但是请允许我提两个小问题:
      一个是$handler的获取,getConfig要被废弃了
      一个是我下载的压缩包没问题,但是包里的文件没有后缀名,自己添加上xlsx的后缀名后可以正常打开,是因为我没有验证hash吗?
      2022-05-24
      回复
    • 冯侃尉
      冯侃尉
      2022-05-24回复北望沣渭
      SDK是不是有可以跳过内置验证的方法?
      2022-05-24
      回复
    查看更多(1)
登录 后发表内容