• Stars
    star
    483
  • Rank 91,050 (Top 2 %)
  • Language
    PHP
  • License
    Apache License 2.0
  • Created over 3 years ago
  • Updated about 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

微信支付 APIv3 的官方 PHP Library,同时也支持 APIv2

微信支付 WeChatPay OpenAPI SDK

[A]Sync Chainable WeChatPay v2&v3's OpenAPI SDK for PHP

GitHub actions Packagist Stars Packagist Downloads Packagist Version Packagist PHP Version Support Packagist License

概览

基于 Guzzle HTTP Client 的微信支付 PHP 开发库。

功能介绍

  1. 微信支付 APIv2 和 APIv3 的 Guzzle HTTP 客户端,支持 同步异步 发送请求,并自动进行请求签名和应答验签

  2. 链式实现的 URI Template

  3. 敏感信息加解密

  4. 回调通知的验签和解密

项目状态

当前版本为 1.4.8 测试版本。 项目版本遵循 语义化版本号。 如果你使用的版本 <=v1.3.2,升级前请参考 升级指南

环境要求

项目支持的环境如下:

  • Guzzle 7.0,PHP >= 7.2.5
  • Guzzle 6.5,PHP >= 7.1.2

我们推荐使用目前处于 Active Support 阶段的 PHP 8 和 Guzzle 7。

安装

推荐使用 PHP 包管理工具 Composer 安装 SDK:

composer require wechatpay/wechatpay

开始

ℹ️ 以下是 微信支付 API v3 的指引。如果你是 API v2 的使用者,请看 README_APIv2

概念

  • 商户 API 证书,是用来证实商户身份的。证书中包含商户号、证书序列号、证书有效期等信息,由证书授权机构(Certificate Authority ,简称 CA)签发,以防证书被伪造或篡改。详情见 什么是商户API证书?如何获取商户API证书?

  • 商户 API 私钥。你申请商户 API 证书时,会生成商户私钥,并保存在本地证书文件夹的文件 apiclient_key.pem 中。为了证明 API 请求是由你发送的,你应使用商户 API 私钥对请求进行签名。

⚠️ 不要把私钥文件暴露在公共场合,如上传到 Github,写在 App 代码中等。

  • 微信支付平台证书。微信支付平台证书是指:由微信支付负责申请,包含微信支付平台标识、公钥信息的证书。你需使用微信支付平台证书中的公钥验证 API 应答和回调通知的签名。

ℹ️ 你需要先手工 下载平台证书 才能使用 SDK 发起请求。

  • 证书序列号。每个证书都有一个由 CA 颁发的唯一编号,即证书序列号。

示例程序:微信支付平台证书下载

<?php

require_once('vendor/autoload.php');

use WeChatPay\Builder;
use WeChatPay\Crypto\Rsa;
use WeChatPay\Util\PemUtil;

// 设置参数

// 商户号
$merchantId = '190000****';

// 从本地文件中加载「商户API私钥」,「商户API私钥」会用来生成请求的签名
$merchantPrivateKeyFilePath = 'file:///path/to/merchant/apiclient_key.pem';
$merchantPrivateKeyInstance = Rsa::from($merchantPrivateKeyFilePath, Rsa::KEY_TYPE_PRIVATE);

// 「商户API证书」的「证书序列号」
$merchantCertificateSerial = '3775B6A45ACD588826D15E583A95F5DD********';

// 从本地文件中加载「微信支付平台证书」,用来验证微信支付应答的签名
$platformCertificateFilePath = 'file:///path/to/wechatpay/cert.pem';
$platformPublicKeyInstance = Rsa::from($platformCertificateFilePath, Rsa::KEY_TYPE_PUBLIC);

// 从「微信支付平台证书」中获取「证书序列号」
$platformCertificateSerial = PemUtil::parseCertificateSerialNo($platformCertificateFilePath);

// 构造一个 APIv3 客户端实例
$instance = Builder::factory([
    'mchid'      => $merchantId,
    'serial'     => $merchantCertificateSerial,
    'privateKey' => $merchantPrivateKeyInstance,
    'certs'      => [
        $platformCertificateSerial => $platformPublicKeyInstance,
    ],
]);

// 发送请求
$resp = $instance->chain('v3/certificates')->get(
    ['debug' => true] // 调试模式,https://docs.guzzlephp.org/en/stable/request-options.html#debug
);
echo $resp->getBody(), PHP_EOL;

文档

同步请求

使用客户端提供的 getputpostpatchdelete 方法发送同步请求。以 Native支付下单 为例。

