PHP对接第三方支付渠道之微信支付v3版本

文接上篇PHP如何更科学地接入第三方渠道,既然已经写到这了,索性创建了一个gitee仓库,地址:https://gitee.com/wuzhh/tp6-payment,有需要的可以去看看。

言归正传,微信支付v3版本刚推出不久,鉴于微信官方一贯语焉不详的尿性,论坛上自然仍旧一片哀嚎,鄙人一路踩坑下来,倒也还算顺利,把过程分享给大家参考~

一、微信支付平台相关配置

1. 配置API证书和API v3密钥

在微信商户平台中找到API安全,这一步按照官方提示操作即可,比较简单不再赘述

2. 分别加载guzzlehttp和wechatpay的composer包

composer require guzzlehttp/guzzle:~6.3
composer require wechatpay/wechatpay-guzzle-middleware:^0.2.2

3. 生成微信支付平台证书

注意,第1步中导出的证书有三个文件,以我的经验只有apiclient_key.pem是有用的,apiclient_cert.pem则没什么用(没发现它有什么用),拿到apiclient_key.pem的路径之后:

  • 进入项目根目录的vendor/wechatpay/wechatpay-guzzle-middleware下
  • 执行如下shell命令:
php tool/CertificateDownloader.php -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath} -c ${wechatpayCertificateFilePath}

上面是官方提供的命令,此处:
apiV3key = 设置的v3秘钥
mchId = 商户号
mchPrivateKeyFilePath = apiclient_key.pem的路径
mchSerialNo = 商户API证书序列号
outputFilePath = 微信支付平台证书的存储路径

你可能会问,-c参数填啥?这里需要说一下这个参数是验证证书用的,填的是微信支付平台证书的路径,因为我们现在是第一次创建证书,所以-c参数不需要填写,需要特别注意一下。

4. 将微信支付的配置填入payment表

