Files
code-bang/backend/application/common/service/TransferAuthorizationService.php
T

373 lines
15 KiB
PHP
Raw Normal View History

2026-10-10 14:10:14 +08:00
<?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-10-10 16:52:01 +08:00
* 文档(2026.05.08):
* - 申请有效期 24 小时,过期未确认须换 out_authorization_no 重新发起
* - 未确认记录保留 30 天,超期后原单号可复用
* - 同一微信号在当前商户转账场景下,WAIT_USER_CONFIRM + TAKING_EFFECT 最多 5 个
* - 一个用户账号对应唯一一个生效中的授权;授权成功后长期有效,须保存单号
2026-10-10 14:10:14 +08:00
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167
*/
class TransferAuthorizationService
{
use ServiceTrait;
/** @var TransferAuthorizations */
public static $Model = TransferAuthorizations::class;
2026-10-10 15:11:14 +08:00
// 授权状态(与微信查询授权单接口 state 取值一致)
2026-10-10 14:10:14 +08:00
const STATE_WAIT_USER_CONFIRM = 'WAIT_USER_CONFIRM'; //已受理,待用户确认
const STATE_TAKING_EFFECT = 'TAKING_EFFECT'; //已授权,生效中(长期有效,直至主动解除)
2026-10-10 15:11:14 +08:00
const STATE_CLOSED = 'CLOSED'; //已关闭,无法转账至该授权对应的用户
2026-10-10 14:10:14 +08:00
/**
* 生成商户侧授权单号:一个用户固定一个单号。
* 微信规则:未确认的授权单 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);
}
2026-10-10 15:11:14 +08:00
/**
2026-10-10 16:52:01 +08:00
* 生成新的商户侧授权单号:授权已被关闭/申请过期时使用。
* 已关闭单号 30 天内不可复用;WAIT 过期也须换号重新申请。
2026-10-10 15:11:14 +08:00
* @param int $userId
* @return string
*/
public static function generateAuthNoUnique($userId): string
{
return self::generateAuthNo($userId) . strtoupper(substr(md5(uniqid('', true)), 0, 6));
}
2026-10-10 14:10:14 +08:00
/**
* 获取用户的授权记录
* @param int $userId
* @return TransferAuthorizations|null
*/
public static function getByUserId($userId)
{
2026-10-10 15:11:14 +08:00
//优先取生效中的授权记录(历史被关闭的记录仍保留,故按状态+时间取最新的)
$record = self::getOne(['user_id' => $userId, 'state' => self::STATE_TAKING_EFFECT], [], ['id', 'desc']);
return $record ?: self::getOne(['user_id' => $userId], [], ['id', 'desc']);
2026-10-10 14:10:14 +08:00
}
/**
* 用户是否已完成免确认收款授权
* @param int $userId
* @return bool
*/
public static function isAuthorized($userId): bool
{
$record = self::getByUserId($userId);
2026-10-10 14:52:18 +08:00
//授权生效后即可凭 out_authorization_no 发起免确认转账;authorization_id 仅作留存
return $record && $record->state == self::STATE_TAKING_EFFECT;
2026-10-10 14:10:14 +08:00
}
/**
* 发起/获取免确认收款授权,供前端拉起授权页
* @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();
2026-10-10 16:52:01 +08:00
$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)));
2026-10-10 17:10:38 +08:00
//注意:is_display_authorization=true 只在授权已生效(TAKING_EFFECT)时可用;
//待确认(WAIT_USER_CONFIRM)/已关闭(CLOSED)会被微信以 INVALID_REQUEST 拒绝且不返回 state,
//会导致误判为“查询授权状态失败”。查状态必须用 false,
//拉起授权页所需的 package 只能来自申请接口的响应或本地记录。
$query = $wechat->queryTransferAuthorization($authNo, false);
2026-10-10 16:52:01 +08:00
$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);
}
2026-10-10 14:52:18 +08:00
2026-10-10 16:52:01 +08:00
/**
* 按微信查询结果决定是否直接返回,无需再申请
* @return array|null 非空表示已可返回给前端;null 表示继续申请
*/
private static function resolveByWechatState($userId, string $authNo, array $query, EasyWeChatService $wechat, $record)
{
2026-10-10 15:11:14 +08:00
$queryState = (string)($query['state'] ?? '');
if ($queryState !== '') {
2026-10-10 16:52:01 +08:00
self::saveAuthorization(
$userId,
$authNo,
$queryState,
(string)($query['authorization_id'] ?? ''),
(string)($query['package_info'] ?? '')
);
2026-10-10 15:11:14 +08:00
if ($queryState === self::STATE_TAKING_EFFECT) {
return [
'authorized' => true,
'state' => $queryState,
];
}
2026-10-10 16:52:01 +08:00
if ($queryState === self::STATE_WAIT_USER_CONFIRM) {
2026-10-10 17:19:40 +08:00
//待确认单有效期内必须复用同一单号(禁止换号,避免把同一 openid 的待确认单堆到 5 笔上限)。
//本地若无可用 package(该状态是本次查单才发现、或记录里的 package 丢失),
//返回 null 交给外层用同一单号重新“发起授权”,微信会幂等返回该单号及其 package_info
2026-10-10 16:52:01 +08:00
return self::pendingPackageResponse($wechat, $query, $record ?: self::getByUserId($userId));
}
//CLOSED 走换号申请
return null;
2026-10-10 14:52:18 +08:00
}
2026-10-10 16:52:01 +08:00
//NOT_FOUND:超期未确认超过 30 天或单号从未存在,可用当前单号重新申请
if (self::isWechatNotFound($query)) {
return null;
}
2026-10-10 17:10:38 +08:00
//查询异常(网络/系统错误)时:本地已生效的授权直接放行,避免误报“查询授权状态失败”;
//不允许再开新单,防止把同一 openid 的待确认/已生效单堆到 5 笔上限
2026-10-10 16:52:01 +08:00
if ($record && $record->state === self::STATE_TAKING_EFFECT) {
2026-10-10 17:10:38 +08:00
return [
'authorized' => true,
'state' => self::STATE_TAKING_EFFECT,
];
2026-10-10 16:52:01 +08:00
}
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);
2026-10-10 15:11:14 +08:00
}
2026-10-10 16:52:01 +08:00
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;
2026-10-10 14:10:14 +08:00
$res = $wechat->applyTransferAuthorization($authNo, $user->openid, $displayName);
2026-10-10 16:52:01 +08:00
//当前单已受理/已终结:改为查单,禁止换号重试
if (self::isApplyConflict($res)) {
2026-10-10 17:10:38 +08:00
$query = $wechat->queryTransferAuthorization($authNo, false);
2026-10-10 16:52:01 +08:00
$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小时未确认申请关闭后再试。');
}
2026-10-10 14:10:14 +08:00
if (empty($res['package_info'])) {
2026-10-10 16:52:01 +08:00
throw new \Exception($res['message'] ?? '发起免确认收款授权失败');
2026-10-10 14:10:14 +08:00
}
2026-10-10 14:52:18 +08:00
self::saveAuthorization(
$userId,
$authNo,
$res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'',
2026-10-10 15:11:14 +08:00
$res['package_info'],
2026-10-10 15:23:55 +08:00
$renew
2026-10-10 14:52:18 +08:00
);
2026-10-10 14:10:14 +08:00
2026-10-10 16:52:01 +08:00
return self::pendingAuthResponse(
$wechat,
$res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
$res['package_info']
);
}
/**
2026-10-10 17:19:40 +08:00
* 待确认:优先用查询返回的 package,其次用本地上次申请保存的 package。
* 两者都没有时返回 null,由调用方用同一单号重新发起授权取回 package,
* 否则会出现“提示待确认但无法拉起授权页”的死路。
* @return array|null
2026-10-10 16:52:01 +08:00
*/
2026-10-10 17:19:40 +08:00
private static function pendingPackageResponse(EasyWeChatService $wechat, array $query, $record)
2026-10-10 16:52:01 +08:00
{
$package = (string)($query['package_info'] ?? '');
if ($package === '' && $record) {
$package = (string)($record->package_info ?? '');
}
if ($package === '') {
2026-10-10 17:19:40 +08:00
return null;
2026-10-10 16:52:01 +08:00
}
return self::pendingAuthResponse($wechat, self::STATE_WAIT_USER_CONFIRM, $package);
}
private static function pendingAuthResponse(EasyWeChatService $wechat, string $state, string $package): array
{
2026-10-10 14:10:14 +08:00
return [
'authorized' => false,
2026-10-10 16:52:01 +08:00
'state' => $state,
'package_info' => $package,
2026-10-10 14:10:14 +08:00
'mch_id' => (string)$wechat->payApp->getMerchant()->getMerchantId(),
'app_id' => $wechat->config['app_id'],
];
}
2026-10-10 16:52:01 +08:00
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;
}
2026-10-10 14:52:18 +08:00
/**
* 保存/更新授权记录
* @param int $userId
* @param string $authNo 商户侧授权单号
* @param string $state 授权状态
* @param string $authorizationId 微信侧授权单号(可为空)
* @param string $packageInfo 拉起授权页的 package(可为空)
2026-10-10 16:52:01 +08:00
* @param bool $renew 是否关闭后换号重新授权(覆盖 out_authorization_no 并清空旧 authorization_id)
2026-10-10 14:52:18 +08:00
* @return void
*/
2026-10-10 15:23:55 +08:00
private static function saveAuthorization($userId, string $authNo, string $state, string $authorizationId = '', string $packageInfo = '', bool $renew = false)
2026-10-10 14:52:18 +08:00
{
2026-10-10 15:23:55 +08:00
//表上存在 uniq_user_id 唯一索引,一个用户只允许一条授权记录,只能更新不能新增
$record = self::getByUserId($userId);
2026-10-10 16:52:01 +08:00
$data = [
'state' => $state,
'out_authorization_no' => $authNo,
];
2026-10-10 15:23:55 +08:00
if ($renew) {
//授权已被关闭/解除:换新单号重新发起,旧的 authorization_id 已失效需清空
2026-10-10 16:52:01 +08:00
$data['authorization_id'] = '';
2026-10-10 15:23:55 +08:00
} elseif ($authorizationId !== '') {
2026-10-10 14:52:18 +08:00
$data['authorization_id'] = $authorizationId;
}
if ($packageInfo !== '') {
$data['package_info'] = $packageInfo;
}
if ($record) {
$record->save($data);
return;
}
2026-10-10 15:23:55 +08:00
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]));
}
2026-10-10 14:52:18 +08:00
}
2026-10-10 14:10:14 +08:00
/**
2026-10-10 16:52:01 +08:00
* 处理免确认收款授权结果回调(用户确认或关闭后微信异步通知)
2026-10-10 14:10:14 +08:00
* @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'] ?? ''));
2026-10-10 16:52:01 +08:00
if (!$outAuthNo || !$state) {
2026-10-10 14:10:14 +08:00
Log::error('免确认收款授权回调参数缺失:' . json_encode($message, JSON_UNESCAPED_UNICODE));
return false;
}
$record = self::getOne(['out_authorization_no' => $outAuthNo]);
if (!$record) {
Log::error('免确认收款授权回调找不到授权记录:' . $outAuthNo);
return false;
}
2026-10-10 16:52:01 +08:00
if ($authorizationId !== '') {
$record->authorization_id = $authorizationId;
}
$record->state = $state;
2026-10-10 14:10:14 +08:00
$record->save();
2026-10-10 16:52:01 +08:00
Log::info('免确认收款授权回调已同步:' . json_encode([
2026-10-10 14:10:14 +08:00
'user_id' => $record->user_id,
'state' => $record->state,
], JSON_UNESCAPED_UNICODE));
return true;
}
}