微信支付V3--JSAPI

2021-07-10
<?php

/**
 * @description: JSPAI支付 V3
 */
class WeixinPay
{
    /**
     * @description: 应用ID
     * @var string 
     */
    protected $appid = '';

    /**
     * @description:  商户号
     * @var string 
     */
    protected $mchid = '';

    /**
     * @description:  证书私钥路径
     * @var string 
     */
    protected $key_path = 'apiclient_key.pem';

    /**
     * @description:  证书serial_no,API安全里面点击证书查看
     * @var string 
     */
    protected $serial_no = '';

    /**
     * @description:  v3秘钥
     * @var string 
     */
    protected $v3_key = '';

    /**
     * @description: 平台证书路径,平台证书需要单独获取,不是商户证书
     * @var string 
     */
    protected $plat_certif_path;

    /**
     * @description: 平台证书序列号与证书公钥
     * @var array 
     */
    protected $plat_certif = [];

    /**
     * @description: 日志内容
     * @var string 
     */
    protected $log_data = '';

    /**
     * @description: 日志记录路径
     * @var string 
     */
    protected $log_path;

    public function __construct()
    {
        $this->log_data = '[ START ] ' . date('Y-m-d H:i:s') . PHP_EOL;
        // tp5获取项目根路径
        $root_path = str_replace('\\', '/', ROOT_PATH);
        $this->key_path         = $root_path . 'certif/mch/' . $this->key_path;
        $this->plat_certif_path = $root_path . 'certif/plat/';
        $this->log_path         = $root_path . 'certif/log/' . date('Ym') . '/';
        if (!is_dir($this->log_path)) {
            mkdir($this->log_path, 0777, true);
        }
        if (!$this->appid) {
            throw new \Exception('APPID不能为空');
        }
        if (!$this->mchid) {
            throw new \Exception('商户号不能为空');
        }
        if (!$this->serial_no) {
            throw new \Exception('商户证书序列号不能为空');
        }
        if (!$this->v3_key) {
            throw new \Exception('V3秘钥不能为空');
        }
    }

    public function __destruct()
    {
        $this->log_data .= '[ END ]' . PHP_EOL;
        $this->_log();
    }

    /**
     * @description: 调起支付,返回前端调用支付的必备参数
     * @param string $number 订单号
     * @param float $price 支付金额 元
     * @param string $user_id 下单人openid
     * @return array
     */
    public function pay($number, $price, $user_id)
    {
        $notify = 'https://127.0.0.1:800/notify_pay';
        $prepay_id = $this->_order($number, $price, $notify, $user_id);

        $data = [
            'appid'     => $this->appid, // 应用ID
            'timeStamp' => time(), // 时间戳
            'nonceStr'  => $this->_randomString(), // 随机字符串
            'package'   => 'prepay_id=' . $prepay_id, // 订单详情扩展字符串
            'signType'  => 'RSA' // 签名方式
        ];
        $data['paySign']  = $this->_sign($data); // 签名

        $this->log_data .= '[ PAY ]' . json_encode($data, JSON_UNESCAPED_UNICODE) . PHP_EOL;

        return $data;
    }

