评论

微信合单支付demo - PHP

微信合单支付php版demo,微信V3加密协议解析

微信合单支付PHP版demo

1、获取API v3证书

/**
 * 获取证书
 * @return mixed
 */
public function getCert()
{
    $url = 'https://api.mch.weixin.qq.com/v3/certificates';

    $timestamp = time();
    $nonce = $this->nonce_str();
    $body = '';
    $sign = $this->sign($url, 'GET', $timestamp, $nonce, $body, $this->getPrivateKey($this->private_key), $this->mch_id,
        $this->serial_no);

    $header = [
        'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $sign,
        'Accept:application/json',
        'User-Agent:' . $this->mch_id,
    ];

    $result = $this->curl($url, '', $header, 'GET');
    $result = json_decode($result, true);

    return $result['data']['0']['serial_no'];
}


2、支付请求

/**
 * @return bool|mixed
 */
public function pay()
{
    $url = 'https://api.mch.weixin.qq.com/v3/combine-transactions/app';

    $requestData = [
        'combine_appid' => $this->app_id,
        'combine_mchid' => $this->mch_id,
        'combine_out_trade_no' => 'app_pay_' . time(),
        'scene_info' => [
            'device_id' => 'pay_device_id',
            'payer_client_ip' => '127.0.0.1',
        ],
        'time_start' => date('c', time()),
        'time_expire' => date('c', time() + 7200),
        'notify_url' => 'http://www.ttglad.com/notify.php',
        'sub_orders' => [
            [
                'mchid' => $this->mch_id,
                'attach' => 'notify with attach',
                'amount' => [
                    'total_amount' => 100,
                    'currency' => 'CNY',
                ],
                'out_trade_no' => 'sub_order_' . time(),
                'sub_mchid' => $this->sub_mch_id, // 二级商户号 需要进件系统生成
                'profit_sharing' => true, // 分账
                'description' => '描述',
            ],
        ],
    ];

    $header = $this->getCurlHeader($url, json_encode($requestData), 'POST');

    $result = $this->curl($url, json_encode($requestData), $header, 'POST');

    return json_decode($result, true);
}


3、支付查询

/**
 * @return mixed
 */
public function payQuery()
{
    $url = 'https://api.mch.weixin.qq.com/v3/combine-transactions/out-trade-no/';
    $url = $url . ''; // 支付单号

    $method = 'GET';
    $data = '';
    $header = $this->getCurlHeader($url, $data, $method);

    $result = $this->curl($url, $data, $header, $method);

    return json_decode($result, true);
}

4、退款

/**
 * @return mixed
 */
public function refund()
{
    $requestData = [
        'sp_appid' => $this->app_id,
        'sub_mchid' => $this->sub_mch_id,
        'transaction_id' => '',// 支付三方流水
        'out_refund_no' => '',// 退款单号

        'notify_url' => 'http://www.ttglad.com/notify_refund.php',
        'amount' => [
            'refund' => 100,
            'total' => 100,
            'currency' => 'CNY',
        ]
    ];

    $header = $this->getCurlHeader($this->refundUrl, json_encode($requestData), 'POST');
    $result = $this->curl($this->refundUrl, json_encode($requestData), $header, 'POST');

    return json_decode($result, true);
}

5、退款查询

/**
 * @return mixed
 */
public function refundQuery()
{
    $url = 'https://api.mch.weixin.qq.com/v3/ecommerce/refunds/id/' . '' . '?sub_mchid=' . $this->sub_mch_id;

    $method = 'GET';
    $data = '';
    $header = $this->getCurlHeader($url, $data, $method);

    $result = $this->curl($url, $data, $header, $method);

    return json_decode($result, true);
}

6、支付通知

