admin管理员组

文章数量:1535374

前言

由于公司业务需要,最近这段时间对接了QQ小程序支付【包括QQ钱包支付 和 QQ小程序内微信支付】,由于网络上相关的资料很少,遂留此文,以备后用。【顺便吐槽一下,官方文档不可全信】

由于业务关系,此处将 QQ钱包支付 和 QQ小程序内微信支付 两种支付放在一起,通过条件选择相应支付方式。如你的业务不需要同时接入两种支付方式,可自由拆分

准备工作

语言:PHP

  1. QQ钱包支付

官方流程图:QQ钱包支付

必要步骤:先开通QQ钱包的商户号,然后和QQ小程序进行绑定。

后端流程:

  • 接收前端参数,如用户ID,商品ID…
  • 根据自有规则生成订单
  • 组装参数,调用QQ 统一下单接口,生成预付单
  • 将QQ后台返回的参数 pre_payid 返回给前端
  • 前端支付成功,QQ后台会异步回调我们的回调接口(调用QQ统一下单接口时传入)
  • 回调接口里根据接收参数处理订单逻辑,如标记订单已完成支付、通知商品购买成功、消费记录…
  1. 微信支付

官方流程图:微信支付

必要步骤:在QQ小程序开发者管理端绑定一个微信支付商户号【这意味着你首先要开通微信支付】

后端流程:

  • 后端流程基本和QQ钱宝支付差不多,不同的是QQ后台不对预付单请求做处理,只是充当一个代理转发的角色-------将请求转发至微信后台【使用微信H5统一下单方式】,在微信后台生成预付单信息
  • 微信H5支付有两个版本,代号 V2V3,区别和文档地址。此文使用的是 V2版本,签名采用的加密方式为 MD5
PHP服务端代码

支付类规范接口

<?php


namespace xxxx;


interface PayInterface
{
    /**
     * @desc 支付  Interface
     */


    /**
     * @desc 用户下单
     * @return mixed
     */
    public function createOrder();


    /**
     * @desc 支付回调
     * @return mixed
     */
    public function payNotify();


    /**
     * @desc 支付订单查询
     * @return mixed
     */
    public function queryPayOrder();


    /**
     * @desc 申请退款
     * @return mixed
     */
    public function payRefund();
}

QQ小程序支付类

<?php


namespace xxxx;


use xxxx;


class QqPay implements PayInterface
{

    /**************************************  QQ参数 【QQ支付相关】    ***************************************/

    //QQ小程序 appID  
    private $appId = '';

    //QQ小程序 secret
    private $appSecret = '';

    //QQ小程序 商户号
    private $mchId = '';

    //支付秘钥---需在QQ商户后台设置
    private $key = '';

    //获取access token url
    private $getTokenUrl = 'https://api.q.qq/api/getToken';

    //QQ钱包支付回调地址---填写处理QQ支付完成逻辑地址(需要公网能访问,不能带参数)
    private $notifyUrl = '';

    //接口API URL base(QQ支付接口基地址)
    private $apiUrlPrefix = 'https://qpay.qq';

    //下单地址URL (QQ钱包支付--生成预付单地址)
    private $unifiedOrderUrl = "/cgi-bin/pay/qpay_unified_order.cgi";


    //查询订单URL(QQ钱包支付)
    private $orderQueryUrl = "/cgi-bin/pay/qpay_order_query.cgi";
    //关闭订单URL (QQ钱包支付)
    private $closeOrderUrl = "/cgi-bin/pay/qpay_close_order.cgi";

    /**************************************  微信参数 【用于QQ小程序调用微信支付】    ***************************************/

    //wx key
    private $wxKey = '';

    //微信公众号appId
    private $wxAppId = '';

    //微信商户号
    private $wxMchId = '';

    //QQ 小程序平台 V2 版 支付回调代理地址
    private $notifyUrlQqAgent = 'https://api.q.qq/wxpay/notify';

