转账功能优化

This commit is contained in:
Dev
2026-10-10 14:52:18 +08:00
parent 5f3dc03137
commit 2541fc1dfb
4 changed files with 219 additions and 52 deletions
@@ -161,13 +161,8 @@ class EasyWeChatService
'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),
],
],
//转账场景报备信息:必须与 transfer_scene_id 对应(1005=佣金报酬 需传「岗位类型」「报酬说明」两条)
'transfer_scene_report_infos' => $this->buildTransferSceneReportInfos($desc),
];
//规则:转账金额 >= 2,000 元时必须传入收款人姓名(加密)
if ($amount >= 2000 && $name !== '') {
@@ -256,6 +251,72 @@ class EasyWeChatService
return $server->serve();
}
/**
* 构造转账场景报备信息
* 报备信息必须与 transfer_scene_id 一一对应,且 info_type 不可重复,否则接口会报
* 「未传入完整且对应的转账场景报备信息」:
* 1000 现金营销 -> 活动名称 + 奖励说明
* 1005 佣金报酬 -> 岗位类型 + 报酬说明
* 可通过 extra/common.php 的 transfer_scene_report_infos 覆盖
* @param string $desc 转账备注,用于 info_content 缺省时兜底
* @return array
*/
private function buildTransferSceneReportInfos(string $desc = ''): array
{
$reportInfos = $this->config['transfer_scene_report_infos'] ?? [];
if (!$reportInfos) {
$sceneId = (string)($this->config['transfer_scene_id'] ?? '1005');
$reportInfos = $sceneId === '1000'
? [
['info_type' => '活动名称', 'info_content' => $desc ?: '平台活动'],
['info_type' => '奖励说明', 'info_content' => $desc ?: '活动奖励'],
]
: [
['info_type' => '岗位类型', 'info_content' => '平台接单用户'],
['info_type' => '报酬说明', 'info_content' => $desc ?: '用户提现'],
];
}
//info_type ≤15 字符、info_content ≤32 字符
return array_map(function ($item) use ($desc) {
$infoType = mb_substr((string)($item['info_type'] ?? ''), 0, 15);
$infoContent = mb_substr((string)($item['info_content'] ?? ''), 0, 32);
if ($infoContent === '') {
$infoContent = mb_substr($desc ?: '佣金报酬', 0, 32);
}
return [
'info_type' => $infoType,
'info_content' => $infoContent,
];
}, $reportInfos);
}
/**
* 查询免确认收款授权单(用于同步授权状态:授权回调丢失时以微信侧状态为准)
* 接口:GET /v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/{no}
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167
* @param string $out_authorization_no 商户侧授权单号
* @return array {out_authorization_no, authorization_id, state, ...};失败时含 message
*/
public function queryTransferAuthorization($out_authorization_no)
{
try {
$response = $this->payApp->getClient()->get(
'v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/' . $out_authorization_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(),
];
}
}
/**
* 查询新版商家转账单状态
* @param string $out_bill_no 商户单号
@@ -58,9 +58,8 @@ class TransferAuthorizationService
{
$record = self::getByUserId($userId);
return $record
&& $record->state == self::STATE_TAKING_EFFECT
&& $record->authorization_id;
//授权生效后即可凭 out_authorization_no 发起免确认转账;authorization_id 仅作留存
return $record && $record->state == self::STATE_TAKING_EFFECT;
}
/**
@@ -86,34 +85,50 @@ class TransferAuthorizationService
];
}
//发起授权申请(授权单号固定,复用同一单号可重新发起)
$authNo = self::generateAuthNo($userId);
$wechat = new EasyWeChatService();
//本地状态可能与微信侧不一致(如授权回调未收到):先查单同步,微信侧已生效则直接放行
$query = $wechat->queryTransferAuthorization($authNo);
if (($query['state'] ?? '') === self::STATE_TAKING_EFFECT) {
self::saveAuthorization($userId, $authNo, self::STATE_TAKING_EFFECT, $query['authorization_id'] ?? '');
return [
'authorized' => true,
'state' => self::STATE_TAKING_EFFECT,
];
}
//发起授权申请(授权单号固定,复用同一单号可重新发起)
$displayName = mb_substr($displayName ?: ($user->nickname ?: ('用户' . $userId)), 0, 32);
$res = $wechat->applyTransferAuthorization($authNo, $user->openid, $displayName);
if (empty($res['package_info'])) {
throw new \Exception($res['message'] ?? '发起免确认收款授权失败');
//package 仅在授权状态为 WAIT_USER_CONFIRM 时返回;拿不到时再查单确认微信侧是否已生效
$query = $wechat->queryTransferAuthorization($authNo);
if (($query['state'] ?? '') === self::STATE_TAKING_EFFECT) {
self::saveAuthorization($userId, $authNo, self::STATE_TAKING_EFFECT, $query['authorization_id'] ?? '');
return [
'authorized' => true,
'state' => self::STATE_TAKING_EFFECT,
];
}
throw new \Exception($res['message'] ?? ($query['message'] ?? '发起免确认收款授权失败'));
}
//保存/更新授权记录(package_info 用于前端拉起授权页,state 当前为待用户确认)
$data = [
'state' => $res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'package_info' => $res['package_info'],
];
if ($record) {
$record->save($data);
} else {
self::create(array_merge([
'user_id' => $userId,
'out_authorization_no' => $authNo,
'authorization_id' => '',
], $data));
}
self::saveAuthorization(
$userId,
$authNo,
$res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'',
$res['package_info']
);
return [
'authorized' => false,
'state' => $data['state'],
'state' => $res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'package_info' => $res['package_info'],
//前端 wx.requestMerchantTransfer 需要:商户号 + AppID + package
'mch_id' => (string)$wechat->payApp->getMerchant()->getMerchantId(),
@@ -121,6 +136,40 @@ class TransferAuthorizationService
];
}
/**
* 保存/更新授权记录
* @param int $userId
* @param string $authNo 商户侧授权单号
* @param string $state 授权状态
* @param string $authorizationId 微信侧授权单号(可为空)
* @param string $packageInfo 拉起授权页的 package(可为空)
* @return void
*/
private static function saveAuthorization($userId, string $authNo, string $state, string $authorizationId = '', string $packageInfo = '')
{
$record = self::getByUserId($userId);
$data = ['state' => $state];
if ($authorizationId !== '') {
$data['authorization_id'] = $authorizationId;
}
if ($packageInfo !== '') {
$data['package_info'] = $packageInfo;
}
if ($record) {
$record->save($data);
return;
}
self::create(array_merge([
'user_id' => $userId,
'out_authorization_no' => $authNo,
'authorization_id' => $authorizationId,
'package_info' => $packageInfo,
], $data));
}
/**
* 处理免确认收款授权结果回调(用户授权成功后微信异步通知)
* @param array $message 解密后的授权结果,含 out_authorization_no / authorization_id / state
+6
View File
@@ -28,6 +28,12 @@ return [
'notify_url' => Env::get('app.url') . '/api/order/payNotify',
// 新版商家转账(用户确认模式)转账场景ID:1005=佣金报酬
'transfer_scene_id' => Env::get('wechat.transfer_scene_id', '1005'),
// 转账场景报备信息:必须与 transfer_scene_id 严格对应,否则报「未传入完整且对应的转账场景报备信息」
// 1005(佣金报酬) 固定传两条:岗位类型 + 报酬说明;info_type ≤15 字符,info_content ≤32 字符,且 info_type 不可重复
'transfer_scene_report_infos' => [
['info_type' => '岗位类型', 'info_content' => '平台接单用户'],
['info_type' => '报酬说明', 'info_content' => '用户提现'],
],
// 新版商家转账结果回调地址(公网可访问、HTTPS、不带参数)
'transfer_notify_url' => Env::get('app.url') . '/api/account/transferNotify',
// 免确认收款授权结果回调地址(公网可访问、HTTPS、不带参数)
@@ -153,41 +153,92 @@ Page({
this.setData({ withdrawName: e.detail.value });
},
onConfirmWithdraw() {
const { withdrawName, withdrawAmount } = this.data;
//提交前提现先确保完成免确认收款授权(授权后审核通过即可免确认直接到账零钱)
this.ensureTransferAuthorized()
.then(() => this.submitWithdraw())
.catch((msg) => {
wx.showToast({ title: typeof msg === 'string' && msg ? msg : '未完成授权,已取消提现', icon: 'none' });
const title = msg && msg.message
? msg.message
: (typeof msg === 'string' && msg ? msg : '未完成授权,已取消提现');
wx.showToast({ title: title, icon: 'none', duration: 2500 });
});
},
// 确保完成免确认收款授权:已授权直接通过;未授权拉起微信授权页
ensureTransferAuthorized() {
return fetchTransferAuthorization().then((res) => {
if (res.code != 1) {
throw new Error(res.msg || '获取授权状态失败');
}
const data = res.data || {};
if (data.authorized) {
return;
}
//package_info 仅在授权状态为 WAIT_USER_CONFIRM(待用户确认)时才返回,缺失则无法拉起
if (!data.package_info || !data.mch_id || !data.app_id) {
throw new Error('免确认收款授权参数缺失,请稍后重试');
}
//文档要求:调起前须判断微信客户端/基础库是否支持 requestMerchantTransfer
if (!wx.canIUse('requestMerchantTransfer')) {
throw new Error('当前微信版本过低,请更新至最新版本后再授权');
}
//拉起授权用的 appId 必须与发起授权接口传入的 appid 为同一个
let appId = data.app_id;
const miniProgram = wx.getAccountInfoSync ? (wx.getAccountInfoSync().miniProgram || {}) : {};
if (miniProgram.appId && miniProgram.appId !== data.app_id) {
throw new Error('授权商户AppID与当前小程序不一致,无法拉起授权');
}
if (miniProgram.appId) {
appId = miniProgram.appId;
}
return this.requestMerchantTransfer({
mchId: String(data.mch_id),
appId: appId,
package: data.package_info,
});
});
},
// 拉起微信免确认收款授权页(requestMerchantTransfer)
requestMerchantTransfer(params) {
return new Promise((resolve, reject) => {
fetchTransferAuthorization().then((res) => {
if (res.code != 1) {
reject(res.msg || '获取授权状态失败');
return;
}
const data = res.data || {};
if (data.authorized) {
resolve();
return;
}
wx.requestMerchantTransfer({
mchId: data.mch_id,
appId: data.app_id,
package: data.package_info,
success: () => {
wx.showToast({ title: '授权成功', icon: 'success' });
resolve();
},
fail: (err) => {
console.log('requestMerchantTransfer fail', err);
reject('需完成免确认收款授权后才能提现');
},
});
}).catch(() => reject('网络异常,请稍后重试'));
//在用户点击手势链路中调起:先二次确认,避免异步请求后直接调起被客户端拒绝
wx.showModal({
title: '免确认收款授权',
content: '提现前需先开通免确认收款授权,点击「去授权」在微信授权页完成确认后即可提现。',
confirmText: '去授权',
cancelText: '取消',
success: (modalRes) => {
if (!modalRes.confirm) {
reject('已取消授权,无法提现');
return;
}
try {
wx.requestMerchantTransfer(Object.assign({}, params, {
success: (res) => {
//success 仅代表授权页展示成功,不代表用户已确认授权,最终以微信异步回调为准
console.log('requestMerchantTransfer success', res);
const errMsg = (res && (res.err_msg || res.errMsg)) || '';
if (errMsg && errMsg !== 'requestMerchantTransfer:ok') {
reject(errMsg === 'requestMerchantTransfer:cancel'
? '已取消授权,无法提现'
: '拉起授权失败:' + errMsg);
return;
}
wx.showToast({ title: '请在授权页完成确认', icon: 'none', duration: 2000 });
resolve();
},
fail: (err) => {
console.log('requestMerchantTransfer fail', err);
const errMsg = (err && (err.errMsg || err.err_msg)) || '';
reject(errMsg ? '拉起授权失败:' + errMsg : '拉起授权失败,请更新微信后重试');
},
}));
} catch (e) {
reject('当前微信版本不支持免确认收款授权,请更新后重试');
}
},
fail: () => reject('已取消授权,无法提现'),
});
});
},
// 原提现提交逻辑