public function notify()
{
    $header = $this->getHeaders();
    $body = $GLOBALS['HTTP_RAW_POST_DATA'];

    if (empty($header) || empty($body)) {
        throw new Exception('通知参数为空', 2001);
    }

    $timestamp = $header['WECHATPAY-TIMESTAMP'];
    $nonce = $header['WECHATPAY-NONCE'];
    $signature = $header['WECHATPAY-SIGNATURE'];
    $serialNo = $header['WECHATPAY-SERIAL'];
    if (empty($timestamp) || empty($nonce) || empty($signature) || empty($serialNo)) {
        throw new Exception('通知头参数为空', 2002);
    }

    $cert = $this->getCertBySerialNo($serialNo);

    $message = "$timestamp\n$nonce\n$body\n";

    //校验签名
    if (!$this->verify($message, $signature, $cert['plainCertificate'])) {
        throw new Exception('验签失败', 2005);
    }

    $decodeBody = json_decode($body, true);
    if (empty($decodeBody) || !isset($decodeBody['resource'])) {
        throw new Exception('通知参数内容为空', 2003);
    }
    $decodeBodyResource = $decodeBody['resource'];
    $decodeData = $this->decryptToString($decodeBodyResource['associated_data'], $decodeBodyResource['nonce'],
        $decodeBodyResource['ciphertext'], '');

    $decodeData = json_decode($decodeData, true);
    if (empty($decodeData)) {
        throw new Exception('通知参数解密发生错误', 2004);
    }

    // todo 业务逻辑
}



7、其他方法

/**
 * 初始化参数
 */
public function __construct()
{
    parent::__construct();

    // 微信支付 商户号
    $this->mch_id = '';
    // 二级商户号,需要走进件系统生成
    $this->sub_mch_id = '';
    // 微信支付 商户号绑定的appid
    $this->app_id = '';

    // 商户私钥
    $this->private_key = '';

    // 商户证书序列号
    // 如何查看证书序列号:https://wechatpay-api.gitbook.io/wechatpay-api-v3/chang-jian-wen-ti/zheng-shu-xiang-guan#ru-he-cha-kan-zheng-shu-xu-lie-hao
    $this->serial_no = '';

    // apiv3秘钥:https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/api-v3-mi-yao
    $this->mch_key = '';
}

/**
 * @param $serialNo
 * @return mixed
 */
private function getCertBySerialNo($serialNo)
{
    $url = 'https://api.mch.weixin.qq.com/v3/certificates';

    $timestamp = time();
    $nonce = $this->nonce_str();
    $body = '';
    $sign = $this->sign($url, 'GET', $timestamp, $nonce, $body, $this->getPrivateKey($this->private_key), $this->mch_id,
        $this->serial_no);

    $header = [
        'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $sign,
        'Accept:application/json',
        'User-Agent:' . $this->mch_id,
    ];

    $result = $this->curl($url, '', $header, 'GET');
    $cert = json_decode($result, true);

    $return = [];
    if (!empty($cert['data'])) {
        foreach ($cert['data'] as $item) {
            if ($serialNo == $item['serial_no']) {
                $return = $item;
                break;
            }
        }
    }

    return $return;
}

/**
 * @param $url
 * @param $body
 * @param $method
 * @return array
 */
protected function getCurlHeader($url, $body, $method)
{
    $timestamp = time();
    $nonce = $this->nonce_str();
    $sign = $this->sign($url, $method, $timestamp, $nonce, $body, $this->getPrivateKey($this->private_key), $this->mch_id,
        $this->serial_no);

    return [
        'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $sign,
        'Accept:application/json',
        'User-Agent:' . $this->mch_id,
        'Content-Type:application/json',
        'Wechatpay-Serial:' . $this->getCert(),
    ];
}


/**
 * @return string
 */
protected function nonce_str()
{
    static $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $charactersLength = strlen($characters);
    $randomString = '';
    for ($i = 0; $i < 32; $i++) {
        $randomString .= $characters[rand(0, $charactersLength - 1)];
    }
    return $randomString;
}

/**
 * @param $key
 * @return bool|resource
 */
protected function getPrivateKey($key)
{
    return openssl_get_privatekey($key);
}

/**
 * @param $key
 * @return resource
 */
protected function getPublicKey($key)
{
    return openssl_get_publickey($key);
}

/**
 * @param $url
 * @param $http_method
 * @param $timestamp
 * @param $nonce
 * @param $body
 * @param $mch_private_key
 * @param $merchant_id
 * @param $serial_no
 * @return string
 */
protected function sign($url, $http_method, $timestamp, $nonce, $body, $mch_private_key, $merchant_id, $serial_no)
{
    $url_parts = parse_url($url);
    $canonical_url = ($url_parts['path'] . (!empty($url_parts['query']) ? "?${url_parts['query']}" : ""));

    $message = $http_method . "\n" .
        $canonical_url . "\n" .
        $timestamp . "\n" .
        $nonce . "\n" .
        $body . "\n";

    openssl_sign($message, $raw_sign, $mch_private_key, 'sha256WithRSAEncryption');
    $sign = base64_encode($raw_sign);

    $schema = 'WECHATPAY2-SHA256-RSA2048 ';
    $token = sprintf('mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
        $merchant_id, $nonce, $timestamp, $serial_no, $sign);

    return $token;
}