try {
    $resp = $instance
    ->chain('v3/pay/transactions/native')
    ->post(['json' => [
        'mchid'        => '1900006XXX',
        'out_trade_no' => 'native12177525012014070332333',
        'appid'        => 'wxdace645e0bc2cXXX',
        'description'  => 'Image形象店-深圳腾大-QQ公仔',
        'notify_url'   => 'https://weixin.qq.com/',
        'amount'       => [
            'total'    => 1,
            'currency' => 'CNY'
        ],
    ]]);

    echo $resp->getStatusCode(), PHP_EOL;
    echo $resp->getBody(), PHP_EOL;
} catch (\Exception $e) {
    // 进行错误处理
    echo $e->getMessage(), PHP_EOL;
    if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
        $r = $e->getResponse();
        echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL;
        echo $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL;
    }
    echo $e->getTraceAsString(), PHP_EOL;
}

请求成功后,你会获得一个 GuzzleHttp\Psr7\Response 的应答对象。 阅读 Guzzle 文档 Using Response 进一步了解如何访问应答内的信息。

异步请求

使用客户端提供的 getAsyncputAsyncpostAsyncpatchAsyncdeleteAsync 方法发送异步请求。以 退款 为例。

$promise = $instance
->chain('v3/refund/domestic/refunds')
->postAsync([
    'json' => [
        'transaction_id' => '1217752501201407033233368018',
        'out_refund_no'  => '1217752501201407033233368018',
        'amount'         => [
            'refund'   => 888,
            'total'    => 888,
            'currency' => 'CNY',
        ],
    ],
])
->then(static function($response) {
    // 正常逻辑回调处理
    echo $response->getBody(), PHP_EOL;
    return $response;
})
->otherwise(static function($e) {
    // 异常错误处理
    echo $e->getMessage(), PHP_EOL;
    if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
        $r = $e->getResponse();
        echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL;
        echo $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL;
    }
    echo $e->getTraceAsString(), PHP_EOL;
});
// 同步等待
$promise->wait();

[get|post|put|patch|delete]Async 返回的是 Guzzle Promises。你可以做两件事:

  • 成功时使用 then() 处理得到的 Psr\Http\Message\ResponseInterface,(可选地)将它传给下一个 then()
  • 失败时使用 otherwise() 处理异常

最后使用 wait() 等待请求执行完成。

同步还是异步

对于大部分开发者,我们建议使用同步的模式,因为它更加易于理解。

如果你是具有异步编程基础的开发者,在某些连续调用 API 的场景,将多个操作通过 then() 流式串联起来会是一种优雅的实现方式。例如, 以函数链的形式流式下载交易帐单

链式 URI Template

URI Template 是表达 URI 中变量的一种方式。微信支付 API 使用这种方式表示 URL Path 中的单号或者 ID。

# 使用微信支付订单号查询订单
GET /v3/pay/transactions/id/{transaction_id}

# 使用商户订单号查询订单
GET /v3/pay/transactions/out-trade-no/{out_trade_no}

使用 链式 URI Template,你能像书写代码一样流畅地书写 URL,轻松地输入路径并传递 URL 参数。配置接口描述包后还能开启 IDE提示

链式串联的基本单元是 URI Path 中的 segmentssegments 之间以 -> 连接。连接的规则如下:

  • 普通 segment
    • 直接书写。例如 v3->pay->transactions->native
    • 使用 chain()。例如 chain('v3/pay/transactions/native')
  • 包含连字号(-)的 segment
    • 使用驼峰 camelCase 风格书写。例如 merchant-service 可写成 merchantService
    • 使用 {'foo-bar'} 方式书写。例如 {'merchant-service'}
  • Path 变量。URL 中的 Path 变量应使用这种写法,避免自行组装或者使用 chain(),导致大小写处理错误
    • 推荐使用 _variable_name_ 方式书写,支持 IDE 提示。例如 v3->pay->transactions->id->_transaction_id_
    • 使用 {'{variable_name}'} 方式书写。例如 v3->pay->transactions->id->{'{transaction_id}'}
  • 请求的 HTTP METHOD 作为链式最后的执行方法。例如 v3->pay->transactions->native->post([ ... ])
  • Path 变量的值,以同名参数传入执行方法
  • Query 参数,以名为 query 的参数传入执行方法

查询订单 GET 方法为例:

$promise = $instance
->v3->pay->transactions->id->_transaction_id_
->getAsync([
    // Query 参数
    'query' => ['mchid' => '1230000109'],
    // 变量名 => 变量值
    'transaction_id' => '1217752501201407033233368018',
]);

