diff --git a/backend/application/common/service/EasyWeChatService.php b/backend/application/common/service/EasyWeChatService.php index 774fe7e..02359d1 100644 --- a/backend/application/common/service/EasyWeChatService.php +++ b/backend/application/common/service/EasyWeChatService.php @@ -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 商户单号 diff --git a/backend/application/common/service/TransferAuthorizationService.php b/backend/application/common/service/TransferAuthorizationService.php index fa2cc7a..daf8d91 100644 --- a/backend/application/common/service/TransferAuthorizationService.php +++ b/backend/application/common/service/TransferAuthorizationService.php @@ -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 diff --git a/backend/application/extra/common.php b/backend/application/extra/common.php index 548edd3..021e44b 100644 --- a/backend/application/extra/common.php +++ b/backend/application/extra/common.php @@ -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、不带参数) diff --git a/frontend/miniapp/pages/usercenter/balance/index.js b/frontend/miniapp/pages/usercenter/balance/index.js index 316adb0..427bc67 100644 --- a/frontend/miniapp/pages/usercenter/balance/index.js +++ b/frontend/miniapp/pages/usercenter/balance/index.js @@ -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('已取消授权,无法提现'), + }); }); }, // 原提现提交逻辑