$userId, 'state' => self::STATE_TAKING_EFFECT], [], ['id', 'desc']); return $record ?: self::getOne(['user_id' => $userId], [], ['id', 'desc']); } /** * 用户是否已完成免确认收款授权 * @param int $userId * @return bool */ public static function isAuthorized($userId): bool { $record = self::getByUserId($userId); //授权生效后即可凭 out_authorization_no 发起免确认转账;authorization_id 仅作留存 return $record && $record->state == self::STATE_TAKING_EFFECT; } /** * 发起/获取免确认收款授权,供前端拉起授权页 * @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('无法获取用户微信身份,请先登录小程序'); } $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))); //注意:is_display_authorization=true 只在授权已生效(TAKING_EFFECT)时可用; //待确认(WAIT_USER_CONFIRM)/已关闭(CLOSED)会被微信以 INVALID_REQUEST 拒绝且不返回 state, //会导致误判为“查询授权状态失败”。查状态必须用 false, //拉起授权页所需的 package 只能来自申请接口的响应或本地记录。 $query = $wechat->queryTransferAuthorization($authNo, false); $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, (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) { //待确认单有效期内必须复用同一单号(禁止换号,避免把同一 openid 的待确认单堆到 5 笔上限)。 //本地若无可用 package(该状态是本次查单才发现、或记录里的 package 丢失), //返回 null 交给外层用同一单号重新“发起授权”,微信会幂等返回该单号及其 package_info return self::pendingPackageResponse($wechat, $query, $record ?: self::getByUserId($userId)); } //CLOSED 走换号申请 return null; } //NOT_FOUND:超期未确认超过 30 天或单号从未存在,可用当前单号重新申请 if (self::isWechatNotFound($query)) { return null; } //查询异常(网络/系统错误)时:本地已生效的授权直接放行,避免误报“查询授权状态失败”; //不允许再开新单,防止把同一 openid 的待确认/已生效单堆到 5 笔上限 if ($record && $record->state === self::STATE_TAKING_EFFECT) { return [ 'authorized' => true, 'state' => self::STATE_TAKING_EFFECT, ]; } 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; } /** * 向微信发起授权申请 * @param bool $isRetry 是否为重试(防止递归) */ private static function requestAuthorization($user, string $authNo, string $displayName, EasyWeChatService $wechat, bool $renew, bool $isRetry = false): array { $userId = $user->id; $res = $wechat->applyTransferAuthorization($authNo, $user->openid, $displayName); 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'])) { //发起失败(单号已被占用/重入参数不一致/系统异常等,不同版本文案不同,故不做关键字匹配): //先查单按微信侧实际状态处理,而不是把原始错误抛给用户 $query = $wechat->queryTransferAuthorization($authNo, false); $resolved = self::resolveByWechatState($userId, $authNo, $query, $wechat, self::getByUserId($userId)); if ($resolved !== null) { return $resolved; } if (!$isRetry) { //微信侧查无此单(本次发起未落单):用同一单号重试一次 if (self::isWechatNotFound($query)) { return self::requestAuthorization($user, $authNo, $displayName, $wechat, $renew, true); } //单已存在但拿不到可拉起授权页的 package(已终结,或重入参数不一致导致该单号不可复用): //换全新单号重新发起,保证用户一定能拿到 package;原未确认单 24 小时后自动关闭 return self::requestAuthorization($user, self::generateAuthNoUnique($userId), $displayName, $wechat, true, true); } throw new \Exception('发起免确认收款授权失败:' . ($res['message'] ?? '请稍后重试')); } self::saveAuthorization( $userId, $authNo, $res['state'] ?? self::STATE_WAIT_USER_CONFIRM, '', $res['package_info'], $renew ); return self::pendingAuthResponse( $wechat, $res['state'] ?? self::STATE_WAIT_USER_CONFIRM, $res['package_info'] ); } /** * 待确认:优先用查询返回的 package,其次用本地上次申请保存的 package。 * 两者都没有时返回 null,由调用方用同一单号重新发起授权取回 package, * 否则会出现“提示待确认但无法拉起授权页”的死路。 * @return array|null */ private static function pendingPackageResponse(EasyWeChatService $wechat, array $query, $record) { $package = (string)($query['package_info'] ?? ''); if ($package === '' && $record) { $package = (string)($record->package_info ?? ''); } if ($package === '') { return null; } 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' => $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; } /** * 保存/更新授权记录 * @param int $userId * @param string $authNo 商户侧授权单号 * @param string $state 授权状态 * @param string $authorizationId 微信侧授权单号(可为空) * @param string $packageInfo 拉起授权页的 package(可为空) * @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, 'out_authorization_no' => $authNo, ]; if ($renew) { //授权已被关闭/解除:换新单号重新发起,旧的 authorization_id 已失效需清空 $data['authorization_id'] = ''; } elseif ($authorizationId !== '') { $data['authorization_id'] = $authorizationId; } if ($packageInfo !== '') { $data['package_info'] = $packageInfo; } if ($record) { $record->save($data); return; } try { self::create(array_merge([ 'user_id' => $userId, 'out_authorization_no' => $authNo, 'authorization_id' => $authorizationId, 'package_info' => $packageInfo, ], $data)); } catch (\Exception $e) { //并发下可能撞 uniq_user_id / uniq_out_auth_no,回退为更新已有记录 $exists = self::getByUserId($userId); if (!$exists) { throw $e; } $exists->save(array_merge($data, ['out_authorization_no' => $authNo])); } } /** * 处理免确认收款授权结果回调(用户确认或关闭后微信异步通知) * @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 || !$state) { Log::error('免确认收款授权回调参数缺失:' . json_encode($message, JSON_UNESCAPED_UNICODE)); return false; } $record = self::getOne(['out_authorization_no' => $outAuthNo]); if (!$record) { Log::error('免确认收款授权回调找不到授权记录:' . $outAuthNo); return false; } if ($authorizationId !== '') { $record->authorization_id = $authorizationId; } $record->state = $state; $record->save(); Log::info('免确认收款授权回调已同步:' . json_encode([ 'user_id' => $record->user_id, 'state' => $record->state, ], JSON_UNESCAPED_UNICODE)); return true; } }