关闭订单 POST 方法为例:

$promise = $instance
->v3->pay->transactions->outTradeNo->_out_trade_no_->close
->postAsync([
    // 请求消息
    'json' => ['mchid' => '1230000109'],
    // 变量名 => 变量值
    'out_trade_no' => '1217752501201407033233368018',
]);

更多例子

视频文件上传

官方开发文档地址

// 参考上述指引说明,并引入 `MediaUtil` 正常初始化,无额外条件
use WeChatPay\Util\MediaUtil;
// 实例化一个媒体文件流,注意文件后缀名需符合接口要求
$media = new MediaUtil('/your/file/path/video.mp4');

$resp = $instance-
>chain('v3/merchant/media/video_upload')
->post([
    'body'    => $media->getStream(),
    'headers' => [
        'content-type' => $media->getContentType(),
    ]
]);

营销图片上传

官方开发文档地址

use WeChatPay\Util\MediaUtil;
$media = new MediaUtil('/your/file/path/image.jpg');
$resp = $instance
->v3->marketing->favor->media->imageUpload
->post([
    'body'    => $media->getStream(),
    'headers' => [
        'Content-Type' => $media->getContentType(),
    ]
]);

敏感信息加/解密

为了保证通信过程中敏感信息字段(如用户的住址、银行卡号、手机号码等)的机密性,

  • 微信支付要求加密上送的敏感信息
  • 微信支付会加密下行的敏感信息

下面以 特约商户进件 为例,演示如何进行 敏感信息加解密

use WeChatPay\Crypto\Rsa;
// 做一个匿名方法,供后续方便使用,$platformPublicKeyInstance 见初始化章节
$encryptor = static function(string $msg) use ($platformPublicKeyInstance): string {
    return Rsa::encrypt($msg, $platformPublicKeyInstance);
};

$resp = $instance
->chain('v3/applyment4sub/applyment/')
->post([
    'json' => [
        'business_code' => 'APL_98761234',
        'contact_info'  => [
            'contact_name'      => $encryptor('张三'),
            'contact_id_number' => $encryptor('110102YYMMDD888X'),
            'mobile_phone'      => $encryptor('13000000000'),
            'contact_email'     => $encryptor('[email protected]'),
        ],
        //...
    ],
    'headers' => [
        // $platformCertificateSerial 见初始化章节
        'Wechatpay-Serial' => $platformCertificateSerial,
    ],
]);

签名

你可以使用 Rsa::sign() 计算调起支付时所需参数签名。以 JSAPI支付 为例。

use WeChatPay\Formatter;
use WeChatPay\Crypto\Rsa;

$merchantPrivateKeyFilePath = 'file:///path/to/merchant/apiclient_key.pem';
$merchantPrivateKeyInstance = Rsa::from($merchantPrivateKeyFilePath);

$params = [
    'appId'     => 'wx8888888888888888',
    'timeStamp' => (string)Formatter::timestamp(),
    'nonceStr'  => Formatter::nonce(),
    'package'   => 'prepay_id=wx201410272009395522657a690389285100',
];
$params += ['paySign' => Rsa::sign(
    Formatter::joinedByLineFeed(...array_values($params)),
    $merchantPrivateKeyInstance
), 'signType' => 'RSA'];

echo json_encode($params);

回调通知

回调通知受限于开发者/商户所使用的WebServer有很大差异,这里只给出开发指导步骤,供参考实现。

  1. 从请求头部Headers,拿到Wechatpay-SignatureWechatpay-NonceWechatpay-TimestampWechatpay-SerialRequest-ID,商户侧Web解决方案可能有差异,请求头可能大小写不敏感,请根据自身应用来定;
  2. 获取请求body体的JSON纯文本;
  3. 检查通知消息头标记的Wechatpay-Timestamp偏移量是否在5分钟之内;
  4. 调用SDK内置方法,构造验签名串然后经Rsa::verfify验签;
  5. 消息体需要解密的,调用SDK内置方法解密;
  6. 如遇到问题,请拿Request-ID点击这里,联系官方在线技术支持;

样例代码如下:

use WeChatPay\Crypto\Rsa;
use WeChatPay\Crypto\AesGcm;
use WeChatPay\Formatter;

$inWechatpaySignature = '';// 请根据实际情况获取
$inWechatpayTimestamp = '';// 请根据实际情况获取
$inWechatpaySerial = '';// 请根据实际情况获取
$inWechatpayNonce = '';// 请根据实际情况获取
$inBody = '';// 请根据实际情况获取,例如: file_get_contents('php://input');

