文件
fenleixinxi/application/common/library/WxTransfer.php
MiAn ea26329e80 feat: 新增用户真实姓名与实名认证功能,优化后台用户管理
1. 前端个人中心新增真实姓名输入与实名状态展示,支持微信提现核验
2. 后端Auth接口新增真实姓名修改与手机号冲突校验逻辑
3. 后台新增用户详情综合看板,展示佣金、提现、分销等全量数据
4. 优化微信转账逻辑,支持paying状态重试时查询微信单号避免重复创建
5. 新增数据库ALTER脚本同步transfer_status字段结构
6. 新增部署与数据库变更工具脚本,支持线上线下环境同步
2026-10-28 16:21:22 +08:00

599 行
23 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\common\library;
use addons\epay\library\Service as EpayService;
use think\Db;
use think\Log;
/**
* 微信商家转账(V3「商家转账」,接口 /v3/fund-app/mch-transfer/transfer-bills)
*
* 业务链路:
* 后台审核通过 → submit() 调微信创建转账单 → 用户微信确认收款 → 微信回调 transferNotify()
* → applyResult() 把提现单置为 paid,并记支出流水/站内信。
*
* 约束(微信侧):
* - 只能 PHP 8.0+ 且 version=v3(MchTransfer 插件只存在于 v3new);
* - 转账场景固定 1005「佣金报酬」,必须带两条 transfer_scene_report_infos;
* - notify_url 必须 HTTPS 且公网可访问;
* - 单笔最低 0.1 元;≥2000 元必须传收款人姓名;
* - 资金从「运营账户」出,余额不足会失败。
*/
class WxTransfer
{
/** 默认转账场景:佣金报酬 */
const DEFAULT_SCENE_ID = '1005';
/** 提现单状态流转:auditing → paying → paid / failed */
/**
* 判断当前环境是否具备商家转账能力
*
* @return bool
*/
public static function isReady()
{
if (PHP_VERSION_ID < 80000) {
return false;
}
if (!EpayService::isVersionV3()) {
return false;
}
self::loadSdk();
return class_exists('\Yansongda\Pay\Plugin\Wechat\V3\Marketing\MchTransfer\CreatePlugin')
&& class_exists('\Yansongda\Pay\Shortcut\Wechat\MchTransferShortcut');
}
/**
* 创建转账单
*
* 调用方需先把提现单置为 paying 并写入 out_bill_no(同一单重试必须复用同一单号)。
*
* @param array $withdraw fc_withdraw 行
* @return array{state:string,out_bill_no:string,transfer_bill_no:string,package_info:string}
* @throws \RuntimeException
*/
public static function submit(array $withdraw)
{
if (!self::isReady()) {
throw new \RuntimeException('当前环境不支持商家转账(需要 PHP 8.0+ 且 epay 插件 version=v3)');
}
$outBillNo = trim((string)(isset($withdraw['out_bill_no']) ? $withdraw['out_bill_no'] : ''));
if ($outBillNo === '') {
throw new \RuntimeException('提现单缺少商户单号(out_bill_no)');
}
$ext = Db::name('fc_user_ext')->where('user_id', (int)$withdraw['user_id'])->find();
if (!$ext) {
throw new \RuntimeException('用户扩展信息不存在,无法转账');
}
$openid = trim((string)$ext['openid']);
if ($openid === '' || strpos($openid, 'h5_') === 0) {
throw new \RuntimeException('该用户没有可用的微信 openid,无法转账');
}
$amountCents = (int)round((float)$withdraw['amount'] * 100);
if ($amountCents < 10) {
throw new \RuntimeException('单笔转账金额不能低于 0.1 元');
}
$realname = trim((string)(isset($ext['realname']) ? $ext['realname'] : ''));
if ($amountCents >= 200000 && $realname === '') {
throw new \RuntimeException('转账金额≥2000 元时必须填写收款人真实姓名');
}
$config = EpayService::getConfig('wechat', []);
$pay = \Yansongda\Pay\Pay::wechat($config);
$params = [
// mini:用 miniapp_id 作为 appid
'_type' => 'mini',
'out_bill_no' => $outBillNo,
'openid' => $openid,
'transfer_amount' => $amountCents,
'transfer_remark' => '分销佣金提现',
'transfer_scene_id' => self::sceneId(),
'transfer_scene_report_infos' => self::sceneInfos(),
'user_recv_perception' => '劳务报酬',
];
$notifyUrl = self::notifyUrl();
if ($notifyUrl !== '') {
$params['notify_url'] = $notifyUrl;
}
if ($realname !== '') {
$params['user_name'] = $realname;
}
try {
$result = $pay->mchTransfer($params);
} catch (\Throwable $e) {
$detail = self::errorDetail($e);
Log::record('商家转账创建失败:提现单 ' . $withdraw['withdraw_no'] . ',' . $detail, 'error');
throw new \RuntimeException('微信转账失败:' . $detail);
}
$res = $result instanceof \Yansongda\Supports\Collection ? $result->all() : (array)$result;
$state = isset($res['state']) ? (string)$res['state'] : '';
if ($state === '') {
Log::record('商家转账应答异常:' . json_encode($res, JSON_UNESCAPED_UNICODE), 'error');
throw new \RuntimeException('微信转账应答异常');
}
return [
'state' => $state,
'out_bill_no' => $outBillNo,
'transfer_bill_no' => isset($res['transfer_bill_no']) ? (string)$res['transfer_bill_no'] : '',
'package_info' => isset($res['package_info']) ? (string)$res['package_info'] : '',
];
}
/**
* 查询转账单(回调丢失时补偿用)
*
* @param string $outBillNo
* @return array
* @throws \RuntimeException
*/
public static function query($outBillNo)
{
if (!self::isReady()) {
throw new \RuntimeException('当前环境不支持商家转账');
}
$config = EpayService::getConfig('wechat', []);
$pay = \Yansongda\Pay\Pay::wechat($config);
try {
$result = $pay->mchTransfer(['_action' => 'query', 'out_bill_no' => $outBillNo]);
} catch (\Throwable $e) {
throw new \RuntimeException('查询转账单失败:' . $e->getMessage());
}
return $result instanceof \Yansongda\Supports\Collection ? $result->all() : (array)$result;
}
/**
* 审核通过并自动发起商家转账
*
* 流程:auditing → paying(落 out_bill_no)→ 调微信创建转账单 → 立即结果 / 等待回调
* 失败时回滚为 auditing,允许重新审核。
* 若处于 paying 状态重试,先向微信查询单号状态,避免重复创建转账单。
*
* @param int $withdrawId
* @return array{state:string,withdrawState:string}
* @throws \RuntimeException
*/
public static function approve($withdrawId)
{
$withdraw = Db::name('fc_withdraw')->where('id', (int)$withdrawId)->find();
if (!$withdraw) {
throw new \RuntimeException('提现记录不存在');
}
if (!in_array((string)$withdraw['status'], ['auditing', 'paying'], true)) {
throw new \RuntimeException('该提现记录已处理(当前状态:' . $withdraw['status'] . ')');
}
// 若当前单据卡在 paying 状态,先主动向微信查询是否已创建转账单
if ((string)$withdraw['status'] === 'paying' && !empty($withdraw['out_bill_no'])) {
try {
$queryRes = self::query($withdraw['out_bill_no']);
if (!empty($queryRes['state'])) {
$state = self::applyResult($queryRes);
$withdrawState = (string)Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->value('status');
return ['state' => $state, 'withdrawState' => $withdrawState];
}
} catch (\Throwable $e) {
Log::record('提现记录 #' . $withdrawId . ' 处于 paying,微信查询补偿跳过:' . $e->getMessage(), 'notice');
}
}
$outBillNo = !empty($withdraw['out_bill_no']) ? $withdraw['out_bill_no'] : self::makeOutBillNo($withdraw);
$now = time();
// 1) 先落「转账中」,防止重复点击造成重复转账
Db::transaction(function () use ($withdraw, $outBillNo, $now) {
$locked = Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->lock(true)->find();
if (!$locked || !in_array((string)$locked['status'], ['auditing', 'paying'], true)) {
throw new \RuntimeException('该提现记录已处理,请刷新后重试');
}
Db::name('fc_withdraw')->where('id', (int)$locked['id'])->update([
'status' => 'paying',
'out_bill_no' => $outBillNo,
'transfer_status' => '',
'transfer_id' => '',
'package_info' => '',
'fail_reason' => '',
'updatetime' => $now,
]);
});
// 2) 调微信创建转账单;失败回滚为待审核
try {
$res = self::submit(array_merge($withdraw, ['out_bill_no' => $outBillNo]));
} catch (\Throwable $e) {
Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->where('status', 'paying')->update([
'status' => 'auditing',
'fail_reason' => mb_substr($e->getMessage(), 0, 200),
'updatetime' => time(),
]);
throw $e;
}
$transferStatus = substr((string)$res['state'], 0, 32);
Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->update([
'transfer_id' => isset($res['transfer_bill_no']) ? (string)$res['transfer_bill_no'] : '',
'package_info' => isset($res['package_info']) ? (string)$res['package_info'] : '',
'transfer_status' => $transferStatus,
'transfer_time' => time(),
]);
// 3) 应用立即结果(SUCCESS → paid;FAIL/CANCELLED → failed;其余保持 paying)
$state = self::applyResult([
'out_bill_no' => $outBillNo,
'state' => $res['state'],
'transfer_bill_no' => isset($res['transfer_bill_no']) ? (string)$res['transfer_bill_no'] : '',
'package_info' => isset($res['package_info']) ? (string)$res['package_info'] : '',
'fail_reason' => isset($res['fail_reason']) ? $res['fail_reason'] : '',
]);
$withdrawState = (string)Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->value('status');
return ['state' => $state, 'withdrawState' => $withdrawState];
}
/**
* 主动同步单笔提现的转账状态(回调缺失时兜底)
*
* @param int $withdrawId
* @return string 同步后的提现单状态
*/
public static function sync($withdrawId)
{
$withdraw = Db::name('fc_withdraw')->where('id', (int)$withdrawId)->find();
if (!$withdraw) {
return 'ignored';
}
if ((string)$withdraw['status'] !== 'paying') {
return (string)$withdraw['status'];
}
$outBillNo = trim((string)$withdraw['out_bill_no']);
if ($outBillNo === '') {
return 'ignored';
}
try {
$res = self::query($outBillNo);
} catch (\Throwable $e) {
Log::record('查询转账单失败:' . $outBillNo . ',' . $e->getMessage(), 'error');
return 'paying';
}
return self::applyResult($res);
}
/**
* 撤销「待用户确认」的转账单
*
* 仅当提现单为 paying 且有 out_bill_no 时可撤销;撤销后置为 failed,可重新申请。
*
* @param int $withdrawId
* @return string 处理后的提现单状态
* @throws \RuntimeException
*/
public static function cancel($withdrawId)
{
$withdraw = Db::name('fc_withdraw')->where('id', (int)$withdrawId)->find();
if (!$withdraw) {
throw new \RuntimeException('提现记录不存在');
}
if ((string)$withdraw['status'] !== 'paying') {
throw new \RuntimeException('只有「转账中」的提现单可以撤销(当前:' . $withdraw['status'] . ')');
}
$outBillNo = trim((string)$withdraw['out_bill_no']);
if ($outBillNo === '') {
throw new \RuntimeException('提现单缺少商户单号,无法撤销');
}
if (!self::isReady()) {
throw new \RuntimeException('当前环境不支持商家转账');
}
$config = EpayService::getConfig('wechat', []);
$pay = \Yansongda\Pay\Pay::wechat($config);
try {
$result = $pay->mchTransfer([
'_action' => 'cancel',
'_type' => 'mini',
'out_bill_no' => $outBillNo,
]);
} catch (\Throwable $e) {
$detail = self::errorDetail($e);
Log::record('撤销商家转账失败:' . $outBillNo . ',' . $detail, 'error');
throw new \RuntimeException('撤销失败:' . $detail);
}
$res = $result instanceof \Yansongda\Supports\Collection ? $result->all() : (array)$result;
$state = isset($res['state']) ? (string)$res['state'] : 'CANCELLED';
return self::applyResult([
'out_bill_no' => $outBillNo,
'state' => $state === 'CANCELLED' ? 'CANCELLED' : $state,
'transfer_bill_no' => isset($res['transfer_bill_no']) ? (string)$res['transfer_bill_no'] : '',
'fail_reason' => '已撤销',
]);
}
/**
* 应用转账结果(创建应答 / 回调 / 主动查询 共用)
*
* @param array $data 微信返回或回调解密后的数据
* @return string 处理后的提现单状态(paid / failed / paying / ignored)
*/
public static function applyResult(array $data)
{
$outBillNo = trim((string)(isset($data['out_bill_no']) ? $data['out_bill_no'] : ''));
if ($outBillNo === '') {
return 'ignored';
}
$state = strtoupper(trim((string)(isset($data['state']) ? $data['state'] : '')));
$transferBillNo = isset($data['transfer_bill_no']) ? (string)$data['transfer_bill_no'] : '';
$packageInfo = isset($data['package_info']) ? (string)$data['package_info'] : '';
$failReason = isset($data['fail_reason']) ? (string)$data['fail_reason'] : '';
$withdraw = Db::name('fc_withdraw')->where('out_bill_no', $outBillNo)->find();
if (!$withdraw) {
Log::record('商家转账回调:未找到提现单 out_bill_no=' . $outBillNo, 'error');
return 'ignored';
}
// 已到终态:幂等返回
if ((string)$withdraw['status'] === 'paid') {
return 'paid';
}
// 金额校验:成功回调的转账金额必须与提现申请一致(防止串单/异常)
$transferAmount = isset($data['transfer_amount']) ? (int)$data['transfer_amount'] : null;
$expectedCents = (int)round((float)$withdraw['amount'] * 100);
if ($state === 'SUCCESS' && $transferAmount !== null && $transferAmount !== $expectedCents) {
Log::record(
'商家转账金额不一致:提现单 ' . $outBillNo . ' 申请 ' . $expectedCents . ' 分,回调 ' . $transferAmount . ' 分',
'error'
);
Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->update([
'transfer_status' => 'AMOUNT_MISMATCH',
'fail_reason' => '回调金额与申请不一致,请人工核对',
'updatetime' => time(),
]);
return 'ignored';
}
$now = time();
if ($state === 'SUCCESS') {
Db::transaction(function () use ($withdraw, $outBillNo, $transferBillNo, $now) {
$locked = Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->lock(true)->find();
if (!$locked || (string)$locked['status'] === 'paid') {
return;
}
$affected = Db::name('fc_withdraw')
->where('id', (int)$locked['id'])
->where('status', '<>', 'paid')
->update([
'status' => 'paid',
'paytime' => $now,
'transfer_status' => 'SUCCESS',
'transfer_id' => $transferBillNo,
'fail_reason' => '',
'transfer_time' => $now,
'updatetime' => $now,
]);
if (!$affected) {
return;
}
Db::name('fc_finance_flow')->insert([
'user_id' => (int)$locked['user_id'],
'flow_no' => 'FWD' . date('YmdHis') . $locked['id'] . bin2hex(random_bytes(2)),
'type' => 'expense',
'scene' => '佣金提现',
'amount' => $locked['amount'],
'balance' => 0,
'related_id' => (int)$locked['id'],
'remark' => '微信商家转账 ' . $transferBillNo,
'createtime' => $now,
]);
Db::name('fc_message')->insert([
'user_id' => (int)$locked['user_id'],
'type' => 'system',
'title' => '提现已到账',
'content' => '您的提现申请「' . $locked['withdraw_no'] . '」已转入微信零钱,金额:' . $locked['amount'] . ' 元。',
'isread' => 0,
'createtime' => $now,
]);
});
// 订阅消息:提现到账(未配置模板/未授权时静默跳过)
\app\common\library\WxSubscribe::send(
(int)$withdraw['user_id'],
'transfer',
[
'amount1' => ['value' => number_format((float)$withdraw['amount'], 2) . '元'],
'thing2' => ['value' => '分销佣金提现'],
'time3' => ['value' => date('Y-m-d H:i')],
],
'pages/distribution/index'
);
return 'paid';
}
if (in_array($state, ['FAIL', 'CANCELLED'], true)) {
Db::transaction(function () use ($withdraw, $transferBillNo, $failReason, $state, $now) {
$locked = Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->lock(true)->find();
if (!$locked || in_array((string)$locked['status'], ['paid', 'rejected'], true)) {
return;
}
Db::name('fc_withdraw')->where('id', (int)$locked['id'])->update([
'status' => 'failed',
'transfer_status' => $state,
'transfer_id' => $transferBillNo,
'fail_reason' => mb_substr($failReason, 0, 200),
'transfer_time' => $now,
'updatetime' => $now,
]);
Db::name('fc_message')->insert([
'user_id' => (int)$locked['user_id'],
'type' => 'system',
'title' => '提现转账失败',
'content' => '您的提现申请「' . $locked['withdraw_no'] . '」转账失败' . ($failReason !== '' ? ':' . $failReason : '') . ',可重新申请。',
'isread' => 0,
'createtime' => $now,
]);
});
return 'failed';
}
// WAIT_USER_CONFIRM / ACCEPTED / PROCESSING:保持 paying,等待用户确认或回调
$updateData = [
'transfer_status' => substr($state, 0, 32),
'transfer_time' => $now,
'updatetime' => $now,
];
if ($transferBillNo !== '') {
$updateData['transfer_id'] = $transferBillNo;
}
if ($packageInfo !== '') {
$updateData['package_info'] = $packageInfo;
}
Db::name('fc_withdraw')->where('id', (int)$withdraw['id'])->update($updateData);
return 'paying';
}
/* ==================== 配置与工具 ==================== */
/** 转账场景 ID(默认 1005 佣金报酬) */
public static function sceneId()
{
$v = trim((string)self::cfg('transfer.sceneId', ''));
return $v !== '' ? $v : self::DEFAULT_SCENE_ID;
}
/** 场景报备信息(佣金报酬固定两条) */
public static function sceneInfos()
{
$raw = self::cfg('transfer.sceneInfos', '');
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded) && !empty($decoded)) {
return $decoded;
}
}
return [
['info_type' => '岗位类型', 'info_content' => '推广员'],
['info_type' => '报酬说明', 'info_content' => '分销佣金'],
];
}
/** 转账结果回调地址(必须 HTTPS 且公网可访问) */
public static function notifyUrl()
{
$configured = trim((string)self::cfg('transfer.notifyUrl', ''));
if ($configured !== '' && preg_match('#^https://#i', $configured)) {
return $configured;
}
$root = '';
if (function_exists('request')) {
try {
$root = (string)request()->root(true);
} catch (\Throwable $e) {
$root = '';
}
}
if ($root === '' && function_exists('request')) {
try {
$root = (string)request()->domain();
} catch (\Throwable $e) {
$root = '';
}
}
$root = rtrim(preg_replace('#/[a-zA-Z0-9_\-]+\.php$#i', '', $root), '/');
if ($root === '' || !preg_match('#^https://#i', $root)) {
// 微信要求 notify_url 必须 HTTPS;本地/HTTP 环境不传,改用主动查询同步结果
return '';
}
return $root . '/api/v1/pay/transfer-notify';
}
/** 生成商户转账单号:TB + 提现单号(≤32,字母数字) */
public static function makeOutBillNo(array $withdraw)
{
$no = preg_replace('/[^A-Za-z0-9]/', '', (string)$withdraw['withdraw_no']);
return substr('TB' . $no, 0, 32);
}
/** 读取业务配置 */
protected static function cfg($key, $default = null)
{
$row = Db::name('fc_config')->where('key', $key)->find();
if (!$row || !isset($row['value']) || $row['value'] === '') {
return $default;
}
return $row['value'];
}
/**
* 提取微信返回的错误原因(yansongda 的异常 message 往往只有“状态码异常”)
*
* @param \Throwable $e
* @return string
*/
protected static function errorDetail(\Throwable $e)
{
$response = null;
if (isset($e->response)) {
$response = $e->response;
}
if ($response instanceof \Yansongda\Supports\Collection) {
$response = $response->all();
}
if (is_array($response)) {
if (!empty($response['message'])) {
return (string)$response['message'];
}
if (!empty($response['code'])) {
return (string)$response['code'];
}
}
return $e->getMessage();
}
/** 注册 epay 内置 SDK 命名空间(后台/CLI 场景下钩子可能未触发) */
protected static function loadSdk()
{
if (!class_exists('\Yansongda\Pay\Pay')) {
(new \addons\epay\Epay())->actionBegin();
}
}
}