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

206 lines
7.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;
// 授权状态
const STATE_WAIT_USER_CONFIRM = 'WAIT_USER_CONFIRM'; //已受理,待用户确认
const STATE_TAKING_EFFECT = 'TAKING_EFFECT'; //已授权,生效中(长期有效,直至主动解除)
/**
* 生成商户侧授权单号:一个用户固定一个单号。
* 微信规则:未确认的授权单 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);
}
/**
* 获取用户的授权记录
* @param int $userId
* @return TransferAuthorizations|null
*/
public static function getByUserId($userId)
{
return self::getOne(['user_id' => $userId]);
}
/**
* 用户是否已完成免确认收款授权
* @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('无法获取用户微信身份,请先登录小程序');
}
//已授权的用户直接返回,不再重复发起
$record = self::getByUserId($userId);
if ($record && self::isAuthorized($userId)) {
return [
'authorized' => true,
'state' => $record->state,
];
}
$authNo = self::generateAuthNo($userId);
$wechat = new EasyWeChatService();
//本地状态可能与微信侧不一致(如授权回调未收到):先查单同步,微信侧已生效则直接放行
$query = $wechat->queryTransferAuthorization($authNo);
if (($query['state'] ?? '') === self::STATE_TAKING_EFFECT) {
self::saveAuthorization($userId, $authNo, self::STATE_TAKING_EFFECT, $query['authorization_id'] ?? '');
return [
'authorized' => true,
'state' => self::STATE_TAKING_EFFECT,
];
}
//发起授权申请(授权单号固定,复用同一单号可重新发起)
$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 时返回;拿不到时再查单确认微信侧是否已生效
$query = $wechat->queryTransferAuthorization($authNo);
if (($query['state'] ?? '') === self::STATE_TAKING_EFFECT) {
self::saveAuthorization($userId, $authNo, self::STATE_TAKING_EFFECT, $query['authorization_id'] ?? '');
return [
'authorized' => true,
'state' => self::STATE_TAKING_EFFECT,
];
}
throw new \Exception($res['message'] ?? ($query['message'] ?? '发起免确认收款授权失败'));
}
//保存/更新授权记录(package_info 用于前端拉起授权页,state 当前为待用户确认)
self::saveAuthorization(
$userId,
$authNo,
$res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'',
$res['package_info']
);
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(可为空)
* @return void
*/
private static function saveAuthorization($userId, string $authNo, string $state, string $authorizationId = '', string $packageInfo = '')
{
$record = 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;
}
}