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(); } } }