转账功能优化

This commit is contained in:
Dev
2026-10-10 14:10:14 +08:00
parent e1d14ff759
commit 5f3dc03137
9 changed files with 483 additions and 50 deletions
@@ -138,60 +138,191 @@ class EasyWeChatService
}
/**
* 商家转账到零钱(v3, 原 v2 企业付款已停用)
* 需在商户平台开通「商家转账」产品权限
* @param string $order_sn 提现单号(字母或数字)
* @param string $openid
* @param string $name 收款用户姓名(商家转账不校验时可留空)
* 免确认收款转账(商家转账-用户已授权模式)
* 接口:POST /v3/fund-app/mch-transfer/transfer-bills/transfer
* 前置:用户需先通过 /v3/fund-app/mch-transfer/user-confirm-authorization 完成授权,
* 转账时以授权单(out_authorization_no / authorization_id 二选一)定位收款人
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4014399371
* @param string $order_sn 提现单号(out_bill_no, 字母或数字)
* @param string $name 收款用户姓名(金额>=2000元时必须传,需加密)
* @param float $amount 金额(单位:元)
* @param string $desc
* @return array {out_batch_no, batch_id, create_time};失败时含 message
* @param string $out_authorization_no 商户侧授权单号
* @param string $desc 转账备注(≤32 字符)
* @return array {out_bill_no, transfer_bill_no, state, transfer_amount, ...};失败时含 message
*/
public function mchPay($order_sn, $openid, $name, $amount, $desc = '用户提现')
public function mchPay($order_sn, $name, $amount, $out_authorization_no, $desc = '用户提现')
{
$params = [
'appid' => $this->config['app_id'],
'out_batch_no' => $order_sn,
'batch_name' => '用户提现',
'batch_remark' => $desc,
'total_amount' => (int)round($amount * 100), // 转为分
'total_num' => 1,
'transfer_detail_list' => [
[
'out_detail_no' => $order_sn . 'D1', // 明细单号(字母数字,保持唯一)
'transfer_amount' => (int)round($amount * 100),
'transfer_remark' => $desc,
'openid' => $openid,
try {
$params = [
'appid' => $this->config['app_id'],
'out_bill_no' => $order_sn,
'transfer_scene_id' => $this->config['transfer_scene_id'] ?? '',
'transfer_amount' => (int)round($amount * 100), // 转为分
'transfer_remark' => mb_substr($desc, 0, 32), // 最多 32 字符
//授权转账:以授权单定位收款人,不再传 openid
'out_authorization_no' => $out_authorization_no,
//必须传转账场景报备信息,需按商户平台申请的场景准确填写
'transfer_scene_report_infos' => [
[
'info_type' => '活动名称',
'info_content' => mb_substr($desc ?: '佣金报酬', 0, 32),
],
],
],
];
Log::info('商家转账-params' . json_encode($params, JSON_UNESCAPED_UNICODE));
$response = $this->payApp->getClient()->postJson('v3/transfer/batches', $params);
$res = $response->toArray(false);
Log::info('商家转账-res' . json_encode($res, JSON_UNESCAPED_UNICODE));
];
//规则:转账金额 >= 2,000 元时必须传入收款人姓名(加密)
if ($amount >= 2000 && $name !== '') {
$params['user_name'] = $this->encryptSensitive($name);
}
//含敏感字段加密时须带 Wechatpay-Serial(微信支付公钥ID/平台证书序列号)
$client = $this->payApp->getClient();
$platformCerts = $this->config['platform_certs'] ?? [];
if ($platformCerts) {
$client = $client->withHeader('Wechatpay-Serial', (string)array_key_first($platformCerts));
}
Log::info('商家转账-params' . json_encode($params, JSON_UNESCAPED_UNICODE));
$response = $client->postJson('v3/fund-app/mch-transfer/transfer-bills/transfer', $params);
$res = $response->toArray(false);
Log::info('商家转账-res' . json_encode($res, JSON_UNESCAPED_UNICODE));
return $res;
return $res;
} catch (\Exception $e) {
Log::error('商家转账请求异常:' . $e->getMessage());
return [
'message' => $e->getMessage(),
];
}
}
/**
* 查询商家转账批次状态
* @param string $apply_sn 商家转账单号(out_batch_no)
* @return array 转账批次状态及明细
* 发起免确认收款授权(用户确认模式的前置授权)
* 接口:POST /v3/fund-app/mch-transfer/user-confirm-authorization
* 返回 package_info 供前端 wx.requestMerchantTransfer 拉起授权页
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167
* @param string $out_authorization_no 商户侧授权单号(每用户唯一,未确认单30天后单号可复用)
* @param string $openid 收款用户 openid
* @param string $display_name 授权详情展示的开通账号名称(≤32字符)
* @return array {out_authorization_no, state, create_time, package_info};失败时含 message
*/
public function applyTransferAuthorization($out_authorization_no, $openid, $display_name)
{
try {
$params = [
'appid' => $this->config['app_id'],
'out_authorization_no' => $out_authorization_no,
'openid' => $openid,
'transfer_scene_id' => $this->config['transfer_scene_id'] ?? '',
'user_display_name' => mb_substr($display_name, 0, 32),
'authorization_notify_url' => $this->config['transfer_authorization_notify_url'] ?? '',
];
Log::info('免确认收款授权-params' . json_encode($params, JSON_UNESCAPED_UNICODE));
$response = $this->payApp->getClient()->postJson('v3/fund-app/mch-transfer/user-confirm-authorization', $params);
$res = $response->toArray(false);
Log::info('免确认收款授权-res' . json_encode($res, JSON_UNESCAPED_UNICODE));
return $res;
} catch (\Exception $e) {
Log::error('免确认收款授权请求异常:' . $e->getMessage());
return [
'message' => $e->getMessage(),
];
}
}
/**
* 处理免确认收款授权结果回调(验签+解密),返回 PSR-7 响应
* 注意:handlePaid 只处理 TRANSACTION.SUCCESS,授权回调须用通用管道
* @param \Closure $handler function(PayMessage $message){ 业务处理;异常时抛出则返回失败 }
* @return \Psr\Http\Message\ResponseInterface
*/
public function handleTransferAuthorizationNotify(\Closure $handler)
{
$traceId = date('YmdHis') . substr(md5(uniqid('', true)), 0, 6);
Log::info('免确认收款授权回调[收到请求] trace=' . $traceId . ' ' . json_encode([
'body' => (string)$this->payApp->getRequest()->getBody(),
], JSON_UNESCAPED_UNICODE));
$server = $this->payApp->getServer();
$server->with(function (PayMessage $message, \Closure $next) use ($handler, $traceId) {
Log::info('免确认收款授权回调[解密数据] trace=' . $traceId . ' ' . json_encode($message->toArray(), JSON_UNESCAPED_UNICODE));
//回调签名验证(失败抛异常 -> serve 返回失败,微信将重试)
$this->payApp->getValidator()->validate($this->payApp->getRequest());
$handler($message, $traceId);
return $next($message);
});
return $server->serve();
}
/**
* 查询新版商家转账单状态
* @param string $out_bill_no 商户单号
* @return array 转账单状态及明细
*/
public function queryTransferBill($out_bill_no)
{
try {
$response = $this->payApp->getClient()->get(
"v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{$out_bill_no}"
);
$res = $response->toArray(false);
Log::info('查询商家转账单-res' . json_encode($res, JSON_UNESCAPED_UNICODE));
return $res;
} catch (\Exception $e) {
Log::error('查询商家转账单异常:' . $e->getMessage());
return [
'message' => $e->getMessage(),
];
}
}
/**
* 敏感信息加密:新版商家转账的收款人姓名需使用微信支付公钥加密(RSA-OAEP)
* @param string $plaintext
* @return string base64 密文
* @throws \Exception
*/
public function encryptSensitive(string $plaintext): string
{
if ($plaintext === '') {
return '';
}
$platformCerts = $this->config['platform_certs'] ?? [];
if (!$platformCerts) {
throw new \Exception('未配置微信支付公钥(platform_certs),无法加密收款人姓名');
}
//取第一个可用公钥(微信支付公钥ID -> 公钥文件)
$certFile = reset($platformCerts);
if (!is_file($certFile)) {
throw new \Exception('微信支付公钥文件不存在:' . $certFile);
}
$publicKey = openssl_pkey_get_public(file_get_contents($certFile));
if (!$publicKey) {
throw new \Exception('微信支付公钥解析失败');
}
$encrypted = '';
if (!openssl_public_encrypt($plaintext, $encrypted, $publicKey, OPENSSL_PKCS1_OAEP_PADDING)) {
throw new \Exception('收款人姓名加密失败');
}
return base64_encode($encrypted);
}
/**
* 查询商家转账单状态(提现已改用新版接口,统一走新版查询)
* @param string $apply_sn 提现单号(out_bill_no)
* @return array 转账单状态及明细
*/
public function queryBalance($apply_sn)
{
$response = $this->payApp->getClient()->get("v3/transfer/batches/out-batch-no/{$apply_sn}", [
'query' => [
'need_query_detail' => 'true',
'offset' => 0,
'limit' => 20,
'detail_status' => 'ALL',
],
]);
$res = $response->toArray(false);
Log::info('商家转账查询-res' . json_encode(['apply_sn' => $apply_sn, 'res' => $res], JSON_UNESCAPED_UNICODE));
return $res;
//提现单已改用新版商家转账接口创建,查询统一走新版,避免查不到单据
return $this->queryTransferBill($apply_sn);
}
/**