您的当前位置:首页>全部文章>文章详情

微信商户转账

发表于:2025-05-10 17:04:38浏览:51次TAG: #PHP #微信商户转账

一、定义

商家转账支持微信商户向微信用户转账,为商户提供免费、安全的转账服务。支持集成到商家自有业务系统,需有研发能力可接入。商户需在用户收款流程中,拉起微信官方页面,由用户确认收款方式后方可成功转账,资金实时到账,转账成功的资金不支持退回。

二、流程示意

三、调用

$ser = new \app\common\utils\WechatMerchantTransfer([
    'mchid'=>'微信商户号',
    'apiV3key'=>'APIv3密钥',
    'appid'=>'商户AppID',
    'notify_url'=>'通知地址,必须是https',
    'mchSerialNo'=>'商户API证书的序列号',
    'mchPrivateKeyFilePath'=>'商户API证书私钥路径',
    'platformCertPublicKeyPath'=>'平台证书公钥存放的路径',
]);
// 发起转账
$result = $ser->transfer($order_sn, $openid, $amount, $remark, $transferSceneId,$transfer_scene_report_infos);
// 撤销转账
$result = $ser->cancel($order_sn);
// 商户单号查询转账单
$result = $ser->query($order_sn);
// 微信单号查询转账单
$result = $ser->queryByThird($third_order_sn);
// 商家转账回调通知处理
$result = $ser->handleCallback($headers, $encryptedBody);

四、类

<?php
namespace app\common\utils;
/**
 * 微信商户转账
 * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4012716434
 * 描述:适用于转账给用户,但是需要用户交互,用户需要调起“确认收款”界面。本类使用“商户API证书私钥”+“平台证书公钥”
 */
class WechatMerchantTransfer {
    private $mchid;
    private $appid;
    private $apiV3key;
    private $mchPrivateKeyFilePath;
    private $mchSerialNo;
    private $platformCertPublicKeyPath;
    private $notify_url;
    private $error;
    const AUTH_TAG_LENGTH_BYTE = 16;
    /**
     * 初始化
     * @param $options
     */
    public function __construct($options) {
        // 微信商户号
        $this->mchid = $options['mchid'];
        // 【APIv3密钥】在账号中心->API安全->APIv3密钥中设置
        $this->apiV3key = $options['apiV3key'];
        // 【商户AppID】 是微信开放平台和微信公众平台为开发者的应用程序(APP、小程序、公众号、企业号corpid即为此AppID)提供的一个唯一标识。
        $this->appid = $options['appid'];
        // 【通知地址】 异步接收微信支付结果通知的回调地址,通知url必须为公网可访问的URL,必须为HTTPS,不能携带参数。
        $this->notify_url = $options['notify_url'];
        // 【商户API证书的序列号】
        $this->mchSerialNo = $options['mchSerialNo'];
        /**
         * 【商户API证书私钥】商户API证书私钥存放的路径,你需要获取商户API证书私钥(apiclient_key.pem),并保存到某个路径。
         * 商户API证书作用
         *      私钥:本地加密数据,然后再传输到微信服务器,保存到商户本地
         *      公钥:微信服务器解密用,保存到微信服务器
         */
        $this->mchPrivateKeyFilePath =  $options['mchPrivateKeyFilePath'];
        /**
         * 【平台证书公钥】平台证书公钥存放的路径
         * 平台证书作用
         *      私钥:微信服务器加密数据用,保存到微信服务器
         *      公钥:本地解密微信回调回来的数据用,保存到商户本地
         * 平台证书 与 微信支付证书 区别
         *      两者二选一即可
         * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4013053420
         * 下载平台证书链接:https://github.com/wechatpay-apiv3/CertificateDownloader/releases
         * 下载平台证书命令:java -jar CertificateDownloader.jar -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}
         */
        $this->platformCertPublicKeyPath = $options['platformCertPublicKeyPath'];
    }