    //wap_url WAP网站URL地址 【微信H5下单必填参数,详见:https://pay.weixin.qq/wiki/doc/api/H5.php?chapter=9_20&index=1】
    private $wapUrl = '';

    //wap_name WAP 网站名 【微信H5下单必填参数】
    private $wapName = '';

    //QQ小程序内微信支付回调url---填写处理微信支付完成逻辑地址(需要公网能访问,不能带参数)【微信H5支付成功通知地址,微信回调的实际是QQ后台地址,然后QQ后台转发到此地址】
    private $wxNotifyUrl = '';

    //H5下单代理url base
    private $qqAgentUrl = 'https://api.q.qq/wxpay/unifiedorder';


    /**
     * @desc 用户下单  创建预支付订单
     * @return array|mixed|string[]
     * @author BaTianHu
     */
    public function createOrder()
    {
        //校验用户登录态--如无特殊原因,此步骤强烈建议在逻辑层之前进行(如:鉴权层),如未登陆,或者权限不够,直接返回

        //接收参数,如用户ID,商品ID...

		//查询相应信息,并验证信息的有效性,如商品信息,如无效,直接返回
		

		//todo 如有需要,可将订单逻辑拆分出去,会显得数据层级更合理,更易维护
        try {
            //生成订单号
            $orderNo = Pay::createOrderNo();
            //应付价格 单位 /分
            $totalFee = $price * 100;

			//赋值
            $pay = new Pay();
            $pay->order_no = $orderNo;
			......
			$pay->created_at = time();

			//保存订单
            if ($pay->save(false)) {
                //预下单
                $retInfo = $this->unifiedOrder($orderNo, $totalFee, $isWx);

                //判断预字符订单是否生成成功
                if ($retInfo['return_code'] === 'SUCCESS' && $retInfo['result_code'] === 'SUCCESS') {
                    //QQ钱包支付需返回参数
                    $data['prepay_id'] = $retInfo['prepay_id'];

                    if ($isWx) {
                    	//微信支付需返回参数-----用于前端跳转微信支付
                        $data['mweb_url'] = $retInfo['mweb_url'];
                        //用于前端查询订单状态
                        $data['order_no'] = $orderNo;
                    }
                    return ['errcode' => 'ok', 'errmsg' => '创建订单成功', 'data' => $data];
                }

                //todo 如果需要,这里可以存一个日志,保存失败原因

                return ['errcode' => 'fail', 'errmsg' => '预下单失败'];

            } else {
                return ['errcode' => 'fail', 'errmsg' => '创建订单失败'];
            }

        } catch (\Exception $exception) {
            //todo 记录错误信息
            
            return ['errcode' => 'fail', 'errmsg' => '创建订单失败'];
        }
    }


    /**
     * @desc 支付回调【QQ钱包支付】
     * @return array|false|mixed
     * @throws Exception
     * @author BaTianHu
     */
    public function payNotify()
    {
        $notifyDataXml = file_get_contents("php://input");

        $data = OtherCommon::xml_to_data($notifyDataXml);

        $sign = $data['sign'];
        unset($data['sign']);

        if ($sign <> $this->sign($data)) {
            // 验签失败
            return $this->setRetInfo('签名失败');
        }

        // 如果订单已支付,进行业务处理并返回核销信息
        if(isset($data['trade_state']) && $data['trade_state'] == 'SUCCESS') {

            //处理订单支付逻辑---此处调用处理订单逻辑方法(根据各自业务场景或有不同)
            $notifyDealInfo = Pay::payNotifyPro($data['out_trade_no'], $data['total_fee'], $data['transaction_id']);

            if ($notifyDealInfo === true) {
                //逻辑处理成功
                return $this->setRetInfo();
            }
            //逻辑处理失败
            return $this->setRetInfo('逻辑处理失败');
        }

        //参数格式错误
        return $this->setRetInfo('参数格式校验错误');
    }


