授权确认优化
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user