Files
code-bang/backend/application/common/service/TransferAuthorizationService.php
T
2026-10-10 17:10:38 +08:00

368 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\service;
use app\common\model\account\TransferAuthorizations;
use app\common\traits\ServiceTrait;
use think\Log;
/**
* 免确认收款授权(商家转账-用户确认模式的前置授权)
* 流程:
* 1. 用户发起提现时,前端调本服务 apply() 发起授权申请,
* 拿到 package_info 后用 wx.requestMerchantTransfer 拉起授权页;
* 2. 用户授权成功后,微信异步回调 transferAuthorizationNotify,
* 本服务记录 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
{
use ServiceTrait;
/** @var TransferAuthorizations */
public static $Model = TransferAuthorizations::class;
// 授权状态(与微信查询授权单接口 state 取值一致)
const STATE_WAIT_USER_CONFIRM = 'WAIT_USER_CONFIRM'; //已受理,待用户确认
const STATE_TAKING_EFFECT = 'TAKING_EFFECT'; //已授权,生效中(长期有效,直至主动解除)
const STATE_CLOSED = 'CLOSED'; //已关闭,无法转账至该授权对应的用户
/**
* 生成商户侧授权单号:一个用户固定一个单号。
* 微信规则:未确认的授权单 30 天后单号可复用重新发起,因此复用同一单号即可
* @param int $userId
* @return string 仅含字母数字,最长 32 位
*/
public static function generateAuthNo($userId): string
{
return 'TXAUTH' . str_pad((string)intval($userId), 10, '0', STR_PAD_LEFT);
}
/**
* 生成新的商户侧授权单号:授权已被关闭/申请过期时使用。
* 已关闭单号 30 天内不可复用;WAIT 过期也须换号重新申请。
* @param int $userId
* @return string
*/
public static function generateAuthNoUnique($userId): string
{
return self::generateAuthNo($userId) . strtoupper(substr(md5(uniqid('', true)), 0, 6));
}
/**
* 获取用户的授权记录
* @param int $userId
* @return TransferAuthorizations|null
*/
public static function getByUserId($userId)
{
//优先取生效中的授权记录(历史被关闭的记录仍保留,故按状态+时间取最新的)
$record = self::getOne(['user_id' => $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) {
//待确认单有效期内必须复用,禁止再申请(计入 5 笔上限)
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;
}
/**
* 向微信发起授权申请
*/
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 (self::isApplyConflict($res)) {
$query = $wechat->queryTransferAuthorization($authNo, false);
$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'] ?? '发起免确认收款授权失败');
}
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
*/
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' => $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
* @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;
}
}