CREATE TABLE `payment`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '渠道ID',
  `channel_name` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '支付渠道名称',
  `channel_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '支付渠道代码',
  `channel_logo` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '支付渠道LOGO',
  `slogan` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '支付渠道标语',
  `class_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '处理模块',
  `merchant_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '商户ID',
  `appid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Appid',
  `app_secret` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'App Secret',
  `gateway_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '支付地址',
  `notify_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '通知地址',
  `cipher_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'RSA' COMMENT '加密方式',
  `private_key` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '加密私钥,通常rsa模式需要',
  `public_key` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '加密公钥,通常rsa模式需要',
  `api_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '加密秘钥,通常md5模式需要',
  `extra_params` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '额外参数',
  `format` char(4) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'JSON' COMMENT '接口参数格式,默认json',
  `return` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'success' COMMENT '回调成功返回标识,success',
  `os` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '支持系统,android,ios',
  `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态,1=开启,0=关闭',
  `sort` int(10) NULL DEFAULT NULL COMMENT '排序值',
  `pay_test` tinyint(1) NOT NULL DEFAULT 0 COMMENT '测试模式,开启后支付1分',
  `created_at` int(10) NULL DEFAULT NULL COMMENT '创建时间',
  `updated_at` int(10) NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `idx_code`(`channel_code`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

二、微信支付后台处理模块

1. 为了确保所有支付类有共同的调用接口,实现支付接口类

<?php

namespace payment;

interface PaymentInterface {
    public function doPay($params);
    public function notify($content);
}

这样就能确保上文注入的支付实例都有共同的支付和回调方法

2. 微信支付具体逻辑:


<?php

namespace payment\wxpay;

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Exception\RequestException;
use WechatPay\GuzzleMiddleware\WechatPayMiddleware;
use WechatPay\GuzzleMiddleware\Util\PemUtil;

use app\model\Payment;
use payment\PaymentInterface;

/**
 * 微信支付
 */
class WxPay implements PaymentInterface
{
    private $appid;                 // appid
    private $appSecret;             // appSecret
    private $apiV3Key;              // V3秘钥
    private $notifyUrl;             // 通知地址
    private $gatewayUrl;            // 接口地址
    private $merchantId;            // 商户号
    private $merchantSerialNumber;  // 商户API证书序列号
    private $merchantPrivateKey;    // 商户私钥
    private $payCertificate;        // 支付平台证书
    private $privateKey;            // 商户私钥
    private $publicKey;             // 商户公钥
    private $payTest;               // 测试支付开关
    private $extraConfigs;          // 额外配置

    public function __construct()
    {
        // code...
    }

    public function config(Payment $config)
    {
        $this->appid                = $config->appid;
        $this->appSecret            = $config->app_secret;
        $this->extraConfigs         = $config->extra_params;
        $this->apiV3Key             = $this->extraConfigs->apiV3Key ?? '';
        $this->notifyUrl            = $config->notify_url;
        // https://api.mch.weixin.qq.com/pay/unifiedorder
        $this->gatewayUrl           = $config->gateway_url;                                             // 支付网关
        // 商户相关配置
        $this->merchantId           = $config->merchant_id;                                             // 商户号
        $this->merchantSerialNumber = $this->extraConfigs->serialNumber ?? '';                          // 商户API证书序列号
        $this->merchantPrivateKey   = PemUtil::loadPrivateKeyFromString($config->private_key);          // 商户私钥
        // $this->merchantPrivateKey    = PemUtil::loadPrivateKey(dirname(__FILE__)."/apiclient_key.pem");          // 商户私钥
        $this->privateKey           = $config->private_key;                                             // 商户私钥
        // 微信支付平台配置
        $this->payCertificate       = PemUtil::loadCertificateFromString($config->public_key);          // 微信支付平台证书
        // $this->payCertificate        = PemUtil::loadCertificate(dirname(__FILE__)."/wechatpay.pem");             // 微信支付平台证书
        $this->publicKey            = $config->public_key;                                              // 微信支付平台证书
        // 测试支付开关
        $this->payTest              = $config->pay_test;
    }

    /*
    * 支付方法
    */
    public function doPay($params)
    {
        if (! $this->apiV3Key) {
            return ['status' => 0, 'message' => '支付API秘钥未配置'];
        }
        if (! $this->merchantSerialNumber) {
            return ['status' => 0, 'message' => '商户API证书序列号未配置'];
        }

        // 构造一个WechatPayMiddleware
        $wechatpayMiddleware = WechatPayMiddleware::builder()
            ->withMerchant($this->merchantId, $this->merchantSerialNumber, $this->merchantPrivateKey) // 传入商户相关配置
            ->withWechatPay([ $this->payCertificate ])  // 可传入多个微信支付平台证书,参数类型为array
            ->build();

        // 将WechatPayMiddleware添加到Guzzle的HandlerStack中
        $stack = HandlerStack::create();
        $stack->push($wechatpayMiddleware, 'wechatpay');

        // 创建Guzzle HTTP Client时,将HandlerStack传入
        $client = new Client(['handler' => $stack]);

        // 接下来,正常使用Guzzle发起API请求,WechatPayMiddleware会自动地处理签名和验签
        try {
            $trade_type = $params['trade_type'] ?? 'app';

            $requestData = [
                'json' => [ // JSON请求体
                    'appid'         => $this->appid,
                    'mchid'         => $this->merchantId,
                    'description'   => $params['goods_name'],
                    'out_trade_no'  => $params['order_no'],
                    'notify_url'    => $this->notifyUrl,
                    'amount'        => [
                        'total'             => !$this->payTest ? $params['amount'] : 1,
                        'currency'          => 'CNY'
                    ],
                    'scene_info'    => [
                        'payer_client_ip'   => $params['client_ip'] ?? '127.0.0.1'
                    ],
                    'attach'        => $params['attach'] ?? ''
                ],
                'headers' => ['Accept' => 'application/json']
            ];

            if ($trade_type == 'jsapi') {

                if (!isset($params['openid']) || empty($params['openid'])) {
                    return ['status' => 0, 'message' => 'Openid不能为空'];
                }

                $requestData['json']['payer'] = [
                    'openid' => $params['openid']
                ];
            }

            if ($trade_type == 'h5') {
                $requestData['json']['scene_info']['h5_info']['type'] = "Wap";
            }

            $resp = $client->request('POST', $this->gatewayUrl . "pay/transactions/{$trade_type}", $requestData);

            $content = $resp->getBody();
            $data = json_decode($content, true);

            $ret = [];
            switch ($trade_type) {
                // APP支付
                case 'app':
                    $ret = ['orderInfo' => $this->getOrderInfo($data['prepay_id'])];
                    break;
                // 公众号支付
                case 'jsapi':
                    $ret = ['prepay_id' => $data['prepay_id']];
                    break;
                // h5支付
                case 'h5':
                    $ret = ['h5_url' => $data['h5_url']];
                    break;
                // 扫码支付
                case 'native':
                    $ret = ['code_url' => $data['code_url']];
                    break;
                
                default:
                    # code...
                    break;
            }
        } catch (RequestException $e) {
            // 进行错误处理
            $data = [];
            if ($e->hasResponse()) {
                // echo $e->getResponse()->getStatusCode().' '.$e->getResponse()->getReasonPhrase()."\n";
                // echo $e->getResponse()->getBody();
                $content = $e->getResponse()->getBody();
                $data  = json_decode($content, true);
            }

            return ['status' => 0, 'message' => $data['message'] ?? '支付失败:'.$e->getMessage()];
        }

        return ['status' => 1, 'message' => 'SUCCESS', 'data' => $ret];
    }

    public function getOrderInfo($prepay_id)
    {
        $nonceStr   = randomString(16);
        // $package     = "prepay_id={$prepay_id}";
        $package    = "Sign=WXPay";
        $timestamp  = time();
        $paySign    = $this->paySign($this->appid, $timestamp, $nonceStr, $package);
        return [
            'appid'     => $this->appid,
            'noncestr'  => $nonceStr,
            'package'   => $package,
            'partnerid' => $this->merchantId,
            'prepayid'  => $prepay_id,
            'timestamp' => $timestamp,
            'sign'      => $paySign,
        ];
    }

    public function transactions(array $params) 
    {
        $url = '';
        if (isset($params['transaction_id']) && !empty($params['transaction_id'])) {
            $url = 'pay/  transactions/id/'.$params['transaction_id'];
        }
        if (isset($params['out_trade_no']) && !empty($params['out_trade_no'])) {
            $url = 'pay/transactions/out-trade-no/'.$params['out_trade_no'];
        }

        if (empty($url)) {
            return false;
        }

        $url .= '?mchid='.$this->merchantId;

        // 构造一个WechatPayMiddleware
        $wechatpayMiddleware = WechatPayMiddleware::builder()
            ->withMerchant($this->merchantId, $this->merchantSerialNumber, $this->merchantPrivateKey) // 传入商户相关配置
            ->withWechatPay([ $this->payCertificate ])  // 可传入多个微信支付平台证书,参数类型为array
            ->build();

        // 将WechatPayMiddleware添加到Guzzle的HandlerStack中
        $stack = HandlerStack::create();
        $stack->push($wechatpayMiddleware, 'wechatpay');

        // 创建Guzzle HTTP Client时,将HandlerStack传入
        $client = new Client(['handler' => $stack]);

        // 接下来,正常使用Guzzle发起API请求,WechatPayMiddleware会自动地处理签名和验签
        try {
            $resp = $client->request('GET', $this->requestUrl . $url,[
                'headers' => ['Accept' => 'application/json']
            ]);

            // echo $resp->getStatusCode().' '.$resp->getReasonPhrase()."\n";
            // echo $resp->getBody()."\n";exit;
            $content = $resp->getBody();
            $data = json_decode($content, true);

            if ($data['trade_state'] === 'SUCCESS') {
                return ['status' => 1, 'message' => "SUCCESS", 'data' => $data];
            }else{
                return ['status' => 0, 'message' => $data['trade_state_desc'] ?? '', 'trade_state' => $data['trade_state']];
            }
        } catch (RequestException $e) {
            // 进行错误处理
            $res_data = [];
            if ($e->hasResponse()) {
                $content = $e->getResponse()->getBody();
                $res_data = json_decode($content, true);
            }

            return ['status' => 0, 'message' => $res_data['message'] ?? '支付失败'];
        }
    }

    public function decryptCiphertext($ciphertext)
    {
        $ciphertext = $this->urlsafe_b64decode($ciphertext);
        $privateKey = $this->privateKey;
        $iv = substr($privateKey, 0, 16);

        $decrypted = openssl_decrypt($ciphertext, 'aes-256-cbc', $privateKey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $iv);
        return $decrypted;
    }

    // 支付签名
    public function paySign($appId, $timeStamp, $nonceStr, $package)
    {
        $str = "{$appId}\n{$timeStamp}\n{$nonceStr}\n{$package}\n";
        $privateKey = $this->privateKey;
        openssl_sign($str, $encrypt_data, openssl_get_privatekey($privateKey), 'sha256WithRSAEncryption');
        $encrypt_data = base64_encode($encrypt_data);
        return $encrypt_data;
    }

    // 解密数据
    public function decryptSign($ciphertext, $associatedData, $nonceStr)
    {
        $ciphertext = base64_decode($ciphertext);

        if (function_exists('\sodium_crypto_aead_aes256gcm_is_available') && \sodium_crypto_aead_aes256gcm_is_available()) {
            //$APIv3_KEY就是在商户平台后端设置是APIv3秘钥
            $orderData = \sodium_crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $this->apiV3Key);
            $orderData = json_decode($orderData, true);

            return $orderData;
        }else{
            exit('缺乏PHP扩展:sodium,请安装该扩展或切换到PHP7.3+版本');
        }
        
        return $result;
    }

    /**将字符串安全编码
     *
     * @param  $string
     *
     * @return string
     */
    public function urlsafe_b64encode($string) 
    {
        $data = base64_encode($string);
        $data = str_replace(array('+', '/', '='), array('-', '_', ''), $data);
        return $data;
    }

    /**将字符串安全解码
     *
     * @param  $string
     *
     * @return string
     */
    public function urlsafe_b64decode($string) 
    {
        $data = str_replace(array('-', '_'), array('+', '/'), $string);
        $mod4 = strlen($data) % 4;
        if ($mod4) {
            $data .= substr('====', $mod4);
        }
        return base64_decode($data);
    }

    public function notify($content)
    {
        // 通知部分过段时间再更,端午节就更到这了
    }
}

有部分方法文中没上,我个人的项目中用上了,就暂且保留吧

3. 微信支付回调逻辑:

通知部分过段时间再更,明天就算端午节就更到这了,有需要的小伙伴可以留言,我争取尽快补上~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,757评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,478评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,540评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,593评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,903评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,329评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,659评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,383评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,055评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,337评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,864评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,227评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,820评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,999评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,750评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,365评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,260评论 2 258

推荐阅读更多精彩内容