    /**
     * @desc QQ预下单  【QQ钱包支付  or  QQ内微信支付】
     * @param string $orderNo
     * @param int $totalFee
     * @param bool $isWx
     * @return array
     * @throws LocalRedisException
     * @author BaTianHu
     */
    private function unifiedOrder(string $orderNo, int $totalFee, $isWx = false) :array
    {

        $params['nonce_str'] = 'hdakhgdjsa';  //随机数---生成一个随机数
        $params['body'] = '测试---不可描述';  //商品描述
        $params['out_trade_no'] = $orderNo;  //订单号--商户平台订单号
        $params['total_fee'] = $totalFee;    //总金额 单位 /分
        $params['spbill_create_ip'] = OtherCommon::getUserIp();  //终端IP---用户端实际IP

        if ($isWx) {
            //QQ内微信支付

            $params['appid'] = $this->wxAppId;
            $params['mch_id'] = $this->wxMchId;
            $params['sign_type'] = 'MD5';  //签名方式
            $params['notify_url'] = $this->notifyUrlQqAgent;
            $params['trade_type'] = 'MWEB';  //交易类型
            $params['scene_info'] = '{"h5_info": {"type":"Wap","wap_url": '.$this->wapUrl.',"wap_name": "'.$this->wapName.'"}}';

            //签名
            $params['sign'] = $this->sign($params, $this->wxKey);

            //获取代理支付地址
            $url = $this->getQqAgentUrl($this->qqAgentUrl);

        } else {
            //QQ钱包支付

            $params['appid'] = $this->appId;
            $params['mch_id'] = $this->mchId;
            $params['fee_type'] = 'CNY';  //货币类型  人民币
            $params['notify_url'] = OtherCommon::getBaseUrl($this->notifyUrl, 'https://');  //回调地址
            $params['trade_type'] = 'MINIAPP';  //支付场景

            //签名
            $params['sign'] = $this->sign($params);

            //支付地址
            $url = $this->getQqUrl($this->unifiedOrderUrl);

        }

        //数组转xml
        $xml = OtherCommon::data_to_xml($params);

        //请求QQ预下单接口
        $response = OtherCommon::postXmlCurl($xml, $url);

        //返回数组结果
        return OtherCommon::xml_to_data($response);
    }


    /**
     * @desc 支付订单查询
     * @return mixed
     */
    public function queryPayOrder()
    {
        // TODO: Implement queryPayOrder() method.
    }

    /**
     * @desc 申请退款
     * @return mixed
     */
    public function payRefund()
    {
        // TODO: Implement payRefund() method.
    }


    /**
     * @desc 生成|校验 签名 
     * @param array $signParam  参与签名的参数
     * @param string $key 默认为QQ支付 key
     * @return string
     * @author BaTianHu
     */
    private function sign(array $signParam, string $key = '')
    {
        $sign = '';
        if (empty($signParam)) {
            return $sign;
        }

        //按字母升序排序
        ksort($signParam);

        $parts = array();
        foreach ($signParam as $k => $v) {
            $parts[] = $k . '=' . $v;
        }
        $sign = implode('&', $parts);

        if (empty($key)) {
            $key = $this->key;
        }
        $sign = $sign . "&key=".$key;

        return strtoupper(md5($sign));
    }


    /**
     * @desc 组装请求目的地址
     * @param $relativeUrl
     * @return string
     * @author BaTianHu
     */
    private function getQqUrl($relativeUrl)
    {
        return $this->apiUrlPrefix.$relativeUrl;
    }


    /**
     * @desc 设置返回信息
     * @param string $errMsg 错误信息
     * @return array|false|mixed
     * @author BaTianHu
     */
    private function setRetInfo(string $errMsg = '')
    {
        if (empty($errMsg)) {
            //处理成功
            $data['return_code'] = 'SUCCESS';
        } else {
            //处理失败
            $data['return_code'] = 'FAIL';
            //失败原因
            $data['return_msg'] = $errMsg;
        }

        return OtherCommon::data_to_xml($data);
    }