    /**
     * @description: 支付通知回调
     * @param array $param 请求参数
     * @param array $header 请求头
     * @return array
     */
    public function notify($param, $header)
    {
        $this->log_data .= '[ NOTIFY PARAM ]' . json_encode($param, JSON_UNESCAPED_UNICODE) . PHP_EOL . 
                        '[ NOTIFY HEADER ]' .  json_encode($header, JSON_UNESCAPED_UNICODE) . PHP_EOL;

        $serial = $header['wechatpay-serial']; //证书序列号

        $public_key = $this->_verify_plat_serial($serial);
        if (!$public_key) {
            return ['code' => 'ERROR', 'message' => '请求错误'];
        }

        if (!$this->_verify_plat_sign($header['wechatpay-timestamp'], $header['wechatpay-nonce'], json_encode($param, JSON_UNESCAPED_UNICODE), $header['wechatpay-signature'], $public_key)) {
            return ['code' => 'ERROR', 'message' => '签名错误'];
        }

        $data  = $param['resource'];

        $info = $this->_decryptToString($data['ciphertext'], $this->v3_key, $data['nonce'], $data['associated_data']);

        $this->log_data .= '[ NOTIFY DATA ]' . $info . PHP_EOL;

        $info = json_decode($info, true);
        if (!$info) {
            return ['code' => 'ERROR', 'message' => '解密失败'];
        }
        if ($info['appid'] != $this->appid || $info['mchid'] != $this->mchid) {
            return ['code' => 'ERROR', 'message' => '商户信息错误'];
        }
		$order = '';
        if (!$order) {
            return ['code' => 'ERROR', 'message' => '订单不存在'];
        }
        if ($order['state'] == 3) {
            return ['code' => 'SUCCESS', 'message' => '已支付'];
        }

        $data = [
            'order_header_id'  => $order['id'],
            'out_trade_no'     => $info['out_trade_no'],     // 商户订单号,自定义的订单号
            'transaction_id'   => $info['transaction_id'],   // 微信支付订单号,微信的订单号
            'trade_type'       => $info['trade_type'],       // 交易类型
            'trade_state'      => $info['trade_state'],      // 交易状态
            'bank_type'        => $info['bank_type'],        // 付款银行
            'success_time'     => $info['success_time'],     // 支付完成时间
            'trade_state_desc' => $info['trade_state_desc'], // 交易状态描述
            'create_time'      => time(),
        ];

        if ($info['trade_state'] == 'SUCCESS') {
            return ['code' => 'SUCCESS', 'message' => '支付成功'];
        } else {
            return ['code' => 'ERROR', 'message' => '支付失败'];
        }
    }

    /**
     * @description: 下单获取预支付id
     * @param string $number 订单号
     * @param float $price 支付金额 元
     * @param string $notify 回调通知地址,https
     * @param string $user_id 下单人openid
     * @return string
     */
    private function _order($number, $price, $notify, $user_id)
    {
        $data = [
            'appid'        => $this->appid, // 应用ID
            'mchid'        => $this->mchid, // 直连商户号
            'description'  => '参会会议费', // 商品描述
            'out_trade_no' => $number, // 商户订单号
            'notify_url'   => $notify, // 通知地址,https://
            'amount'       => ['total' => (int)bcmul($price, 100)], // 订单金额
            'payer'        => ['openid' => $user_id], // 支付者
        ];
        $param = json_encode($data);
        $authorization = $this->_get_sign('post', '/v3/pay/transactions/jsapi', $param);

        $header = [
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json; charset=utf-8',
                'Content-Length: ' . strlen($param),
                $authorization
            ],
            CURLOPT_USERAGENT => 'https://zh.wikipedia.org/wiki/User_agent'
        ];

        // 本人自定义的curl类
        $curl = new Curl('https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi', 60, 'post', $param, $header);
        $result = $curl->curl_func();

        $this->log_data .= '[ ORDER ]' . json_encode($data, JSON_UNESCAPED_UNICODE) . PHP_EOL . 
                        '[ ORDER RESULT ]' . $result . PHP_EOL;