$apiv3Key = '';// 在商户平台上设置的APIv3密钥

// 根据通知的平台证书序列号,查询本地平台证书文件,
// 假定为 `/path/to/wechatpay/inWechatpaySerial.pem`
$platformPublicKeyInstance = Rsa::from('file:///path/to/wechatpay/inWechatpaySerial.pem', Rsa::KEY_TYPE_PUBLIC);

// 检查通知时间偏移量,允许5分钟之内的偏移
$timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp);
$verifiedStatus = Rsa::verify(
    // 构造验签名串
    Formatter::joinedByLineFeed($inWechatpayTimestamp, $inWechatpayNonce, $inBody),
    $inWechatpaySignature,
    $platformPublicKeyInstance
);
if ($timeOffsetStatus && $verifiedStatus) {
    // 转换通知的JSON文本消息为PHP Array数组
    $inBodyArray = (array)json_decode($inBody, true);
    // 使用PHP7的数据解构语法,从Array中解构并赋值变量
    ['resource' => [
        'ciphertext'      => $ciphertext,
        'nonce'           => $nonce,
        'associated_data' => $aad
    ]] = $inBodyArray;
    // 加密文本消息解密
    $inBodyResource = AesGcm::decrypt($ciphertext, $apiv3Key, $nonce, $aad);
    // 把解密后的文本转换为PHP Array数组
    $inBodyResourceArray = (array)json_decode($inBodyResource, true);
    // print_r($inBodyResourceArray);// 打印解密后的结果
}

异常处理

Guzzle 默认已提供基础中间件\GuzzleHttp\Middleware::httpErrors来处理异常,文档可见这里。 本SDK自v1.1对异常处理做了微调,各场景抛送出的异常如下:

  • HTTP网络错误,如网络连接超时、DNS解析失败等,送出\GuzzleHttp\Exception\RequestException
  • 服务器端返回了 5xx HTTP 状态码,送出\GuzzleHttp\Exception\ServerException;
  • 服务器端返回了 4xx HTTP 状态码,送出\GuzzleHttp\Exception\ClientException;
  • 服务器端返回了 30x HTTP 状态码,如超出SDK客户端重定向设置阈值,送出\GuzzleHttp\Exception\TooManyRedirectsException;
  • 服务器端返回了 20x HTTP 状态码,如SDK客户端逻辑处理失败,例如应答签名验证失败,送出\GuzzleHttp\Exception\RequestException
  • 请求签名准备阶段,HTTP请求未发生之前,如PHP环境异常、商户私钥异常等,送出\UnexpectedValueException;
  • 初始化时,如把商户证书序列号配置成平台证书序列号,送出\InvalidArgumentException;

以上示例代码,均含有catchotherwise错误处理场景示例,测试用例也覆盖了5xx/4xx/20x异常,开发者可参考这些代码逻辑进行错误处理。

定制

当默认的本地签名和验签方式不适合你的系统时,你可以通过实现signer或者verifier中间件来定制签名和验签,比如,你的系统把商户私钥集中存储,业务系统需通过远程调用进行签名。 以下示例用来演示如何替换SDK内置中间件,来实现远程请求签名结果验签,供商户参考实现。

use GuzzleHttp\Client;
use GuzzleHttp\Middleware;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

// 假设集中管理服务器接入点为内网`http://192.168.169.170:8080/`地址,并提供两个URI供签名及验签
// - `/wechatpay-merchant-request-signature` 为请求签名
// - `/wechatpay-response-merchant-validation` 为响应验签
$client = new Client(['base_uri' => 'http://192.168.169.170:8080/']);

// 请求参数签名,返回字符串形如`\WeChatPay\Formatter::authorization`返回的字符串
$remoteSigner = function (RequestInterface $request) use ($client, $merchantId): string {
    return (string)$client->post('/wechatpay-merchant-request-signature', ['json' => [
        'mchid' => $merchantId,
        'verb'  => $request->getMethod(),
        'uri'   => $request->getRequestTarget(),
        'body'  => (string)$request->getBody(),
    ]])->getBody();
};

// 返回结果验签,返回可以是4xx,5xx,与远程验签应用约定返回字符串'OK'为验签通过
$remoteVerifier = function (ResponseInterface $response) use ($client, $merchantId): string {
    [$nonce]     = $response->getHeader('Wechatpay-Nonce');
    [$serial]    = $response->getHeader('Wechatpay-Serial');
    [$signature] = $response->getHeader('Wechatpay-Signature');
    [$timestamp] = $response->getHeader('Wechatpay-Timestamp');
    return (string)$client->post('/wechatpay-response-merchant-validation', ['json' => [
        'mchid'     => $merchantId,
        'nonce'     => $nonce,
        'serial'    => $serial,
        'signature' => $signature,
        'timestamp' => $timestamp,
        'body'      => (string)$response->getBody(),
    ]])->getBody();
};