/**
 * @param $message
 * @param $signature
 * @param $merchantPublicKey
 * @return bool|int
 */
private function verify($message, $signature, $merchantPublicKey)
{
    if (empty($merchantPublicKey)) {
        return false;
    }

    if (!in_array('sha256WithRSAEncryption', \openssl_get_md_methods(true))) {
        throw new \RuntimeException("当前PHP环境不支持SHA256withRSA");
    }
    $signature = base64_decode($signature);
    return openssl_verify($message, $signature, $this->getPublicKey($merchantPublicKey), 'sha256WithRSAEncryption');
}

/**
 * @param $url
 * @param array $data
 * @param $header
 * @param string $method
 * @param int $time_out
 * @return mixed
 */
private function curl($url, $data = [], $header, $method = 'POST', $time_out = 3)
{
    $curl = curl_init();
    // 设置curl允许执行的最长秒数

    curl_setopt($curl, CURLOPT_URL, $url);
    curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
    curl_setopt($curl, CURLOPT_HEADER, false);
    curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($curl, CURLOPT_TIMEOUT, $time_out);
    curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);

    if ($method == 'POST') {
        curl_setopt($curl, CURLOPT_POST, true);
        curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
    }

    // 执行操作
    $result = curl_exec($curl);

    curl_close($curl);
    return $result;
}

/**
 * @param $associatedData
 * @param $nonceStr
 * @param $ciphertext
 * @param $aesKey
 * @return bool|string
 */
private function decryptToString($associatedData, $nonceStr, $ciphertext, $aesKey = '')
{
    if (empty($aesKey)) {
        $aesKey = $this->mch_key;
    }
    $ciphertext = \base64_decode($ciphertext);
    if (strlen($ciphertext) <= self::AUTH_TAG_LENGTH_BYTE) {
        return false;
    }

    // ext-sodium (default installed on >= PHP 7.2)
    if (function_exists('\sodium_crypto_aead_aes256gcm_is_available') &&
        \sodium_crypto_aead_aes256gcm_is_available()) {
        return \sodium_crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $aesKey);
    }

    // ext-libsodium (need install libsodium-php 1.x via pecl)
    if (function_exists('\Sodium\crypto_aead_aes256gcm_is_available') &&
        \Sodium\crypto_aead_aes256gcm_is_available()) {
        return \Sodium\crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $aesKey);
    }

    // openssl (PHP >= 7.1 support AEAD)
    if (PHP_VERSION_ID >= 70100 && in_array('aes-256-gcm', \openssl_get_cipher_methods())) {
        $ctext = substr($ciphertext, 0, -self::AUTH_TAG_LENGTH_BYTE);
        $authTag = substr($ciphertext, -self::AUTH_TAG_LENGTH_BYTE);

        return \openssl_decrypt($ctext, 'aes-256-gcm', $aesKey, \OPENSSL_RAW_DATA, $nonceStr,
            $authTag, $associatedData);
    }

    throw new \RuntimeException('AEAD_AES_256_GCM需要PHP 7.1以上或者安装libsodium-php');
}

/**
 * @return array
 */
private function getHeaders()
{
    $headers = array();
    foreach ($_SERVER as $key => $value) {
        if ('HTTP_' == substr($key, 0, 5)) {
            $headers[str_replace('_', '-', substr($key, 5))] = $value;
        }
        if (isset($_SERVER['PHP_AUTH_DIGEST'])) {
            $header['AUTHORIZATION'] = $_SERVER['PHP_AUTH_DIGEST'];
        } elseif (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
            $header['AUTHORIZATION'] = base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . $_SERVER['PHP_AUTH_PW']);
        }
        if (isset($_SERVER['CONTENT_LENGTH'])) {
            $header['CONTENT-LENGTH'] = $_SERVER['CONTENT_LENGTH'];
        }
        if (isset($_SERVER['CONTENT_TYPE'])) {
            $header['CONTENT-TYPE'] = $_SERVER['CONTENT_TYPE'];
        }
    }
    return $headers;
}


