215 lines
8.8 KiB
PHP
215 lines
8.8 KiB
PHP
<?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;
|
||
}
|
||
}
|