$stack = $instance->getDriver()->select()->getConfig('handler');
// 卸载SDK内置签名中间件
$stack->remove('signer');
// 注册内网远程请求签名中间件
$stack->before('prepare_body', Middleware::mapRequest(
    static function (RequestInterface $request) use ($remoteSigner): RequestInterface {
        return $request->withHeader('Authorization', $remoteSigner($request));
    }
), 'signer');
// 卸载SDK内置验签中间件
$stack->remove('verifier');
// 注册内网远程请求验签中间件
$stack->before('http_errors', static function (callable $handler) use ($remoteVerifier): callable {
    return static function (RequestInterface $request, array $options = []) use ($remoteVerifier, $handler) {
        return $handler($request, $options)->then(
            static function(ResponseInterface $response) use ($remoteVerifier, $request): ResponseInterface {
                $verified = '';
                try {
                    $verified = $remoteVerifier($response);
                } catch (\Throwable $exception) {}
                if ($verified === 'OK') { //远程验签约定,返回字符串`OK`作为验签通过
                    throw new RequestException('签名验签失败', $request, $response, $exception ?? null);
                }
                return $response;
            }
        );
    };
}, 'verifier');

// 链式/同步/异步请求APIv3即可,例如:
$instance->v3->certificates->getAsync()->then(static function($res) { return $res->getBody(); })->wait();

常见问题

如何下载平台证书?

使用内置的微信支付平台证书下载器

composer exec CertificateDownloader.php -- -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}

微信支付平台证书下载后,下载器会用获得的平台证书对返回的消息进行验签。下载器同时开启了 Guzzledebug => true 参数,方便查询请求/响应消息的基础调试信息。

ℹ️ 什么是APIv3密钥?如何设置?

证书和回调解密需要的AesGcm解密在哪里?

请参考AesGcm.php,例如内置的平台证书下载工具解密代码如下:

AesGcm::decrypt($cert->ciphertext, $apiv3Key, $cert->nonce, $cert->associated_data);

配合swoole使用时,上传文件接口报错

建议升级至swoole 4.6+,swoole在 4.6.0 中增加了native-curl(swoole/swoole-src#3863)支持,我们测试能正常使用了。 更详细的信息,请参考#36

如何加载公/私钥和证书

v1.2提供了统一的加载函数 Rsa::from($thing, $type)

  • Rsa::from($thing, $type) 支持从文件/字符串加载公/私钥和证书,使用方法可参考 RsaTest.php
  • Rsa::fromPkcs1是个语法糖,支持加载 PKCS#1 格式的公/私钥,入参是 base64 字符串
  • Rsa::fromPkcs8是个语法糖,支持加载 PKCS#8 格式的私钥,入参是 base64 字符串
  • Rsa::fromSpki是个语法糖,支持加载 SPKI 格式的公钥,入参是 base64 字符串
  • Rsa::pkcs1ToSpki是个 RSA公钥 格式转换函数,入参是 base64 字符串

如何计算商家券发券 API 的签名

使用 Hash::sign()计算 APIv2 的签名,示例请参考 APIv2 文档的 数据签名

为什么 URL 上的变量 OpenID,请求时被替换成小写了?

本 SDK 把 URL 中的大写视为包含连字号的 segment。请求时, camelCase 会替换为 camel-case。相关 issue 可参考 #56#69

为了避免大小写错乱,URL 中存在变量时的正确做法是:使用 链式 URI Template 的 Path 变量。比如:

  • 推荐写法 ->v3->marketing->favor->users->_openid_->coupons->post(['openid' => 'AbcdEF12345'])
  • ->v3->marketing->favor->users->{'{openid}'}->coupons->post(['openid' => 'AbcdEF12345'])
  • ->chain('{+myurl}')->post(['myurl' => 'v3/marketing/favor/users/AbcdEF12345/coupons'])
  • ->{'{+myurl}'}->post(['myurl' => 'v3/marketing/favor/users/AbcdEF12345/coupons'])

联系我们

如果你发现了BUG或者有任何疑问、建议,请通过issue进行反馈。

也欢迎访问我们的开发者社区

链接

License

Apache-2.0 License