微信商户转账
一、定义
商家转账支持微信商户向微信用户转账,为商户提供免费、安全的转账服务。支持集成到商家自有业务系统,需有研发能力可接入。商户需在用户收款流程中,拉起微信官方页面,由用户确认收款方式后方可成功转账,资金实时到账,转账成功的资金不支持退回。
二、流程示意
三、调用
$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;
}
}