    /**
     * 发起转账 API
     * @param $order_sn string 【商户单号】 商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一
     * @param $openid string 【收款用户OpenID】 用户在商户appid下的唯一标识。发起转账前需获取到用户的OpenID
     * @param $amount float 【转账金额】 转账金额单位为“分”。
     * @param $remark string 【转账备注】 转账备注,用户收款时可见该备注信息,UTF8编码,最多允许32个字符
     * @param $transferSceneId int 【转账场景ID】 该笔转账使用的转账场景,可前往“商户平台-产品中心-商家转账”中申请。如:1000(现金营销),1006(企业报销)等
     * @param $transfer_scene_report_infos array 【转账场景报备信息】 各转账场景下需报备的内容,商户需要按照所属转账场景规则传参,详情 https://pay.weixin.qq.com/doc/v3/merchant/4013774588
     * @return array|mixed
     */
    public function transfer($order_sn, $openid, $amount, $remark, $transferSceneId,$transfer_scene_report_infos)
    {
        $url = '/v3/fund-app/mch-transfer/transfer-bills';
        $body = [
            'appid' => $this->appid,
            'out_bill_no' => $order_sn,
            'openid' => $openid,
            'notify_url' => $this->notify_url,
            'transfer_amount' => (int)$amount,
            'transfer_remark' => $remark,
            'transfer_scene_id' => $transferSceneId,
            'transfer_scene_report_infos' => $transfer_scene_report_infos
        ];
        $jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE);
        $result = $this->sendRequest('POST',$url, $jsonBody);
        if ($result) {
            $result['appid'] = $this->appid;
        }
        return $result;
    }

    /**
     * 撤销转账 API
     * @param $order_sn string 【商户单号】 商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一
     * @return false|mixed
     */
    public function cancel($order_sn) {
        $url = "/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{$order_sn}/cancel";
        return $this->sendRequest('POST',$url);
    }

    /**
     * 商户单号查询转账单 API
     * @param $order_sn string 【商户单号】 商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一
     * @return false|mixed
     */
    public function query($order_sn) {
        $url = "/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{$order_sn}";
        return $this->sendRequest('GET',$url);
    }

    /**
     * 微信单号查询转账单 API
     * @param $third_order_sn string 【微信转账单号】 微信转账单号,微信商家转账系统返回的唯一标识
     * @return false|mixed
     */
    public function queryByThird($third_order_sn) {
        $url = "/v3/fund-app/mch-transfer/transfer-bills/transfer-bill-no/{$third_order_sn}";
        return $this->sendRequest('GET',$url);
    }

    /**
     * 商家转账回调通知处理
     * @param $headers array 微信转账回调头部数据,格式:数组
     * @param $encryptedBody string 微信转账回调主体数据,格式:JSON
     * @return false|mixed
     */
    public function handleCallback($headers, $encryptedBody) {
        //$headers 微信转账回调头部数据,格式:数组
        //{
        //    "accept": "*/*",
        //    "host": "sales.billiwat.com",
        //    "connection": "Keep-Alive",
        //    "pragma": "no-cache",
        //    "wechatpay-signature-type": "WECHATPAY2-SHA256-RSA2048",
        //    "wechatpay-signature": "fSwYDra3tX5eBI1XdCXu2XmHzK4/7EN1MKJOaCDkMyQ3W9LjDT4lcoqMrTCK1OjlsmOj0aZ0QZSYa67Mwd/+WeswKNTXMv1XodWT5apEXBXvtgYfFRZ3zEXNbzh4wTrMfG0Z9/JesyS9J/X9aoSci/e/r8pHFar26wq/Jna/qOsxY0GDbgKvuQqJ3mBwAVt5iRuo2PZAazb8zyq6qw3JDI5qmPAb0gGieQxGaNIXJc6sw1Q/vNc3UuGeIAA0KLTAUqcNFsEPkfOq0cmSzxgEZSw2WcjKbcb/XENBrmrLMEBQVoc70itePzt7BG/Vdd8F06dYyJYi3b12DDRrXpvegg==",
        //    "wechatpay-serial": "76D4720781662368ABD2335151648AD965ABCA27",
        //    "wechatpay-timestamp": "1746606105",
        //    "wechatpay-nonce": "OYiyRtBjRUg9nKAG7JdyZnN9b57GH1dK",
        //    "content-type": "application/json",
        //    "user-agent": "Mozilla/4.0",
        //    "content-length": "779"
        //}
        $signature = $headers['wechatpay-signature']?? '';
        $timestamp = $headers['wechatpay-timestamp']?? '';
        $nonce = $headers['wechatpay-nonce']?? '';
        $serial = $headers['wechatpay-serial']?? '';
        //$encryptedBody 微信转账回调主体数据,格式:JSON
        //{
        //    "id": "78152163-2590-5f19-a8f0-d4bf1dde1df6",
        //    "create_time": "2025-05-07T16:21:24+08:00",
        //    "resource_type": "encrypt-resource",
        //    "event_type": "MCHTRANSFER.BILL.FINISHED",
        //    "summary": "商家转账单据终态通知",
        //    "resource": {
        //        "original_type": "mch_payment",
        //        "algorithm": "AEAD_AES_256_GCM",
        //        "ciphertext": "aLF9niAG0iK9PhX20ybi0sO29+UPGv3eAFLj/vvRe6HCxM28ZoDm1/geq/KyhCqCN1x9hDs7cks6EFRpGmTd6eI+mZPxEzMRiJrP+2me86K8vYsWzwLOYuPEBxk2RtOIdVhiZrle4OKWDTWFP+KmgwS22ss97HZj6bR+Xg5baaVtOWFtXFCfXlbSrfvtKQdIAorLKIDUry2lSYul6uB2c3QevJD1XYY7sZ/aP5W/2JhsYcorpCLLLvZyVZCrDaXN4fi8H/mVmdwuJF1aOdPOuGEKE38pzliZBeupD5NVIe5W2xTiYT2S3XIY/6KdztFlT1rg7IbRRLcbRntnBfZ7+u8evvPyRKq/7YsheVNu52yqwLBMDu0hNr0hZYR57l8Wvupp6KRblGpn4JvbuSTzxr6DjipDINmzK7EqJzpYEyU=",
        //        "associated_data": "mch_payment",
        //        "nonce": "wBzNMYWgifus"
        //    }
        //}
        // 验签
        $result = $this->verifySign($timestamp,$nonce,$encryptedBody,$signature,$this->platformCertPublicKeyPath);
        if (!$result) {
            return false;
        }
        $decryptedData = $this->decryptData($encryptedBody);
        if (!$decryptedData) {
            return $this->setError("数据解密失败");
        }
        // 解密后数据示例:
        //{
        //    "mch_id": "1653909620",
        //    "out_bill_no": "186025050716342963629089",
        //    "transfer_bill_no": "1330507003142943540001817645232569",
        //    "transfer_amount": 10,
        //    "state": "SUCCESS",
        //    "openid": "ohYIb5RGCH5886Qcg2QRZJLXj5mo",
        //    "create_time": "2025-05-07T16:34:30+08:00",
        //    "update_time": "2025-05-07T16:34:32+08:00"
        //}
        return json_decode($decryptedData, true);
    }

    /**
     * 报文解密
     * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
     * @param $encryptedData
     * @return false|string
     * @throws \SodiumException
     */
    private function decryptData($encryptedData) {
        $encryptedData = json_decode($encryptedData, true);
        $associatedData = $encryptedData['resource']['associated_data'];
        $nonceStr = $encryptedData['resource']['nonce'];
        $ciphertext = base64_decode($encryptedData['resource']['ciphertext']);
        if (strlen($ciphertext) <= self::AUTH_TAG_LENGTH_BYTE) {
            return self::setError('数据长度异常');
        }
        // 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, $this->apiV3key);
        }
        // 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, $this->apiV3key);
        }
        // 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', $this->apiV3key, \OPENSSL_RAW_DATA, $nonceStr,$authTag, $associatedData);
        }
        return self::setError('AEAD_AES_256_GCM需要PHP 7.1以上或者安装libsodium-php');
    }
    /**
     * 验证微信支付应答签名
     * @param string $timestamp HTTP头中的时间戳(Wechatpay-Timestamp)
     * @param string $nonce HTTP头中的随机串(Wechatpay-Nonce)
     * @param string $body 应答报文主体(原始Response Body,未被篡改)
     * @param string $signature HTTP头中的签名(Wechatpay-Signature,Base64编码)
     * @param string $publicKeyPath 微信支付公钥或是平台证书公钥文件路径(.pem格式)
     * @return bool 验签成功返回true,失败返回false
     */
    private function verifySign(
        string $timestamp,
        string $nonce,
        string $body,
        string $signature,
        string $publicKeyPath
    ): bool {
        // 1. 构造验签串(严格按文档格式:时间戳\n随机串\nbody\n,包括最后的换行符)
        $signString = "{$timestamp}\n{$nonce}\n{$body}\n";
        // 2. 加载微信支付公钥/平台证书公钥
        $publicKey = file_get_contents($publicKeyPath);
        if (!$publicKey) {
            return $this->setError("公钥文件不存在或不可读");
        }
        $publicKeyResource = openssl_pkey_get_public($publicKey);
        if (!$publicKeyResource) {
            return $this->setError("公钥格式错误");
        }
        // 3. Base64解码签名(注意处理可能的填充问题)
        $decodedSignature = base64_decode(str_replace('\\','',$signature), true);
        if ($decodedSignature === false) {
            return $this->setError("Base64解码失败");
        }
        // 4. 执行签名验证(使用SHA256withRSA算法)
        $result = openssl_verify(
            $signString,
            $decodedSignature,
            $publicKeyResource,
            OPENSSL_ALGO_SHA256
        );
        // 返回验签结果(1表示成功,0表示失败,-1表示错误)
        return $result === 1 ? true : self::setError('验签失败');
    }

    /**
     * 签名
     * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4012365334
     * @param $method
     * @param $url
     * @param $body
     * @return string
     * @throws \Random\RandomException
     */
    private function generateSignature($method, $url, $body = '') {
        $timestamp = time();
        $nonceStr = bin2hex(random_bytes(16));
        $message = sprintf("%s\n%s\n%s\n%s\n%s\n", $method, $url, $timestamp, $nonceStr, $body);
        $privateKey = openssl_pkey_get_private(file_get_contents($this->mchPrivateKeyFilePath));
        openssl_sign($message, $signature, $privateKey, 'SHA256');
        $signature = base64_encode($signature);
        return sprintf('WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"',
            $this->mchid, $nonceStr, $signature, $timestamp, $this->mchSerialNo);
    }

    /**
     * 发起请求
     * @param $method
     * @param $url
     * @param $body
     * @return false|mixed
     */
    private function sendRequest($method,$url,$body = '') {
        $headers = [
            'Authorization:' . $this->generateSignature('POST', $url, $body),
            'Accept:application/json',
            'Wechatpay-Serial:' . $this->mchSerialNo,
            'Content-Type:application/json',
            'User-Agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat'
        ];
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, 'https://api.mch.weixin.qq.com' . $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
        curl_setopt($ch, CURLOPT_VERBOSE, 1);
        if ($method === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            if ($body) {
                curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
            }
        } elseif ($method === 'GET') {
            curl_setopt($ch, CURLOPT_HTTPGET, true);
        }
        $response = curl_exec($ch);
        if (curl_errno($ch)) {
            $error = curl_error($ch);
            curl_close($ch);
            return  $this->setError($error);
        }
        curl_close($ch);
        return json_decode($response, true);
    }
    /**
     * 获取异常
     * @return mixed
     */
    public function getError()
    {
        return $this->error;
    }
    /**
     * 设置异常
     * @param $message
     * @return false
     */
    private function setError($message)
    {
        $this->error = $message;
        return false;
    }
}