        return json_decode($result, true)['prepay_id'];
    }


    /**
     * @description: 数据签名
     * @param array $data 需要签名的数据
     * @return string
     */
    private function _sign($data)
    {
        $str = $data['appid'] . "\n" . $data['timeStamp'] . "\n" . $data['nonceStr'] . "\n" . $data['package']. "\n";
        $key = $this->_private_key();
        openssl_sign($str, $result, $key, 'sha256WithRSAEncryption');
        openssl_free_key($key);
        return base64_encode($result);
    }

    /**
     * @description: 获取商户私钥对象
     * @param {*}
     * @return resource
     */
    private function _private_key()
    {
        return openssl_get_privatekey(file_get_contents($this->key_path));
    }

    /**
     * 解密数据(证书/回调报文)
     *
     * @param string $ciphertext -  base64编码的密文。
     * @param string $key - 密钥,32字节字符串。
     * @param string $nonceStr - 随机字符串。
     * @param string $associatedData - 附加的验证数据,可能空字符串。
     *
     * @return string - The utf-8 plaintext.
     */
    private function _decryptToString($ciphertext, $key, $nonceStr, $associatedData)
    {
        $ciphertext = base64_decode($ciphertext);
        $ctext = substr($ciphertext, 0, -16);
        $authTag = substr($ciphertext, -16);

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

    /**
     * @description: 验证平台证书序列号
     * @param string $serial 平台证书序列号
     * @return string|false 证书公钥或false(序列号不匹配)
     */
    private function _verify_plat_serial($serial)
    {
        $i = 2;
        while ($i-- > 0) {
            $this->_get_local_plat_certif();
            if (!$this->plat_certif) {
                $this->_get_plat_certif();
                $this->_get_local_plat_certif();
            }
            if (array_key_exists($serial, $this->plat_certif)) {
                return $this->plat_certif[$serial];
            } elseif ($i > 0) {
                $this->_get_plat_certif();
            }
        }
        return false;
    }

    /**
     * @description: 获取下载到本地的平台证书信息
     * @param {*}
     * @return void
     */
    private function _get_local_plat_certif()
    {
        $plat_certif = [];
        $dir = opendir($this->plat_certif_path);
        while ($file = readdir($dir)) {
            if ($file != '.' && $file != '..') {
                $serial = explode('_', $file)[1];
                $serial = explode('.', $serial)[0];
                $plat_certif[$serial] = file_get_contents($this->plat_certif_path . $file);
            }
        }
        $this->plat_certif = $plat_certif;
    }

    /**
     * @description: 获取平台证书/序列号保存在本地
     * @param {*}
     * @return void
     */
    private function _get_plat_certif()
    {
        $authorization = $this->_get_sign('get', '/v3/certificates');

        $header = [
            CURLOPT_HTTPHEADER => [
                $authorization, 
                'Accept: application/json', 
            ],
            CURLOPT_USERAGENT => 'https://zh.wikipedia.org/wiki/User_agent'
        ];

        // 获取平台证书
        $curl    = new Curl('https://api.mch.weixin.qq.com/v3/certificates', 60, 'get', [], $header);
        $results = $curl->curl_func();

        $this->log_data .= '[ PLAT DATA ]' . $results . PHP_EOL;

        $results = json_decode($results, true)['data'];
        
        foreach ($results as $result) {
            $info = $this->_decryptToString($result['encrypt_certificate']['ciphertext'], 
                                            $this->v3_key, 
                                            $result['encrypt_certificate']['nonce'], 
                                            $result['encrypt_certificate']['associated_data']
                                        );
            file_put_contents($this->plat_certif_path . 'wechatpay_' . $result['serial_no'] . '.pem', $info); // serial_no为序列号,pem为证书,证书内容时平台证书公钥
        }
    }
    
    /**
     * @description: 验证回调签名
     * @param int $timestamp 应答时间戳
     * @param string $nonce 应答随机串
     * @param string $data 应答主体
     * @param string $signature 应答签名数据
     * @param string $public_key
     * @return bool
     */
    private function _verify_plat_sign($timestamp, $nonce, $data, $signature, $public_key)
    {
        $ydata  = $timestamp . "\n" . $nonce . "\n" . str_replace('\\', '', $data) . "\n";

        $signature = base64_decode($signature);

        $verify = openssl_verify($ydata, $signature, $public_key, 'sha256WithRSAEncryption');
        return (bool)$verify;
    }

    /**
     * @description: 生成请求头签名
     * @param string $method 请求方法
     * @param string $url 请求地址
     * @param string $data 请求体
     * @return string
     */
    private function _get_sign($method, $url, $data = '')
    {
        $timestamp = time();
        $nonce     = $this->_randomString();
        $sign      = strtoupper($method) . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $data . "\n";
        openssl_sign($sign, $raw_sign, $this->_private_key(), 'sha256WithRSAEncryption');
        $raw_sign  = base64_encode($raw_sign);

        $token = sprintf('Authorization: WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
            $this->mchid, $nonce, $timestamp, $this->serial_no, $raw_sign);
        
        return $token;
    }

    /**
     * @description: 获取随机数
     * @param int $len 长度
     * @return string
     */
    private function _randomString($len = 32)
    {
        $string = '';
        $char = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
        for ($i = 0; $i < $len; $i++) {
            $string .= $char[mt_rand(0, strlen($char) - 1)];
        }
        return $string;
    }

    /**
     * @description: 日志记录
     * @return void
     */
    private function _log()
    {
        error_log($this->log_data, 3, $this->_get_log_file());
    }

    /**
     * @description: 获取日志文件名,每天一个日志文件存放在当前年月目录下,当日志文件大小超过2M时,生成新日志文件
     * @param {*}
     * @return string
     */
    private function _get_log_file()
    {
        $file = $this->log_path . date('d') . '.log';
        if (is_file($file) && filesize($file) > 2097152) {
            rename($file, $this->log_path . date('Ymd') . '_' . uniqid(date('His') . '_', true) . '.log');
        }
        return $file;
    }
}

 

{/if}