From beffbd648946cd0cb88729f79eb0b734c61ece6f Mon Sep 17 00:00:00 2001 From: Dev Date: Sat, 10 Oct 2026 16:52:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=88=E6=9D=83=E7=A1=AE=E8=AE=A4=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/service/EasyWeChatService.php | 10 +- .../service/TransferAuthorizationService.php | 195 +++++++++++++++--- 2 files changed, 171 insertions(+), 34 deletions(-) diff --git a/backend/application/common/service/EasyWeChatService.php b/backend/application/common/service/EasyWeChatService.php index cebf3cb..38e95fb 100644 --- a/backend/application/common/service/EasyWeChatService.php +++ b/backend/application/common/service/EasyWeChatService.php @@ -297,13 +297,19 @@ class EasyWeChatService * 接口: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 商户侧授权单号 + * @param bool $isDisplayAuthorization 是否在响应中返回 package_info(调起授权页/授权信息页) * @return array {out_authorization_no, authorization_id, state, ...};失败时含 message */ - public function queryTransferAuthorization($out_authorization_no) + public function queryTransferAuthorization($out_authorization_no, $isDisplayAuthorization = false) { try { + $options = []; + if ($isDisplayAuthorization) { + $options['query'] = ['is_display_authorization' => 'true']; + } $response = $this->payApp->getClient()->get( - 'v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/' . rawurlencode($out_authorization_no) + 'v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/' . rawurlencode($out_authorization_no), + $options ); $res = $response->toArray(false); Log::info('查询免确认收款授权-res' . json_encode($res, JSON_UNESCAPED_UNICODE)); diff --git a/backend/application/common/service/TransferAuthorizationService.php b/backend/application/common/service/TransferAuthorizationService.php index 99b5825..b85b538 100644 --- a/backend/application/common/service/TransferAuthorizationService.php +++ b/backend/application/common/service/TransferAuthorizationService.php @@ -15,6 +15,11 @@ use think\Log; * 本服务记录 authorization_id 并将状态置为 TAKING_EFFECT; * 3. 后台审核提现时,校验授权为 TAKING_EFFECT 后走 * /v3/fund-app/mch-transfer/transfer-bills/transfer 完成免确认转账。 + * 文档(2026.05.08): + * - 申请有效期 24 小时,过期未确认须换 out_authorization_no 重新发起 + * - 未确认记录保留 30 天,超期后原单号可复用 + * - 同一微信号在当前商户转账场景下,WAIT_USER_CONFIRM + TAKING_EFFECT 最多 5 个 + * - 一个用户账号对应唯一一个生效中的授权;授权成功后长期有效,须保存单号 * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167 */ class TransferAuthorizationService @@ -41,9 +46,8 @@ class TransferAuthorizationService } /** - * 生成新的商户侧授权单号:授权已被关闭/解除时使用。 - * 微信仅明确“未确认的授权单 30 天后单号可复用”,已确认后又被关闭的单号复用存在失败风险, - * 因此关闭后重新授权使用全新单号(原单号 ≤22 位,仍满足 32 位、字母数字限制) + * 生成新的商户侧授权单号:授权已被关闭/申请过期时使用。 + * 已关闭单号 30 天内不可复用;WAIT 过期也须换号重新申请。 * @param int $userId * @return string */ @@ -92,39 +96,109 @@ class TransferAuthorizationService throw new \Exception('无法获取用户微信身份,请先登录小程序'); } - $authNo = self::generateAuthNo($userId); $wechat = new EasyWeChatService(); + $record = self::getByUserId($userId); + //必须查本地当前单号,不能每次都用固定单号:固定单号一旦 CLOSED, + //再换新号申请会把同一 openid 下未确认单堆到 5 笔上限 + $authNo = ($record && $record->out_authorization_no) + ? (string)$record->out_authorization_no + : self::generateAuthNo($userId); + $displayName = self::sanitizeDisplayName($displayName ?: ($user->nickname ?: ('用户' . $userId))); - //每次提现前都回查微信侧最新授权状态并同步到本地: - //授权生效后长期有效(TAKING_EFFECT),但用户可能在微信端主动解除授权, - //只看本地记录会导致解除后仍按已授权放行,直到后台转账失败才暴露 - $query = $wechat->queryTransferAuthorization($authNo); + $query = $wechat->queryTransferAuthorization($authNo, true); + $resolved = self::resolveByWechatState($userId, $authNo, $query, $wechat, $record); + if ($resolved !== null) { + return $resolved; + } + + $queryState = (string)($query['state'] ?? ''); + $renew = false; + //CLOSED:过期未确认/用户或商户关闭后须换单号;30 天内原单号不可再用 + if ($queryState === self::STATE_CLOSED) { + $authNo = self::generateAuthNoUnique($userId); + $renew = true; + } + + return self::requestAuthorization($user, $authNo, $displayName, $wechat, $renew); + } + + /** + * 按微信查询结果决定是否直接返回,无需再申请 + * @return array|null 非空表示已可返回给前端;null 表示继续申请 + */ + private static function resolveByWechatState($userId, string $authNo, array $query, EasyWeChatService $wechat, $record) + { $queryState = (string)($query['state'] ?? ''); if ($queryState !== '') { - self::saveAuthorization($userId, $authNo, $queryState, $query['authorization_id'] ?? ''); + self::saveAuthorization( + $userId, + $authNo, + $queryState, + (string)($query['authorization_id'] ?? ''), + (string)($query['package_info'] ?? '') + ); if ($queryState === self::STATE_TAKING_EFFECT) { return [ 'authorized' => true, 'state' => $queryState, ]; } + if ($queryState === self::STATE_WAIT_USER_CONFIRM) { + //待确认单有效期内必须复用,禁止再申请(计入 5 笔上限) + return self::pendingPackageResponse($wechat, $query, $record ?: self::getByUserId($userId)); + } + //CLOSED 走换号申请 + return null; } - //未生效:发起授权申请(未确认的单号可复用重新发起) - //CLOSED(用户或商户已关闭) 时复用原单号存在发起失败风险,换新单号并覆盖当前记录 - $renew = ($queryState === self::STATE_CLOSED); - if ($renew) { - $authNo = self::generateAuthNoUnique($userId); + //NOT_FOUND:超期未确认超过 30 天或单号从未存在,可用当前单号重新申请 + if (self::isWechatNotFound($query)) { + return null; } - $displayName = mb_substr($displayName ?: ($user->nickname ?: ('用户' . $userId)), 0, 32); + + //查询失败时禁止再开新单,避免把同一 openid 的待确认/已生效单堆到 5 笔上限 + if ($record && $record->state === self::STATE_TAKING_EFFECT) { + throw new \Exception('查询授权状态失败,请稍后重试'); + } + if ($record && $record->state === self::STATE_WAIT_USER_CONFIRM && !empty($record->package_info)) { + return self::pendingAuthResponse($wechat, self::STATE_WAIT_USER_CONFIRM, (string)$record->package_info); + } + if (!empty($query['message'])) { + throw new \Exception('查询授权状态失败,请稍后重试'); + } + + return null; + } + + /** + * 向微信发起授权申请 + */ + private static function requestAuthorization($user, string $authNo, string $displayName, EasyWeChatService $wechat, bool $renew): array + { + $userId = $user->id; $res = $wechat->applyTransferAuthorization($authNo, $user->openid, $displayName); - if (empty($res['package_info'])) { - //package 仅在授权状态为 WAIT_USER_CONFIRM 时返回,拿不到时透出微信返回的错误信息 - throw new \Exception($res['message'] ?? ($query['message'] ?? '发起免确认收款授权失败')); + //当前单已受理/已终结:改为查单,禁止换号重试 + if (self::isApplyConflict($res)) { + $query = $wechat->queryTransferAuthorization($authNo, true); + $resolved = self::resolveByWechatState($userId, $authNo, $query, $wechat, self::getByUserId($userId)); + if ($resolved !== null) { + return $resolved; + } + } + + if (self::isQuantityLimit($res)) { + $record = self::getByUserId($userId); + if ($record && $record->state === self::STATE_WAIT_USER_CONFIRM && !empty($record->package_info)) { + return self::pendingAuthResponse($wechat, $record->state, (string)$record->package_info); + } + throw new \Exception('当前微信账号在本转账场景下待确认/已生效的授权已达上限(最多5个)。请先在微信中完成已发起的授权确认,或等待24小时未确认申请关闭后再试。'); + } + + if (empty($res['package_info'])) { + throw new \Exception($res['message'] ?? '发起免确认收款授权失败'); } - //保存/更新授权记录(package_info 用于前端拉起授权页,state 当前为待用户确认) self::saveAuthorization( $userId, $authNo, @@ -134,16 +208,70 @@ class TransferAuthorizationService $renew ); + return self::pendingAuthResponse( + $wechat, + $res['state'] ?? self::STATE_WAIT_USER_CONFIRM, + $res['package_info'] + ); + } + + /** + * 待确认:优先用查询返回的 package,否则用本地上次申请保存的 package + */ + private static function pendingPackageResponse(EasyWeChatService $wechat, array $query, $record): array + { + $package = (string)($query['package_info'] ?? ''); + if ($package === '' && $record) { + $package = (string)($record->package_info ?? ''); + } + if ($package === '') { + throw new \Exception('授权申请待用户确认,请在微信中完成授权,勿重复发起'); + } + + return self::pendingAuthResponse($wechat, self::STATE_WAIT_USER_CONFIRM, $package); + } + + private static function pendingAuthResponse(EasyWeChatService $wechat, string $state, string $package): array + { return [ 'authorized' => false, - 'state' => $res['state'] ?? self::STATE_WAIT_USER_CONFIRM, - 'package_info' => $res['package_info'], - //前端 wx.requestMerchantTransfer 需要:商户号 + AppID + package + 'state' => $state, + 'package_info' => $package, 'mch_id' => (string)$wechat->payApp->getMerchant()->getMerchantId(), 'app_id' => $wechat->config['app_id'], ]; } + private static function sanitizeDisplayName(string $displayName): string + { + $displayName = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $displayName) ?: ''; + $displayName = trim($displayName); + + return mb_substr($displayName !== '' ? $displayName : '用户', 0, 32); + } + + private static function isWechatNotFound(array $res): bool + { + $code = (string)($res['code'] ?? ''); + $message = (string)($res['message'] ?? ''); + + return $code === 'NOT_FOUND' || mb_strpos($message, '不存在') !== false; + } + + private static function isQuantityLimit(array $res): bool + { + $message = (string)($res['message'] ?? ''); + + return mb_strpos($message, '授权数量达到限制') !== false || mb_strpos($message, '数量达到限制') !== false; + } + + private static function isApplyConflict(array $res): bool + { + $message = (string)($res['message'] ?? ''); + + return mb_strpos($message, '已解除或已授权成功') !== false; + } + /** * 保存/更新授权记录 * @param int $userId @@ -151,18 +279,20 @@ class TransferAuthorizationService * @param string $state 授权状态 * @param string $authorizationId 微信侧授权单号(可为空) * @param string $packageInfo 拉起授权页的 package(可为空) - * @param bool $renew 是否解除后重新授权(覆盖 out_authorization_no 并清空旧 authorization_id) + * @param bool $renew 是否关闭后换号重新授权(覆盖 out_authorization_no 并清空旧 authorization_id) * @return void */ private static function saveAuthorization($userId, string $authNo, string $state, string $authorizationId = '', string $packageInfo = '', bool $renew = false) { //表上存在 uniq_user_id 唯一索引,一个用户只允许一条授权记录,只能更新不能新增 $record = self::getByUserId($userId); - $data = ['state' => $state]; + $data = [ + 'state' => $state, + 'out_authorization_no' => $authNo, + ]; if ($renew) { //授权已被关闭/解除:换新单号重新发起,旧的 authorization_id 已失效需清空 - $data['out_authorization_no'] = $authNo; - $data['authorization_id'] = ''; + $data['authorization_id'] = ''; } elseif ($authorizationId !== '') { $data['authorization_id'] = $authorizationId; } @@ -194,7 +324,7 @@ class TransferAuthorizationService } /** - * 处理免确认收款授权结果回调(用户授权成功后微信异步通知) + * 处理免确认收款授权结果回调(用户确认或关闭后微信异步通知) * @param array $message 解密后的授权结果,含 out_authorization_no / authorization_id / state * @return bool */ @@ -203,7 +333,7 @@ class TransferAuthorizationService $outAuthNo = trim((string)($message['out_authorization_no'] ?? '')); $authorizationId = trim((string)($message['authorization_id'] ?? '')); $state = trim((string)($message['state'] ?? '')); - if (!$outAuthNo || !$authorizationId) { + if (!$outAuthNo || !$state) { Log::error('免确认收款授权回调参数缺失:' . json_encode($message, JSON_UNESCAPED_UNICODE)); return false; } @@ -214,11 +344,12 @@ class TransferAuthorizationService return false; } - //记录微信侧授权单号,标记授权生效(授权长期有效,直至用户或商户主动解除) - $record->authorization_id = $authorizationId; - $record->state = $state ?: self::STATE_TAKING_EFFECT; + if ($authorizationId !== '') { + $record->authorization_id = $authorizationId; + } + $record->state = $state; $record->save(); - Log::info('免确认收款授权生效:' . json_encode([ + Log::info('免确认收款授权回调已同步:' . json_encode([ 'user_id' => $record->user_id, 'state' => $record->state, ], JSON_UNESCAPED_UNICODE));