转账功能优化

This commit is contained in:
Dev
2026-10-10 14:10:14 +08:00
parent e1d14ff759
commit 5f3dc03137
9 changed files with 483 additions and 50 deletions
@@ -0,0 +1,156 @@
<?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);
return $record
&& $record->state == self::STATE_TAKING_EFFECT
&& $record->authorization_id;
}
/**
* 发起/获取免确认收款授权,供前端拉起授权页
* @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();
$displayName = mb_substr($displayName ?: ($user->nickname ?: ('用户' . $userId)), 0, 32);
$res = $wechat->applyTransferAuthorization($authNo, $user->openid, $displayName);
if (empty($res['package_info'])) {
throw new \Exception($res['message'] ?? '发起免确认收款授权失败');
}
//保存/更新授权记录(package_info 用于前端拉起授权页,state 当前为待用户确认)
$data = [
'state' => $res['state'] ?? self::STATE_WAIT_USER_CONFIRM,
'package_info' => $res['package_info'],
];
if ($record) {
$record->save($data);
} else {
self::create(array_merge([
'user_id' => $userId,
'out_authorization_no' => $authNo,
'authorization_id' => '',
], $data));
}
return [
'authorized' => false,
'state' => $data['state'],
'package_info' => $res['package_info'],
//前端 wx.requestMerchantTransfer 需要:商户号 + AppID + package
'mch_id' => (string)$wechat->payApp->getMerchant()->getMerchantId(),
'app_id' => $wechat->config['app_id'],
];
}
/**
* 处理免确认收款授权结果回调(用户授权成功后微信异步通知)
* @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;
}
}