| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- <?php
- namespace App\Services\Agent;
- use App\Models\Agent\Agent;
- use App\Models\Agent\AgentBalanceLog;
- use App\Models\Agent\AgentSetting;
- use App\Models\Agent\AgentWithdrawAccount;
- use App\Models\Agent\AgentWithdrawal;
- use App\Models\RechargeChannel;
- use App\Services\PaymentOrderService;
- use App\Services\WithdrawalDestination;
- use App\Services\WithdrawalName;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Hash;
- use Illuminate\Support\Str;
- /**
- * 代理提现,和会员前台一样使用总后台配置的提现通道(agent_settings.recharge_channel_group_id 对应组合的提现类型):
- * - usdt:填 USDT 数量,按提现汇率(汇率 + 汇率差)折算后从余额冻结,总后台人工打款;
- * - rgtx 人工提现:填人民币金额,总后台人工打款;
- * - 三方代付(DF001/DF002/DF005 钱宝、JDpay、NOwithdraw、ZIMUwithdraw、ZIMUcash):填人民币金额,
- * 总后台审核通过时向三方下单(PaymentOrderService::requestPayout),三方回调成功扣冻结、失败退回余额。
- * 合营代理只能提取超出保证金的盈利部分。代理手续费按 agents.withdraw_fee_rate 从提现金额中扣除。
- * 状态:pending 待审核 → approved 已通过(人工打款)/ processing 三方代付中 → paid 已打款;rejected 已驳回、failed 代付失败(均退回余额)。
- */
- class AgentWithdrawalService
- {
- public const MANUAL_CHANNELS = ['usdt', 'rgtx'];
- public const OPEN_STATUSES = ['pending', 'approved', 'processing'];
- private const MAX_ACCOUNTS_PER_CHANNEL = 5;
- public function __construct(private ?AgentWalletService $wallets = null)
- {
- $this->wallets ??= new AgentWalletService();
- }
- public static function isProviderChannel(string $channel): bool
- {
- return !in_array($channel, self::MANUAL_CHANNELS, true);
- }
- /** 提现通道,格式同会员 GET /api/wallet/withdrawChannel。 */
- public function channels(Agent $agent): array
- {
- $list = WithdrawalDestination::describeChannels(RechargeChannel::getFormatChannel(2, $this->groupId()));
- foreach ($list as &$item) {
- $item['requires_real_name'] = WithdrawalName::usesOrderName((string) $item['value']);
- }
- unset($item);
- return ['list' => array_values($list), 'real_name' => (string) $agent->real_name];
- }
- public function accounts(Agent $agent, ?string $channel = null): array
- {
- $query = AgentWithdrawAccount::query()->where('agent_id', $agent->id);
- if ($channel !== null && $channel !== '') $query->where('type', $channel);
- return $query->orderByDesc('id')->get()->map(fn (AgentWithdrawAccount $account) => $this->present($account, $agent))->all();
- }
- /** 新增/修改提现账户。USDT:network + account(地址);其他通道:card_no、bank_name 等,规则同会员绑卡。 */
- public function saveAccount(Agent $agent, array $data): array
- {
- $channel = (string) ($data['type'] ?? 'usdt');
- if (RechargeChannel::checkWithdrawChannel($channel, $this->groupId()) === false) throw new \RuntimeException('不支持此提现方式');
- if ($channel === 'usdt') {
- $network = strtoupper(trim((string) ($data['network'] ?? '')));
- $address = trim((string) ($data['account'] ?? ''));
- if (!in_array($network, ['TRC20', 'ERC20'], true)) throw new \InvalidArgumentException('请选择公链 TRC20 或 ERC20');
- $pattern = $network === 'ERC20' ? '/^0x[0-9a-fA-F]{40}$/D' : '/^T[1-9A-HJ-NP-Za-km-z]{33}$/D';
- if (!preg_match($pattern, $address)) throw new \InvalidArgumentException('USDT 地址格式错误');
- $fields = ['account' => $address, 'network' => $network, 'bank_name' => '', 'details' => null];
- } else {
- if (!isset($data['card_no']) && isset($data['account'])) $data['card_no'] = $data['account'];
- try {
- $destination = WithdrawalDestination::resolve($channel, $data);
- } catch (\Exception $e) {
- throw new \InvalidArgumentException($e->getMessage());
- }
- $details = array_filter([
- 'bank_branch' => $destination['bank_branch'] ?? null,
- 'qr_image' => $destination['qr_image'] ?? null,
- 'payout_method' => $channel === 'rgtx' ? WithdrawalDestination::manualResponse($destination['bank_name'], $destination['card_no'], $destination['qr_image'] ?? null)['payout_method'] : null,
- ], fn ($v) => $v !== null && $v !== '');
- $fields = ['account' => $destination['card_no'], 'network' => '', 'bank_name' => $destination['bank_name'], 'details' => $details ?: null];
- }
- return DB::transaction(function () use ($agent, $data, $channel, $fields) {
- $locked = Agent::query()->lockForUpdate()->findOrFail($agent->id);
- if (WithdrawalName::usesOrderName($channel) && trim((string) $locked->real_name) === '') {
- // 和会员一样:首次绑定需要填写真实姓名,之后不能自行修改(由总后台修改)。
- $name = WithdrawalName::normalize((string) ($data['real_name'] ?? ''));
- if ($name === '') throw new \InvalidArgumentException('请输入真实姓名');
- $locked->real_name = $name;
- $locked->save();
- }
- if (!empty($data['id'])) {
- $account = AgentWithdrawAccount::query()->where('agent_id', $locked->id)->lockForUpdate()->findOrFail((int) $data['id']);
- if ($account->type !== $channel) throw new \InvalidArgumentException('不能修改账户的提现方式');
- } else {
- $count = AgentWithdrawAccount::query()->where('agent_id', $locked->id)->where('type', $channel)->count();
- if ($count >= self::MAX_ACCOUNTS_PER_CHANNEL) throw new \RuntimeException('已达添加上限');
- $account = new AgentWithdrawAccount(['agent_id' => $locked->id, 'type' => $channel]);
- }
- $account->fill($fields + [
- 'name' => trim((string) ($data['name'] ?? $account->name ?? '')),
- 'status' => (int) ($data['status'] ?? $account->status ?? 1),
- ])->save();
- return $this->present($account->fresh(), $locked);
- });
- }
- /**
- * 申请提现。$amount:USDT 账户为 USDT 数量,其他通道为人民币金额(最多 2 位小数)。
- */
- public function request(Agent $agent, int $accountId, string $amount, string $withdrawPassword, string $requestId, string $remark = ''): AgentWithdrawal
- {
- if (bccomp($amount, '0', 4) <= 0) {
- throw new \InvalidArgumentException('提现金额必须大于0');
- }
- $requestKey = 'agent:' . $agent->id . ':' . $requestId;
- return DB::transaction(function () use ($agent, $accountId, $amount, $withdrawPassword, $remark, $requestKey): AgentWithdrawal {
- $lockedAgent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
- if ((int) $lockedAgent->status !== Agent::STATUS_ENABLED) {
- throw new \RuntimeException('代理账号已禁用');
- }
- if ($lockedAgent->withdraw_password === '' || !Hash::check($withdrawPassword, (string) $lockedAgent->withdraw_password)) {
- throw new \InvalidArgumentException('提现密码错误');
- }
- // First consistent read is after the agent lock, so concurrent retries see the committed request.
- $existing = AgentWithdrawal::query()->where('request_key', $requestKey)->first();
- if ($existing) {
- $this->assertSameRequest($existing, $accountId, $amount);
- return $existing;
- }
- $account = AgentWithdrawAccount::query()
- ->where('agent_id', $lockedAgent->id)
- ->where('status', 1)
- ->lockForUpdate()
- ->findOrFail($accountId);
- $channel = (string) $account->type;
- $config = RechargeChannel::checkWithdrawChannel($channel, $this->groupId());
- if ($config === false) throw new \RuntimeException('不支持此提现方式');
- $min = (float) ($config->min ?? 0);
- $max = (float) ($config->max ?? 0);
- if ((float) $amount < $min || ($max > 0 && (float) $amount > $max)) {
- throw new \RuntimeException("提现金额范围为{$min}-{$max}" . ($channel === 'usdt' ? ' USDT' : ''));
- }
- $holder = '';
- if (WithdrawalName::usesOrderName($channel)) {
- $holder = trim((string) $lockedAgent->real_name);
- if ($holder === '') throw new \RuntimeException('请先在提现账户中填写真实姓名');
- }
- if ($channel === 'usdt') {
- $rate = AgentExchangeRate::withdraw();
- $usdtAmount = bcadd($amount, '0', 4);
- $rmbAmount = bcmul($usdtAmount, $rate, 4);
- $scale = 4;
- } else {
- if (!preg_match('/^\d+(\.\d{1,2})?$/D', $amount)) throw new \InvalidArgumentException('提现金额最多 2 位小数');
- $rate = '0';
- $usdtAmount = '0';
- $rmbAmount = bcadd($amount, '0', 4);
- $scale = 2; // 三方按分打款,手续费和到账金额保留 2 位
- }
- if (bccomp($this->wallets->withdrawable($lockedAgent), $rmbAmount, 4) < 0) {
- if ($lockedAgent->isJoint()) throw new \RuntimeException('可提现盈利不足(保证金不可提现)');
- if (bccomp((string) $lockedAgent->balance, $rmbAmount, 4) >= 0) {
- $days = (int) AgentSetting::current()->equity_recharge_lock_days;
- throw new \RuntimeException("可提现余额不足(充值未满 {$days} 天的金额暂不可提现)");
- }
- throw new \RuntimeException('代理余额不足');
- }
- $fee = bcdiv(bcmul($rmbAmount, (string) $lockedAgent->withdraw_fee_rate, 8), '100', $scale);
- $actual = bcsub($rmbAmount, $fee, 4);
- if (bccomp($actual, '0', 4) <= 0) {
- throw new \RuntimeException('扣除手续费后到账金额必须大于0');
- }
- // 平台付给三方代付的成本(按到账金额下单);USDT / 人工提现不记(链上成本不计)。
- $channelFee = self::isProviderChannel($channel)
- ? PaymentOrderService::feeFields($actual, (string) ($config->rate ?? 0), $config->fee_fixed ?? 0)
- : PaymentOrderService::feeFields($actual, 0);
- $before = bcadd((string) $lockedAgent->balance, '0', 4);
- $lockedAgent->balance = bcsub($before, $rmbAmount, 4);
- $lockedAgent->realtime_balance = bcsub((string) $lockedAgent->realtime_balance, $rmbAmount, 4);
- $lockedAgent->frozen_balance = bcadd((string) $lockedAgent->frozen_balance, $rmbAmount, 4);
- $lockedAgent->save();
- $details = (array) ($account->details ?? []);
- $withdrawal = AgentWithdrawal::query()->create([
- 'order_no' => 'AW' . now()->format('YmdHis') . strtoupper(Str::random(8)),
- 'request_key' => $requestKey,
- 'agent_id' => $lockedAgent->id,
- 'account_id' => $account->id,
- 'account_type' => $channel,
- 'account_snapshot' => array_filter([
- 'type' => $channel,
- 'name' => $account->name,
- 'account' => $account->account,
- 'network' => $account->network,
- 'bank_name' => $account->bank_name,
- 'bank_branch' => $details['bank_branch'] ?? null,
- 'qr_image' => $details['qr_image'] ?? null,
- 'payout_method' => $details['payout_method'] ?? null,
- 'holder_name' => $holder,
- ], fn ($v) => $v !== null),
- 'amount' => $rmbAmount,
- 'fee' => $fee,
- 'fee_rate' => bcdiv((string) $lockedAgent->withdraw_fee_rate, '100', 6),
- 'channel_fee_rate' => $channelFee['fee_rate'],
- 'channel_fee_fixed' => $channelFee['fee_fixed'],
- 'channel_fee' => bcadd($channelFee['fee'], '0', 4),
- 'actual_amount' => $actual,
- 'usdt_amount' => $usdtAmount,
- 'actual_usdt_amount' => $channel === 'usdt' ? bcdiv($actual, $rate, 4) : '0',
- 'exchange_rate' => $rate,
- 'status' => 'pending',
- 'remark' => $remark,
- ]);
- AgentBalanceLog::query()->create([
- 'agent_id' => $lockedAgent->id,
- 'type' => 'withdraw_freeze',
- 'amount' => bcsub('0', $rmbAmount, 4),
- 'before_balance' => $before,
- 'after_balance' => $lockedAgent->balance,
- 'reference_type' => 'agent_withdrawal',
- 'reference_id' => $withdrawal->id,
- 'operator_type' => 'agent',
- 'operator_id' => $lockedAgent->id,
- 'idempotency_key' => 'withdraw-freeze:' . $withdrawal->id,
- 'remark' => $remark,
- ]);
- return $withdrawal->fresh();
- });
- }
- /**
- * 总后台审核:
- * - approve:USDT / 人工提现变为 approved,等人工打款后 paid;三方通道直接向三方下单,变为 processing,下单失败保持待审核并返回原因;
- * - reject:pending / approved 驳回并退回余额;
- * - paid:approved 的人工打款完成。
- */
- public function audit(int $withdrawalId, string $action, int $adminId, string $remark = ''): AgentWithdrawal
- {
- if (!in_array($action, ['approve', 'reject', 'paid'], true)) {
- throw new \InvalidArgumentException('审核动作错误');
- }
- return DB::transaction(function () use ($withdrawalId, $action, $adminId, $remark): AgentWithdrawal {
- $withdrawal = AgentWithdrawal::query()->lockForUpdate()->findOrFail($withdrawalId);
- $agent = Agent::query()->lockForUpdate()->findOrFail($withdrawal->agent_id);
- $channel = (string) ($withdrawal->account_type ?: 'usdt');
- if ($action === 'approve') {
- if ($withdrawal->status !== 'pending') {
- throw new \RuntimeException('只有待审核订单可以通过');
- }
- if (self::isProviderChannel($channel)) {
- $snapshot = (array) $withdrawal->account_snapshot;
- try {
- $ret = PaymentOrderService::requestPayout($channel, (string) $withdrawal->order_no, AgentPaymentService::payerId((int) $agent->id),
- (string) $withdrawal->actual_amount, (string) ($snapshot['holder_name'] ?? ''), (string) ($snapshot['account'] ?? ''),
- (string) ($snapshot['bank_name'] ?? ''), ['agent_withdrawal_id' => $withdrawal->id]);
- } catch (\Exception $e) {
- throw new \RuntimeException($e->getMessage() ?: '代付下单失败');
- }
- $withdrawal->pay_no = $ret['pay_no'];
- $withdrawal->pay_data = $ret['pay_data'];
- $withdrawal->status = 'processing';
- } else {
- $withdrawal->status = 'approved';
- }
- $withdrawal->audited_at = now();
- $withdrawal->audit_admin_id = $adminId;
- } elseif ($action === 'reject') {
- if (!in_array($withdrawal->status, ['pending', 'approved'], true)) {
- throw new \RuntimeException('当前订单不能驳回');
- }
- $this->refund($withdrawal, $agent, 'admin', $adminId, $remark);
- $withdrawal->status = 'rejected';
- $withdrawal->audited_at = now();
- $withdrawal->audit_admin_id = $adminId;
- } else {
- if ($withdrawal->status !== 'approved') {
- throw new \RuntimeException('只有已通过订单可以标记已付款');
- }
- $this->settle($withdrawal, $agent);
- }
- $withdrawal->audit_remark = $remark;
- $withdrawal->save();
- return $withdrawal->fresh();
- });
- }
- /** 三方代付回调结果:true 成功、false 失败、null 处理中(只记录回调)。只处理 processing 状态,重复回调无副作用。 */
- public function providerResult(int $withdrawalId, ?bool $success, array $params): void
- {
- DB::transaction(function () use ($withdrawalId, $success, $params) {
- $withdrawal = AgentWithdrawal::query()->lockForUpdate()->find($withdrawalId);
- if (!$withdrawal || $withdrawal->status !== 'processing') return;
- $withdrawal->callback_data = json_encode($params, JSON_UNESCAPED_UNICODE);
- if ($success !== null) {
- $agent = Agent::query()->lockForUpdate()->findOrFail($withdrawal->agent_id);
- if ($success) {
- $this->settle($withdrawal, $agent);
- } else {
- $this->refund($withdrawal, $agent, 'system', null, '三方代付失败');
- $withdrawal->status = 'failed';
- $withdrawal->audit_remark = trim($withdrawal->audit_remark . ' 三方代付失败,已退回余额');
- }
- }
- $withdrawal->save();
- });
- }
- private function settle(AgentWithdrawal $withdrawal, Agent $agent): void
- {
- if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
- throw new \RuntimeException('代理冻结余额异常');
- }
- $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
- $agent->save();
- $withdrawal->status = 'paid';
- $withdrawal->paid_at = now();
- }
- private function refund(AgentWithdrawal $withdrawal, Agent $agent, string $operatorType, ?int $operatorId, string $remark): void
- {
- $before = bcadd((string) $agent->balance, '0', 4);
- if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
- throw new \RuntimeException('代理冻结余额异常');
- }
- $agent->balance = bcadd($before, (string) $withdrawal->amount, 4);
- $agent->realtime_balance = bcadd((string) $agent->realtime_balance, (string) $withdrawal->amount, 4);
- $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
- $agent->save();
- AgentBalanceLog::query()->firstOrCreate(
- ['idempotency_key' => 'withdraw-refund:' . $withdrawal->id],
- [
- 'agent_id' => $agent->id,
- 'type' => 'withdraw_refund',
- 'amount' => $withdrawal->amount,
- 'before_balance' => $before,
- 'after_balance' => $agent->balance,
- 'reference_type' => 'agent_withdrawal',
- 'reference_id' => $withdrawal->id,
- 'operator_type' => $operatorType,
- 'operator_id' => $operatorId,
- 'remark' => $remark,
- ]
- );
- $this->wallets->evaluateActivation($agent);
- }
- private function groupId(): int
- {
- return max(1, (int) AgentSetting::current()->recharge_channel_group_id);
- }
- /** 账户输出:details 展开到顶层;人工提现补 payout_method;需要实名的通道带上持卡人姓名。 */
- private function present(AgentWithdrawAccount $account, Agent $agent): array
- {
- $row = $account->toArray();
- $details = (array) ($row['details'] ?? []);
- unset($row['details']);
- $row['channel'] = $row['type'];
- $row['card_no'] = $row['type'] === 'usdt' ? '' : (string) $row['account'];
- $row['bank_branch'] = $details['bank_branch'] ?? null;
- $row['qr_image'] = $details['qr_image'] ?? null;
- $row['payout_method'] = $details['payout_method'] ?? null;
- $row['holder_name'] = WithdrawalName::usesOrderName((string) $row['type']) ? (string) $agent->real_name : '';
- return $row;
- }
- private function assertSameRequest(AgentWithdrawal $withdrawal, int $accountId, string $amount): void
- {
- $submitted = ($withdrawal->account_type ?: 'usdt') === 'usdt' ? (string) $withdrawal->usdt_amount : (string) $withdrawal->amount;
- if ((int) $withdrawal->account_id !== $accountId || bccomp($submitted, $amount, 4) !== 0) {
- throw new \RuntimeException('request_id 已用于其他提现申请');
- }
- }
- }
|