    /**
     * @desc 组装支付代理地址【QQ小程序平台-代理微信H5支付】
     * @param $baseAgentUrl
     * @return string
     * @throws LocalRedisException
     * @author BaTianHu
     */
    private function getQqAgentUrl($baseAgentUrl)
    {
        //获取access token
        $accessToken = $this->getAccessToken();

        //组装真实回调url
        $UrlEncodedNotifyUrl = urlencode(OtherCommon::getBaseUrl($this->wxNotifyUrl, 'https://'));

        //组装返回QQ代理微信支付URL
        return $baseAgentUrl.'?appid='.$this->appId.'&access_token='.$accessToken.'&&real_notify_url='.$UrlEncodedNotifyUrl;
    }

    /**
     * @desc 获取AccessToken
     * @return bool|mixed|string
     * @throws LocalRedisException
     * @author BaTianHu
     */
    private function getAccessToken()
    {
        //先尝试redis获取AccessToken
        $accessToken = $redis->get(ConstKeyHelpers::QQ_ACCESS_TOKEN_PRO);
        if (empty($accessToken)) {
            $param = [
                'grant_type' => 'client_credential',
                'appid'      => $this->appId,
                'secret'      => $this->appSecret,
            ];

			//请求QQ接口,获取AccessToken
            $retInfo = OtherCommon::curlGet($this->getTokenUrl, $param);
            $retInfo = json_decode($retInfo, true);

            if ($retInfo['errcode'] === 0) {
                $accessToken = $retInfo['access_token'];
                $expressTime = $retInfo['expires_in'] - 100;
                //将AccessToken存入redis
                $redis->set(ConstKeyHelpers::QQ_ACCESS_TOKEN_PRO, $accessToken, $expressTime);
            }
        }

        return $accessToken;
    }


}

公共方法文件

<?php

namespace xxx;

use xxxxx;

class OtherCommon
{
    /**
     * @desc 组装绝对地址 || 获取网站基地址
     * @param string $relativeUrl
     * @param string $protocolType  示例:https://
     * @return string
     * @author BaTianHu
     */
    public static function getBaseUrl($relativeUrl = '', string $protocolType = '') :string
    {
        if (empty($protocolType)) {
            $protocolType = self::getHttpType();
        }
        return $protocolType.self::getHostDomain().$relativeUrl;
    }

    /**
     * @desc 获取当前网址协议(HTTP/HTTPS)
     * @return string
     * @author BaTianHu
     */
    public static function getHttpType() :string
    {
        return ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https')) ? 'https://' : 'http://';
    }

    //获取host信息
    public static function getHostDomain()
    {
        return $_SERVER['HTTP_HOST'] ?? '';
    }

    /**
     * @desc 获取用户的 IP 地址
     * @return mixed|string
     * @author BaTianHu
     */
    public static function getUserIp()
    {
        return $_SERVER['REMOTE_ADDR'] ?? '';
    }

    /**
     * @param $stringOne
     * @param $stringTwo
     * @return float|int
     * 返回两个字符串的相似度
     * vine 2019年4月22日10:10:57
     */
    public static function diffWords($stringOne, $stringTwo)
    {
        $sameNumber = similar_text($stringOne, $stringTwo);
        return $sameNumber * 2 / (strlen($stringOne) + strlen($stringTwo));
    }

    /**
     * @desc curl get
     * @param string $url
     * @param array $arr
     * @return bool|string
     * @author BaTianHu
     */
    public static function curlGet(string $url, array $arr = [])
    {
        //组装get参数
        if (!empty($arr)) {
            $tempArr = [];
            foreach ($arr as $k => $v) {
                $tempArr[] = $k.'='.$v;
            }
            $str = implode('&',$tempArr);
            $url = $url.'?'.$str;
        }

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);