8、注意事项

支付通知需要根据通知header里面的证书序号获取证书

合单支付同一个子单不允许在不同的父单支付


9、最近写了个支付的package,仅供参考:https://github.com/ttglad/payment



参考文档:


合单支付文档:https://pay.weixin.qq.com/wiki/doc/apiv3/wxpay/pages/combine.shtml
接口规则:https://wechatpay-api.gitbook.io/wechatpay-api-v3/
最后一次编辑于  2021-04-16  
点赞 7
收藏
评论

18 个评论

  • 微醉的丶阳光
    微醉的丶阳光
    2020-09-19

    V3的APP下单接口,提示错误的签名,验签失败,post的query参数是怎么传的

    2020-09-19
    赞同
    回复
  • 微醉的丶阳光
    微醉的丶阳光
    2020-09-19

    V3的APP下单接口,提示错误的签名,验签失败,post的query参数是怎么传的

    2020-09-19
    赞同
    回复
  • 微醉的丶阳光
    微醉的丶阳光
    2020-09-19

    V3的APP下单接口,提示错误的签名,验签失败,post的query参数是怎么传的

    2020-09-19
    赞同
    回复
  • 微醉的丶阳光
    微醉的丶阳光
    2020-09-19

    V3的APP下单接口,提示错误的签名,验签失败,post的query参数是怎么传的

    2020-09-19
    赞同
    回复
  • 阿勇
    阿勇
    2020-07-23
        /**
         * 商户证书公钥
         * @param $serialNo
         * @return mixed
         */
        private function getCertBySerialNo($serialNo)
        {
            $url = 'https://api.mch.weixin.qq.com/v3/certificates';
    
            $timestamp = time();
            $nonce = $this->nonce_str();
            $body = '';
            $sign = $this->sign($url, 'GET', $timestamp, $nonce, $body, $this->getPrivateKey($this->private_key), $this->mch_id,
                $this->serial_no);
            $header = [
                'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $sign,
                'Accept:application/json',
                'User-Agent:' . $this->mch_id,
            ];
    
            $result = $this->curl($url, '', $header, 'GET');
            $cert = json_decode($result, true);
            $return = [];
            if (!empty($cert['data'])) {
                foreach ($cert['data'] as $item) {
                    //if ($serialNo == $item['serialNo']) {
                    if ($serialNo == $item['serial_no']) {
                        $return = $item;
                        break;
                    }
                }
            }
            $decodeBodyResource = $return['encrypt_certificate'];
            $decodeData = $this->decryptToString($decodeBodyResource['associated_data'], $decodeBodyResource['nonce'], $decodeBodyResource['ciphertext'], '');
            $return['plainCertificate'] = $decodeData;
            return $return;
        }
    
    
    
    2020-07-23
    赞同
    回复
  • 阿勇
    阿勇
    2020-07-23
    签名认证好像一直过不去 $cert['plainCertificate'] 好像没有的
    
    getCertBySerialNo 里
    serialNo也是错误,应该是 serial_no
    
    
    2020-07-23
    赞同
    回复 1
    • 陶玉龙
      陶玉龙
      2020-11-24
      serial_no 改了,至于$cert['plainCertificate'],可能是你签名有问题
      2020-11-24
      回复
  • 记忆窃玉
    记忆窃玉
    2020-06-06

    {

        "code": "NO_AUTH",

        "message": "服务商商户号未开通该产品的受理权限"

    }

    2020-06-06
    赞同
    回复 2
    • 记忆窃玉
      记忆窃玉
      2020-06-06
      报这个问题,why?
      2020-06-06
      回复
    • My🌟
      My🌟
      2021-07-30回复记忆窃玉
      我也是报这个错误 你解决了吗
      2021-07-30
      回复
  • sushangyu
    sushangyu
    2020-05-15

    combine_mchid和mchid可以统一个值吗?我都不知道combine_mchid这是哪里有的,其实为什么会有合单的呢,我之所以会做合单是因为我那个商户号居然不能设置api密钥,只有apiv3密钥可以设置

    2020-05-15
    赞同
    回复 1
    • 陶玉龙
      陶玉龙
      2020-05-25
      两个值是同一个,微信是这么回复的
      2020-05-25
      回复

正在加载...

登录 后发表内容