Files
code-bang/backend/application/common/service/TransferAuthorizationService.php
T
2026-10-10 15:11:14 +08:00

215 lines
8.8 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 完成免确认转账。
* 文档: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 天后单号可复用”,已确认后又被关闭的单号复用存在失败风险,
* 因此关闭后重新授权使用全新单号(原单号 ≤22 位,仍满足 32 位、字母数字限制)
* @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('无法获取用户微信身份,请先登录小程序');
}
$authNo = self::generateAuthNo($userId);
$wechat = new EasyWeChatService();
//每次提现前都回查微信侧最新授权状态并同步到本地:
//授权生效后长期有效(TAKING_EFFECT),但用户可能在微信端主动解除授权,
//只看本地记录会导致解除后仍按已授权放行,直到后台转账失败才暴露
$query = $wechat->queryTransferAuthorization($authNo);
$queryState = (string)($query['state'] ?? '');
if ($queryState !== '') {
self::saveAuthorization($userId, $authNo, $queryState, $query['authorization_id'] ?? '');
if ($queryState === self::STATE_TAKING_EFFECT) {
return [
'authorized' => true,
'state' => $queryState,
];
}
}
//未生效:发起授权申请(未确认的单号可复用重新发起)
//CLOSED(用户或商户已关闭) 时复用原单号存在发起失败风险,改用全新单号并新建记录
$forceCreate = ($queryState === self::STATE_CLOSED);
if ($forceCreate) {
$authNo = self::generateAuthNoUnique($userId);
}
$displayName = mb_substr($displayName ?: ($user->nickname ?: ('用户' . $userId)), 0, 32);
$res = $wechat->applyTransferAuthorization($authNo, $user->openid, $displayName);
if (empty($res['package_info'])) {
//package 仅在授权状态为 WAIT_USER_CONFIRM 时返回,拿不到时透出微信返回的错误信息
throw new \Exception($res['message'] ?? ($query['message'] ?? '发起免确认收款授权失败'));
}
//保存/更新授权记录(package_info 用于前端拉起授权页,state 当前为待用户确认)
self::saveAuthorization(
$userId,
$authNo,
$res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'',
$res['package_info'],
$forceCreate
);
return [
'authorized' => false,
'state' => $res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'package_info' => $res['package_info'],
//前端 wx.requestMerchantTransfer 需要:商户号 + AppID + package
'mch_id' => (string)$wechat->payApp->getMerchant()->getMerchantId(),
'app_id' => $wechat->config['app_id'],
];
}
/**
* 保存/更新授权记录
* @param int $userId
* @param string $authNo 商户侧授权单号
* @param string $state 授权状态
* @param string $authorizationId 微信侧授权单号(可为空)
* @param string $packageInfo 拉起授权页的 package(可为空)
* @param bool $forceCreate 是否强制新建记录(授权单已关闭换新单号时使用)
* @return void
*/
private static function saveAuthorization($userId, string $authNo, string $state, string $authorizationId = '', string $packageInfo = '', bool $forceCreate = false)
{
$record = $forceCreate ? null : self::getByUserId($userId);
$data = ['state' => $state];
if ($authorizationId !== '') {
$data['authorization_id'] = $authorizationId;
}
if ($packageInfo !== '') {
$data['package_info'] = $packageInfo;
}
if ($record) {
$record->save($data);
return;
}
self::create(array_merge([
'user_id' => $userId,
'out_authorization_no' => $authNo,
'authorization_id' => $authorizationId,
'package_info' => $packageInfo,
], $data));
}
/**
* 处理免确认收款授权结果回调(用户授权成功后微信异步通知)
* @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 || !$authorizationId) {
Log::error('免确认收款授权回调参数缺失:' . json_encode($message, JSON_UNESCAPED_UNICODE));
return false;
}
$record = self::getOne(['out_authorization_no' => $outAuthNo]);
if (!$record) {
Log::error('免确认收款授权回调找不到授权记录:' . $outAuthNo);
return false;
}
//记录微信侧授权单号,标记授权生效(授权长期有效,直至用户或商户主动解除)
$record->authorization_id = $authorizationId;
$record->state = $state ?: self::STATE_TAKING_EFFECT;
$record->save();
Log::info('免确认收款授权生效:' . json_encode([
'user_id' => $record->user_id,
'state' => $record->state,
], JSON_UNESCAPED_UNICODE));
return true;
}
}