        $output = curl_exec($ch);
        curl_close($ch);
        return $output;
    }


    /**
     * @desc post curl
     * @param string $url
     * @param array $data
     * @return bool|string
     * @author BaTianHu
     */
    public static function postJsonCurl(string $url, array $data)
    {
        $data_string = json_encode($data);

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
        curl_setopt($ch, CURLOPT_POSTFIELDS,$data_string);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER,true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, array(
                'Content-Type: application/json',
                'Accept: application/json',
                'User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; SV1)',
                'Content-Length: ' . strlen($data_string))
        );

		//忽略ssl检查
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

        $data = curl_exec($ch);
        if (curl_errno($ch)) {
            $data = curl_error($ch);
        }

        curl_close($ch);
        return $data;
    }


    /**
     * @desc 以post方式提交xml到对应的接口url
     *
     * @param string $xml 需要post的xml数据
     * @param string $url url
     * @param bool $useCert 是否需要证书,默认不需要
     * @param int $second url执行超时时间,默认30s
     * @return bool|string
     * @author BaTianHu
     */
    public static function postXmlCurl(string $xml, string $url, $useCert = false, $second = 30)
    {
        $ch = curl_init();
        //设置超时
        curl_setopt($ch, CURLOPT_TIMEOUT, $second);
        curl_setopt($ch,CURLOPT_URL, $url);
        curl_setopt($ch,CURLOPT_SSL_VERIFYPEER,FALSE);
        curl_setopt($ch,CURLOPT_SSL_VERIFYHOST,2);
        //设置header
        curl_setopt($ch, CURLOPT_HEADER, FALSE);
        //要求结果为字符串且输出到屏幕上
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
        if($useCert == true){
            //设置证书
            //使用证书:cert 与 key 分别属于两个.pem文件
            curl_setopt($ch,CURLOPT_SSLCERTTYPE,'PEM');
            //curl_setopt($ch,CURLOPT_SSLCERT, WxPayConfig::SSLCERT_PATH);
            curl_setopt($ch,CURLOPT_SSLKEYTYPE,'PEM');
            //curl_setopt($ch,CURLOPT_SSLKEY, WxPayConfig::SSLKEY_PATH);
        }
        //post提交方式
        curl_setopt($ch, CURLOPT_POST, TRUE);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
        //运行curl
        $data = curl_exec($ch);

        curl_close($ch);
        return $data;
    }

    /**
     * @desc 数组 转 XML
     * @param array $params 参数名称
     * @return false|string
     * @author BaTianHu
     */
    public static function data_to_xml(array $params)
    {
        if (count($params) <= 0) {
            return false;
        }

        $xml = "<xml>";
        foreach ($params as $key=>$val) {
            if (is_numeric($val)){
                $xml.="<".$key.">".$val."</".$key.">";
            }else{
                $xml.="<".$key."><![CDATA[".$val."]]></".$key.">";
            }
        }
        $xml.="</xml>";
        return $xml;
    }

    /**
     * @desc 将xml转为array
     * @param string $xml
     * @return false|mixed
     * @author BaTianHu
     */
    public static function xml_to_data(string $xml = '')
    {
        if (empty($xml)) {
            return [];
        }
        //将XML转为array 禁止引用外部xml实体
        libxml_disable_entity_loader(true);
        return json_decode(json_encode(simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA)), true);
    }
}
值得注意的事

I. QQ钱包支付通知文档中,trade_state 参数写的是 首字母大写 Success,但其实是全大写 SUCCESS

II. QQ商户后台无法登陆,下载安装安全控件后依然无法登陆

若安装安全控件后不生效,页面一直提示未安装的情况,可安装如下浏览器操作。(适用于Windows及Mac OS等系统)

1、Chrome浏览器

若浏览器已升级到Chrome 76.0.3809.87(简称Chrome 76)及以上版本,请查看下述指引:

(1)在浏览器的地址栏输入 chrome://flags/#enable-nacl

(2)找到Native Client插件,将Native Client的状态改为Enabled

(3)重启浏览器,再重新登录QQ钱包商户平台,尝试安装安全控件

2、Internet Explorer 11浏览器

请打开如下链接:https://support.microsoft/zh-cn/help/17621/internet-explorer-downloads安装 Internet Explorer 11浏览器后,再尝试安装安全控件。

本文标签: 钱包程序qq