diff --git a/backend/application/api/controller/Account.php b/backend/application/api/controller/Account.php index 06d5666..d68894f 100644 --- a/backend/application/api/controller/Account.php +++ b/backend/application/api/controller/Account.php @@ -10,18 +10,22 @@ use app\common\service\AccountWithdrawService; use app\common\service\CategoryService; use app\common\service\DemandBiddingService; use app\common\service\DemandService; +use app\common\service\EasyWeChatService; use app\common\service\GoodsService; use app\common\model\Category; use app\common\service\OrderService; +use app\common\service\TransferAuthorizationService; use app\common\service\UserService; +use EasyWeChat\Pay\Message as PayMessage; use fast\Tree; use think\Exception; use think\exception\HttpResponseException; +use think\Log; use think\Validate; class Account extends Api { - protected $noNeedLogin = []; + protected $noNeedLogin = ['transferAuthorizationNotify']; protected $noNeedRight = ['*']; public function logs() @@ -94,4 +98,65 @@ class Account extends Api $this->error($e->getMessage()); } } + + /** + * 免确认收款授权(发起/查询),供前端 wx.requestMerchantTransfer 拉起授权页 + * @ApiMethod (POST) + */ + public function transferAuthorization() + { + try { + $user_id = $this->auth->id ?? 0; + if (!$user_id) { + throw new \Exception('请先登录'); + } + $user = UserService::get($user_id); + if (!$user) { + throw new \Exception('用户不存在'); + } + //授权详情展示账号名:优先微信实名(提现填写的姓名),其次昵称 + $displayName = $user->username ?: ($user->nickname ?: ''); + $res = TransferAuthorizationService::apply($user, $displayName); + + $this->success('', $res); + } catch (\Exception $e) { + if ($e instanceof HttpResponseException) { + throw $e; + } + $this->error($e->getMessage()); + } + } + + /** + * 免确认收款授权结果回调(微信异步通知,已加入 noNeedLogin) + * @return void + */ + public function transferAuthorizationNotify() + { + header('Content-Type: application/json; charset=utf-8'); + try { + // PSR-7 响应(SDK 已完成验签/解密,业务失败返回 500 由微信重试) + $response = (new EasyWeChatService())->handleTransferAuthorizationNotify( + function (PayMessage $message) { + TransferAuthorizationService::handleAuthorizationNotify($message->toArray()); + } + ); + http_response_code($response->getStatusCode()); + foreach ($response->getHeaders() as $name => $values) { + header($name . ': ' . implode(', ', $values)); + } + $body = $response->getBody()->getContents(); + Log::info('免确认收款授权回调[应答] http_code=' . $response->getStatusCode() . ' body=' . $body); + echo $body; + exit; + } catch (\Throwable $e) { + Log::error('免确认收款授权回调[异常]:' . json_encode([ + 'msg' => $e->getMessage(), + 'file' => $e->getFile() . ':' . $e->getLine(), + ], JSON_UNESCAPED_UNICODE)); + http_response_code(500); + echo json_encode(['code' => 'FAIL', 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE); + exit; + } + } } \ No newline at end of file diff --git a/backend/application/common/model/account/TransferAuthorizations.php b/backend/application/common/model/account/TransferAuthorizations.php new file mode 100644 index 0000000..58cf36e --- /dev/null +++ b/backend/application/common/model/account/TransferAuthorizations.php @@ -0,0 +1,23 @@ +id); if($type == AccountWithdrawService::TYPE_WECHAT){ - //发起微信商家转账到用户零钱(v3,原 v2 企业付款已停用) - $payRes = (new EasyWeChatService())->mchPay($withdraw_record->apply_sn, $user->openid, $user->username, $amount); - if (empty($payRes['batch_id'])) { + //免确认收款授权校验:未授权无法以授权单发起转账 + $auth = TransferAuthorizationService::getByUserId($userId); + if (!TransferAuthorizationService::isAuthorized($userId)) { + throw new \Exception('该用户未完成免确认收款授权,无法发起转账,请通知用户在小程序余额页完成授权后重新审核'); + } + //发起微信商家转账到用户零钱(v3授权转账:以授权单定位收款人,免用户确认收款) + $payRes = (new EasyWeChatService())->mchPay($withdraw_record->apply_sn, $user->username, $amount, $auth->out_authorization_no); + //以 transfer_bill_no(微信转账单号) 判断单据是否创建成功 + if (empty($payRes['transfer_bill_no'])) { throw new \Exception($payRes['message'] ?? '商家转账发起失败'); } - //批次受理成功(资金到账以微信侧为准,可通过 queryBalance 查询批次状态) - $withdraw_record->result_status = 'SUCCESS'; + //授权转账免用户确认,状态记为微信返回的 state,最终以回调/查单为准 + $withdraw_record->result_status = $payRes['state'] ?? ''; $withdraw_record->save(); } } else { diff --git a/backend/application/common/service/EasyWeChatService.php b/backend/application/common/service/EasyWeChatService.php index 7b2af73..774fe7e 100644 --- a/backend/application/common/service/EasyWeChatService.php +++ b/backend/application/common/service/EasyWeChatService.php @@ -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); } /** diff --git a/backend/application/common/service/TransferAuthorizationService.php b/backend/application/common/service/TransferAuthorizationService.php new file mode 100644 index 0000000..fa2cc7a --- /dev/null +++ b/backend/application/common/service/TransferAuthorizationService.php @@ -0,0 +1,156 @@ + $userId]); + } + + /** + * 用户是否已完成免确认收款授权 + * @param int $userId + * @return bool + */ + public static function isAuthorized($userId): bool + { + $record = self::getByUserId($userId); + + return $record + && $record->state == self::STATE_TAKING_EFFECT + && $record->authorization_id; + } + + /** + * 发起/获取免确认收款授权,供前端拉起授权页 + * @param \app\common\model\Users $user 用户(需含 openid) + * @param string $displayName 授权详情中展示的开通账号名称 + * @return array {authorized: bool, state, package_info?, mch_id?, app_id?} + * @throws \Exception + */ + public static function apply($user, string $displayName = ''): array + { + $userId = $user->id; + if (empty($user->openid)) { + throw new \Exception('无法获取用户微信身份,请先登录小程序'); + } + + //已授权的用户直接返回,不再重复发起 + $record = self::getByUserId($userId); + if ($record && self::isAuthorized($userId)) { + return [ + 'authorized' => true, + 'state' => $record->state, + ]; + } + + //发起授权申请(授权单号固定,复用同一单号可重新发起) + $authNo = self::generateAuthNo($userId); + $wechat = new EasyWeChatService(); + $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_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)); + } + + return [ + 'authorized' => false, + 'state' => $data['state'], + 'package_info' => $res['package_info'], + //前端 wx.requestMerchantTransfer 需要:商户号 + AppID + package + 'mch_id' => (string)$wechat->payApp->getMerchant()->getMerchantId(), + 'app_id' => $wechat->config['app_id'], + ]; + } + + /** + * 处理免确认收款授权结果回调(用户授权成功后微信异步通知) + * @param array $message 解密后的授权结果,含 out_authorization_no / authorization_id / state + * @return bool + */ + public static function handleAuthorizationNotify(array $message): bool + { + $outAuthNo = trim((string)($message['out_authorization_no'] ?? '')); + $authorizationId = trim((string)($message['authorization_id'] ?? '')); + $state = trim((string)($message['state'] ?? '')); + if (!$outAuthNo || !$authorizationId) { + Log::error('免确认收款授权回调参数缺失:' . json_encode($message, JSON_UNESCAPED_UNICODE)); + return false; + } + + $record = self::getOne(['out_authorization_no' => $outAuthNo]); + if (!$record) { + Log::error('免确认收款授权回调找不到授权记录:' . $outAuthNo); + return false; + } + + //记录微信侧授权单号,标记授权生效(授权长期有效,直至用户或商户主动解除) + $record->authorization_id = $authorizationId; + $record->state = $state ?: self::STATE_TAKING_EFFECT; + $record->save(); + Log::info('免确认收款授权生效:' . json_encode([ + 'user_id' => $record->user_id, + 'state' => $record->state, + ], JSON_UNESCAPED_UNICODE)); + + return true; + } +} diff --git a/backend/application/extra/common.php b/backend/application/extra/common.php index d101a33..548edd3 100644 --- a/backend/application/extra/common.php +++ b/backend/application/extra/common.php @@ -26,5 +26,11 @@ return [ : [], // 支付结果通知地址(也可在下单时单独覆盖) 'notify_url' => Env::get('app.url') . '/api/order/payNotify', + // 新版商家转账(用户确认模式)转账场景ID:1005=佣金报酬 + 'transfer_scene_id' => Env::get('wechat.transfer_scene_id', '1005'), + // 新版商家转账结果回调地址(公网可访问、HTTPS、不带参数) + 'transfer_notify_url' => Env::get('app.url') . '/api/account/transferNotify', + // 免确认收款授权结果回调地址(公网可访问、HTTPS、不带参数) + 'transfer_authorization_notify_url' => Env::get('app.url') . '/api/account/transferAuthorizationNotify', ], ]; \ No newline at end of file diff --git a/frontend/miniapp/config/api.js b/frontend/miniapp/config/api.js index 46add54..f7726c3 100644 --- a/frontend/miniapp/config/api.js +++ b/frontend/miniapp/config/api.js @@ -102,6 +102,7 @@ module.exports = { FetchAccountInfo: ApiRootUrl + 'account/info', //账户详情 FetchAccountLogs: ApiRootUrl + 'account/logs', //余额日志 AccountApplyWithdraw: ApiRootUrl + 'account/applyWithdraw', //提现申请 + AccountTransferAuthorization: ApiRootUrl + 'account/transferAuthorization', //免确认收款授权(发起/查询) // 消息 MessageList: ApiRootUrl + 'message/list', //消息列表 diff --git a/frontend/miniapp/pages/usercenter/balance/index.js b/frontend/miniapp/pages/usercenter/balance/index.js index e0aa48f..316adb0 100644 --- a/frontend/miniapp/pages/usercenter/balance/index.js +++ b/frontend/miniapp/pages/usercenter/balance/index.js @@ -1,5 +1,5 @@ // pages/usercenter/balance/index.js -import { fetchAccountInfo, fetchAccountLogs, accountApplyWithdraw } from '../../../services/usercenter/fetchBalance'; +import { fetchAccountInfo, fetchAccountLogs, accountApplyWithdraw, fetchTransferAuthorization } from '../../../services/usercenter/fetchBalance'; Page({ /** @@ -24,6 +24,7 @@ Page({ { title: '提现次数', desc: '每个自然日最多可发起 10 次提现申请' }, { title: '单日限额', desc: '同一自然日累计提现金额不超过 2000 元,超出请次日再试' }, { title: '到账方式', desc: '转账至本人微信零钱,需填写与微信实名一致的姓名' }, + { title: '免确认收款', desc: '提现需完成免确认收款授权,授权后审核通过将直接到账零钱' }, { title: '到账时间', desc: '审核通过后 1-3 个工作日内到账,节假日顺延' }, { title: '审核说明', desc: '工作日统一审核处理,审核期间该笔金额将暂时冻结' }, { title: '驳回处理', desc: '被驳回的提现金额将自动退回可提现余额,可重新发起' }, @@ -152,6 +153,45 @@ 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' }); + }); + }, + // 确保完成免确认收款授权:已授权直接通过;未授权拉起微信授权页 + ensureTransferAuthorized() { + 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('网络异常,请稍后重试')); + }); + }, + // 原提现提交逻辑 + submitWithdraw() { const { withdrawName, withdrawAmount } = this.data; let params = { 'name': withdrawName, diff --git a/frontend/miniapp/services/usercenter/fetchBalance.js b/frontend/miniapp/services/usercenter/fetchBalance.js index 2267e54..b0288b1 100644 --- a/frontend/miniapp/services/usercenter/fetchBalance.js +++ b/frontend/miniapp/services/usercenter/fetchBalance.js @@ -13,4 +13,9 @@ export function fetchAccountLogs(param) { /** 获取提现申请 */ export function accountApplyWithdraw(param) { return wx.$util.request(wx.$api.AccountApplyWithdraw, param, 'POST'); +} + +/** 免确认收款授权(发起/查询),返回 package_info 供 wx.requestMerchantTransfer 拉起授权页 */ +export function fetchTransferAuthorization() { + return wx.$util.request(wx.$api.AccountTransferAuthorization, {}, 'POST'); } \ No newline at end of file