doge 3 дней назад
Родитель
Сommit
c5fd9b2f84

+ 5 - 2
app/Http/Controllers/admin/Agent.php

@@ -177,12 +177,15 @@ class Agent extends AgentApiController
             $data = $request->validate([
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
                 'agent_id' => ['nullable', 'integer'], 'order_no' => ['nullable', 'string', 'max:40'],
-                'status' => ['nullable', 'in:pending,approved,rejected,paid'],
+                'status' => ['nullable', 'in:pending,approved,processing,rejected,paid,failed'], 'channel' => ['nullable', 'string', 'max:32'],
                 'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
             ]);
             $query = AgentWithdrawal::query()->with('agent:id,username,real_name');
             foreach (['agent_id', 'order_no', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
-            return $this->paginate($query, $data);
+            if (!empty($data['channel'])) $query->where('account_type', $data['channel']);
+            $result = $this->paginate($query, $data);
+            $result['list']->each->makeVisible(['pay_data', 'callback_data']);
+            return $result;
         });
     }
 

+ 35 - 36
app/Http/Controllers/agent/WalletController.php

@@ -30,44 +30,33 @@ class WalletController extends AgentApiController
         });
     }
 
-    public function accounts(Request $request)
+    /** 提现通道(总后台配置),格式同会员 GET /api/wallet/withdrawChannel。 */
+    public function withdrawChannels(AgentWithdrawalService $service)
     {
-        return $this->execute(function () use ($request) {
-            return AgentWithdrawAccount::query()->where('agent_id', $this->currentAgent()->id)
-                ->where('type', 'usdt')->orderByDesc('id')->get();
+        return $this->execute(fn () => $service->channels($this->currentAgent()->fresh()));
+    }
+
+    public function accounts(Request $request, AgentWithdrawalService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['type' => ['nullable', 'string', 'max:32']]);
+            return $service->accounts($this->currentAgent(), $data['type'] ?? null);
         });
     }
 
-    public function saveAccount(Request $request)
+    /** 新增/修改提现账户:type 为提现通道(默认 usdt),字段规则同会员绑卡。 */
+    public function saveAccount(Request $request, AgentWithdrawalService $service)
     {
-        return $this->execute(function () use ($request) {
-            // 代理提现仅支持 USDT,地址按公链校验格式。
+        return $this->execute(function () use ($request, $service) {
             $data = $request->validate([
-                'id' => ['nullable', 'integer'], 'name' => ['nullable', 'string', 'max:128'],
-                'network' => ['required', 'in:TRC20,ERC20'],
-                'account' => ['required', 'string', 'max:255', function ($attribute, $value, $fail) use ($request) {
-                    $pattern = $request->input('network') === 'ERC20' ? '/^0x[0-9a-fA-F]{40}$/D' : '/^T[1-9A-HJ-NP-Za-km-z]{33}$/D';
-                    if (!preg_match($pattern, (string) $value)) $fail('USDT 地址格式错误');
-                }],
+                'id' => ['nullable', 'integer'], 'type' => ['nullable', 'string', 'max:32'], 'name' => ['nullable', 'string', 'max:128'],
+                'network' => ['nullable', 'string', 'in:TRC20,ERC20,trc20,erc20'], 'account' => ['nullable', 'string', 'max:255'],
+                'card_no' => ['nullable', 'string', 'max:255'], 'bank_name' => ['nullable', 'string', 'max:128'],
+                'bank_branch' => ['nullable', 'string', 'max:255'], 'qr_image' => ['nullable', 'string', 'max:255'],
+                'payout_method' => ['nullable', 'string', 'in:bank,wechat,alipay'], 'real_name' => ['nullable', 'string', 'max:64'],
                 'status' => ['nullable', 'integer', 'in:0,1'],
             ]);
-            $data['type'] = 'usdt';
-            $data['bank_name'] = '';
-            $isNew = empty($data['id']);
-            foreach (['name', 'network'] as $field) {
-                if ($isNew || array_key_exists($field, $data)) {
-                    $data[$field] = trim((string) ($data[$field] ?? ''));
-                }
-            }
-            if ($isNew || array_key_exists('status', $data)) {
-                $data['status'] = $data['status'] ?? 1;
-            }
-            $agent = $this->currentAgent();
-            $account = $isNew ? new AgentWithdrawAccount() : AgentWithdrawAccount::query()->where('agent_id', $agent->id)->findOrFail($data['id']);
-            $account->fill($data);
-            $account->agent_id = $agent->id;
-            $account->save();
-            return $account;
+            return $service->saveAccount($this->currentAgent(), $data);
         });
     }
 
@@ -78,7 +67,7 @@ class WalletController extends AgentApiController
             DB::transaction(function () use ($data) {
                 $account = AgentWithdrawAccount::query()->where('agent_id', $this->currentAgent()->id)
                     ->lockForUpdate()->findOrFail($data['id']);
-                if (AgentWithdrawal::query()->where('account_id', $account->id)->whereIn('status', ['pending', 'approved'])->exists()) {
+                if (AgentWithdrawal::query()->where('account_id', $account->id)->whereIn('status', AgentWithdrawalService::OPEN_STATUSES)->exists()) {
                     throw new \RuntimeException('该账户有处理中的提现,不能删除');
                 }
                 $account->delete();
@@ -92,10 +81,12 @@ class WalletController extends AgentApiController
         return $this->execute(function () use ($request) {
             $data = $request->validate([
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
-                'status' => ['nullable', 'in:pending,approved,rejected,paid'], 'start_date' => ['nullable', 'date_format:Y-m-d'],
-                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+                'status' => ['nullable', 'in:pending,approved,processing,rejected,paid,failed'], 'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'], 'channel' => ['nullable', 'string', 'max:32'],
             ]);
-            return $this->pageQuery(AgentWithdrawal::query()->where('agent_id', $this->currentAgent()->id), $data);
+            $query = AgentWithdrawal::query()->where('agent_id', $this->currentAgent()->id);
+            if (!empty($data['channel'])) $query->where('account_type', $data['channel']);
+            return $this->pageQuery($query, $data);
         });
     }
 
@@ -103,11 +94,19 @@ class WalletController extends AgentApiController
     {
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate([
-                'account_id' => ['required', 'integer'], 'usdt_amount' => ['required', 'numeric', 'min:0.0001', new DecimalNumber()],
+                // USDT 账户传 usdt_amount(USDT 数量),其他通道传 amount(人民币,最多 2 位小数)
+                'account_id' => ['required', 'integer'],
+                'usdt_amount' => ['required_without:amount', 'nullable', 'numeric', 'min:0.0001', new DecimalNumber()],
+                'amount' => ['required_without:usdt_amount', 'nullable', 'numeric', 'min:0.01', new DecimalNumber(14, 2)],
                 'withdraw_password' => ['required', 'string'], 'remark' => ['nullable', 'string', 'max:500'],
                 'request_id' => ['required', 'string', 'max:64'],
             ]);
-            return $service->request($this->currentAgent(), (int) $data['account_id'], (string) $data['usdt_amount'], $data['withdraw_password'], $data['request_id'], (string) ($data['remark'] ?? ''));
+            $account = AgentWithdrawAccount::query()->where('agent_id', $this->currentAgent()->id)->findOrFail((int) $data['account_id']);
+            $amount = $account->type === 'usdt' ? ($data['usdt_amount'] ?? null) : ($data['amount'] ?? null);
+            if ($amount === null || $amount === '') {
+                throw new \InvalidArgumentException($account->type === 'usdt' ? '请输入提现的 USDT 数量' : '请输入提现金额');
+            }
+            return $service->request($this->currentAgent(), (int) $data['account_id'], (string) $amount, $data['withdraw_password'], $data['request_id'], (string) ($data['remark'] ?? ''));
         });
     }
 

+ 1 - 25
app/Http/Controllers/api/Wallet.php

@@ -289,31 +289,7 @@ class Wallet extends BaseController
         } else {
             $recharge_channel_group_id = request()->user->recharge_channel_group_id;
         }
-        $list = RechargeChannel::getFormatChannel(2, $recharge_channel_group_id);
-        foreach ($list as &$item) {
-            $channel = $item['value'];
-            $item['requires_real_name'] = true;
-            $item['requires_bank_name'] = WithdrawalDestination::requiresBankName($channel);
-            $item['requires_bank_branch'] = $channel === 'DF001';
-            if ($channel === 'DF001') {
-                $item['bank_name_label'] = lang('银行名称');
-                $item['bank_branch_label'] = lang('开户行');
-            }
-            if ($channel === 'rgtx') {
-                $item['payout_methods'] = [];
-                foreach (WithdrawalDestination::MANUAL_METHODS as $value => $label) {
-                    $method = ['value' => $value, 'label' => lang($label),
-                        'requires_bank_name' => $value === 'bank', 'requires_card_no' => $value !== 'wechat',
-                        'requires_bank_branch' => $value === 'bank',
-                        'requires_qr_image' => $value !== 'bank'];
-                    if ($value === 'bank') {
-                        $method['bank_name_label'] = lang('银行名称');
-                        $method['bank_branch_label'] = lang('开户行');
-                    }
-                    $item['payout_methods'][] = $method;
-                }
-            }
-        }
+        $list = WithdrawalDestination::describeChannels(RechargeChannel::getFormatChannel(2, $recharge_channel_group_id));
         return $this->success($list);
     }
 

+ 18 - 2
app/Models/Agent/AgentWithdrawal.php

@@ -5,12 +5,28 @@ class AgentWithdrawal extends BaseModel
 {
     protected $table = 'agent_withdrawals';
     protected $fillable = ['order_no', 'request_key', 'agent_id', 'account_id', 'account_type', 'account_snapshot', 'amount', 'fee', 'actual_amount',
-        'usdt_amount', 'actual_usdt_amount', 'exchange_rate', 'status', 'remark', 'audit_remark', 'audit_admin_id', 'audited_at', 'paid_at'];
-    protected $hidden = ['request_key'];
+        'usdt_amount', 'actual_usdt_amount', 'exchange_rate', 'status', 'remark', 'audit_remark', 'audit_admin_id', 'audited_at', 'paid_at',
+        'pay_no', 'pay_data', 'callback_data'];
+    // 三方代付的原始返回/回调只给总后台看(列表里 makeVisible)。
+    protected $hidden = ['request_key', 'pay_data', 'callback_data'];
+    protected $appends = ['status_text', 'channel'];
+
+    public const STATUS_TEXT = ['pending' => '待审核', 'approved' => '已通过', 'processing' => '代付中', 'paid' => '已打款', 'rejected' => '已驳回', 'failed' => '代付失败'];
     protected $casts = ['agent_id' => 'integer', 'amount' => 'decimal:4', 'fee' => 'decimal:4', 'actual_amount' => 'decimal:4',
         'usdt_amount' => 'decimal:4', 'actual_usdt_amount' => 'decimal:4', 'exchange_rate' => 'decimal:4',
         'account_snapshot' => 'array', 'audited_at' => 'datetime', 'paid_at' => 'datetime'];
 
+    public function getStatusTextAttribute(): string
+    {
+        return self::STATUS_TEXT[(string) $this->status] ?? '';
+    }
+
+    /** 提现通道,同 account_type(历史数据为空时是 usdt)。 */
+    public function getChannelAttribute(): string
+    {
+        return (string) ($this->attributes['account_type'] ?? '') ?: 'usdt';
+    }
+
     public function agent()
     {
         return $this->belongsTo(Agent::class);

+ 249 - 57
app/Services/Agent/AgentWithdrawalService.php

@@ -4,30 +4,122 @@ 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;
 
 /**
- * 代理提现,仅 USDT。代理填写 USDT 数量,按提现汇率(汇率 + 汇率差)折算后从余额冻结;
- * 合营代理只能提取超出保证金的盈利部分。打款由总后台人工完成。
+ * 代理提现,和会员前台一样使用总后台配置的提现通道(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 function request(Agent $agent, int $accountId, string $usdtAmount, string $withdrawPassword, string $requestId, string $remark = ''): AgentWithdrawal
+    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
     {
-        if (bccomp($usdtAmount, '0', 4) <= 0) {
+        $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, $usdtAmount, $withdrawPassword, $remark, $requestKey): AgentWithdrawal {
+        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('代理账号已禁用');
@@ -38,7 +130,7 @@ class AgentWithdrawalService
             // 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, $usdtAmount);
+                $this->assertSameRequest($existing, $accountId, $amount);
                 return $existing;
             }
             $account = AgentWithdrawAccount::query()
@@ -46,44 +138,71 @@ class AgentWithdrawalService
                 ->where('status', 1)
                 ->lockForUpdate()
                 ->findOrFail($accountId);
-            if ($account->type !== 'usdt') {
-                throw new \RuntimeException('仅支持提现到 USDT 地址');
+            $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' : ''));
             }
-            $rate = AgentExchangeRate::withdraw();
-            $amount = bcmul($usdtAmount, $rate, 4);
-            if (bccomp($this->wallets->withdrawable($lockedAgent), $amount, 4) < 0) {
+            $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) {
                 throw new \RuntimeException($lockedAgent->isJoint() ? '可提现盈利不足(保证金不可提现)' : '代理余额不足');
             }
 
-            $fee = bcdiv(bcmul($amount, (string) $lockedAgent->withdraw_fee_rate, 8), '100', 4);
-            $actual = bcsub($amount, $fee, 4);
+            $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');
             }
 
             $before = bcadd((string) $lockedAgent->balance, '0', 4);
-            $lockedAgent->balance = bcsub($before, $amount, 4);
-            $lockedAgent->realtime_balance = bcsub((string) $lockedAgent->realtime_balance, $amount, 4);
-            $lockedAgent->frozen_balance = bcadd((string) $lockedAgent->frozen_balance, $amount, 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' => $account->type,
-                'account_snapshot' => [
-                    'type' => $account->type,
+                'account_type' => $channel,
+                'account_snapshot' => array_filter([
+                    'type' => $channel,
                     'name' => $account->name,
                     'account' => $account->account,
                     'network' => $account->network,
-                ],
-                'amount' => $amount,
+                    '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,
                 'actual_amount' => $actual,
                 'usdt_amount' => $usdtAmount,
-                'actual_usdt_amount' => bcdiv($actual, $rate, 4),
+                'actual_usdt_amount' => $channel === 'usdt' ? bcdiv($actual, $rate, 4) : '0',
                 'exchange_rate' => $rate,
                 'status' => 'pending',
                 'remark' => $remark,
@@ -92,7 +211,7 @@ class AgentWithdrawalService
             AgentBalanceLog::query()->create([
                 'agent_id' => $lockedAgent->id,
                 'type' => 'withdraw_freeze',
-                'amount' => bcsub('0', $amount, 4),
+                'amount' => bcsub('0', $rmbAmount, 4),
                 'before_balance' => $before,
                 'after_balance' => $lockedAgent->balance,
                 'reference_type' => 'agent_withdrawal',
@@ -107,6 +226,12 @@ class AgentWithdrawalService
         });
     }
 
+    /**
+     * 总后台审核:
+     * - 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)) {
@@ -116,56 +241,42 @@ class AgentWithdrawalService
         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('只有待审核订单可以通过');
                 }
-                $withdrawal->status = 'approved';
+                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('当前订单不能驳回');
                 }
-                $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' => 'admin',
-                        'operator_id' => $adminId,
-                        'remark' => $remark,
-                    ]
-                );
+                $this->refund($withdrawal, $agent, 'admin', $adminId, $remark);
                 $withdrawal->status = 'rejected';
                 $withdrawal->audited_at = now();
                 $withdrawal->audit_admin_id = $adminId;
-                $this->wallets->evaluateActivation($agent);
             } else {
                 if ($withdrawal->status !== 'approved') {
                     throw new \RuntimeException('只有已通过订单可以标记已付款');
                 }
-                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();
+                $this->settle($withdrawal, $agent);
             }
 
             $withdrawal->audit_remark = $remark;
@@ -174,9 +285,90 @@ class AgentWithdrawalService
         });
     }
 
-    private function assertSameRequest(AgentWithdrawal $withdrawal, int $accountId, string $usdtAmount): void
+    /** 三方代付回调结果: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
     {
-        if ((int) $withdrawal->account_id !== $accountId || bccomp((string) $withdrawal->usdt_amount, $usdtAmount, 4) !== 0) {
+        $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 已用于其他提现申请');
         }
     }

+ 162 - 93
app/Services/PaymentOrderService.php

@@ -6,6 +6,8 @@ use App\Constants\HttpStatus;
 use App\Models\PaymentOrder;
 use App\Models\Agent\AgentPaymentOrder;
 use App\Services\Agent\AgentPaymentService;
+use App\Services\Agent\AgentWithdrawalService;
+use App\Models\Agent\AgentWithdrawal;
 use App\Models\RechargeChannel;
 use App\Models\User;
 use App\Models\Wallet as WalletModel;
@@ -779,99 +781,10 @@ class PaymentOrderService extends BaseService
                 ->where('status', self::STATUS_STAY)
                 ->first();
             if (!$order) throw new Exception("订单不存在_{$orderId}", HttpStatus::CUSTOM_ERROR);
-            $amount = $order->amount;
-            if (!PaymentProviderService::requestEnabled((string)$order->channel, 2)) {
-                throw new Exception('支付公司已禁用', HttpStatus::CUSTOM_ERROR);
-            }
-            if (!RechargeChannel::query()->where('data_type', 2)->where('type', $order->channel)->available()->exists()) {
-                throw new Exception('提现通道已停用或删除', HttpStatus::CUSTOM_ERROR);
-            }
-            $amount = number_format($amount, 2, '.', '');
-            if (NoPayService::isWithdrawChannel($order->channel)) {
-                $ret = NoPayService::withdraw($amount, $order->order_no, (string)$order->member_id, (string)$order->account, (string)$order->card_no);
-                Log::channel('payment')->info('NO提现接口调用', [
-                    'order_id' => $order->id,
-                    'order_no' => $order->order_no,
-                    'member_id' => $order->member_id,
-                    'amount' => $amount,
-                    'q_account' => $order->card_no,
-                    'response' => $ret,
-                ]);
-                if (($ret['code'] ?? -1) != 0) {
-                    throw new Exception($ret['msg'] ?? 'NO提现失败', HttpStatus::CUSTOM_ERROR);
-                }
-                $order->pay_no = $ret['data']['orderNo'] ?? '';
-                $order->pay_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
-            } elseif (ZimuPayService::isPayoutChannel($order->channel)) {
-                self::assertZimuBalanceEnough($amount, [
-                    'order_id' => $order->id,
-                    'order_no' => $order->order_no,
-                    'member_id' => $order->member_id,
-                    'channel' => $order->channel,
-                ]);
-                if (ZimuPayService::isCashChannel($order->channel)) {
-                    $ret = ZimuPayService::cash(
-                        $amount,
-                        $order->order_no,
-                        (string)$order->account,
-                        (string)$order->card_no,
-                        (string)$order->bank_name,
-                        ZimuPayService::cashPayMethod((string)$order->bank_name)
-                    );
-                } else {
-                    $ret = ZimuPayService::withdraw($amount, $order->order_no, (string)$order->member_id, (string)$order->card_no, (string)$order->account);
-                }
-                Log::channel('payment')->info('ZIMU提现/代付接口调用', [
-                    'order_id' => $order->id,
-                    'order_no' => $order->order_no,
-                    'member_id' => $order->member_id,
-                    'amount' => $amount,
-                    'channel' => $order->channel,
-                    'response' => $ret,
-                ]);
-                if (($ret['code'] ?? -1) != 0) {
-                    throw new Exception($ret['msg'] ?? 'ZIMU提现/代付失败', HttpStatus::CUSTOM_ERROR);
-                }
-                $order->pay_no = $ret['data']['orderNo'] ?? '';
-                $order->pay_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
-            } elseif (JdPayService::isChannel($order->channel)) {
-                self::assertJdBalanceEnough($amount, [
-                    'order_id' => $order->id,
-                    'order_no' => $order->order_no,
-                    'member_id' => $order->member_id,
-                    'address' => $order->card_no,
-                ]);
-                $ret = JdPayService::remit($amount, $order->order_no, $order->card_no);
-                Log::channel('payment')->info('JD下发接口调用', [
-                    'order_id' => $order->id,
-                    'order_no' => $order->order_no,
-                    'member_id' => $order->member_id,
-                    'amount' => $amount,
-                    'address' => $order->card_no,
-                    'response' => $ret,
-                ]);
-                if (($ret['code'] ?? 0) != 200) {
-                    $logContext = [
-                        'order_id' => $order->id,
-                        'order_no' => $order->order_no,
-                        'member_id' => $order->member_id,
-                        'amount' => $amount,
-                        'address' => $order->card_no,
-                        'response' => $ret,
-                    ];
-                    Log::channel('payment_error')->error('JD下发接口失败', $logContext);
-                    Log::error('JD下发接口失败', $logContext);
-                    throw new Exception($ret['message'] ?? 'JD下发失败', HttpStatus::CUSTOM_ERROR);
-                }
-                $order->pay_no = $ret['data']['orderNo'] ?? '';
-                $order->pay_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
-            } else {
-                $ret = QianBaoService::payout(
-                    $amount, $order->order_no, $order->bank_name, $order->account, $order->card_no, $order->channel
-                );
-                Log::error('第三方代付接口调用:' . json_encode($ret, JSON_UNESCAPED_UNICODE));
-                if ($ret['code'] != 200) throw new Exception($ret['msg'], HttpStatus::CUSTOM_ERROR);
-            }
+            $ret = self::requestPayout((string)$order->channel, (string)$order->order_no, (string)$order->member_id, $order->amount,
+                (string)$order->account, (string)$order->card_no, (string)$order->bank_name, ['order_id' => $order->id]);
+            if ($ret['pay_no'] !== null) $order->pay_no = $ret['pay_no'];
+            if ($ret['pay_data'] !== null) $order->pay_data = $ret['pay_data'];
             $order->status = self::STATUS_PROCESS;
             $order->save();
         } catch (Exception $e) {
@@ -888,6 +801,109 @@ class PaymentOrderService extends BaseService
         return ['code' => 0, 'msg' => 'ok'];
     }
 
+    /**
+     * 向三方发起代付(NO/808/JD/钱宝),会员提现和代理提现共用。失败抛异常;成功返回三方单号和下单返回。
+     * $memberId:会员为 member_id,代理为 AG{代理ID};$context 只用于日志。
+     */
+    public static function requestPayout(string $channel, string $orderNo, string $memberId, $amount, string $accountName, string $cardNo, string $bankName, array $context = []): array
+    {
+        $payNo = null;
+        $payData = null;
+        if (!PaymentProviderService::requestEnabled($channel, 2)) {
+            throw new Exception('支付公司已禁用', HttpStatus::CUSTOM_ERROR);
+        }
+        if (!RechargeChannel::query()->where('data_type', 2)->where('type', $channel)->available()->exists()) {
+            throw new Exception('提现通道已停用或删除', HttpStatus::CUSTOM_ERROR);
+        }
+        $amount = number_format($amount, 2, '.', '');
+        if (NoPayService::isWithdrawChannel($channel)) {
+            $ret = NoPayService::withdraw($amount, $orderNo, $memberId, $accountName, $cardNo);
+            Log::channel('payment')->info('NO提现接口调用', [
+                'order_id' => ($context['order_id'] ?? null),
+                'order_no' => $orderNo,
+                'member_id' => $memberId,
+                'amount' => $amount,
+                'q_account' => $cardNo,
+                'response' => $ret,
+            ]);
+            if (($ret['code'] ?? -1) != 0) {
+                throw new Exception($ret['msg'] ?? 'NO提现失败', HttpStatus::CUSTOM_ERROR);
+            }
+            $payNo = $ret['data']['orderNo'] ?? '';
+            $payData = json_encode($ret, JSON_UNESCAPED_UNICODE);
+        } elseif (ZimuPayService::isPayoutChannel($channel)) {
+            self::assertZimuBalanceEnough($amount, [
+                'order_id' => ($context['order_id'] ?? null),
+                'order_no' => $orderNo,
+                'member_id' => $memberId,
+                'channel' => $channel,
+            ]);
+            if (ZimuPayService::isCashChannel($channel)) {
+                $ret = ZimuPayService::cash(
+                    $amount,
+                    $orderNo,
+                    $accountName,
+                    $cardNo,
+                    $bankName,
+                    ZimuPayService::cashPayMethod($bankName)
+                );
+            } else {
+                $ret = ZimuPayService::withdraw($amount, $orderNo, $memberId, $cardNo, $accountName);
+            }
+            Log::channel('payment')->info('ZIMU提现/代付接口调用', [
+                'order_id' => ($context['order_id'] ?? null),
+                'order_no' => $orderNo,
+                'member_id' => $memberId,
+                'amount' => $amount,
+                'channel' => $channel,
+                'response' => $ret,
+            ]);
+            if (($ret['code'] ?? -1) != 0) {
+                throw new Exception($ret['msg'] ?? 'ZIMU提现/代付失败', HttpStatus::CUSTOM_ERROR);
+            }
+            $payNo = $ret['data']['orderNo'] ?? '';
+            $payData = json_encode($ret, JSON_UNESCAPED_UNICODE);
+        } elseif (JdPayService::isChannel($channel)) {
+            self::assertJdBalanceEnough($amount, [
+                'order_id' => ($context['order_id'] ?? null),
+                'order_no' => $orderNo,
+                'member_id' => $memberId,
+                'address' => $cardNo,
+            ]);
+            $ret = JdPayService::remit($amount, $orderNo, $cardNo);
+            Log::channel('payment')->info('JD下发接口调用', [
+                'order_id' => ($context['order_id'] ?? null),
+                'order_no' => $orderNo,
+                'member_id' => $memberId,
+                'amount' => $amount,
+                'address' => $cardNo,
+                'response' => $ret,
+            ]);
+            if (($ret['code'] ?? 0) != 200) {
+                $logContext = [
+                    'order_id' => ($context['order_id'] ?? null),
+                    'order_no' => $orderNo,
+                    'member_id' => $memberId,
+                    'amount' => $amount,
+                    'address' => $cardNo,
+                    'response' => $ret,
+                ];
+                Log::channel('payment_error')->error('JD下发接口失败', $logContext);
+                Log::error('JD下发接口失败', $logContext);
+                throw new Exception($ret['message'] ?? 'JD下发失败', HttpStatus::CUSTOM_ERROR);
+            }
+            $payNo = $ret['data']['orderNo'] ?? '';
+            $payData = json_encode($ret, JSON_UNESCAPED_UNICODE);
+        } else {
+            $ret = QianBaoService::payout(
+                $amount, $orderNo, $bankName, $accountName, $cardNo, $channel
+            );
+            Log::error('第三方代付接口调用:' . json_encode($ret, JSON_UNESCAPED_UNICODE));
+            if ($ret['code'] != 200) throw new Exception($ret['msg'], HttpStatus::CUSTOM_ERROR);
+        }
+        return ['pay_no' => $payNo, 'pay_data' => $payData];
+    }
+
     /**
      * @description: 创建代付订单 (自动直接到账,包括用户钱包的扣款和提现记录的生成以及余额日志的创建)
      * @param {*} $memberId 会员ID
@@ -1163,6 +1179,17 @@ class PaymentOrderService extends BaseService
     {
         if (ZimuPayService::getAppId() !== '' && ($params['appId'] ?? '') === ZimuPayService::getAppId()) {
             $info = self::findOne(['order_no' => $params['mchOrderNo'] ?? '']);
+            if (!$info && ($agentPayout = self::findAgentPayout((string)($params['mchOrderNo'] ?? '')))) {
+                if (!ZimuPayService::verifyNotify($params)
+                    || bccomp(ZimuPayService::amount($agentPayout->actual_amount), ZimuPayService::amount($params['amount'] ?? 0), 2) !== 0) {
+                    Log::error('ZIMU代理提现回调验签或金额不一致', ['mch_order_no' => $params['mchOrderNo'] ?? '']);
+                    return false;
+                }
+                $cash = ZimuPayService::isCashChannel((string)$agentPayout->account_type);
+                return self::agentPayoutResult($agentPayout, (string)($params['status'] ?? ''),
+                    $cash ? ZimuPayService::CASH_STATUS_SUCCESS : ZimuPayService::WITHDRAW_STATUS_SUCCESS,
+                    $cash ? ZimuPayService::CASH_STATUS_FAIL : ZimuPayService::WITHDRAW_STATUS_FAIL, $params);
+            }
             if (!$info || $info->type != self::TYPE_PAYOUT || !ZimuPayService::isPayoutChannel($info->channel)) {
                 Log::error('ZIMU提现/代付回调订单不存在或类型不匹配', [
                     'mch_order_no' => $params['mchOrderNo'] ?? '',
@@ -1194,6 +1221,15 @@ class PaymentOrderService extends BaseService
 
         if (NoPayService::getWithdrawMerchantId() !== '' && ($params['appId'] ?? '') === NoPayService::getWithdrawMerchantId()) {
             $info = self::findOne(['order_no' => $params['merchantOrderNo'] ?? '']);
+            if (!$info && ($agentPayout = self::findAgentPayout((string)($params['merchantOrderNo'] ?? '')))) {
+                if (!NoPayService::verifyWithdrawNotify($params)
+                    || bccomp(NoPayService::amount($agentPayout->actual_amount), NoPayService::amount($params['amount'] ?? 0), 2) !== 0) {
+                    Log::error('NO代理提现回调验签或金额不一致', ['merchant_order_no' => $params['merchantOrderNo'] ?? '']);
+                    return false;
+                }
+                return self::agentPayoutResult($agentPayout, (string)($params['state'] ?? ''),
+                    NoPayService::STATE_SUCCESS, NoPayService::STATE_FAIL, $params);
+            }
             if (!$info || $info->type != self::TYPE_PAYOUT || !NoPayService::isWithdrawChannel($info->channel)) {
                 Log::error('NO钱包提现回调订单不存在或类型不匹配', [
                     'merchant_order_no' => $params['merchantOrderNo'] ?? '',
@@ -1226,6 +1262,14 @@ class PaymentOrderService extends BaseService
         if (($params['userCode'] ?? '') === JdPayService::getMerchantId()) {
             $info = self::findOne(['order_no' => $params['customerOrderCode'] ?? ''])
                 ?: static::$MODEL::where('pay_no', $params['orderCode'] ?? '')->first();
+            if (!$info && ($agentPayout = self::findAgentPayout((string)($params['customerOrderCode'] ?? ''), (string)($params['orderCode'] ?? '')))) {
+                if (!JdPayService::verifyRemitNotify($params)
+                    || bccomp(JdPayService::amount($agentPayout->actual_amount), JdPayService::amount($params['amount'] ?? 0), 2) !== 0) {
+                    return false;
+                }
+                return self::agentPayoutResult($agentPayout, (string)($params['status'] ?? ''),
+                    JdPayService::REMIT_STATUS_SUCCESS, JdPayService::REMIT_STATUS_FAIL, $params);
+            }
             if (!$info || $info->type != self::TYPE_PAYOUT) {
                 return false;
             }
@@ -1243,6 +1287,15 @@ class PaymentOrderService extends BaseService
         if (($params['merchantNum'] ?? '') == QianBaoService::getMerchantId()) {
 
             $info = self::findOne(['order_no' => $params['orderNo']]);
+            if (!$info && ($agentPayout = self::findAgentPayout((string)($params['orderNo'] ?? '')))) {
+                if (bccomp((string)$agentPayout->actual_amount, (string)($params['amount'] ?? 0), 2) !== 0
+                    || ($params['sign'] ?? '') != QianBaoService::verifyNotifySign($params['state'] ?? '', $params['orderNo'], $params['amount'])) {
+                    return false;
+                }
+                // 钱宝:state=1 成功,其他为失败(与会员代付一致)
+                $state = (string)($params['state'] ?? '');
+                return self::agentPayoutResult($agentPayout, $state, '1', $state === '1' ? '' : $state, $params);
+            }
             if ($info) {
                 // 判断金额是不是正确认
                 if ($info->amount != $params['amount']) {
@@ -1268,6 +1321,22 @@ class PaymentOrderService extends BaseService
 
     }
 
+    /** 三方代付回调的订单号不在会员订单里时,查代理提现(订单号 AW 开头;JD 也可按三方单号)。 */
+    private static function findAgentPayout(string $orderNo, string $payNo = ''): ?AgentWithdrawal
+    {
+        if ($orderNo === '' && $payNo === '') return null;
+        $order = $orderNo === '' ? null : AgentWithdrawal::query()->where('order_no', $orderNo)->first();
+        return $order ?: ($payNo === '' ? null : AgentWithdrawal::query()->where('pay_no', $payNo)->first());
+    }
+
+    /** 代理提现代付结果:成功扣冻结、失败退回代理余额;其他状态只记录回调。重复回调幂等。 */
+    private static function agentPayoutResult(AgentWithdrawal $withdrawal, string $status, string $success, string $fail, array $params): bool
+    {
+        $result = $status === $success ? true : ($fail !== '' && $status === $fail ? false : null);
+        app(AgentWithdrawalService::class)->providerResult((int)$withdrawal->id, $result, $params);
+        return true;
+    }
+
     /**
      * @description: 处理代付订单
      * @param {*} $params

+ 31 - 0
app/Services/WithdrawalDestination.php

@@ -17,6 +17,37 @@ final class WithdrawalDestination
         'alipay' => '支付宝',
     ];
 
+    /** 提现通道列表加上前端表单需要的字段(requires_* 和人工提现的收款方式),会员和代理共用。 */
+    public static function describeChannels(array $list): array
+    {
+        foreach ($list as &$item) {
+            $channel = $item['value'];
+            $item['requires_real_name'] = true;
+            $item['requires_bank_name'] = WithdrawalDestination::requiresBankName($channel);
+            $item['requires_bank_branch'] = $channel === 'DF001';
+            if ($channel === 'DF001') {
+                $item['bank_name_label'] = lang('银行名称');
+                $item['bank_branch_label'] = lang('开户行');
+            }
+            if ($channel === 'rgtx') {
+                $item['payout_methods'] = [];
+                foreach (WithdrawalDestination::MANUAL_METHODS as $value => $label) {
+                    $method = ['value' => $value, 'label' => lang($label),
+                        'requires_bank_name' => $value === 'bank', 'requires_card_no' => $value !== 'wechat',
+                        'requires_bank_branch' => $value === 'bank',
+                        'requires_qr_image' => $value !== 'bank'];
+                    if ($value === 'bank') {
+                        $method['bank_name_label'] = lang('银行名称');
+                        $method['bank_branch_label'] = lang('开户行');
+                    }
+                    $item['payout_methods'][] = $method;
+                }
+            }
+        }
+        unset($item);
+        return $list;
+    }
+
     public static function requiresBankName(string $channel): bool
     {
         return $channel === QianBaoService::ALIPAY_TO_CARD || ZimuPayService::isCashChannel($channel);

+ 30 - 0
database/migrations/2026_11_08_170000_add_agent_withdrawal_channels.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 代理提现和会员前台一样支持总后台配置的全部提现通道(USDT、人工提现 rgtx、银行卡/支付宝/数字人民币 DF00x、
+ * JD、NO、808 等)。通道存在 agent_withdrawals.account_type;三方代付需要记录三方单号和回调。
+ * 提现账户沿用 agent_withdraw_accounts:type = 通道,account = 卡号/地址,bank_name,details 存开户行/收款码等。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('agent_withdrawals', function (Blueprint $t) {
+            if (!Schema::hasColumn('agent_withdrawals', 'pay_no')) $t->string('pay_no', 64)->nullable()->index()->comment('三方代付单号');
+            if (!Schema::hasColumn('agent_withdrawals', 'pay_data')) $t->text('pay_data')->nullable()->comment('三方代付下单返回');
+            if (!Schema::hasColumn('agent_withdrawals', 'callback_data')) $t->text('callback_data')->nullable()->comment('三方代付回调');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('agent_withdrawals', function (Blueprint $t) {
+            foreach (['pay_no', 'pay_data', 'callback_data'] as $column) {
+                if (Schema::hasColumn('agent_withdrawals', $column)) $t->dropColumn($column);
+            }
+        });
+    }
+};

+ 3 - 19
docs/代理/代理后台调整-前端接入.md

@@ -9,7 +9,7 @@
 | 模块 | 改动 |
 |---|---|
 | 登录 / 个人信息 | 新增代理类型(合营/入股)、平台邀请地址、余额构成、方案或持股比例;去掉层级字段 |
-| 资金账户 | 新增余额构成接口;**新增充值:和会员前台一样支持后台配置的全部通道**(USDT、人工充值、808、NO、JD、三斤等),见 [代理充值-前端接入.md](代理充值-前端接入.md);提现**只支持 USDT**,按 USDT 数量提交 |
+| 资金账户 | 新增余额构成接口;**新增充值:和会员前台一样支持后台配置的全部通道**(USDT、人工充值、808、NO、JD、三斤等),见 [代理充值-前端接入.md](代理充值-前端接入.md);**提现和会员前台一样支持后台配置的全部通道**,见 [代理提现-前端接入.md](代理提现-前端接入.md) |
 | 所有会员 | 列表字段改为和总后台会员列表一致;不提供充值、提现、冻结、解冻 |
 | 会员盈亏日报(新) | 每个会员每天一行 |
 | 分成方案(新) | 合营代理选择总后台配置的方案并提交申请 |
@@ -84,25 +84,9 @@
 
 充值页(USDT、人工充值、808/NO/JD/三斤在线充值,以及充值记录)的完整说明见 **[代理充值-前端接入.md](代理充值-前端接入.md)**。
 
-### 提现账户:只支持 USDT
+### 提现
 
-- `GET /agent/wallet/accounts`:只返回 USDT 账户,`type` 参数已删除。
-- `POST /agent/wallet/accounts`:参数改为 `id`(修改时传)、`name`(备注)、`network`(**必填**,`TRC20` 或 `ERC20`)、`account`(**必填**,USDT 地址,后端校验格式)、`status`。`type`、`bank_name`、`details` 不再接收。
-- `POST /agent/wallet/accounts/delete`:不变。
-
-### `POST /agent/wallet/withdrawals`
-
-| 参数 | 必填 | 说明 |
-|---|---:|---|
-| `account_id` | 是 | USDT 提现账户 |
-| `usdt_amount` | 是 | **提现的 USDT 数量**(原来的 `amount` 已改名) |
-| `withdraw_password` | 是 | |
-| `request_id` | 是 | 防重复提交的唯一串,重试时传同一个 |
-| `remark` | 否 | |
-
-折算后的余额金额 = `usdt_amount` × 提现汇率,不能超过 `withdrawable_amount`。合营代理超出时返回「可提现盈利不足(保证金不可提现)」。
-
-提现记录 `GET /agent/wallet/withdrawals` 新增字段:`usdt_amount`、`actual_usdt_amount`(扣手续费后实际到账的 USDT)、`exchange_rate`。`amount`、`fee`、`actual_amount` 仍是余额单位。
+提现通道、提现账户、申请提现和提现记录的完整说明见 **[代理提现-前端接入.md](代理提现-前端接入.md)**。和会员前台一样支持总后台配置的全部提现通道(USDT、人工提现、银行卡/支付宝/数字人民币、JD、NO、808 等)。
 
 ### 资金流水 `GET /agent/wallet/logs`
 

+ 214 - 0
docs/代理/代理提现-前端接入.md

@@ -0,0 +1,214 @@
+# 代理后台-提现(前端接入)
+
+给代理独立后台前端看,是提现页和提现账户页的完整说明。
+
+代理提现和会员前台一样,可以使用总后台「提现通道」里配置的全部通道:
+
+| 通道 `value` | 名称 | 到账方式 |
+|---|---|---|
+| `usdt` | USDT | 填 USDT 数量,总后台人工打款 |
+| `rgtx` | 人工提现(银行卡 / 微信 / 支付宝) | 总后台人工打款 |
+| `DF001` | 银行卡 | 三方代付,自动到账 |
+| `DF002` | 支付宝 | 三方代付,自动到账 |
+| `DF005` | 数字人民币 | 三方代付,自动到账 |
+| `JDpay` | JD 钱包 | 三方代付,自动到账 |
+| `NOwithdraw` | NO 钱包 | 三方代付,自动到账 |
+| `ZIMUwithdraw` | 808 钱包地址 | 三方代付,自动到账 |
+| `ZIMUcash` | 808 人民币代付 | 三方代付,自动到账 |
+
+实际可用哪些由总后台决定(「入股配置」里选择的**通道组合**的提现类型 ∩ 已启用通道),以 `GET /agent/wallet/withdraw/channels` 返回为准。
+
+请求头带 `Authorization: Bearer <agent_token>`。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。
+
+## 0. 本次改动
+
+| 接口 | 改动 |
+|---|---|
+| `GET /agent/wallet/withdraw/channels` | **新增**:可用提现通道,格式同会员前台 `GET /api/wallet/withdrawChannel` |
+| `GET /agent/wallet/accounts` | 返回全部通道的账户(原来只有 USDT);新增 `type` 筛选和 `channel`、`card_no`、`bank_branch`、`qr_image`、`payout_method`、`holder_name` 字段 |
+| `POST /agent/wallet/accounts` | 新增 `type`(提现通道,不传为 `usdt`)及各通道的字段 `card_no`、`bank_name`、`bank_branch`、`payout_method`、`qr_image`、`real_name`;USDT 账户的参数不变 |
+| `POST /agent/wallet/withdrawals` | USDT 账户仍传 `usdt_amount`;**其他通道传 `amount`(人民币)** |
+| `GET /agent/wallet/withdrawals` | 新增状态 `processing`(代付中)、`failed`(代付失败),新增 `channel` 筛选,新增字段 `channel`、`status_text`、`pay_no` |
+
+## 1. 规则(页面文案需要用到)
+
+- **可提现金额**:`GET /agent/wallet/summary` 的 `withdrawable_amount`(余额单位,人民币)。合营代理只能提取超出保证金的盈利,入股代理可以提取全部余额。
+- **USDT**:输入 USDT 数量,扣除余额 = USDT 数量 × 提现汇率(`withdraw_exchange_rate`,即汇率 + 汇率差)。
+- **其他通道**:输入人民币金额,最多 2 位小数,扣除余额 = 输入金额。
+- **金额区间**:通道的 `config.min`~`config.max`(USDT 通道按 USDT 数量,其他按人民币)。
+- **手续费**:按代理的提现费率(总后台设置)从提现金额中扣除,实际到账 = 金额 − 手续费;三方通道的通道费率由平台承担,不扣代理。
+- **真实姓名**:除 `usdt`、`JDpay` 外,其他通道都需要收款人真实姓名。代理没有真实姓名时,首次绑定这些通道的账户必须填写 `real_name`,之后不能自行修改(联系平台修改)。
+- 提交后金额立即冻结(`frozen_balance`),驳回或代付失败会退回余额。
+
+## 2. 页面流程
+
+```
+GET /agent/wallet/withdraw/channels  →  选择通道
+GET /agent/wallet/accounts?type=<通道>  →  选择账户;没有就新增 POST /agent/wallet/accounts
+POST /agent/wallet/withdrawals  →  USDT 传 usdt_amount,其他传 amount
+GET /agent/wallet/withdrawals  →  查看进度
+```
+
+## 3. 提现通道
+
+### `GET /agent/wallet/withdraw/channels`
+
+```json
+{
+  "list": [
+    {"key":0,"label":"人工提现","value":"rgtx","logo":"…","config":{"min":"100.00","max":"50000.00","rate":"0.0000"},
+     "requires_real_name":true,"requires_bank_name":false,"requires_bank_branch":false,
+     "payout_methods":[
+       {"value":"bank","label":"银行卡","requires_bank_name":true,"requires_card_no":true,"requires_bank_branch":true,"requires_qr_image":false,
+        "bank_name_label":"银行名称","bank_branch_label":"开户行"},
+       {"value":"wechat","label":"微信","requires_bank_name":false,"requires_card_no":false,"requires_bank_branch":false,"requires_qr_image":true},
+       {"value":"alipay","label":"支付宝","requires_bank_name":false,"requires_card_no":true,"requires_bank_branch":false,"requires_qr_image":true}
+     ]},
+    {"key":1,"label":"JD钱包","value":"JDpay","logo":"…","config":{"min":"1.00","max":"49999.00","rate":"0.0020"},
+     "requires_real_name":false,"requires_bank_name":false,"requires_bank_branch":false}
+  ],
+  "real_name": ""
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `value` / `label` / `logo` | 通道类型、名称、图标。`value` 用作账户的 `type` |
+| `config.min` / `config.max` | 金额区间(USDT 通道为 USDT 数量,其他为人民币) |
+| `requires_real_name` | 是否需要收款人真实姓名(代理端:`usdt`、`JDpay` 为 `false`) |
+| `requires_bank_name` | 是否需要银行名称(`DF001`、`ZIMUcash`) |
+| `requires_bank_branch` | 是否需要开户行支行(`DF001`) |
+| `bank_name_label` / `bank_branch_label` | 输入框标题(有时才返回) |
+| `payout_methods` | 仅 `rgtx`:收款方式及各自需要的字段 |
+| `real_name`(外层) | 代理当前的真实姓名;为空时,需要实名的通道在绑定账户时要求填写 |
+
+## 4. 提现账户
+
+### `GET /agent/wallet/accounts`
+
+参数:`type`(可选,提现通道)。返回数组,每项:
+
+| 字段 | 说明 |
+|---|---|
+| `id` | 账户 ID,提现时传 `account_id` |
+| `type` / `channel` | 提现通道(两个字段相同) |
+| `name` | 备注 |
+| `account` | USDT 为地址;其他为收款账号(同 `card_no`) |
+| `network` | USDT 公链 `TRC20` / `ERC20`,其他为空 |
+| `card_no` | 收款账号(卡号、支付宝账号、钱包地址等),USDT 为空 |
+| `bank_name` | 银行名称;其他通道为通道默认名(如 `JD钱包`、`NO钱包`、`支付宝`、`数字人民币`),人工提现为 `银行卡名称` / `微信` / `支付宝` |
+| `bank_branch` | 开户行支行,没有为 `null` |
+| `qr_image` | 收款码图片,没有为 `null` |
+| `payout_method` | 人工提现的收款方式 `bank` / `wechat` / `alipay`,其他为 `null` |
+| `holder_name` | 收款人姓名(需要实名的通道为代理真实姓名,否则为空) |
+| `status` | `1` 启用、`0` 停用(停用的账户不能提现) |
+
+### `POST /agent/wallet/accounts`(新增 / 修改)
+
+修改时传 `id`;账户的 `type` 不能修改。每个通道最多 5 个账户,超出返回 `已达添加上限`。
+
+| 参数 | 说明 |
+|---|---|
+| `id` | 修改时传 |
+| `type` | 提现通道 `value`,不传为 `usdt` |
+| `name` | 备注,可选 |
+| `status` | `1` 启用(默认)/ `0` 停用 |
+| `real_name` | 真实姓名。代理还没有真实姓名、且通道 `requires_real_name=true` 时**必填**,最多 64 字 |
+
+各通道需要的字段:
+
+| 通道 | 需要的参数 |
+|---|---|
+| `usdt` | `network`(`TRC20` / `ERC20`)、`account`(USDT 地址,按公链校验格式) |
+| `rgtx` + `payout_method=bank` | `bank_name`、`bank_branch`、`card_no`(银行卡号) |
+| `rgtx` + `payout_method=wechat` | `qr_image`(微信收款码,https 图片地址) |
+| `rgtx` + `payout_method=alipay` | `card_no`(支付宝账号)、`qr_image`(支付宝收款码,https 图片地址) |
+| `DF001` | `bank_name`、`bank_branch`、`card_no` |
+| `ZIMUcash` | `bank_name`、`card_no` |
+| `DF002`、`DF005`、`JDpay`、`NOwithdraw`、`ZIMUwithdraw` | `card_no`(收款账号 / 钱包地址) |
+
+示例(人工提现到银行卡,首次填写真实姓名):
+
+```json
+{"type":"rgtx","payout_method":"bank","bank_name":"工商银行","bank_branch":"深圳南山支行","card_no":"6222000000000000","real_name":"张三"}
+```
+
+成功返回保存后的账户(字段同列表)。常见错误:
+
+| `msg` | 原因 |
+|---|---|
+| `不支持此提现方式` | 通道不在代理可用列表里 |
+| `请输入真实姓名` | 需要实名但代理没有真实姓名,且没传 `real_name` |
+| `请输入银行名称` / `请输入开户行支行名称` / `请输入收款账号` | 缺少对应字段 |
+| `请上传有效的收款码图片` | `qr_image` 不是 https 图片地址 |
+| `请输入支付宝账号` | 人工提现支付宝缺少账号 |
+| `USDT 地址格式错误` / `请选择公链 TRC20 或 ERC20` | USDT 账户 |
+| `不能修改账户的提现方式` | 修改时传了不同的 `type` |
+
+### `POST /agent/wallet/accounts/delete`
+
+参数:`id`。有处理中的提现(待审核 / 已通过 / 代付中)时返回 `该账户有处理中的提现,不能删除`。
+
+## 5. 申请提现
+
+### `POST /agent/wallet/withdrawals`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `account_id` | 是 | 提现账户 ID |
+| `usdt_amount` | USDT 账户必填 | 提现的 USDT 数量,最多 4 位小数 |
+| `amount` | 其他通道必填 | 提现金额(人民币),最多 2 位小数 |
+| `withdraw_password` | 是 | 提现密码 |
+| `request_id` | 是 | 防重复提交的唯一串(如 UUID)。网络重试时传同一个,不会重复扣款;同一个 `request_id` 换了账户或金额返回 `request_id 已用于其他提现申请` |
+| `remark` | 否 | 备注 |
+
+返回提现记录(字段见第 6 节),`status=pending`。
+
+常见错误:
+
+| `msg` | 原因 |
+|---|---|
+| `请输入提现的 USDT 数量` / `请输入提现金额` | USDT 账户没传 `usdt_amount`,或其他账户没传 `amount` |
+| `提现金额范围为100-50000`(USDT 带 ` USDT`) | 超出通道区间 |
+| `提现金额最多 2 位小数` | 人民币通道金额格式 |
+| `不支持此提现方式` | 账户的通道已不在可用列表(总后台调整了通道组合) |
+| `请先在提现账户中填写真实姓名` | 需要实名的通道,代理没有真实姓名 |
+| `可提现盈利不足(保证金不可提现)` / `代理余额不足` | 超过可提现金额 |
+| `扣除手续费后到账金额必须大于0` | 金额太小 |
+| `提现密码错误` / `代理账号已禁用` | |
+
+## 6. 提现记录
+
+### `GET /agent/wallet/withdrawals`
+
+参数:`page`、`limit`、`status`、`channel`(提现通道)、`start_date`、`end_date`。
+
+返回 `{"total":…, "page":…, "limit":…, "list":[…]}`,`list` 每项:
+
+| 字段 | 说明 |
+|---|---|
+| `id` / `order_no` | 提现 ID、订单号 |
+| `channel` | 提现通道(同 `account_type`) |
+| `account_snapshot` | 提交时的收款信息:`account`、`network`、`bank_name`、`bank_branch`、`qr_image`、`payout_method`、`holder_name`、`name` |
+| `amount` | 扣除的余额(人民币) |
+| `fee` | 手续费(人民币) |
+| `actual_amount` | 实际到账(人民币) |
+| `usdt_amount` / `actual_usdt_amount` / `exchange_rate` | 仅 USDT:提交的 USDT 数量、扣手续费后实际到账的 USDT、提现汇率;其他通道为 `0` |
+| `status` / `status_text` | 见下表 |
+| `pay_no` | 三方代付单号(三方通道审核后才有) |
+| `remark` | 提交时的备注 |
+| `audit_remark` | 审核备注、驳回原因、代付失败说明 |
+| `audited_at` / `paid_at` / `created_at` | 审核时间、打款时间、申请时间 |
+
+`status`:
+
+| 值 | `status_text` | 说明 |
+|---|---|---|
+| `pending` | 待审核 | 金额已冻结 |
+| `approved` | 已通过 | USDT / 人工提现:等待平台人工打款 |
+| `processing` | 代付中 | 三方通道:已提交三方,等待到账结果 |
+| `paid` | 已打款 | 完成 |
+| `rejected` | 已驳回 | 金额已退回余额,原因见 `audit_remark` |
+| `failed` | 代付失败 | 三方打款失败,金额已退回余额 |
+
+资金流水(`GET /agent/wallet/logs`):提交时记 `withdraw_freeze`(负数),驳回或代付失败时记 `withdraw_refund`(正数)。

+ 76 - 13
docs/代理/代理首页.md

@@ -1,29 +1,33 @@
-# 代理后台-代理首页
+# 代理后台-代理首页(前端接入)
 
-需要请求头 `Authorization: Bearer <agent_token>`。
+请求头带 `Authorization: Bearer <agent_token>`。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。
 
-## 首页汇总
+首页需要两个接口:
 
-### `GET /agent/dashboard`
+| 区域 | 接口 |
+|---|---|
+| 顶部欢迎语、代理类型标签(合营/入股)、邀请链接 | `GET /agent/profile`(登录后已有,见 [代理后台调整-前端接入.md](代理后台调整-前端接入.md) 第 1 节) |
+| 日期筛选 + 统计卡片 + 资金汇总 + 投注与盈亏 | `GET /agent/dashboard`(本文) |
+
+## 1. 首页汇总 `GET /agent/dashboard`
+
+只统计**本代理名下的直属会员**(二级代理已取消)。
 
 | 参数 | 类型 | 必填 | 默认 | 说明 |
 |---|---|---:|---|---|
-| `start_date` | string | 否 | 当天 | 开始日期,`Y-m-d` |
-| `end_date` | string | 否 | `start_date` | 结束日期,不能早于开始日期 |
+| `start_date` | string | 否 | 当天 | 开始日期 `Y-m-d` |
+| `end_date` | string | 否 | 同 `start_date` | 结束日期,不能早于开始日期;跨度不超过 366 天 |
 
 ```http
-GET /agent/dashboard?start_date=2026-08-01&end_date=2026-08-24
+GET /agent/dashboard?start_date=2026-11-08&end_date=2026-11-08
 ```
 
-返回当前代理加整棵下级树的数据:
-
 ```json
 {
   "code": 0,
-  "timestamp": 1787568000,
+  "timestamp": 1794124800,
   "msg": "OK",
   "data": {
-    "agent_count": 3,
     "member_count": 25,
     "member_balance": "100000.0000",
     "deposit_amount": "50000.0000",
@@ -37,9 +41,68 @@ GET /agent/dashboard?start_date=2026-08-01&end_date=2026-08-24
     "valid_bet_amount": "28000.0000",
     "win_loss": "-3000.0000",
     "commission_amount": "310.0000",
-    "company_profit": "39600.0000"
+    "company_profit": "39600.0000",
+    "agent_count": 0
   }
 }
 ```
 
-金额均为 4 位小数字符串。`win_loss` 正数表示会员赢;`agent_count` 是下级代理数,不包含当前代理。`company_profit=deposit_amount-withdraw_amount-bonus_amount-rebate_amount`,手动加扣款不参与公司盈亏。
+金额都是 4 位小数的字符串,笔数、人数是整数。
+
+## 2. 页面字段对照
+
+### 统计卡片
+
+| 页面 | 字段 | 说明 |
+|---|---|---|
+| 总会员数 | `member_count` | 名下会员总数(**不受日期影响**) |
+| 总投注笔数 | `bet_count` | 日期内的已结算注单数 |
+| 总会员输赢 | `win_loss` | 日期内会员输赢合计。**正数 = 会员赢(平台亏),负数 = 会员输** |
+
+### 资金汇总
+
+| 页面 | 字段 | 说明 |
+|---|---|---|
+| 会员总余额 | `member_balance` | 名下会员当前钱包可用余额合计(**实时值,不受日期影响**) |
+| 总充值金额 | `deposit_amount` | 日期内到账的充值:USDT 充值 + 在线充值 + 人工充值 |
+| 总提现金额 | `withdraw_amount` | 日期内成功的提现:USDT 提现 + 三方代付 + 人工提现 |
+| 总手动加款 | `manual_credit` | 后台人工加款、订单补偿(不含后台赠送) |
+| 总手动扣款 | `manual_debit` | 后台人工扣款(正数展示) |
+| 总优惠金额 | `bonus_amount` | 注册赠送、优惠活动、即充即送、充值返现、老用户回归、后台赠送 |
+| 总返水金额 | `rebate_amount` | 返水、回水、笔笔返 |
+| 总佣金金额 | `commission_amount` | 本代理日期内**已入账的合营分成**(按分成日期),**可以为负**(会员赢时代理承担)。入股代理为 `0`。建议页面文案改为「合营分成」,入股代理可隐藏此行 |
+| 公司总盈亏 | `company_profit` | `总充值 − 总提现 − 总优惠 − 总返水`;手动加扣款不参与 |
+
+### 投注与盈亏
+
+| 页面 | 字段 | 说明 |
+|---|---|---|
+| 总投注金额 | `bet_amount` | 日期内已结算注单的投注额 |
+| 总有效投注 | `valid_bet_amount` | 有效投注(三方游戏取三方返回的有效投注,其他等于投注额) |
+| 总会员输赢 | `win_loss` | 同统计卡片 |
+
+注单范围:PC28、体育、极速、六合彩、三方游戏的已结算注单,按下单时间统计(三方游戏按结算时间)。
+
+### 其他字段
+
+| 字段 | 说明 |
+|---|---|
+| `agent_count` | 已废弃:二级代理取消后固定为 `0`,页面不要展示 |
+
+## 3. 顶部信息(`GET /agent/profile`)
+
+| 页面 | 字段 |
+|---|---|
+| 欢迎语里的账号 | `username` |
+| 类型标签(合营 / 入股) | `type`(`1` 合营 / `2` 入股)、`type_text` |
+| 邀请链接 | `pc_register_url`、`h5_register_url` |
+
+欢迎语里的称呼建议按 `type` 区分(例如合营代理显示「尊敬的合营代理」、入股代理显示「尊敬的股东」),不要固定写「股东」。
+
+## 4. 常见错误
+
+| `msg` | 原因 |
+|---|---|
+| `开始日期不能大于结束日期` / `end_date` 校验信息 | 结束日期早于开始日期 |
+| `单次报表查询不能超过366天` | 日期跨度过大 |
+| `请先登录` | token 无效或过期 |

+ 118 - 0
docs/前端接口/后台文件上传-前端接入.md

@@ -0,0 +1,118 @@
+# 后台文件上传-前端接入
+
+后台通用上传接口,供菜单图标、Banner、充值渠道 Logo、公告图片、电子游戏素材等场景复用。上传完成后将返回的 `data.path` 写入对应业务保存接口的字段即可。
+
+- **基础路径**:`/admin`
+- **鉴权**:`Authorization: Bearer <admin_token>`(`admin.jwt`,后台登录成功后的令牌)
+- **权限**:仅需有效管理员登录态,**不**走 `check.button.uri` 按钮权限校验
+- **成功**:`code === 0`
+- **响应结构**:
+
+```json
+{"code":0,"timestamp":1785900000,"msg":"OK","data":{"path":"https://admin.example.com/storage/images/abc123.png"}}
+```
+
+`code !== 0` 时展示 `msg`;校验失败时 `msg` 会附带具体原因(如格式或大小)。
+
+---
+
+## 1. 上传图片
+
+| 项目 | 说明 |
+| --- | --- |
+| 方法 | `POST` |
+| 路径 | `/admin/upload/uploadFile` |
+| Content-Type | `multipart/form-data` |
+| 文件字段名 | `image`(必填) |
+
+**限制**
+
+| 项 | 值 |
+| --- | --- |
+| 格式 | `jpg`、`jpeg`、`png`、`webp` |
+| 大小 | 最大 **10MB**(10240 KB) |
+
+**成功 `data`**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `path` | string | 可访问的完整图片 URL;相对路径会按 `app.url` 补全为绝对地址 |
+
+**存储**:`storage/app/public/images/`;同名内容(按文件 MD5 命名)已存在时直接返回已有地址,不重复上传。
+
+**示例(浏览器)**
+
+```javascript
+const formData = new FormData();
+formData.append('image', file); // File 对象
+
+const res = await fetch('/admin/upload/uploadFile', {
+  method: 'POST',
+  headers: { Authorization: `Bearer ${adminToken}` },
+  body: formData,
+});
+const json = await res.json();
+if (json.code === 0) {
+  const imageUrl = json.data.path;
+}
+```
+
+**Axios**:`Content-Type` 勿手动设 `multipart/form-data`(由浏览器带 boundary),只传 `FormData` 与 `Authorization` 即可。
+
+---
+
+## 2. 上传视频(或大图/附件)
+
+| 项目 | 说明 |
+| --- | --- |
+| 方法 | `POST` |
+| 路径 | `/admin/upload/uploadVideo` |
+| Content-Type | `multipart/form-data` |
+| 文件字段名 | `file`(必填) |
+
+**限制**
+
+| 项 | 值 |
+| --- | --- |
+| 视频格式 | `mp4`、`mov`、`avi`、`webm` |
+| 图片格式 | 同上接口亦接受 `jpg`、`jpeg`、`png`、`webp`(按扩展名存入 `images` 或 `videos` 目录) |
+| 大小 | 最大 **50MB**(51200 KB) |
+
+**成功 `data`**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `path` | string | 完整访问 URL |
+
+纯图片走本接口时,`path` 与 `uploadFile` 行为一致;视频文件落在 `storage/app/public/videos/`。
+
+---
+
+## 3. 常见错误
+
+| 场景 | 典型 `code` / `msg` |
+| --- | --- |
+| 未带或无效 Token | 鉴权相关业务码,`msg` 为未授权说明 |
+| 未传文件 / 字段名错误 | 自定义错误,`msg` 为校验文案(如 `image` 必填) |
+| 格式或超限 | `code` 多为 `-3`(`CUSTOM_ERROR`),`msg` 含校验第一条错误 |
+| 服务端未收到文件 | `101021`,`msg` 为「文件上传错误」 |
+
+---
+
+## 4. 与其它接口的配合
+
+| 业务 | 上传接口 | 保存时使用的字段 |
+| --- | --- | --- |
+| 菜单图标 | `uploadFile` | `POST /admin/menu/submit` → `icon` |
+| 充值渠道 Logo | `uploadFile` | `POST /admin/rechargeChannel/saveLogo` → `logo` |
+| 电子游戏平台图 | `uploadFile` | `POST /admin/egame/update` → `logo` / `logo_pc` / `logo_h5` / `icon` |
+| 公告图片 | `uploadFile` | 公告提交接口中对应语言 `content` 等 |
+
+保存接口一般接受 `data.path` 的完整 URL,或带 `/storage/...` 的相对路径(依各业务文档为准);展示时可直接用作 `<img src>`。
+
+---
+
+## 5. 实现说明(供联调参考)
+
+- 路由定义:`routes/admin.php` → `Route::prefix('/upload')` 下 `uploadFile`、`uploadVideo`
+- 控制器:`App\Http\Controllers\admin\Upload`(`store` / `video`)

+ 9 - 0
docs/后端/代理合营与入股-部署说明.md

@@ -13,6 +13,7 @@ php artisan migrate --force
 | `2026_11_07_120000_agent_joint_venture_and_equity.php` | 见下方明细 |
 | `2026_11_07_130000_add_agent_joint_venture_admin_menus.php` | 总后台菜单:入股配置、分成方案申请、代理充值记录、会员盈亏日报,以及保存/审核按钮。代理菜单挂在标题含「代理」的一级菜单下;找不到时挂到根节点,需要手动调整 |
 | `2026_11_08_150000_add_type_to_agent_applications.php` | `agent_applications.type`(1 合营,默认 / 2 入股):会员申请时选择的代理类型,由 melbet_sport-api 的 `POST /api/agent/apply` 写入。**需要先于 melbet 新代码执行**,否则会员提交申请会因缺少该列报错 |
+| `2026_11_08_170000_add_agent_withdrawal_channels.php` | 代理提现支持全部提现通道:`agent_withdrawals` 新增 `pay_no`、`pay_data`、`callback_data`(三方代付)。通道存在原有的 `account_type`,提现账户沿用 `agent_withdraw_accounts`(`type` = 通道,`details` 存开户行/收款码/收款方式) |
 | `2026_11_08_120000_create_agent_payment_orders.php` | 代理人民币充值:新表 `agent_payment_orders`(字段和状态码同 `payment_orders`)、`agent_settings.recharge_channel_group_id`(默认 1),以及「代理人民币充值」菜单和 4 个按钮(挂在「代理充值记录」同一个父菜单下) |
 
 `2026_11_07_120000_agent_joint_venture_and_equity.php` 的内容:
@@ -50,6 +51,14 @@ php artisan migrate --force
 - 代理通道 = `agent_settings.recharge_channel_group_id` 对应组合的充值类型 ∩ 已启用通道和支付公司。三方配置了「充值白名单用户」时代理不可用该通道。
 - 代理订单不在 `payment_orders` 里,因此不会进入会员报表、资金汇总、后台实时推送。
 
+## 代理提现通道
+
+- 通道 = `agent_settings.recharge_channel_group_id` 对应组合的提现类型 ∩ 已启用通道,账户绑定规则复用会员的 `WithdrawalDestination::resolve()`,通道列表复用 `WithdrawalDestination::describeChannels()`(会员 `withdrawChannel` 也改为调用它)。
+- 需要实名的通道(除 usdt、JDpay)使用 `agents.real_name`;为空时代理首次绑卡填写,之后只能由总后台修改。
+- 三方代付:总后台审核通过时调用 `PaymentOrderService::requestPayout()`(从会员 `createPayout()` 拆出来的三方下单部分,会员逻辑不变),付款人标识 `AG{代理ID}`,订单号沿用 `AW…`。
+- 回调仍然是 `/api/pay/notify` → `PaymentOrderService::receiveOrder()`:各家先按原逻辑查会员订单,**查不到再按订单号(JD 还会按三方单号)查代理提现**,验签和金额(`actual_amount`)校验后交给 `AgentWithdrawalService::providerResult()`:成功扣冻结、失败退回余额,只处理 `processing` 状态,重复回调无副作用。
+- USDT 和人工提现仍然人工打款。
+
 ## 实时保证金原理
 
 - `agent_profit_entries` 每条已结算注单对应一行(唯一键 `fact_id + revision`)。

+ 28 - 1
docs/总后台/代理合营与入股-前端接入.md

@@ -281,7 +281,34 @@
 
 ## 9. 代理提现
 
-`GET /admin/agent/withdrawals` 新增字段:`usdt_amount`(申请的 USDT 数量)、`actual_usdt_amount`(扣手续费后实际打款的 USDT)、`exchange_rate`(提现汇率 = 汇率 + 汇率差)。`amount`、`fee`、`actual_amount` 仍是余额单位。审核接口不变,打款仍然人工完成。
+代理提现和会员一样支持总后台配置的全部提现通道,代理使用「入股配置」里 `recharge_channel_group_id` 对应组合的**提现类型**。
+
+### `GET /admin/agent/withdrawals`
+
+新增参数 `channel`(提现通道);`status` 新增 `processing`、`failed`。
+
+字段:
+- `channel`(同 `account_type`):`usdt`、`rgtx`(人工提现)、`DF001`/`DF002`/`DF005`、`JDpay`、`NOwithdraw`、`ZIMUwithdraw`、`ZIMUcash`;
+- `status_text`;
+- `account_snapshot`:收款信息 `account`(地址/卡号/账号)、`network`、`bank_name`、`bank_branch`、`qr_image`、`payout_method`(人工提现 `bank`/`wechat`/`alipay`)、`holder_name`(收款人真实姓名);
+- `amount`(扣除的余额)、`fee`(代理手续费)、`actual_amount`(实际打款的人民币);
+- `usdt_amount`、`actual_usdt_amount`、`exchange_rate`:仅 USDT 通道有值;
+- `pay_no`、`pay_data`、`callback_data`:三方代付单号、下单返回、回调原文。
+
+`status`:`pending` 待审核、`approved` 已通过(待人工打款)、`processing` 代付中、`paid` 已打款、`rejected` 已驳回、`failed` 代付失败。
+
+### `POST /admin/agent/withdrawals/audit`
+
+参数不变(`id`、`action`、`remark`),`approve` 按通道不同:
+
+| 通道 | `approve` | 之后 |
+|---|---|---|
+| `usdt`、`rgtx` | 变为 `approved` | 人工打款后调用 `action=paid` |
+| 三方通道(DF00x、JD、NO、808) | **直接向三方发起代付**,按 `actual_amount` 打款,变为 `processing` | 三方回调成功 → `paid`;失败 → `failed` 并自动退回代理余额 |
+
+- 三方下单失败(支付公司已禁用、通道已停用、商户余额不足、三方返回错误等)时接口返回错误原因,订单保持 `pending`,可以稍后重试或驳回。
+- `reject` 只能用于 `pending`、`approved`;`processing` 的订单等三方回调结果。
+- 页面按钮:`pending` 显示「通过」「驳回」(三方通道的「通过」文案建议为「代付」);`approved` 显示「已打款」「驳回」;`processing` 只展示。
 
 ## 10. 独立代理申请审核
 

+ 1 - 0
routes/agent.php

@@ -44,6 +44,7 @@ Route::middleware('agent.auth')->group(function () {
     Route::post('/wallet/recharge/manual/image', [WalletController::class, 'submitManualImage']);
     Route::post('/wallet/recharge/manual/cancel', [WalletController::class, 'cancelManualPayment']);
     Route::get('/wallet/payment-orders', [WalletController::class, 'paymentOrders']);
+    Route::get('/wallet/withdraw/channels', [WalletController::class, 'withdrawChannels']);
     Route::get('/wallet/accounts', [WalletController::class, 'accounts']);
     Route::post('/wallet/accounts', [WalletController::class, 'saveAccount']);
     Route::post('/wallet/accounts/delete', [WalletController::class, 'deleteAccount']);

+ 1 - 0
tests/Integration/AgentApiRegressionTest.php

@@ -106,6 +106,7 @@ class AgentApiRegressionTest extends TestCase
 
     private function fundedAccount(Agent $agent): AgentWithdrawAccount
     {
+        $this->paymentChannels(); // 提现按总后台通道组合校验(组合 1 含 usdt)
         Agent::query()->whereKey($agent->id)->update(['balance' => 100, 'realtime_balance' => 100]);
         return AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
             'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);

+ 60 - 0
tests/Integration/AgentDashboardTest.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\agent\ProfileController;
+use App\Services\Agent\AgentReportService;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 代理首页 GET /agent/dashboard:只统计本代理名下会员,按日期汇总资金和投注。 */
+class AgentDashboardTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    public function test_dashboard_sums_only_own_members_within_the_date_range(): void
+    {
+        $agent = $this->agent();
+        $other = $this->agent();
+        foreach (['m1' => $agent, 'm2' => $agent, 'x1' => $other] as $memberId => $owner) {
+            DB::table('users')->insert(['member_id' => $memberId, 'username' => $memberId, 'agent_id' => $owner->id, 'created_at' => now()]);
+            DB::table('wallets')->insert(['member_id' => $memberId, 'available_balance' => 100]);
+        }
+        $in = '2026-09-07 10:00:00';
+        $out = '2026-09-01 10:00:00';
+        foreach ([['m1', 500, $in], ['m2', 300, $in], ['m1', 999, $out], ['x1', 777, $in]] as [$member, $amount, $at]) {
+            DB::table('recharges')->insert(['member_id' => $member, 'status' => 1, 'amount' => $amount, 'created_at' => $at]);
+        }
+        DB::table('recharges')->insert(['member_id' => 'm1', 'status' => 0, 'amount' => 50, 'created_at' => $in]); // 未到账
+        DB::table('payment_orders')->insert(['member_id' => 'm2', 'status' => 2, 'type' => '1', 'amount' => 200, 'created_at' => $in]);
+        DB::table('withdraws')->insert(['member_id' => 'm1', 'status' => 1, 'amount' => 150, 'created_at' => $in]);
+        foreach ([['人工充值', null, 80], ['人工扣款', null, -30], ['优惠活动', null, 20], ['返水', null, 10], ['人工充值', 0, 5]] as [$type, $related, $amount]) {
+            DB::table('balance_logs')->insert(['member_id' => 'm1', 'change_type' => $type, 'related_id' => $related, 'amount' => $amount, 'created_at' => $in]);
+        }
+
+        $request = Request::create('/agent/dashboard', 'GET', ['start_date' => '2026-09-07', 'end_date' => '2026-09-07']);
+        $request->attributes->set('agent', $agent);
+        $this->app->instance('request', $request);
+        $data = (new ProfileController())->dashboard($request, new AgentReportService())->getData(true);
+        $this->assertSame(0, $data['code'], (string) $data['msg']);
+        $summary = $data['data'];
+        $this->assertSame(2, $summary['member_count']);
+        $this->assertSame('200.0000', $summary['member_balance']);
+        $this->assertSame('1000.0000', $summary['deposit_amount'], 'recharges + paid online orders in range');
+        $this->assertSame('150.0000', $summary['withdraw_amount']);
+        $this->assertSame('80.0000', $summary['manual_credit']);
+        $this->assertSame('30.0000', $summary['manual_debit']);
+        $this->assertSame('25.0000', $summary['bonus_amount'], '优惠活动 + 人工充值(赠送)');
+        $this->assertSame('10.0000', $summary['rebate_amount']);
+        $this->assertSame('815.0000', $summary['company_profit'], '1000 - 150 - 25 - 10');
+        $this->assertSame('0.0000', $summary['commission_amount']);
+        $this->assertSame(0, $summary['agent_count']);
+        foreach (['bet_count', 'bet_amount', 'valid_bet_amount', 'win_loss'] as $field) $this->assertArrayHasKey($field, $summary);
+
+        $request = Request::create('/agent/dashboard', 'GET', ['start_date' => '2026-09-08', 'end_date' => '2026-09-07']);
+        $request->attributes->set('agent', $agent);
+        $this->assertNotSame(0, (new ProfileController())->dashboard($request, new AgentReportService())->getData(true)['code']);
+    }
+}

+ 1 - 0
tests/Integration/AgentJointVentureTest.php

@@ -198,6 +198,7 @@ class AgentJointVentureTest extends TestCase
         $this->process($this->fact($userId, '5000', 'self:lottery', '2026-09-08 01:00:00'), $agent);
         $agent = $agent->fresh();
         $this->assertSame('1000.0000', $this->wallets->withdrawable($agent));
+        $this->paymentChannels();
         $account = AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
             'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);
         $service = new AgentWithdrawalService($this->wallets);

+ 2 - 29
tests/Integration/AgentPaymentOrderTest.php

@@ -32,25 +32,7 @@ class AgentPaymentOrderTest extends TestCase
 
     private function payments(): void
     {
-        Schema::create('recharge_channel', function (Blueprint $t) {
-            $t->id(); $t->integer('from'); $t->integer('data_type'); $t->string('key');
-            $t->string('name'); $t->string('type'); $t->decimal('rate', 8, 4);
-            $t->decimal('min')->nullable(); $t->decimal('max')->nullable(); $t->string('fixed')->nullable();
-            $t->integer('status'); $t->integer('sort'); $t->timestamps();
-        });
-        Schema::create('recharge_channel_group', function (Blueprint $t) {
-            $t->id(); $t->string('name'); $t->text('recharge_type')->nullable();
-            $t->text('withdraw_type')->nullable(); $t->text('activity_type')->nullable(); $t->timestamps();
-        });
-        $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php';
-        DB::table('recharge_channel')->insert($fixture['channels']);
-        DB::table('recharge_channel_group')->insert($fixture['groups']);
-        foreach (['2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php',
-            '2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php',
-            '2026_10_28_150000_add_recharge_channel_logo.php', '2026_10_28_160000_create_recharge_method_logos.php',
-            '2026_11_08_120000_create_agent_payment_orders.php'] as $file) {
-            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
-        }
+        $this->paymentChannels();
         config(['app.url' => 'https://api.example.test', 'app.jd_pay_mch_id' => 'JD-MCH', 'app.jd_pay_key' => 'jd-secret']);
         $wallets = new AgentWalletService();
         $this->payments = new AgentPaymentService($wallets, new AgentEquityService($wallets));
@@ -222,16 +204,7 @@ class AgentPaymentOrderTest extends TestCase
     public function test_member_online_order_still_lands_in_payment_orders(): void
     {
         $this->payments();
-        Schema::drop('payment_orders');
-        Schema::create('payment_orders', function (Blueprint $t) {
-            $t->id(); $t->tinyInteger('type'); $t->string('order_no')->unique(); $t->string('member_id');
-            $t->decimal('amount', 18, 2); $t->decimal('fee', 18, 4)->default(0); $t->string('channel')->nullable();
-            $t->string('bank_name')->nullable(); $t->integer('payment_type')->nullable(); $t->tinyInteger('status')->default(0);
-            $t->string('state')->default('0'); $t->string('callback_url')->nullable(); $t->text('callback_data')->nullable();
-            $t->string('pay_no')->nullable(); $t->text('pay_url')->nullable(); $t->text('pay_data')->nullable();
-            $t->string('image')->nullable(); $t->tinyInteger('is_send')->default(0); $t->string('remark')->nullable();
-            $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps();
-        });
+        $this->fullPaymentOrders();
         $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-2', 'url' => '']]);
         $result = PaymentOrderService::createPayOrder(PaymentOrder::class, 'M1001', 100, 'JDpay', 2,
             RechargeChannel::checkRechargeChannel('JDpay', 2));

+ 175 - 0
tests/Integration/AgentWithdrawalChannelTest.php

@@ -0,0 +1,175 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentSetting;
+use App\Models\Agent\AgentWithdrawal;
+use App\Services\Agent\AgentWithdrawalService;
+use App\Services\Payment\JdPayService;
+use App\Services\PaymentOrderService;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use Illuminate\Support\Facades\DB;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 代理提现使用总后台配置的提现通道:账户绑定规则、三方代付下单与回调、人工通道。 */
+class AgentWithdrawalChannelTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    private AgentWithdrawalService $withdrawals;
+
+    private function setUpChannels(int $groupId = 2): void
+    {
+        $this->paymentChannels();
+        if (!\Illuminate\Support\Facades\Schema::hasColumn('payment_orders', 'order_no')) $this->fullPaymentOrders();
+        AgentSetting::current();
+        DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => $groupId]);
+        config(['app.url' => 'https://api.example.test', 'app.jd_pay_mch_id' => 'JD-MCH', 'app.jd_pay_key' => 'jd-secret',
+            'app.jd_remit_gateway' => 'https://jd.example.test/remit', 'app.jd_balance_gateway' => 'https://jd.example.test/balance']);
+        $this->withdrawals = new AgentWithdrawalService();
+    }
+
+    protected function tearDown(): void
+    {
+        JdPayService::$handler = null;
+        parent::tearDown();
+    }
+
+    private function funded(string $balance = '1000', float $feeRate = 1): Agent
+    {
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        Agent::query()->whereKey($agent->id)->update(['balance' => $balance, 'realtime_balance' => $balance, 'withdraw_fee_rate' => $feeRate]);
+        return $agent->fresh();
+    }
+
+    private function fakeJd(array ...$responses): \ArrayObject
+    {
+        $history = new \ArrayObject();
+        $stack = HandlerStack::create(new MockHandler(array_map(fn ($r) => new Response(200, [], json_encode($r)), $responses)));
+        $stack->push(Middleware::history($history));
+        JdPayService::$handler = $stack;
+        return $history;
+    }
+
+    private function remitCallback(AgentWithdrawal $withdrawal, string $status): array
+    {
+        $amount = JdPayService::amount($withdrawal->actual_amount);
+        $params = ['userCode' => 'JD-MCH', 'orderCode' => 'JDR-1', 'customerOrderCode' => $withdrawal->order_no, 'amount' => $amount, 'status' => $status];
+        $params['sign'] = JdPayService::signature(['JDR-1', $withdrawal->order_no, $amount, 'JD-MCH', $status], 'jd-secret');
+        return $params;
+    }
+
+    public function test_channels_follow_the_agent_group_with_member_form_flags(): void
+    {
+        $this->setUpChannels(2);
+        $result = $this->withdrawals->channels($this->agent());
+        $this->assertEqualsCanonicalizing(['rgtx', 'JDpay', 'NOwithdraw', 'ZIMUwithdraw'], array_column($result['list'], 'value'));
+        $byValue = array_column($result['list'], null, 'value');
+        $this->assertSame(['bank', 'wechat', 'alipay'], array_column($byValue['rgtx']['payout_methods'], 'value'));
+        $this->assertFalse($byValue['JDpay']['requires_real_name'], 'JD wallet pays by address only');
+        $this->assertTrue($byValue['NOwithdraw']['requires_real_name']);
+        $this->setUpChannels(1);
+        $this->assertContains('usdt', array_column($this->withdrawals->channels($this->agent())['list'], 'value'));
+    }
+
+    public function test_accounts_validate_like_member_binding_and_bind_real_name_once(): void
+    {
+        $this->setUpChannels(2);
+        $agent = $this->agent();
+        try { $this->withdrawals->saveAccount($agent, ['type' => 'usdt', 'network' => 'TRC20', 'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE']); $this->fail('usdt not in group 2'); }
+        catch (\RuntimeException $e) { $this->assertSame('不支持此提现方式', $e->getMessage()); }
+        try { $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行', 'bank_branch' => '深圳支行', 'card_no' => '6222']); $this->fail('real name'); }
+        catch (\InvalidArgumentException $e) { $this->assertSame('请输入真实姓名', $e->getMessage()); }
+        try { $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行', 'card_no' => '6222', 'real_name' => '张三']); $this->fail('branch'); }
+        catch (\InvalidArgumentException $e) { $this->assertSame('请输入开户行支行名称', $e->getMessage()); }
+        $bank = $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行',
+            'bank_branch' => '深圳支行', 'card_no' => '6222', 'real_name' => '张三']);
+        $this->assertSame(['rgtx', '6222', '工商银行', '深圳支行', 'bank', '张三'],
+            [$bank['channel'], $bank['card_no'], $bank['bank_name'], $bank['bank_branch'], $bank['payout_method'], $bank['holder_name']]);
+        $this->assertSame('张三', $agent->fresh()->real_name);
+        $this->withdrawals->saveAccount($agent, ['type' => 'NOwithdraw', 'card_no' => 'NO-1', 'real_name' => '李四']);
+        $this->assertSame('张三', $agent->fresh()->real_name, 'real name is bound once');
+        $jd = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']);
+        $this->assertSame(['JD钱包', ''], [$jd['bank_name'], $jd['holder_name']]);
+        $this->assertCount(3, $this->withdrawals->accounts($agent));
+        $this->assertCount(1, $this->withdrawals->accounts($agent, 'JDpay'));
+    }
+
+    public function test_jd_withdrawal_pays_out_through_provider_and_callback_settles_once(): void
+    {
+        $this->setUpChannels(2);
+        $agent = $this->funded('1000', 1);
+        $account = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']);
+        $withdrawal = $this->withdrawals->request($agent, $account['id'], '100.00', 'withdraw-old', 'jd-1');
+        $this->assertSame(['JDpay', '100.0000', '1.0000', '99.0000', 'pending'],
+            [$withdrawal->channel, $withdrawal->amount, $withdrawal->fee, $withdrawal->actual_amount, $withdrawal->status]);
+        $this->assertSame(['900.0000', '100.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]);
+
+        $history = $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 200, 'data' => ['orderNo' => 'JDR-1']]);
+        $approved = $this->withdrawals->audit($withdrawal->id, 'approve', 1);
+        $this->assertSame(['processing', 'JDR-1'], [$approved->status, $approved->pay_no]);
+        parse_str((string) $history[1]['request']->getBody(), $sent);
+        $this->assertSame([$withdrawal->order_no, '99.00', 'JD-ADDR'], [$sent['orderCode'], $sent['amount'], $sent['address']], 'pays the amount after the agent fee');
+        try { $this->withdrawals->audit($withdrawal->id, 'reject', 1, 'late'); $this->fail('processing cannot be rejected'); }
+        catch (\RuntimeException $e) { $this->assertSame('当前订单不能驳回', $e->getMessage()); }
+
+        $forged = $this->remitCallback($approved, JdPayService::REMIT_STATUS_SUCCESS);
+        $forged['amount'] = '100.00';
+        $this->assertFalse(PaymentOrderService::receiveOrder($forged), 'amount must match the payout');
+        $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_SUCCESS)));
+        $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_FAIL)), 'late failure is ignored');
+        $paid = $withdrawal->fresh();
+        $this->assertSame('paid', $paid->status);
+        $this->assertNotNull($paid->paid_at);
+        $this->assertSame(['900.0000', '0.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]);
+        $this->assertArrayNotHasKey('pay_data', $paid->toArray(), 'raw provider data stays admin-only');
+    }
+
+    public function test_failed_payout_refunds_and_provider_error_keeps_order_pending(): void
+    {
+        $this->setUpChannels(2);
+        $agent = $this->funded('1000', 0);
+        $account = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']);
+        $withdrawal = $this->withdrawals->request($agent, $account['id'], '200', 'withdraw-old', 'jd-2');
+
+        $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 500, 'message' => '通道维护']);
+        try { $this->withdrawals->audit($withdrawal->id, 'approve', 1); $this->fail('provider error'); }
+        catch (\RuntimeException $e) { $this->assertSame('通道维护', $e->getMessage()); }
+        $this->assertSame('pending', $withdrawal->fresh()->status);
+        $this->assertSame('200.0000', $agent->fresh()->frozen_balance);
+
+        $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 200, 'data' => ['orderNo' => 'JDR-1']]);
+        $approved = $this->withdrawals->audit($withdrawal->id, 'approve', 1);
+        $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_FAIL)));
+        $this->assertSame('failed', $withdrawal->fresh()->status);
+        $this->assertSame(['1000.0000', '0.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]);
+        $this->assertSame(1, DB::table('agent_balance_logs')->where('type', 'withdraw_refund')->count());
+    }
+
+    public function test_manual_channels_and_limits(): void
+    {
+        $this->setUpChannels(2);
+        $agent = $this->funded('100000', 0);
+        $account = $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'alipay', 'card_no' => 'ali@example.test',
+            'qr_image' => 'https://example.test/qr.png', 'real_name' => '张三']);
+        foreach (['50' => '提现金额范围为100-50000', '100.123' => '提现金额最多 2 位小数'] as $amount => $message) {
+            try { $this->withdrawals->request($agent, $account['id'], (string) $amount, 'withdraw-old', 'bad-' . $amount); $this->fail($message); }
+            catch (\Exception $e) { $this->assertSame($message, $e->getMessage()); }
+        }
+        $withdrawal = $this->withdrawals->request($agent, $account['id'], '500', 'withdraw-old', 'manual');
+        $this->assertSame('张三', $withdrawal->account_snapshot['holder_name']);
+        $this->assertSame('https://example.test/qr.png', $withdrawal->account_snapshot['qr_image']);
+        $this->assertSame('approved', $this->withdrawals->audit($withdrawal->id, 'approve', 1)->status, 'no provider call for manual payout');
+        $this->assertSame('paid', $this->withdrawals->audit($withdrawal->id, 'paid', 1)->status);
+        $this->assertSame('0.0000', $agent->fresh()->frozen_balance);
+
+        DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => 1]); // 组合 1 没有人工提现
+        $this->expectExceptionMessage('不支持此提现方式');
+        $this->withdrawals->request($agent, $account['id'], '500', 'withdraw-old', 'outside-group');
+    }
+}

+ 40 - 0
tests/Support/AgentTestEnvironment.php

@@ -126,6 +126,46 @@ trait AgentTestEnvironment
         parent::tearDown();
     }
 
+    /** 充值/提现通道(tests/fixtures 的线上快照)、支付公司开关,以及代理人民币充值、提现通道迁移。可重复调用。 */
+    private function paymentChannels(): void
+    {
+        if (Schema::hasTable('recharge_channel')) return;
+        Schema::create('recharge_channel', function (Blueprint $t) {
+            $t->id(); $t->integer('from'); $t->integer('data_type'); $t->string('key');
+            $t->string('name'); $t->string('type'); $t->decimal('rate', 8, 4);
+            $t->decimal('min')->nullable(); $t->decimal('max')->nullable(); $t->string('fixed')->nullable();
+            $t->integer('status'); $t->integer('sort'); $t->timestamps();
+        });
+        Schema::create('recharge_channel_group', function (Blueprint $t) {
+            $t->id(); $t->string('name'); $t->text('recharge_type')->nullable();
+            $t->text('withdraw_type')->nullable(); $t->text('activity_type')->nullable(); $t->timestamps();
+        });
+        $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php';
+        DB::table('recharge_channel')->insert($fixture['channels']);
+        DB::table('recharge_channel_group')->insert($fixture['groups']);
+        foreach (['2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php',
+            '2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php',
+            '2026_10_28_150000_add_recharge_channel_logo.php', '2026_10_28_160000_create_recharge_method_logos.php',
+            '2026_11_08_120000_create_agent_payment_orders.php', '2026_11_08_170000_add_agent_withdrawal_channels.php'] as $file) {
+            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
+        }
+    }
+
+    /** 用 payment_orders 的完整字段替换简化表(三方下单/回调会按 order_no、pay_no 查会员订单)。 */
+    private function fullPaymentOrders(): void
+    {
+        Schema::drop('payment_orders');
+        Schema::create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->tinyInteger('type'); $t->string('order_no')->unique(); $t->string('member_id');
+            $t->decimal('amount', 18, 2); $t->decimal('fee', 18, 4)->default(0); $t->string('channel')->nullable();
+            $t->string('bank_name')->nullable(); $t->integer('payment_type')->nullable(); $t->tinyInteger('status')->default(0);
+            $t->string('state')->default('0'); $t->string('callback_url')->nullable(); $t->text('callback_data')->nullable();
+            $t->string('pay_no')->nullable(); $t->text('pay_url')->nullable(); $t->text('pay_data')->nullable();
+            $t->string('image')->nullable(); $t->tinyInteger('is_send')->default(0); $t->string('remark')->nullable();
+            $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps();
+        });
+    }
+
     private function agent(int $type = Agent::TYPE_JOINT): Agent
     {
         static $number = 0;