doge 1 день назад
Родитель
Сommit
f63ed7f203
28 измененных файлов с 1326 добавлено и 81 удалено
  1. 5 0
      app/Console/Commands/PaymentOrder.php
  2. 6 0
      app/Console/Commands/PaymentPayTimeout.php
  3. 57 0
      app/Http/Controllers/admin/Agent.php
  4. 7 0
      app/Http/Controllers/admin/PaymentOrder.php
  5. 63 0
      app/Http/Controllers/agent/WalletController.php
  6. 7 1
      app/Models/Agent/AgentApplication.php
  7. 55 0
      app/Models/Agent/AgentPaymentOrder.php
  8. 2 1
      app/Models/Agent/AgentSetting.php
  9. 1 1
      app/Models/PaymentOrder.php
  10. 13 4
      app/Services/Agent/AgentApplicationService.php
  11. 223 0
      app/Services/Agent/AgentPaymentService.php
  12. 1 1
      app/Services/Agent/AgentWalletService.php
  13. 4 1
      app/Services/Payment/JdPayService.php
  14. 65 19
      app/Services/PaymentOrderService.php
  15. 95 0
      database/migrations/2026_11_08_120000_create_agent_payment_orders.php
  16. 25 0
      database/migrations/2026_11_08_150000_add_type_to_agent_applications.php
  17. 249 0
      docs/代理/代理充值-前端接入.md
  18. 5 33
      docs/代理/代理后台调整-前端接入.md
  19. 58 6
      docs/前端接口/独立代理申请-会员接入.md
  20. 13 0
      docs/后端/代理合营与入股-部署说明.md
  21. 49 3
      docs/总后台/代理合营与入股-前端接入.md
  22. 17 7
      docs/总后台/独立代理申请-审核接入.md
  23. 5 0
      routes/admin.php
  24. 6 0
      routes/agent.php
  25. 21 3
      tests/Integration/AgentApplicationTest.php
  26. 271 0
      tests/Integration/AgentPaymentOrderTest.php
  27. 2 0
      tests/Support/AgentTestEnvironment.php
  28. 1 1
      tests/fixtures/review_agent_application.php

+ 5 - 0
app/Console/Commands/PaymentOrder.php

@@ -37,5 +37,10 @@ class PaymentOrder extends Command
             $item->remark = '超时未处理,自动失败';
             $item->save();
         }
+        // 代理人工充值同样 24 小时未处理自动失败;迁移未执行前跳过。
+        if (!\Illuminate\Support\Facades\DB::getSchemaBuilder()->hasTable('agent_payment_orders')) return 0;
+        \App\Models\Agent\AgentPaymentOrder::query()->whereIn('status', [0, 1, 5])->where('type', 3)
+            ->where('created_at', '<', date('Y-m-d H:i:s', strtotime('-1 day')))
+            ->update(['status' => 3, 'remark' => '超时未处理,自动失败', 'updated_at' => date('Y-m-d H:i:s')]);
     }
 }

+ 6 - 0
app/Console/Commands/PaymentPayTimeout.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Models\Agent\AgentPaymentOrder;
 use App\Models\PaymentOrder;
 use App\Services\AdminRealtime\AdminRealtime;
 use App\Services\PaymentOrderService;
@@ -27,6 +28,11 @@ class PaymentPayTimeout extends Command
                 ->update(['status' => PaymentOrderService::STATUS_TIMEOUT]);
             AdminRealtime::record('payment_orders', $ids);
         } while (count($ids) === 500);
+        // 代理在线充值同样处理(不推送后台实时消息);迁移未执行前跳过。
+        if (!\Illuminate\Support\Facades\DB::getSchemaBuilder()->hasTable('agent_payment_orders')) return 0;
+        AgentPaymentOrder::query()->where('type', PaymentOrderService::TYPE_PAY)
+            ->where('status', PaymentOrderService::STATUS_PROCESS)->where('created_at', '<', $timeoutAt)
+            ->update(['status' => PaymentOrderService::STATUS_TIMEOUT, 'updated_at' => now()]);
         return 0;
     }
 }

+ 57 - 0
app/Http/Controllers/admin/Agent.php

@@ -15,6 +15,7 @@ use App\Services\Agent\AgentWalletService;
 use App\Services\Agent\AgentWithdrawalService;
 use App\Services\Agent\AgentPlanApplicationService;
 use App\Services\Agent\AgentRechargeService;
+use App\Services\Agent\AgentPaymentService;
 use App\Models\Agent\AgentSetting;
 use App\Models\Agent\AgentRecharge;
 use App\Models\Agent\AgentProfitEntry;
@@ -291,7 +292,9 @@ class Agent extends AgentApiController
                 'equity_price_per_percent' => ['required', 'numeric', 'min:0', new DecimalNumber()],
                 'equity_min_percent' => ['required', 'numeric', 'gt:0', 'max:100', new DecimalNumber(3, 4)],
                 'equity_max_percent' => ['required', 'numeric', 'gte:equity_min_percent', 'max:100', new DecimalNumber(3, 4)],
+                'recharge_channel_group_id' => ['nullable', 'integer', \Illuminate\Validation\Rule::exists('recharge_channel_group', 'id')->whereNull('deleted_at')],
             ]);
+            if (array_key_exists('recharge_channel_group_id', $data)) $data['recharge_channel_group_id'] = (int) ($data['recharge_channel_group_id'] ?: 1);
             $setting = AgentSetting::current();
             $setting->fill($data + ['updated_by' => (int) request()->user->id]);
             $setting->equity_enabled = $request->boolean('equity_enabled') ? 1 : 0;
@@ -351,6 +354,60 @@ class Agent extends AgentApiController
         });
     }
 
+    /** 代理人民币充值(在线代收 + 人工充值)。 */
+    public function paymentOrders(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'agent_id' => ['nullable', 'integer'], 'order_no' => ['nullable', 'string', 'max:64'],
+                'type' => ['nullable', 'integer', 'in:1,3'], 'channel' => ['nullable', 'string', 'max:50'],
+                'payment_type' => ['nullable', 'integer'], 'status' => ['nullable', 'integer', 'between:0,7'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            return $service->paginate($data);
+        });
+    }
+
+    public function paymentOrderPayData(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'ids' => ['required', 'array', 'min:1'], 'ids.*' => ['required', 'integer', 'min:1'],
+                'payment_type' => ['required', 'integer'], 'pay_data' => ['required', 'string'],
+            ]);
+            return ['updated' => $service->setPayData($data['ids'], (int) $data['payment_type'], $data['pay_data'])];
+        });
+    }
+
+    public function auditPaymentOrder(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'id' => ['required', 'integer'], 'status' => ['required', 'integer', 'in:2,3'],
+                'remark' => ['required_if:status,3', 'nullable', 'string', 'max:255'],
+            ]);
+            return $service->manualAudit((int) $data['id'], (int) $data['status'], (string) ($data['remark'] ?? ''), (int) request()->user->id);
+        });
+    }
+
+    public function rejectPaymentOrder(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer'], 'remark' => ['nullable', 'string', 'max:120']]);
+            return $service->reject((int) $data['id'], (string) ($data['remark'] ?? ''), (int) request()->user->id);
+        });
+    }
+
+    public function queryPaymentOrder(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer']]);
+            $result = $service->query((int) $data['id']);
+            return ['paid' => (int) ($result['code'] ?? 0) === 1, 'msg' => (string) ($result['msg'] ?? '')];
+        });
+    }
+
     public function profitEntries(Request $request)
     {
         return $this->execute(function () use ($request) {

+ 7 - 0
app/Http/Controllers/admin/PaymentOrder.php

@@ -35,6 +35,13 @@ class PaymentOrder extends Controller
         $data['rgWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 4)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         //人民币提现订单
         $data['rmbWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 2)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        // 代理充值待处理:人工充值待发送收款信息/待审核 + USDT 凭证待审核(与会员订单分开统计)。
+        // 迁移未执行前(先发代码后迁移)表不存在,捕获后跳过,正常情况下不额外查表结构。
+        try {
+            $data['agentRecharge'] = (int)\App\Models\Agent\AgentPaymentOrder::where('type', 3)->whereIn('status', [0, 5])->count()
+                + (int)\App\Models\Agent\AgentRecharge::where('type', 2)->where('status', 0)->count();
+        } catch (\Illuminate\Database\QueryException $e) {
+        }
         // 分享赚钱领取审核:所有未审核申请持续提示,不限制申请日期。
         $data['shareClaim'] = (int)DB::table('share_claims')->where('status', 'pending')->count();
         return $this->success($data);

+ 63 - 0
app/Http/Controllers/agent/WalletController.php

@@ -8,6 +8,7 @@ use App\Models\Agent\AgentWithdrawAccount;
 use App\Models\Agent\AgentWithdrawal;
 use App\Models\Agent\AgentRecharge;
 use App\Services\Agent\AgentExchangeRate;
+use App\Services\Agent\AgentPaymentService;
 use App\Services\Agent\AgentRechargeService;
 use App\Services\Agent\AgentWalletService;
 use App\Services\Agent\AgentWithdrawalService;
@@ -155,6 +156,68 @@ class WalletController extends AgentApiController
         });
     }
 
+    /** 充值通道列表(总后台配置的全部通道,含 USDT、人工充值、808/NO/JD/三斤等),格式同会员 GET /api/wallet/channel。 */
+    public function rechargeChannels(AgentPaymentService $service)
+    {
+        return $this->execute(fn () => ['list' => $service->channels()]);
+    }
+
+    /** 在线充值(三方代收)下单。 */
+    public function createPayment(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'amount' => ['required', 'numeric', 'min:0.01', new DecimalNumber(14, 2)],
+                'payment_type' => ['required', 'string', 'max:50'],
+            ]);
+            return $service->createOnline($this->currentAgent(), (string) $data['amount'], (string) $data['payment_type']);
+        });
+    }
+
+    /** 人工充值:提交金额和付款方式。 */
+    public function createManualPayment(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'amount' => ['required', 'numeric', 'min:0.01', new DecimalNumber(14, 2)],
+                'payment_type' => ['required', 'integer'],
+            ]);
+            return $service->createManual($this->currentAgent(), (string) $data['amount'], (int) $data['payment_type']);
+        });
+    }
+
+    /** 人工充值:收到收款信息后上传转账凭证。 */
+    public function submitManualImage(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer'], 'image' => ['required', 'url', 'max:1000']]);
+            return $service->submitImage($this->currentAgent(), (int) $data['id'], (string) $data['image']);
+        });
+    }
+
+    /** 人工充值:撤回待处理 / 待提交凭证的订单。 */
+    public function cancelManualPayment(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer']]);
+            return $service->cancelManual($this->currentAgent(), (int) $data['id']);
+        });
+    }
+
+    /** 人民币充值记录(在线 + 人工)。 */
+    public function paymentOrders(Request $request, AgentPaymentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'type' => ['nullable', 'integer', 'in:1,3'], 'status' => ['nullable', 'integer', 'between:0,7'],
+                'order_no' => ['nullable', 'string', 'max:64'], 'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            return $service->paginate($data, $this->currentAgent());
+        });
+    }
+
     private function pageQuery($query, array $data): array
     {
         $page = max(1, (int) ($data['page'] ?? 1));

+ 7 - 1
app/Models/Agent/AgentApplication.php

@@ -9,5 +9,11 @@ class AgentApplication extends BaseModel
     protected $table = 'agent_applications';
     protected $guarded = ['id'];
     protected $hidden = ['password_hash', 'withdraw_password_hash', 'pending_user_id', 'pending_username'];
-    protected $casts = ['user_id' => 'integer', 'agent_id' => 'integer', 'reviewed_by' => 'integer'];
+    protected $casts = ['user_id' => 'integer', 'agent_id' => 'integer', 'reviewed_by' => 'integer', 'type' => 'integer'];
+    protected $appends = ['type_text'];
+
+    public function getTypeTextAttribute(): string
+    {
+        return Agent::TYPE_NAMES[(int) ($this->attributes['type'] ?? Agent::TYPE_JOINT)] ?? '';
+    }
 }

+ 55 - 0
app/Models/Agent/AgentPaymentOrder.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Constants\Util;
+use App\Models\PaymentOrder;
+
+/**
+ * 代理人民币充值订单。字段和状态码与会员 payment_orders 一致,
+ * 所以 PaymentOrderService 的三方下单、回调、查单可以直接作用于本模型;入账走代理钱包。
+ * 订单号统一以 ag 开头,回调时据此区分会员订单和代理订单。
+ */
+class AgentPaymentOrder extends PaymentOrder
+{
+    public const ORDER_PREFIX = 'ag';
+
+    public const STATUS_TEXT = [
+        0 => '待处理', 1 => '处理中', 2 => '已到账', 3 => '失败', 4 => '待提交凭证', 5 => '待审核', 6 => '已撤回', 7 => '超时',
+    ];
+
+    protected $table = 'agent_payment_orders';
+    protected $guarded = ['id'];
+    protected $fillable = [];
+    protected $hidden = ['callback_data', 'callback_url'];
+    protected $appends = ['status_text', 'type_text'];
+    protected $casts = [
+        'agent_id' => 'integer', 'type' => 'integer', 'status' => 'integer', 'payment_type' => 'integer',
+        'amount' => 'decimal:2', 'fee' => 'decimal:4', 'is_send' => 'integer', 'audit_admin_id' => 'integer',
+    ];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+
+    public function getStatusTextAttribute(): string
+    {
+        return self::STATUS_TEXT[(int) $this->status] ?? '';
+    }
+
+    public function getTypeTextAttribute(): string
+    {
+        return (int) $this->type === 3 ? '人工充值' : '在线充值';
+    }
+
+    public function getImageAttribute($value)
+    {
+        return $value ? Util::ensureUrl($value) : '';
+    }
+
+    public function setImageAttribute($value)
+    {
+        $this->attributes['image'] = (string) Util::replacePartInUrl($value);
+    }
+}

+ 2 - 1
app/Models/Agent/AgentSetting.php

@@ -4,7 +4,7 @@ namespace App\Models\Agent;
 
 use App\Models\BaseModel;
 
-/** 代理全局配置(单行 id=1):停止分成阈值、入股价格与区间。 */
+/** 代理全局配置(单行 id=1):停止分成阈值、入股价格与区间、充值通道组合。 */
 class AgentSetting extends BaseModel
 {
     protected $table = 'agent_settings';
@@ -14,6 +14,7 @@ class AgentSetting extends BaseModel
         'margin_stop_percent' => 'decimal:4', 'equity_enabled' => 'integer',
         'equity_price_per_percent' => 'decimal:4', 'equity_min_percent' => 'decimal:4',
         'equity_max_percent' => 'decimal:4', 'updated_by' => 'integer',
+        'recharge_channel_group_id' => 'integer',
     ];
 
     public static function current(): self

+ 1 - 1
app/Models/PaymentOrder.php

@@ -24,7 +24,7 @@ class PaymentOrder extends BaseModel
     {
         parent::booted();
         static::creating(function ($order) {
-            if ((int)$order->type === 3 && $order->channel === 'rgcz' && !array_key_exists('recharge_bonus_snapshot', $order->getAttributes())) {
+            if ($order->getTable() === 'payment_orders' && (int)$order->type === 3 && $order->channel === 'rgcz' && !array_key_exists('recharge_bonus_snapshot', $order->getAttributes())) {
                 $snapshot = \App\Services\RechargeBonusService::capture('rgcz', (string)$order->member_id);
                 if ($snapshot !== null) $order->recharge_bonus_snapshot = $snapshot;
             }

+ 13 - 4
app/Services/Agent/AgentApplicationService.php

@@ -18,16 +18,22 @@ class AgentApplicationService
         $data = Validator::make($input, [
             'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'between:1,100'],
             'status' => ['nullable', 'in:pending,approved,rejected'], 'username' => ['nullable', 'string', 'max:64'],
-            'member_id' => ['nullable', 'string', 'max:64'],
+            'member_id' => ['nullable', 'string', 'max:64'], 'type' => ['nullable', 'integer', Rule::in(array_keys(Agent::TYPE_NAMES))],
         ])->validate();
         $query = DB::table('agent_applications as a')->join('users as u', 'u.id', '=', 'a.user_id');
         if (!empty($data['status'])) $query->where('a.status', $data['status']);
         if (!empty($data['username'])) $query->where('a.username', 'like', '%' . $data['username'] . '%');
         if (!empty($data['member_id'])) $query->where('u.member_id', $data['member_id']);
+        if (!empty($data['type'])) $query->where('a.type', (int) $data['type']);
         $total = (clone $query)->count();
-        $rows = $query->select('a.id', 'a.user_id', 'u.member_id', 'u.account as member_account', 'a.username',
+        $rows = $query->select('a.id', 'a.user_id', 'u.member_id', 'u.account as member_account', 'a.username', 'a.type',
             'a.status', 'a.agent_id', 'a.reject_reason', 'a.reviewed_by', 'a.reviewed_at', 'a.created_at')
-            ->orderByDesc('a.id')->forPage((int) ($data['page'] ?? 1), (int) ($data['limit'] ?? 20))->get();
+            ->orderByDesc('a.id')->forPage((int) ($data['page'] ?? 1), (int) ($data['limit'] ?? 20))->get()
+            ->map(function ($row) {
+                $row->type = (int) ($row->type ?: Agent::TYPE_JOINT);
+                $row->type_text = Agent::TYPE_NAMES[$row->type] ?? '';
+                return $row;
+            });
         return ['total' => $total, 'data' => $rows];
     }
 
@@ -53,12 +59,15 @@ class AgentApplicationService
             if ($data['status'] === 'approved') {
                 if (Agent::where('owner_user_id', $member->id)->exists()) throw new \RuntimeException('该会员已是代理');
                 if (Agent::whereRaw('LOWER(username) = ?', [strtolower($application->username)])->exists()) throw new \RuntimeException('代理账号已被使用,请驳回后重新申请');
+                // 默认按会员申请时选择的类型开通;审核时传 type 可改为另一类型。
+                $type = (int) ($data['type'] ?? ($application->type ?: Agent::TYPE_JOINT));
                 $agent = $agents->createFromApplication($application, [
-                    'type' => (int) ($data['type'] ?? Agent::TYPE_JOINT),
+                    'type' => $type,
                     'real_name' => $member->first_name ?: '', 'language' => in_array($member->language, ['zh', 'en', 'vi'], true) ? $member->language : 'zh',
                 ], (int) $actor['operator_id']);
                 $this->bindMember($agent, (int) $member->id);
                 $application->agent_id = $agent->id;
+                $application->type = $type;
             }
             $application->status = $data['status'];
             $application->reject_reason = $data['status'] === 'rejected' ? trim((string) $data['remark']) : '';

+ 223 - 0
app/Services/Agent/AgentPaymentService.php

@@ -0,0 +1,223 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentPaymentOrder;
+use App\Models\Agent\AgentSetting;
+use App\Models\RechargeChannel;
+use App\Services\Payment\JdPayService;
+use App\Services\Payment\NoPayService;
+use App\Services\Payment\PaymentProviderService;
+use App\Services\Payment\ZimuPayService;
+use App\Services\PaymentOrderService;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 代理人民币充值,和会员前台一样使用总后台配置的充值通道(recharge_channel + 通道组合 + 支付公司开关):
+ * - 在线代收(808/NO/JD/三斤等):PaymentOrderService::createPayOrder 下单,三方回调/查单复用会员逻辑,入账到代理钱包;
+ * - 人工充值 rgcz:代理提交金额 → 总后台发送收款信息 → 代理上传凭证 → 总后台审核入账;
+ * - USDT 仍走 AgentRechargeService。
+ * 代理使用 agent_settings.recharge_channel_group_id 指定的通道组合;三方的「充值白名单用户」配置了时,代理不可用该通道。
+ * 入账金额 = 订单金额(和会员一样,手续费由平台承担),合营代理计入保证金;不发充值优惠。
+ */
+class AgentPaymentService
+{
+    private const MAX_OPEN_MANUAL = 5;
+
+    public function __construct(private AgentWalletService $wallets, private AgentEquityService $equity)
+    {
+    }
+
+    public static function payerId(Agent|int $agent): string
+    {
+        return 'AG' . ($agent instanceof Agent ? $agent->id : $agent);
+    }
+
+    public function groupId(): int
+    {
+        return max(1, (int) AgentSetting::current()->recharge_channel_group_id);
+    }
+
+    /** 可用充值通道,格式同会员 GET /api/wallet/channel(不含充值优惠字段)。 */
+    public function channels(): array
+    {
+        return array_values(array_filter(RechargeChannel::getFormatChannel(1, $this->groupId()),
+            fn ($item) => $this->allowed((string) ($item['value'] ?? ''))));
+    }
+
+    /** 在线代收下单,返回支付地址和二维码。 */
+    public function createOnline(Agent $agent, string $amount, string $paymentType): array
+    {
+        if (in_array($paymentType, ['rgcz', 'usdt'], true)) throw new \InvalidArgumentException('该通道请使用对应的充值接口');
+        if (!$this->allowed($paymentType)) throw new \RuntimeException('不支持此充值方式');
+        if (!PaymentProviderService::requestEnabled($paymentType, 1)) throw new \RuntimeException('支付公司已禁用');
+        $groupId = $this->groupId();
+        $channel = RechargeChannel::checkRechargeChannel($paymentType, $groupId);
+        if ($channel === false) throw new \RuntimeException('不支持此充值方式');
+        $result = PaymentOrderService::createPayOrder(AgentPaymentOrder::class, self::payerId($agent), (float) $amount, $paymentType,
+            $groupId, $channel, ['agent_id' => $agent->id], AgentPaymentOrder::ORDER_PREFIX);
+        if ((int) $result['code'] !== 0) throw new \RuntimeException((string) ($result['text'] ?? '充值发起失败'));
+        $order = AgentPaymentOrder::query()->where('order_no', $result['order_no'])->firstOrFail()->makeHidden('pay_data');
+        return ['order' => $order, 'url' => (string) $result['url'], 'image' => (string) ($result['image'] ?? ''), 'text' => (string) ($result['text'] ?? '')];
+    }
+
+    /** 人工充值:提交金额和付款方式,等待总后台发送收款信息。 */
+    public function createManual(Agent $agent, string $amount, int $paymentType): AgentPaymentOrder
+    {
+        $channel = RechargeChannel::checkRechargeChannel('rgcz', $this->groupId());
+        if ($channel === false) throw new \RuntimeException('不支持此充值方式');
+        $min = (float) ($channel->min ?? 0);
+        $max = (float) ($channel->max ?? 0);
+        if (($min > 0 && (float) $amount < $min) || ($max > 0 && (float) $amount > $max)) {
+            throw new \RuntimeException("此充值通道充值金额{$min}-{$max},请输入区间金额");
+        }
+        return DB::transaction(function () use ($agent, $amount, $paymentType) {
+            Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            $open = AgentPaymentOrder::query()->where('agent_id', $agent->id)->where('type', PaymentOrderService::TYPE_SELF_PAY)
+                ->whereIn('status', [PaymentOrderService::STATUS_STAY, PaymentOrderService::STATUS_PROCESS,
+                    PaymentOrderService::STATUS_USER, PaymentOrderService::STATUS_AUDIT])->count();
+            if ($open >= self::MAX_OPEN_MANUAL) throw new \RuntimeException('未完成的人工充值过多,请等待处理');
+            // 订单号 = 秒级时间 + 4 位随机 + 代理 ID 后 4 位;同一代理的请求已被行锁串行,撞号时重新生成。
+            do {
+                $orderNo = PaymentOrderService::createOrderNo(AgentPaymentOrder::ORDER_PREFIX . 'rgcz_', self::payerId($agent));
+            } while (AgentPaymentOrder::query()->where('order_no', $orderNo)->exists());
+            return AgentPaymentOrder::query()->create([
+                'agent_id' => $agent->id, 'type' => PaymentOrderService::TYPE_SELF_PAY,
+                'order_no' => $orderNo,
+                'member_id' => self::payerId($agent), 'amount' => number_format((float) $amount, 2, '.', ''), 'fee' => 0,
+                'payment_type' => $paymentType, 'channel' => 'rgcz', 'status' => PaymentOrderService::STATUS_STAY, 'remark' => '',
+            ]);
+        });
+    }
+
+    /** 人工充值:总后台发送收款信息后,代理上传转账凭证。 */
+    public function submitImage(Agent $agent, int $id, string $image): AgentPaymentOrder
+    {
+        return DB::transaction(function () use ($agent, $id, $image) {
+            $order = AgentPaymentOrder::query()->where('agent_id', $agent->id)->where('type', PaymentOrderService::TYPE_SELF_PAY)
+                ->lockForUpdate()->find($id);
+            if (!$order) throw new \RuntimeException('找不到此记录');
+            if ((int) $order->status !== PaymentOrderService::STATUS_USER) throw new \RuntimeException('待处理中,请稍后');
+            $order->image = $image;
+            $order->status = PaymentOrderService::STATUS_AUDIT;
+            $order->save();
+            return $order;
+        });
+    }
+
+    /** 人工充值:总后台审核前(待处理 / 待提交凭证)代理可撤回,撤回后不占未完成名额。 */
+    public function cancelManual(Agent $agent, int $id): AgentPaymentOrder
+    {
+        return DB::transaction(function () use ($agent, $id) {
+            $order = AgentPaymentOrder::query()->where('agent_id', $agent->id)->where('type', PaymentOrderService::TYPE_SELF_PAY)
+                ->lockForUpdate()->find($id);
+            if (!$order) throw new \RuntimeException('找不到此记录');
+            if (!in_array((int) $order->status, [PaymentOrderService::STATUS_STAY, PaymentOrderService::STATUS_USER], true)) {
+                throw new \RuntimeException('当前状态不可撤回');
+            }
+            $order->status = PaymentOrderService::STATUS_CANCEL;
+            $order->save();
+            return $order;
+        });
+    }
+
+    public function paginate(array $params, ?Agent $agent = null): array
+    {
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = AgentPaymentOrder::query()->with('agent:id,username,real_name,type');
+        if ($agent) {
+            $query->where('agent_id', $agent->id);
+        } elseif (!empty($params['agent_id'])) {
+            $query->where('agent_id', (int) $params['agent_id']);
+        }
+        foreach (['order_no', 'channel', 'type', 'payment_type'] as $field) {
+            if (isset($params[$field]) && $params[$field] !== '') $query->where($field, $params[$field]);
+        }
+        if (isset($params['status']) && $params['status'] !== '') $query->where('status', (int) $params['status']);
+        if (!empty($params['start_date'])) $query->where('created_at', '>=', $params['start_date'] . ' 00:00:00');
+        if (!empty($params['end_date'])) $query->where('created_at', '<=', $params['end_date'] . ' 23:59:59');
+        $total = (clone $query)->count();
+        $list = $query->orderByDesc('id')->forPage($page, $limit)->get();
+        // 在线代收的 pay_data 是三方原始返回,不给代理看;人工充值的 pay_data 是收款信息,需要展示。
+        if ($agent) {
+            foreach ($list as $order) {
+                if ((int) $order->type === PaymentOrderService::TYPE_PAY) $order->makeHidden('pay_data');
+            }
+        }
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    /** 总后台:给待处理的人工充值发送收款信息(可批量),之后代理可上传凭证。 */
+    public function setPayData(array $ids, int $paymentType, string $payData): int
+    {
+        return AgentPaymentOrder::query()->whereIn('id', $ids)->where('type', PaymentOrderService::TYPE_SELF_PAY)
+            ->where('payment_type', $paymentType)->where('status', PaymentOrderService::STATUS_STAY)
+            ->update(['pay_data' => $payData, 'status' => PaymentOrderService::STATUS_USER, 'is_send' => 0, 'updated_at' => now()]);
+    }
+
+    /** 总后台:人工充值审核,2 通过入账,3 驳回。 */
+    public function manualAudit(int $id, int $status, string $remark, int $adminId): AgentPaymentOrder
+    {
+        return DB::transaction(function () use ($id, $status, $remark, $adminId) {
+            $order = AgentPaymentOrder::query()->where('type', PaymentOrderService::TYPE_SELF_PAY)->lockForUpdate()->find($id);
+            if (!$order) throw new \RuntimeException('数据不存在');
+            $remark = trim($remark);
+            if ($status === PaymentOrderService::STATUS_SUCCESS) {
+                if ((int) $order->status !== PaymentOrderService::STATUS_AUDIT) throw new \RuntimeException('代理未提交充值凭证');
+                $order->forceFill(['status' => $status, 'state' => '1', 'remark' => $remark, 'audit_admin_id' => $adminId])->save();
+                $this->credit($order, (string) $order->amount, 'admin', $adminId);
+            } else {
+                if (!in_array((int) $order->status, [0, 1, 4, 5], true)) throw new \RuntimeException('订单已完成,不可驳回');
+                if ($remark === '') throw new \InvalidArgumentException('请填写原因');
+                $order->forceFill(['status' => PaymentOrderService::STATUS_FAIL, 'remark' => $remark, 'audit_admin_id' => $adminId])->save();
+            }
+            return $order;
+        });
+    }
+
+    /** 总后台:拒绝未完成的在线充值,之后三方回调/查单不再入账。 */
+    public function reject(int $id, string $remark, int $adminId): AgentPaymentOrder
+    {
+        return DB::transaction(function () use ($id, $remark, $adminId) {
+            $order = AgentPaymentOrder::query()->where('type', PaymentOrderService::TYPE_PAY)->lockForUpdate()->find($id);
+            if (!$order) throw new \RuntimeException('订单不存在');
+            if (!in_array((int) $order->status, [PaymentOrderService::STATUS_STAY, PaymentOrderService::STATUS_PROCESS, PaymentOrderService::STATUS_TIMEOUT], true)) {
+                throw new \RuntimeException('订单已结束,不可拒绝');
+            }
+            $order->forceFill(['status' => PaymentOrderService::STATUS_FAIL, 'remark' => trim($remark) ?: '后台拒绝', 'audit_admin_id' => $adminId])->save();
+            return $order;
+        });
+    }
+
+    /** 总后台:向三方查单,已支付则入账。 */
+    public function query(int $id): array
+    {
+        $order = AgentPaymentOrder::query()->findOrFail($id);
+        return PaymentOrderService::queryPayOrder($order);
+    }
+
+    /**
+     * 入账到代理钱包。调用方已在事务内把订单置为成功;按订单幂等。
+     * 入股代理的待付款订单在事务提交后尝试扣款开通。
+     */
+    public function credit(AgentPaymentOrder $order, string $amount, string $operatorType, ?int $operatorId): void
+    {
+        $label = (int) $order->type === PaymentOrderService::TYPE_SELF_PAY ? '人工充值' : '在线充值';
+        $this->wallets->adjust((int) $order->agent_id, bcadd($amount, '0', 4), 'recharge',
+            trim($label . ' ' . (string) $order->bank_name) . ' ' . $order->order_no,
+            'agent-payment:' . $order->id, $operatorType, $operatorId, 'agent_payment_order', (int) $order->id);
+        $agentId = (int) $order->agent_id;
+        DB::afterCommit(fn () => $this->equity->fulfillPending($agentId));
+    }
+
+    /** 三方配置了「充值白名单用户」时只对名单内会员开放,代理不在名单内。 */
+    private function allowed(string $type): bool
+    {
+        if (JdPayService::isChannel($type)) return JdPayService::canUserRecharge(0);
+        if (NoPayService::isRechargeChannel($type)) return NoPayService::canUserRecharge(0);
+        if (ZimuPayService::isRechargeChannel($type)) return ZimuPayService::canUserRecharge(0);
+        return true;
+    }
+}

+ 1 - 1
app/Services/Agent/AgentWalletService.php

@@ -29,7 +29,7 @@ class AgentWalletService
     private array $lookups = [];
 
     public const TYPE_NAMES = [
-        'credit' => '人工加款', 'debit' => '人工扣款', 'recharge' => 'USDT充值', 'withdraw_refund' => '提现退回',
+        'credit' => '人工加款', 'debit' => '人工扣款', 'recharge' => '充值', 'withdraw_refund' => '提现退回',
         'withdraw_freeze' => '提现冻结', 'equity_purchase' => '购买股份', 'margin_credit' => '人工增加保证金',
         'margin_refund' => '退还保证金', 'profit_share' => '合营分成', 'commission' => '佣金(旧)',
     ];

+ 4 - 1
app/Services/Payment/JdPayService.php

@@ -35,11 +35,14 @@ class JdPayService extends BaseService
         return rtrim(config('app.url'), '/') . '/api/pay/notify';
     }
 
+    /** 测试可注入 Guzzle HandlerStack/MockHandler;生产为 null。 */
+    public static $handler = null;
+
     public static function getClient(): Client
     {
         return new Client([
             'timeout' => 10.0,
-        ]);
+        ] + (self::$handler ? ['handler' => self::$handler] : []));
     }
 
     public static function isChannel(?string $channel): bool

+ 65 - 19
app/Services/PaymentOrderService.php

@@ -4,6 +4,8 @@ namespace App\Services;
 
 use App\Constants\HttpStatus;
 use App\Models\PaymentOrder;
+use App\Models\Agent\AgentPaymentOrder;
+use App\Services\Agent\AgentPaymentService;
 use App\Models\RechargeChannel;
 use App\Models\User;
 use App\Models\Wallet as WalletModel;
@@ -223,6 +225,23 @@ class PaymentOrderService extends BaseService
             return $result;
         }
         $bonusSnapshot = RechargeBonusService::capture((string)$channelConfig->type, (string)$memberId);
+        $extra = $bonusSnapshot === null ? [] : ['recharge_bonus_snapshot' => $bonusSnapshot];
+        return self::createPayOrder(PaymentOrder::class, $memberId, $amount, $paymentType, $groupId, $channelConfig, $extra);
+    }
+
+    /**
+     * 按通道向三方(808/NO/JD/三斤)发起代收并保存订单。会员和代理共用:
+     * - $model:PaymentOrder(会员)或 AgentPaymentOrder(代理);
+     * - $memberId:付款人标识,会员为 member_id,代理为 AG{代理ID};
+     * - $extra:额外写入订单的字段;$orderPrefix:订单号前缀(代理为 ag)。
+     * 调用方需先校验支付公司启用、通道可用。成功时返回值带 order_no。
+     */
+    public static function createPayOrder(string $model, $memberId, $amount, $paymentType, int $groupId, $channelConfig, array $extra = [], string $orderPrefix = ''): array
+    {
+        $result = [];
+        $result['chat_id'] = $memberId;
+        $result['code'] = 0;
+        $result['url'] = '';
         $channel = ''; // 支付的通道
         $product = SanJinService::product($groupId);
         $max = (float)($channelConfig->max ?? 0);
@@ -298,11 +317,10 @@ class PaymentOrderService extends BaseService
             $data['channel'] = $channel;
             $data['fee'] = $amount * $rate;
             $data['bank_name'] = $bankName;
-            $order_no = self::createOrderNo('zm' . $data['type'] . '_', $memberId);
+            $order_no = self::createOrderNo($orderPrefix . 'zm' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['callback_url'] = ZimuPayService::getPayNotifyUrl();
             $data['remark'] = '充值费率:' . $rate;
-            if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
             $data['status'] = self::STATUS_STAY;
 
             $ret = ZimuPayService::pay($amount, $order_no, (string)$memberId);
@@ -319,7 +337,8 @@ class PaymentOrderService extends BaseService
                 $data['pay_no'] = $item['orderNo'] ?? '';
                 $data['pay_url'] = $payUrl;
                 $data['pay_data'] = json_encode($ret, JSON_UNESCAPED_UNICODE);
-                static::$MODEL::create($data);
+                $model::create($data + $extra);
+                $result['order_no'] = $order_no;
 
                 if ($payUrl) {
                     $result['image'] = asset(self::createPaymentQrCode($payUrl));
@@ -347,11 +366,10 @@ class PaymentOrderService extends BaseService
             $data['channel'] = $channel;
             $data['fee'] = $amount * $rate;
             $data['bank_name'] = $bankName;
-            $order_no = self::createOrderNo('no' . $data['type'] . '_', $memberId);
+            $order_no = self::createOrderNo($orderPrefix . 'no' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['callback_url'] = NoPayService::getDepositNotifyUrl();
             $data['remark'] = '充值费率:' . $rate;
-            if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
             $data['status'] = self::STATUS_STAY;
 
             $ret = NoPayService::pay($amount, $order_no, (string)$memberId, $channel);
@@ -367,7 +385,8 @@ class PaymentOrderService extends BaseService
                 $data['status'] = self::STATUS_PROCESS;
                 $data['pay_url'] = $payUrl;
                 $data['pay_data'] = json_encode($ret, JSON_UNESCAPED_UNICODE);
-                static::$MODEL::create($data);
+                $model::create($data + $extra);
+                $result['order_no'] = $order_no;
 
                 if ($payUrl) {
                     $result['image'] = asset(self::createPaymentQrCode($payUrl));
@@ -395,11 +414,10 @@ class PaymentOrderService extends BaseService
             $data['channel'] = $channel;
             $data['fee'] = $amount * $rate;
             $data['bank_name'] = $bankName;
-            $order_no = self::createOrderNo('jd' . $data['type'] . '_', $memberId);
+            $order_no = self::createOrderNo($orderPrefix . 'jd' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['callback_url'] = JdPayService::getNotifyUrl();
             $data['remark'] = '充值费率:' . $rate;
-            if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
             $data['status'] = self::STATUS_STAY;
 
             $ret = JdPayService::pay($amount, $order_no);
@@ -411,7 +429,8 @@ class PaymentOrderService extends BaseService
                 $data['pay_no'] = $item['orderNo'] ?? '';
                 $data['pay_url'] = $payUrl;
                 $data['pay_data'] = json_encode($ret, JSON_UNESCAPED_UNICODE);
-                static::$MODEL::create($data);
+                $model::create($data + $extra);
+                $result['order_no'] = $order_no;
 
                 if ($payUrl) {
                     $result['image'] = asset(self::createPaymentQrCode($payUrl));
@@ -438,11 +457,10 @@ class PaymentOrderService extends BaseService
         $data['channel'] = $channel;
         $data['fee'] = $amount * $rate;
         $data['bank_name'] = SanJinService::getChannel($paymentType) ?? '';
-        $order_no = self::createOrderNo('sj' . $data['type'] . '_', $memberId);
+        $order_no = self::createOrderNo($orderPrefix . 'sj' . $data['type'] . '_', $memberId);
         $data['order_no'] = $order_no;
         $data['callback_url'] = SanJinService::getNotifyUrl();
         $data['remark'] = '充值费率:' . $rate;
-        if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
         $data['status'] = self::STATUS_STAY;
         $ret = SanJinService::pay(($amount * 100), $order_no, $channel);
         Log::error('三斤支付发起:', $ret);
@@ -455,7 +473,8 @@ class PaymentOrderService extends BaseService
             $data['pay_no'] = $item['tradeNo'];
             $data['pay_url'] = $item['payUrl'];
             $data['pay_data'] = json_encode($ret, JSON_UNESCAPED_UNICODE);
-            $info = static::$MODEL::create($data);
+            $info = $model::create($data + $extra);
+            $result['order_no'] = $order_no;
             // $text = "✅ 支付提示 \n";
             $text = "{$data['bank_name']}充值确认 \n";
             // $text .= "支付方式:{$data['bank_name']} \n";
@@ -481,7 +500,7 @@ class PaymentOrderService extends BaseService
     public static function receivePay($params)
     {
         if (ZimuPayService::getAppId() !== '' && ($params['appId'] ?? '') === ZimuPayService::getAppId()) {
-            $info = self::findOne(['order_no' => $params['mchOrderNo'] ?? '']);
+            $info = self::findPayOrder((string)($params['mchOrderNo'] ?? ''));
             if (!$info || $info->type != self::TYPE_PAY || !ZimuPayService::isRechargeChannel($info->channel)) {
                 Log::error('ZIMU充值回调订单不存在或类型不匹配', [
                     'mch_order_no' => $params['mchOrderNo'] ?? '',
@@ -526,7 +545,7 @@ class PaymentOrderService extends BaseService
         }
 
         if (NoPayService::getDepositMerchantId() !== '' && ($params['appId'] ?? '') === NoPayService::getDepositMerchantId()) {
-            $info = self::findOne(['order_no' => $params['merchantOrderNo'] ?? '']);
+            $info = self::findPayOrder((string)($params['merchantOrderNo'] ?? ''));
             if (!$info || $info->type != self::TYPE_PAY || !NoPayService::isRechargeChannel($info->channel)) {
                 Log::error('NO钱包充值回调订单不存在或类型不匹配', [
                     'merchant_order_no' => $params['merchantOrderNo'] ?? '',
@@ -571,8 +590,7 @@ class PaymentOrderService extends BaseService
         }
 
         if (($params['userCode'] ?? '') === JdPayService::getMerchantId()) {
-            $info = self::findOne(['order_no' => $params['orderCode'] ?? ''])
-                ?: static::$MODEL::where('pay_no', $params['orderCode'] ?? '')->first();
+            $info = self::findPayOrder((string)($params['orderCode'] ?? ''), true);
             if (!$info || $info->type != self::TYPE_PAY) {
                 return false;
             }
@@ -603,7 +621,7 @@ class PaymentOrderService extends BaseService
             $must = ['mchId', 'productId', 'tradeNo', 'outTradeNo', 'amount', 'payAmount', 'state', 'createTime', 'payTime'];
 
 
-            $info = self::findOne(['order_no' => $params['outTradeNo']]);
+            $info = self::findPayOrder((string)($params['outTradeNo'] ?? ''));
             if ($info) {
                 // 平台以分计算转成元
                 $payAmount = $params['payAmount'] / 100;
@@ -648,10 +666,27 @@ class PaymentOrderService extends BaseService
         }
     }
 
+    /**
+     * 按订单号找在线充值订单:ag 开头的是代理订单(agent_payment_orders),其余是会员订单。
+     * JD 回调的 orderCode 可能是三方单号,$byPayNo 时再按 pay_no 查。
+     */
+    public static function findPayOrder(string $orderNo, bool $byPayNo = false): ?PaymentOrder
+    {
+        if ($orderNo === '') return null;
+        // 按 ag 前缀先查代理订单,找不到再查另一张表,避免前缀约定被打破时回调丢单。
+        $models = str_starts_with($orderNo, AgentPaymentOrder::ORDER_PREFIX)
+            ? [AgentPaymentOrder::class, PaymentOrder::class] : [PaymentOrder::class, AgentPaymentOrder::class];
+        $order = $models[0]::where('order_no', $orderNo)->first() ?: $models[1]::where('order_no', $orderNo)->first();
+        if (!$order && $byPayNo) {
+            $order = PaymentOrder::where('pay_no', $orderNo)->first() ?: AgentPaymentOrder::where('pay_no', $orderNo)->first();
+        }
+        return $order;
+    }
+
     private static function applyPayCallback(PaymentOrder $info, $payAmount, string $status, string $successStatus, ?string $failStatus, array $params): bool
     {
         return DB::transaction(function () use ($info, $payAmount, $status, $successStatus, $failStatus, $params) {
-            $order = PaymentOrder::where('id', $info->id)->lockForUpdate()->first();
+            $order = $info::query()->where('id', $info->id)->lockForUpdate()->first();
             if (!$order || (int)$order->type !== self::TYPE_PAY || !in_array((int)$order->status, self::PAY_OPEN_STATUSES, true)) {
                 return false;
             }
@@ -663,6 +698,12 @@ class PaymentOrderService extends BaseService
                 $order->status = self::STATUS_SUCCESS;
                 $order->save();
 
+                if ($order instanceof AgentPaymentOrder) {
+                    // 代理订单入代理钱包(合营计入保证金),不发充值优惠。
+                    app(AgentPaymentService::class)->credit($order, (string)$payAmount, 'system', null);
+                    return true;
+                }
+
                 $wallet = WalletModel::where('member_id', $order->member_id)->lockForUpdate()->first();
                 if (!$wallet) {
                     throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
@@ -1440,10 +1481,15 @@ class PaymentOrderService extends BaseService
      * @return {*}
      */
     public static function singlePayOrder($id)
+    {
+        return self::queryPayOrder(self::findOne(['id' => $id]));
+    }
+
+    /** 向三方查询在线充值订单并按结果入账。会员和代理订单共用。 */
+    public static function queryPayOrder(?PaymentOrder $info): array
     {
         $msg = [];
         $msg['code'] = self::NOT;
-        $info = self::findOne(['id' => $id]);
         if ($info && (int)$info->type === self::TYPE_PAY && in_array((int)$info->status, self::PAY_OPEN_STATUSES, true)) {
             if (NoPayService::isRechargeChannel($info->channel)) {
                 $ret = NoPayService::queryPayOrder($info->order_no, (string)$info->member_id);

+ 95 - 0
database/migrations/2026_11_08_120000_create_agent_payment_orders.php

@@ -0,0 +1,95 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 代理人民币充值:和会员前台一样支持后台配置的全部充值通道(808/NO/JD/三斤在线代收、人工充值)。
+ * - agent_payment_orders 字段和状态码与 payment_orders 一致,PaymentOrderService 的下单、回调、查单直接复用;
+ *   不写入 payment_orders,避免被统计成会员充值;
+ * - agent_settings.recharge_channel_group_id:代理使用的充值通道组合(默认 1);
+ * - 总后台菜单:代理人民币充值,以及发送收款信息/审核/拒绝/查单按钮。
+ */
+return new class extends Migration {
+    private const BUTTONS = [
+        'admin/agent/payment-orders/pay-data' => '代理人民币充值-发送收款信息',
+        'admin/agent/payment-orders/audit' => '代理人民币充值-审核',
+        'admin/agent/payment-orders/reject' => '代理人民币充值-拒绝',
+        'admin/agent/payment-orders/query' => '代理人民币充值-查单',
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('agent_payment_orders')) {
+            Schema::create('agent_payment_orders', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('agent_id');
+                $t->tinyInteger('type')->default(1)->comment('1在线代收 3人工充值');
+                $t->string('order_no', 64)->unique();
+                $t->string('member_id', 64)->default('')->comment('三方支付的付款人标识 AG{代理ID}');
+                $t->decimal('amount', 18, 2);
+                $t->decimal('fee', 18, 4)->default(0);
+                $t->string('channel', 50)->nullable();
+                $t->string('bank_name', 100)->nullable();
+                $t->integer('payment_type')->nullable()->comment('人工充值:付款方式');
+                $t->tinyInteger('status')->default(0)->comment('同 payment_orders:0待处理 1处理中 2成功 3失败 4待提交凭证 5待审核 6已撤回 7超时');
+                $t->string('state', 32)->default('0');
+                $t->string('callback_url', 255)->nullable();
+                $t->text('callback_data')->nullable();
+                $t->string('pay_no')->nullable()->index();
+                $t->text('pay_url')->nullable();
+                $t->text('pay_data')->nullable();
+                $t->string('image', 1000)->nullable();
+                $t->tinyInteger('is_send')->default(0);
+                $t->string('remark', 255)->nullable();
+                $t->unsignedBigInteger('audit_admin_id')->nullable();
+                $t->timestamps();
+                $t->index(['agent_id', 'created_at']);
+                $t->index(['type', 'status']);
+            });
+        }
+        if (!Schema::hasColumn('agent_settings', 'recharge_channel_group_id')) {
+            Schema::table('agent_settings', fn (Blueprint $t) => $t->unsignedBigInteger('recharge_channel_group_id')->default(1));
+        }
+        $this->menus();
+    }
+
+    private function menus(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $now = date('Y-m-d H:i:s');
+        $parentId = (int) DB::table('menus')->where('uri', 'agentRecharges')->where('type', 1)->value('parent_id');
+        $menuId = DB::table('menus')->where('uri', 'agentPaymentOrders')->where('type', 1)->value('id');
+        if (!$menuId) {
+            $menuId = DB::table('menus')->insertGetId([
+                'parent_id' => $parentId, 'title' => '代理人民币充值', 'icon' => null, 'uri' => 'agentPaymentOrders',
+                'permission_name' => 'agent.agentPaymentOrders', 'sort' => 23, 'status' => 1, 'type' => 1,
+                'created_at' => $now, 'updated_at' => $now,
+            ]);
+        }
+        $sort = 0;
+        foreach (self::BUTTONS as $uri => $title) {
+            if (!DB::table('menus')->where('uri', $uri)->exists()) {
+                DB::table('menus')->insert(['parent_id' => $menuId, 'title' => $title, 'icon' => null, 'uri' => $uri,
+                    'permission_name' => str_replace('/', '.', $uri), 'sort' => $sort, 'status' => 1, 'type' => 2,
+                    'created_at' => $now, 'updated_at' => $now]);
+            }
+            $sort++;
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasTable('menus')) {
+            $ids = DB::table('menus')->whereIn('uri', array_merge(['agentPaymentOrders'], array_keys(self::BUTTONS)))->pluck('id');
+            if (Schema::hasTable('role_menu')) DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+            DB::table('menus')->whereIn('id', $ids)->delete();
+        }
+        if (Schema::hasColumn('agent_settings', 'recharge_channel_group_id')) {
+            Schema::table('agent_settings', fn (Blueprint $t) => $t->dropColumn('recharge_channel_group_id'));
+        }
+        Schema::dropIfExists('agent_payment_orders');
+    }
+};

+ 25 - 0
database/migrations/2026_11_08_150000_add_type_to_agent_applications.php

@@ -0,0 +1,25 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 会员申请独立代理时选择代理类型:1 合营(默认,历史申请均为合营)、2 入股。
+ * 申请由 PC 前台(melbet_sport-api)写入,总后台审核通过时按此类型开通代理。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (Schema::hasTable('agent_applications') && !Schema::hasColumn('agent_applications', 'type')) {
+            Schema::table('agent_applications', fn (Blueprint $t) => $t->unsignedTinyInteger('type')->default(1)->comment('1合营 2入股'));
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('agent_applications', 'type')) {
+            Schema::table('agent_applications', fn (Blueprint $t) => $t->dropColumn('type'));
+        }
+    }
+};

+ 249 - 0
docs/代理/代理充值-前端接入.md

@@ -0,0 +1,249 @@
+# 代理后台-充值(前端接入)
+
+给代理独立后台前端看,是充值页的完整说明。
+
+代理充值和会员前台一样,可以使用总后台「充值通道」里配置的全部通道:
+- **USDT**:链上转账;
+- **人工充值**(`rgcz`):总后台人工发送收款信息,代理转账后上传凭证,总后台审核入账;
+- **在线充值**:808(`ZIMUpay`)、NO(`NOpay12` / `NOpay13`)、JD(`JDpay`)、三斤扫码(`wxsm` 等),三方回调自动到账。
+
+请求头带 `Authorization: Bearer <agent_token>`。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。
+
+## 0. 本次改动
+
+| 接口 | 改动 |
+|---|---|
+| `GET /agent/wallet/recharge/channels` | **新增**:可用充值通道列表 |
+| `POST /agent/wallet/recharge/online` | **新增**:在线充值下单 |
+| `POST /agent/wallet/recharge/manual` | **新增**:人工充值提交金额 |
+| `POST /agent/wallet/recharge/manual/image` | **新增**:人工充值上传转账凭证 |
+| `POST /agent/wallet/recharge/manual/cancel` | **新增**:撤回人工充值 |
+| `GET /agent/wallet/payment-orders` | **新增**:人民币充值记录(在线 + 人工) |
+| `GET /agent/wallet/recharge/address` | 不变:USDT 充值地址 |
+| `POST /agent/wallet/recharge` | 不变:USDT 人工确认模式提交凭证 |
+| `GET /agent/wallet/recharges` | 不变:USDT 充值记录 |
+| `GET /agent/wallet/logs` | 流水类型 `recharge` 的名称改为「充值」,USDT、在线、人工充值都用这个类型 |
+
+## 1. 入账规则(页面文案需要用到)
+
+- 人民币充值(在线、人工)按**订单金额**入账,和会员一样不扣手续费,没有充值优惠。
+- USDT 充值按充值汇率折算成余额:入账金额 = USDT 数量 × `exchange_rate`。
+- **合营代理**:充值全部计入保证金,不能提现(只有超出保证金的盈利可以提现)。
+- **入股代理**:有待付款的入股订单时,充值到账后自动扣款开通股份。
+
+## 2. 充值页流程
+
+```
+GET /agent/wallet/recharge/channels  →  渲染通道
+  ├─ value = usdt  → GET /agent/wallet/recharge/address
+  │                    mode=1 自动到账:展示地址/二维码,转账后自动入账
+  │                    mode=2 人工确认:转账后 POST /agent/wallet/recharge 提交凭证
+  ├─ value = rgcz  → POST /agent/wallet/recharge/manual
+  │                    → 等总后台发送收款信息(订单 status=4,pay_data 是收款信息)
+  │                    → 转账后 POST /agent/wallet/recharge/manual/image 上传凭证 → 等审核
+  └─ 其他 value    → POST /agent/wallet/recharge/online
+                       → 打开返回的 url 或展示二维码 image 付款 → 三方回调自动入账
+```
+
+记录页建议分两个标签:「人民币充值」用 `GET /agent/wallet/payment-orders`,「USDT 充值」用 `GET /agent/wallet/recharges`。
+
+## 3. 充值通道
+
+### `GET /agent/wallet/recharge/channels`
+
+无参数。返回 `{"list":[…]}`,每项格式和会员前台 `GET /api/wallet/channel` 相同:
+
+```json
+{"list":[
+  {"key":0,"label":"USDT","value":"usdt","logo":"https://…/usdt.png","config":{"id":1,"type":"usdt","min":"10.00","max":"500000.00","fixed":null,"rate":"0.0000"}},
+  {"key":1,"label":"人工充值","value":"rgcz","logo":"…","config":{"id":15,"type":"rgcz","min":"10.00","max":"500000.00","fixed":null,"rate":"0.0000"}},
+  {"key":2,"label":"JD钱包","value":"JDpay","logo":"…","config":{"id":24,"type":"JDpay","min":"1.00","max":"49999.00","fixed":"","rate":"0.0000"}}
+]}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `value` | 通道类型。在线充值下单时作为 `payment_type` 传入 |
+| `label` / `logo` | 通道名称、图标 |
+| `config.min` / `config.max` | 金额区间(人民币) |
+| `config.fixed` | 固定金额通道(如 `zfbge`)的可选金额,只能选这些金额;其他通道为空 |
+| `config.rate` | 费率(平台承担,代理到账不扣) |
+| `config.range` | 同一类型有多个档位时出现,列出每档的 `min`/`max`/`rate`;此时 `config.min`/`max` 是所有档位的总区间 |
+
+可用通道由总后台决定(「入股配置」里选择的**通道组合** ∩ 已启用的通道和支付公司)。支付公司被禁用,或三方配置了「充值白名单用户」时,对应通道不会出现。列表为空时显示「暂无可用充值方式」。
+
+## 4. 在线充值
+
+### `POST /agent/wallet/recharge/online`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `amount` | 是 | 金额(人民币),最多 2 位小数,需在通道 `config.min`~`config.max` 之间;固定金额通道只能选 `config.fixed` 里的金额 |
+| `payment_type` | 是 | 通道的 `value`,不能是 `usdt` / `rgcz` |
+
+返回:
+
+```json
+{
+  "order": {"id":8,"order_no":"agjd1_20261108120000123400012","type":1,"type_text":"在线充值","amount":"100.00","fee":"0.0000",
+            "channel":"JDpay","bank_name":"JD钱包","status":1,"status_text":"处理中","pay_url":"https://pay.example/…",
+            "created_at":"2026-11-08 12:00:00"},
+  "url": "https://pay.example/…",
+  "image": "https://api.example/…/qr.png",
+  "text": "JD钱包充值确认 …"
+}
+```
+
+- 打开 `url`(新窗口)或展示二维码 `image` 付款;`image` 可能为空,为空时只展示链接。
+- 支付成功后三方回调自动入账,**不需要再提交任何东西**。可以引导到充值记录页查看状态。
+- 订单 30 分钟未支付变为 `7` 超时;超时后再支付成功仍会入账。
+- 请按订单金额付款,金额不符可能无法自动入账。
+
+常见错误:
+
+| `msg` | 原因 |
+|---|---|
+| `该通道请使用对应的充值接口` | `payment_type` 传了 `usdt` 或 `rgcz` |
+| `不支持此充值方式` | 通道不在代理可用列表里 |
+| `支付公司已禁用` | 总后台关闭了该支付公司 |
+| `❌ 此充值通道充值金额10-49999请务必输入区间金额!` | 金额不在区间内(数字随通道变化) |
+| 其他 | 三方返回的下单失败原因,直接展示 |
+
+## 5. 人工充值
+
+流程:提交金额 → 总后台发送收款信息 → 按收款信息转账 → 上传凭证 → 总后台审核入账。
+
+### `POST /agent/wallet/recharge/manual`(提交金额)
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `amount` | 是 | 金额(人民币),最多 2 位小数,需在人工充值通道(`value=rgcz`)的 `config.min`~`config.max` 之间 |
+| `payment_type` | 是 | 付款方式(整数),取值和会员前台人工充值页一致(如支付宝、微信、银行卡),总后台按付款方式发送对应的收款信息 |
+
+返回订单,`status=0`(待处理),提示「已提交,请等待平台发送收款信息」。
+
+- 最多同时 5 笔未完成的人工充值(状态 `0`/`1`/`4`/`5`),超出返回 `未完成的人工充值过多,请等待处理`。
+- 金额超出区间返回 `此充值通道充值金额10-500000,请输入区间金额`。
+- 通道组合里没有人工充值时返回 `不支持此充值方式`。
+
+### 等待收款信息
+
+轮询或刷新 `GET /agent/wallet/payment-orders`(`type=3`)。订单变成 `status=4`(待提交凭证)后,`pay_data` 是总后台发来的收款信息(文本,可能包含换行),展示给代理按此转账。
+
+### `POST /agent/wallet/recharge/manual/image`(上传凭证)
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 订单 ID |
+| `image` | 是 | 转账截图 URL(先用现有上传接口上传图片) |
+
+提交后 `status=5`(待审核)。不是 `status=4` 时调用返回 `待处理中,请稍后`。
+
+### `POST /agent/wallet/recharge/manual/cancel`(撤回)
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 订单 ID |
+
+只有 `status=0`(待处理)和 `status=4`(待提交凭证)可以撤回,撤回后 `status=6`(已撤回),不再占 5 笔名额。已上传凭证的订单不能撤回,返回 `当前状态不可撤回`。
+
+### 超时
+
+- 待处理(`0`)、待审核(`5`)24 小时未处理自动失败(`3`,`remark` 为「超时未处理,自动失败」)。
+- 待提交凭证(`4`)**不会**自动失败。不打算付款时请提示代理撤回,否则会一直占名额。
+
+### 按钮建议
+
+| `status` | 展示 | 操作按钮 |
+|---|---|---|
+| `0` 待处理 | 等待平台发送收款信息 | 撤回 |
+| `4` 待提交凭证 | 展示 `pay_data` 收款信息 | 上传凭证、撤回 |
+| `5` 待审核 | 展示已上传的 `image` | — |
+| `2` 已到账 | | — |
+| `3` 失败 | 展示 `remark` 原因 | — |
+| `6` 已撤回 | | — |
+
+## 6. 人民币充值记录
+
+### `GET /agent/wallet/payment-orders`
+
+| 参数 | 说明 |
+|---|---|
+| `page` / `limit` | 默认 1 / 20,`limit` 最大 100 |
+| `type` | `1` 在线充值 / `3` 人工充值,不传为全部 |
+| `status` | 见下表 |
+| `order_no` | 订单号 |
+| `start_date` / `end_date` | `Y-m-d`,按创建时间 |
+
+返回 `{"total":…, "page":…, "limit":…, "list":[…]}`。`list` 每项:
+
+| 字段 | 说明 |
+|---|---|
+| `id` | 订单 ID(上传凭证、撤回时使用) |
+| `order_no` | 订单号 |
+| `type` / `type_text` | `1` 在线充值 / `3` 人工充值 |
+| `amount` | 金额(人民币,2 位小数),即入账金额 |
+| `fee` | 通道手续费(平台承担,仅展示用,可不展示) |
+| `channel` / `bank_name` | 通道类型、通道名称(人工充值 `channel=rgcz`) |
+| `payment_type` | 人工充值的付款方式 |
+| `status` / `status_text` | 状态 |
+| `pay_url` | 在线充值的支付地址(未支付时可以再次打开) |
+| `pay_data` | **仅人工充值返回**:收款信息;在线充值不返回此字段 |
+| `image` | 人工充值的转账凭证 |
+| `remark` | 失败、驳回原因 |
+| `created_at` / `updated_at` | |
+
+`status`:
+
+| 值 | 名称 | 适用 |
+|---:|---|---|
+| `0` | 待处理 | 人工 |
+| `1` | 处理中(待支付) | 在线 |
+| `2` | 已到账 | 全部 |
+| `3` | 失败 | 全部 |
+| `4` | 待提交凭证 | 人工 |
+| `5` | 待审核 | 人工 |
+| `6` | 已撤回 | 人工 |
+| `7` | 超时(仍可到账) | 在线 |
+
+## 7. USDT 充值(不变,列出便于对照)
+
+### `GET /agent/wallet/recharge/address`
+
+参数 `net`:`trc20`(默认)或 `erc20`。
+
+```json
+{"mode":1,"net":"TRC20","address":"TXyz…","qrcode":"https://…/qr.png","exchange_rate":"7.2000",
+ "public_chain":"…","energy_amount":"65000","energy_trx":"13"}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `mode` | `1` 自动到账:向 `address` 转账即可,链上确认后自动入账,**不需要提交凭证**;`2` 人工确认:转账后调用下面的凭证接口 |
+| `net` / `address` / `qrcode` | 公链、收款地址、二维码图片 |
+| `exchange_rate` | 充值汇率,1 USDT 折合多少余额 |
+| `public_chain`、`energy_amount`、`energy_trx` | 和会员前台充值页相同的提示配置 |
+
+- 自动到账模式只支持 TRC20,`net=erc20` 返回 `自动到账仅支持 TRC20`。地址固定不变,直接转账也会到账(最长约 10 分钟发现)。
+- 人工确认模式下平台没配置地址时返回 `平台暂未配置收款地址`。
+
+### `POST /agent/wallet/recharge`(仅人工确认模式)
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `net` | 是 | `trc20` / `erc20` |
+| `usdt_amount` | 是 | 转账的 USDT 数量,最多 4 位小数 |
+| `to_address` | 是 | 转入的平台地址(充值地址接口返回的 `address`) |
+| `image` | 是 | 转账截图 URL |
+
+总后台审核通过后入账。错误:`当前为自动到账模式,转账后无需提交凭证`、`收款地址不是平台当前的收款地址`、`待确认的充值过多,请等待审核`(最多 5 条待确认)。
+
+### `GET /agent/wallet/recharges`(USDT 充值记录)
+
+参数:`page`、`limit`、`status`(`0` 待确认 / `1` 已到账 / `2` 已驳回 / `3` 已忽略)、`start_date`、`end_date`。
+
+字段:`order_no`、`type`(`1` 自动 / `2` 凭证)、`txid`、`net`、`to_address`、`usdt_amount`、`exchange_rate`、`amount`(入账金额)、`status`、`status_text`、`remark`(驳回原因)、`confirmed_at`、`created_at`。
+
+## 8. 资金流水
+
+`GET /agent/wallet/logs?type=recharge` 查看全部充值入账,`remark` 里带通道名称和订单号,例如 `在线充值 JD钱包 agjd1_…`、`人工充值 agrgcz_…`。

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

@@ -9,7 +9,7 @@
 | 模块 | 改动 |
 |---|---|
 | 登录 / 个人信息 | 新增代理类型(合营/入股)、平台邀请地址、余额构成、方案或持股比例;去掉层级字段 |
-| 资金账户 | 新增余额构成接口;**新增 USDT 充值**;提现**只支持 USDT**,按 USDT 数量提交 |
+| 资金账户 | 新增余额构成接口;**新增充值:和会员前台一样支持后台配置的全部通道**(USDT、人工充值、808、NO、JD、三斤等),见 [代理充值-前端接入.md](代理充值-前端接入.md);提现**只支持 USDT**,按 USDT 数量提交 |
 | 所有会员 | 列表字段改为和总后台会员列表一致;不提供充值、提现、冻结、解冻 |
 | 会员盈亏日报(新) | 每个会员每天一行 |
 | 分成方案(新) | 合营代理选择总后台配置的方案并提交申请 |
@@ -26,7 +26,7 @@
   - 方案要求的保证金交足才开始分成。保证金被亏到方案要求的一定比例以下(默认 10%)时暂停分成,需要再充值到方案保证金的 100% 才恢复。只展示「分成中/已暂停」,不展示实时保证金的具体数值。
   - 分成逐笔实时计算,每天 04:10 入账前一天的分成。当天未入账的亏损会预留,不能提现。
 - **入股**:按比例购买股份。余额够就立即开通;不够就生成待付款订单,充值到账后自动扣款开通。分红每月由平台人工发放,到账后计入余额,可以提现。
-- 余额单位和会员钱包相同。充值按 `exchange_rate_rmb` 把 USDT 折算进余额;提现按「汇率 + 汇率差」把余额折算成 USDT。
+- 余额单位和会员钱包相同(人民币)。人民币充值按订单金额入账;USDT 充值按 `exchange_rate_rmb` 折算进余额;提现按「汇率 + 汇率差」把余额折算成 USDT。
 
 ## 1. 登录与个人信息
 
@@ -80,37 +80,9 @@
 - `recharge_exchange_rate`:充值汇率,1 USDT 折合多少余额
 - `withdraw_exchange_rate`:提现汇率
 
-### `GET /agent/wallet/recharge/address`(新增)
+### 充值
 
-参数 `net`:`trc20`(默认)或 `erc20`。
-
-| 字段 | 说明 |
-|---|---|
-| `mode` | `1` 自动到账:向 `address` 转账即可,链上确认后自动入账,**不需要提交凭证**;`2` 人工确认:转账后调用下面的凭证接口 |
-| `net`、`address`、`qrcode` | 公链、收款地址、二维码图片地址 |
-| `exchange_rate` | 充值汇率 |
-| `public_chain`、`energy_amount`、`energy_trx` | 和会员前台充值页相同的提示配置 |
-
-自动到账模式只支持 TRC20,`net=erc20` 会返回错误。地址固定不变,不打开充值页直接转账也会到账(最长约 10 分钟发现)。
-
-### `POST /agent/wallet/recharge`(新增,仅人工确认模式)
-
-| 参数 | 必填 | 说明 |
-|---|---:|---|
-| `net` | 是 | `trc20` / `erc20` |
-| `usdt_amount` | 是 | 转账的 USDT 数量 |
-| `to_address` | 是 | 转入的平台地址 |
-| `image` | 是 | 转账截图 URL |
-
-总后台审核通过后入账。自动到账模式下调用会返回「当前为自动到账模式,转账后无需提交凭证」。
-
-校验:`to_address` 必须是充值地址接口返回的平台地址,否则返回「收款地址不是平台当前的收款地址」;同时最多 5 条待确认,超出返回「待确认的充值过多,请等待审核」。
-
-### `GET /agent/wallet/recharges`(新增)
-
-参数:`page`、`limit`、`status`(`0` 待确认 / `1` 已到账 / `2` 已驳回 / `3` 已忽略)、`start_date`、`end_date`。
-
-字段:`order_no`、`type`(`1` 自动 / `2` 凭证)、`txid`、`net`、`to_address`、`usdt_amount`、`exchange_rate`、`amount`(入账金额)、`status`、`status_text`、`remark`(驳回原因)、`confirmed_at`、`created_at`。
+充值页(USDT、人工充值、808/NO/JD/三斤在线充值,以及充值记录)的完整说明见 **[代理充值-前端接入.md](代理充值-前端接入.md)**。
 
 ### 提现账户:只支持 USDT
 
@@ -138,7 +110,7 @@
 
 | 值 | 名称 |
 |---|---|
-| `recharge` | USDT充值 |
+| `recharge` | 充值(USDT、在线、人工充值都是这个类型,`remark` 里有订单号) |
 | `profit_share` | 合营分成(可为负) |
 | `equity_purchase` | 购买股份 |
 | `withdraw_freeze` | 提现冻结 |

+ 58 - 6
docs/前端接口/独立代理申请-会员接入.md

@@ -1,8 +1,23 @@
 # 独立代理申请-会员端接入
 
-沿用会员登录态和现有请求封装,成功为 `code=1`,业务数据在 `data`。
+沿用会员登录态和现有请求封装,成功为 `code=1`,业务数据在 `data`;失败为 `code=0`,`msg` 可以直接提示。
 
-## 1. 查询状态
+> **本次变更(代理类型)**:申请时可以选择代理类型 **合营** 或 **入股**。
+> - `POST /api/agent/apply` 新增参数 `type`;
+> - `GET /api/agent/status`(以及申请成功的返回)新增 `type`、`type_text`、`type_options`。
+>
+> 其他字段和流程不变。
+
+## 1. 代理类型
+
+| `type` | 名称 | 说明(可用于页面上的类型介绍) |
+|---:|---|---|
+| `1` | 合营 | 交保证金,按游戏分类比例和平台双向分成:会员输,代理按比例分得;会员赢,代理按比例承担。开通后在代理后台充值保证金并申请分成方案。 |
+| `2` | 入股 | 在代理后台按比例购买股份,平台按月人工分红。 |
+
+入股需要总后台在「入股配置」里开启后才能申请,没开启时 `type_options` 里入股的 `enabled` 为 `false`。
+
+## 2. 查询状态
 
 `GET /api/agent/status`
 
@@ -17,19 +32,56 @@
 | `reject_reason` | 驳回原因 |
 | `can_apply` | 是否可显示申请按钮 |
 | `applied_at` / `reviewed_at` | 申请时间、审核时间,没有时为 `null` |
+| `type` | **新增**。已是代理时为代理的实际类型;否则为最近一次申请选择的类型;从未申请为 `null`。取值见上表 |
+| `type_text` | **新增**。`合营` / `入股`,`type` 为 `null` 时为空字符串 |
+| `type_options` | **新增**。申请表单的类型选项,按顺序渲染 |
+
+`type_options` 示例:
+
+```json
+[
+  {"value":1,"label":"合营","enabled":true},
+  {"value":2,"label":"入股","enabled":false}
+]
+```
 
-优先按 `is_agent` 显示代理状态。已有代理正常时显示代理后台入口,停用时显示“账号已停用”;待审核时显示“审核中”;驳回时显示原因并允许重新申请。
+`enabled=false` 的选项置灰不可选(或直接隐藏)。默认选中 `1` 合营。
 
-## 2. 提交申请
+页面状态:
+- 优先按 `is_agent` 显示代理状态。已有代理正常时显示代理后台入口,停用时显示“账号已停用”;可以同时展示 `type_text`(如“入股代理”)。
+- 待审核时显示“审核中”和申请的类型 `type_text`。
+- 驳回时显示原因并允许重新申请,重新申请可以换类型(表单默认选中上次的 `type`,该选项不可用时选中合营)。
+
+注意:审核时后台可以调整类型,审核通过后以 `type` 返回的实际类型为准。
+
+## 3. 提交申请
 
 `POST /api/agent/apply`
 
 ```json
-{"username":"myagent","password":"Login123!","withdraw_password":"Withdraw123!"}
+{"username":"myagent","password":"Login123!","withdraw_password":"Withdraw123!","type":2}
 ```
 
-三项必填:`username` 为代理账号,`password` 为登录密码,`withdraw_password` 为提现密码。账号支持 4~64 位字母、数字、下划线和短横线,不区分大小写;两个密码均为 6~72 位。
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `username` | 是 | 代理账号,4~64 位字母、数字、下划线和短横线,不区分大小写 |
+| `password` | 是 | 代理后台登录密码,6~72 位 |
+| `withdraw_password` | 是 | 代理提现密码,6~72 位 |
+| `type` | 否(新页面请传) | **新增**。代理类型:`1` 合营、`2` 入股。数字或数字字符串均可;不传按 `1` 合营处理(兼容旧页面) |
 
 成功后返回与状态接口相同的数据,页面切换为“审核中”。审核期间不能重复申请;驳回后可重新提交。代理账号使用申请时设置的密码登录代理后台。
 
+新增的错误提示:
+
+| `msg` | 原因 |
+|---|---|
+| `代理类型错误` | `type` 不是 `1` 或 `2` |
+| `暂未开放入股代理申请` | 选了入股,但总后台没开启入股(`type_options` 里入股 `enabled=false`) |
+
 审核通过或驳回后会自动收到站内信,沿用 `GET /api/message/lists?category=internal` 查看;页面重新进入时刷新申请状态即可。
+
+## 4. 前端需要修改的地方
+
+1. 申请表单增加“代理类型”单选:选项来自 `GET /api/agent/status` 的 `type_options`,`enabled=false` 置灰;默认选中合营;可以配上第 1 节的类型说明。
+2. 提交 `POST /api/agent/apply` 时带上 `type`。
+3. 审核中 / 已开通的状态展示 `type_text`。

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

@@ -12,6 +12,8 @@ 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_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` 的内容:
 
@@ -37,6 +39,17 @@ php artisan migrate --force
 
 代理的 USDT 自动到账复用现有的外部 cron `GET /admin/sync/recharge`:会员同步完成后会接着同步代理。代理打开充值页后 2 小时内每次都查;另外每 10 分钟把所有代理地址全部扫一遍,代理不打开充值页直接转账也能到账。另外可以用 `php artisan agent:recharge-sync {agent_id?}` 手动补单。代理充值地址的私钥存放在 `agent_wallets`,归集任务(`/admin/sync/collect`)在 `wallets` 里找不到地址时会回退查这张表。
 
+## 代理人民币充值
+
+代理和会员一样支持总后台配置的全部充值通道。实现上复用会员的支付代码,不另写三方对接:
+
+- `PaymentOrderService::createPayOrder()` 是从 `createPay()` 拆出来的下单部分,按模型写入订单:会员写 `payment_orders`,代理写 `agent_payment_orders`。代理订单号加 `ag` 前缀,付款人标识为 `AG{代理ID}`。
+- 三方回调仍然是 `/api/pay/harvest`。`PaymentOrderService::findPayOrder()` 按 `ag` 前缀先查代理表、找不到再查会员表(反之亦然),验签、金额校验、状态判断都和会员完全一样;入账时代理订单走 `AgentPaymentService::credit()`(代理钱包 `recharge`,按订单幂等),不改会员钱包、不发充值优惠、不发 Telegram 通知。
+- 查单:`PaymentOrderService::queryPayOrder()`,会员的 `singlePayOrder()` 也改为调用它。
+- `payment:pay-timeout`(30 分钟超时)和 `payment:order`(人工充值状态 0/1/5 24 小时自动失败)同时处理代理订单。待提交凭证(4)和会员一样不自动失败,代理可以撤回(`POST /agent/wallet/recharge/manual/cancel`,状态 6)释放 5 笔未完成名额。
+- 代理通道 = `agent_settings.recharge_channel_group_id` 对应组合的充值类型 ∩ 已启用通道和支付公司。三方配置了「充值白名单用户」时代理不可用该通道。
+- 代理订单不在 `payment_orders` 里,因此不会进入会员报表、资金汇总、后台实时推送。
+
 ## 实时保证金原理
 
 - `agent_profit_entries` 每条已结算注单对应一行(唯一键 `fact_id + revision`)。

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

@@ -15,13 +15,14 @@
 | 入股配置(新菜单) | 设置每 1% 股份的价格、可购买区间,以及合营停止分成的阈值 |
 | 分成方案申请(新菜单) | 审核合营代理提交的方案申请 |
 | 代理充值记录(新菜单) | 代理 USDT 充值列表;人工确认模式下审核凭证 |
+| 代理人民币充值(新菜单) | 代理在线充值(808/NO/JD/三斤等)和人工充值;发送收款信息、审核、拒绝、查单 |
 | 代理返佣记录 | 改为「合营分成记录」:只有盈亏分成,金额可为负;新增分成注单明细 |
 | 代理提现列表 | 仅 USDT,新增 USDT 数量和汇率 |
 | 会员列表 → 会员盈亏日报(新菜单) | 每个会员每天一行,可按代理和代理类型筛选 |
 | 返佣配置(分享) | 游戏分类改为 8 类,去掉「彩票(三方)」 |
 | 绑定/解绑会员 | **删除**。`POST /admin/agent/bind-member` 已移除,会员的所属代理不能修改 |
 
-新菜单已经由迁移写入 `menus` 表,uri 分别是 `agentSettings`、`agentPlanApplications`、`agentRecharges`、`memberDailyReport`。按钮权限有 `admin/agent/settings/save`、`admin/agent/plan-applications/audit`、`admin/agent/recharges/audit`。
+新菜单已经由迁移写入 `menus` 表,uri 分别是 `agentSettings`、`agentPlanApplications`、`agentRecharges`、`agentPaymentOrders`、`memberDailyReport`。按钮权限有 `admin/agent/settings/save`、`admin/agent/plan-applications/audit`、`admin/agent/recharges/audit`、`admin/agent/payment-orders/pay-data`、`admin/agent/payment-orders/audit`、`admin/agent/payment-orders/reject`、`admin/agent/payment-orders/query`。
 
 ## 业务规则(页面文案需要用到)
 
@@ -160,7 +161,7 @@
 
 ```json
 {"id":1,"margin_stop_percent":"10.0000","equity_enabled":1,"equity_price_per_percent":"100000.0000",
- "equity_min_percent":"0.1000","equity_max_percent":"1.0000","updated_by":1,"updated_at":"…"}
+ "equity_min_percent":"0.1000","equity_max_percent":"1.0000","recharge_channel_group_id":1,"updated_by":1,"updated_at":"…"}
 ```
 
 ### `POST /admin/agent/settings`
@@ -172,6 +173,7 @@
 | `equity_price_per_percent` | 是 | 每 1% 股份的价格(余额单位) |
 | `equity_min_percent` | 是 | 单次最少购买比例,例如 0.1 |
 | `equity_max_percent` | 是 | 单次最多购买比例,同时也是每个代理累计持股上限,例如 1 |
+| `recharge_channel_group_id` | 否 | 代理充值使用的**通道组合**(会员管理里的充值通道组合 ID,不能选已删除的组合),默认 1。代理能看到的充值通道 = 该组合的充值类型 ∩ 已启用的通道和支付公司。页面可以用现有的通道组合列表做下拉 |
 
 ## 7. 代理充值记录
 
@@ -198,6 +200,50 @@
 | `status` | 是 | `1` 通过入账,`2` 驳回 |
 | `remark` | 驳回时是 | 驳回原因 |
 
+## 7.1 代理人民币充值
+
+代理和会员一样可以用总后台配置的全部充值通道:在线代收(808、NO、JD、三斤扫码等)和人工充值。通道由「入股配置」里的 `recharge_channel_group_id` 决定。支付公司的启停、通道金额区间、费率都沿用现有配置;某个三方配置了「充值白名单用户」时,代理看不到该通道。
+
+- 订单单独存放(`agent_payment_orders`),订单号以 `ag` 开头。**不进入会员充值订单列表,也不计入任何会员统计和充值优惠**。
+- 入账金额 = 订单金额,进入代理余额;合营代理计入保证金。资金流水类型为 `recharge`。
+- 在线充值:三方回调走现有的 `/api/pay/harvest`,自动入账。30 分钟未支付变为「超时」,超时后到账仍会入账。
+- 人工充值:代理提交金额 → 这里「发送收款信息」 → 代理转账并上传凭证 → 这里审核。待处理/待审核 24 小时未处理自动失败;待提交凭证不会自动失败,代理可以自己撤回(状态 6)。代理提交的金额受人工充值通道的最小/最大金额限制。
+- `GET /admin/paymentOrder/unProcessed` 新增 `agentRecharge`:代理人工充值待发送收款信息/待审核数 + 代理 USDT 凭证待审核数,可以用作新菜单的角标。
+
+### `GET /admin/agent/payment-orders`
+
+参数:`page`、`limit`、`agent_id`、`order_no`、`type`(`1` 在线 / `3` 人工)、`channel`、`payment_type`、`status`、`start_date`、`end_date`。
+
+字段:`id`、`order_no`、`agent`(`id`/`username`/`real_name`/`type`)、`type`、`type_text`、`amount`、`fee`(按通道费率计算的手续费,平台承担)、`channel`、`bank_name`、`payment_type`、`status`、`status_text`、`pay_no`(三方单号)、`pay_url`、`pay_data`、`image`(凭证)、`remark`、`audit_admin_id`、`created_at`、`updated_at`。
+
+`status` 与会员充值订单相同:`0` 待处理、`1` 处理中、`2` 成功、`3` 失败、`4` 待用户提交凭证、`5` 待人工审核、`6` 代理已撤回、`7` 超时。
+
+### `POST /admin/agent/payment-orders/pay-data`(人工充值:发送收款信息)
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `ids` | 是 | 订单 ID 数组,可批量 |
+| `payment_type` | 是 | 只更新付款方式等于该值的订单 |
+| `pay_data` | 是 | 收款信息文本 |
+
+只处理 `status=0` 的人工充值,处理后变为 `4`。返回 `{"updated": 数量}`。
+
+### `POST /admin/agent/payment-orders/audit`(人工充值审核)
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 订单 ID |
+| `status` | 是 | `2` 通过入账(订单须为 `5` 待审核),`3` 驳回(`0/1/4/5` 可驳回) |
+| `remark` | 驳回时是 | 原因 |
+
+### `POST /admin/agent/payment-orders/reject`(拒绝在线充值)
+
+参数 `id`、`remark`(可选)。只能拒绝 `0/1/7` 状态的在线订单,拒绝后三方回调或查单都不会再入账。
+
+### `POST /admin/agent/payment-orders/query`(查单)
+
+参数 `id`。向三方查询在线订单,已支付就立即入账。返回 `{"paid":true|false,"msg":"支付成功|支付中|订单已处理|查询失败:…"}`。
+
 ## 8. 合营分成记录(原代理返佣记录)
 
 ### `GET /admin/agent/commissions`
@@ -239,7 +285,7 @@
 
 ## 10. 独立代理申请审核
 
-`POST /admin/agent/applicationAudit` 删除了 `flow_commission_profile_id`、`profit_commission_profile_id` 两个参数,新增可选的 `type`(默认 `1` 合营)。通过后代理没有方案,需要代理自己去申请。
+`POST /admin/agent/applicationAudit` 删除了 `flow_commission_profile_id`、`profit_commission_profile_id` 两个参数,新增可选的 `type`。会员在 PC 前台申请时已选择类型,不传 `type` 就按会员选的类型开通。`GET /admin/agent/applications` 新增 `type`、`type_text` 字段和 `type` 筛选。详见 `docs/总后台/独立代理申请-审核接入.md`。通过后合营代理没有方案,需要代理自己去申请。
 
 ## 11. 会员盈亏日报
 

+ 17 - 7
docs/总后台/独立代理申请-审核接入.md

@@ -2,13 +2,15 @@
 
 沿用后台登录凭证,成功为 `code=0`。使用已有代理管理权限。
 
+> **本次变更(代理类型)**:会员在 PC 前台申请时会选择代理类型(`1` 合营 / `2` 入股)。列表新增 `type`、`type_text` 字段和 `type` 筛选;审核通过默认按会员选的类型开通,也可以传 `type` 改成另一种。
+
 ## 1. 申请列表
 
 `GET /admin/agent/applications?page=1&limit=20&status=pending`
 
-可选筛选:`status`(`pending/approved/rejected`)、`username`(代理账号)、`member_id`(会员编号)。
+可选筛选:`status`(`pending/approved/rejected`)、`username`(代理账号)、`member_id`(会员编号)、`type`(**新增**,`1` 合营 / `2` 入股)。
 
-列表在 `data.data[]`,总数为 `data.total`。每项包含 `id`(申请 ID)、`user_id`(会员 ID)、`member_id`(会员编号)、`member_account`、`username`、`status`、`agent_id`、`reject_reason`、`created_at`、`reviewed_at`、`reviewed_by`。
+列表在 `data.data[]`,总数为 `data.total`。每项包含 `id`(申请 ID)、`user_id`(会员 ID)、`member_id`(会员编号)、`member_account`、`username`、`type`(**新增**,代理类型)、`type_text`(**新增**,`合营` / `入股`)、`status`、`agent_id`、`reject_reason`、`created_at`、`reviewed_at`、`reviewed_by`。
 
 ## 2. 审核
 
@@ -26,12 +28,20 @@
 {"id":123,"status":"rejected","remark":"请核对申请资料后重新提交"}
 ```
 
-`id` 取申请列表的申请 ID;驳回必须填写 `remark`,最多 500 字。通过时可选传 `flow_commission_profile_id`、`profit_commission_profile_id`,不传使用默认方案。
+`id` 取申请列表的申请 ID;驳回必须填写 `remark`,最多 500 字。
 
-审核成功后刷新列表即可。通过会开通独立一级代理;通过、驳回都会自动发送站内信。已审核的申请不能改成另一审核结果。
+通过时可选传 `type`(`1` 合营 / `2` 入股):不传按会员申请时选的类型开通;传了以传的为准,例如把入股申请改为合营开通:
+
+```json
+{"id":123,"status":"approved","type":1}
+```
+
+开通后申请记录的 `type` 会更新为实际开通的类型,会员前台看到的也是这个类型。审核弹窗建议展示会员选的 `type_text`,并提供类型下拉(默认值为申请的 `type`)。
 
-## 3. 绑定已有代理账号
+原来的 `flow_commission_profile_id`、`profit_commission_profile_id` 参数已删除:通过后合营代理没有分成方案,由代理在代理后台自己申请。
+
+审核成功后刷新列表即可。通过会开通独立一级代理;通过、驳回都会自动发送站内信。已审核的申请不能改成另一审核结果。
 
-已有代理在 `POST /admin/agent/update` 的原请求中增加 `owner_user_id`,值取会员列表的 `id`,不是 `member_id`。`/admin/agent/create` 也支持此字段。
+## 3. 绑定已有代理账号(已删除)
 
-绑定后该会员查询状态即可显示“已是代理”。一个会员只能绑定一个代理账号,已绑定账号不能改绑其他会员。
+`POST /admin/agent/create`、`/admin/agent/update` 不再接收 `owner_user_id`,会员只能通过申请审核成为代理。

+ 5 - 0
routes/admin.php

@@ -176,6 +176,11 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/plan-applications/audit', [AgentAdmin::class, 'auditPlanApplication'])->defaults('permission_uri', 'admin/agent/plan-applications/audit');
             Route::get('/recharges', [AgentAdmin::class, 'recharges'])->defaults('permission_uri', 'admin/agent/recharges');
             Route::post('/recharges/audit', [AgentAdmin::class, 'auditRecharge'])->defaults('permission_uri', 'admin/agent/recharges/audit');
+            Route::get('/payment-orders', [AgentAdmin::class, 'paymentOrders'])->defaults('permission_uri', 'admin/agent/payment-orders');
+            Route::post('/payment-orders/pay-data', [AgentAdmin::class, 'paymentOrderPayData'])->defaults('permission_uri', 'admin/agent/payment-orders/pay-data');
+            Route::post('/payment-orders/audit', [AgentAdmin::class, 'auditPaymentOrder'])->defaults('permission_uri', 'admin/agent/payment-orders/audit');
+            Route::post('/payment-orders/reject', [AgentAdmin::class, 'rejectPaymentOrder'])->defaults('permission_uri', 'admin/agent/payment-orders/reject');
+            Route::post('/payment-orders/query', [AgentAdmin::class, 'queryPaymentOrder'])->defaults('permission_uri', 'admin/agent/payment-orders/query');
             Route::get('/profit-entries', [AgentAdmin::class, 'profitEntries'])->defaults('permission_uri', 'admin/agent/commissions');
         });
 

+ 6 - 0
routes/agent.php

@@ -38,6 +38,12 @@ Route::middleware('agent.auth')->group(function () {
     Route::get('/wallet/recharge/address', [WalletController::class, 'rechargeAddress']);
     Route::post('/wallet/recharge', [WalletController::class, 'submitRecharge']);
     Route::get('/wallet/recharges', [WalletController::class, 'recharges']);
+    Route::get('/wallet/recharge/channels', [WalletController::class, 'rechargeChannels']);
+    Route::post('/wallet/recharge/online', [WalletController::class, 'createPayment']);
+    Route::post('/wallet/recharge/manual', [WalletController::class, 'createManualPayment']);
+    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/accounts', [WalletController::class, 'accounts']);
     Route::post('/wallet/accounts', [WalletController::class, 'saveAccount']);
     Route::post('/wallet/accounts/delete', [WalletController::class, 'deleteAccount']);

+ 21 - 3
tests/Integration/AgentApplicationTest.php

@@ -78,7 +78,7 @@ class AgentApplicationTest extends TestCase
             $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
         });
         foreach (['2026_10_26_110000_create_site_messages.php', '2026_10_27_150000_create_site_realtime_events.php', '2026_10_29_140000_create_agent_applications.php',
-            '2026_11_07_120000_agent_joint_venture_and_equity.php'] as $file) {
+            '2026_11_07_120000_agent_joint_venture_and_equity.php', '2026_11_08_150000_add_type_to_agent_applications.php'] as $file) {
             (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
         }
         foreach ([11 => 'zh', 22 => 'en', 33 => null] as $id => $language) DB::table('users')->insert([
@@ -102,10 +102,10 @@ class AgentApplicationTest extends TestCase
         $this->app->instance('request', $request);
     }
 
-    private function application(int $userId = 11, string $username = 'newagent'): AgentApplication
+    private function application(int $userId = 11, string $username = 'newagent', int $type = Agent::TYPE_JOINT): AgentApplication
     {
         return AgentApplication::create([
-            'user_id' => $userId, 'pending_user_id' => $userId, 'username' => $username, 'pending_username' => $username,
+            'type' => $type, 'user_id' => $userId, 'pending_user_id' => $userId, 'username' => $username, 'pending_username' => $username,
             'password_hash' => password_hash('login-secret', PASSWORD_BCRYPT, ['cost' => 4]),
             'withdraw_password_hash' => password_hash('withdraw-secret', PASSWORD_BCRYPT, ['cost' => 4]), 'status' => 'pending',
         ]);
@@ -254,4 +254,22 @@ class AgentApplicationTest extends TestCase
         $this->assertSame(1, Agent::count());
         $this->assertSame(1, DB::table('site_messages')->count());
     }
+
+    public function test_approval_opens_the_agent_type_chosen_by_the_member_unless_the_admin_overrides_it(): void
+    {
+        $equity = $this->application(11, 'equityagent', Agent::TYPE_EQUITY);
+        $listed = $this->service->listing(['type' => Agent::TYPE_EQUITY])['data'];
+        $this->assertCount(1, $listed);
+        $this->assertSame(Agent::TYPE_EQUITY, $listed[0]->type);
+        $this->assertSame('入股', $listed[0]->type_text);
+        $this->assertSame(0, $this->service->listing(['type' => Agent::TYPE_JOINT])['total']);
+        $this->assertSame(0, $this->audit($equity->id)['code']);
+        $this->assertSame(Agent::TYPE_EQUITY, (int) Agent::where('username', 'equityagent')->value('type'));
+
+        $changed = $this->application(22, 'changedagent', Agent::TYPE_EQUITY);
+        $this->assertSame(0, $this->audit($changed->id, 'approved', ['type' => Agent::TYPE_JOINT])['code']);
+        $this->assertSame(Agent::TYPE_JOINT, (int) Agent::where('username', 'changedagent')->value('type'));
+        $this->assertSame(Agent::TYPE_JOINT, $changed->fresh()->type, 'application records the type actually opened');
+        $this->assertSame('合营', $changed->fresh()->toArray()['type_text']);
+    }
 }

+ 271 - 0
tests/Integration/AgentPaymentOrderTest.php

@@ -0,0 +1,271 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentEquityOrder;
+use App\Models\Agent\AgentPaymentOrder;
+use App\Models\Agent\AgentSetting;
+use App\Models\PaymentOrder;
+use App\Models\RechargeChannel;
+use App\Services\Agent\AgentEquityService;
+use App\Services\Agent\AgentPaymentService;
+use App\Services\Agent\AgentWalletService;
+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\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 代理人民币充值:总后台配置的通道、三方回调入账到代理钱包、人工充值流程。 */
+class AgentPaymentOrderTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    private AgentPaymentService $payments;
+
+    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();
+        }
+        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));
+    }
+
+    /** 直接写库:其他测试类可能已缓存 agent_settings 的旧列清单,fill() 会丢掉新列。 */
+    private function useGroup(int $groupId): void
+    {
+        AgentSetting::current();
+        DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => $groupId]);
+    }
+
+    private function onlineOrder(Agent $agent, string $amount = '100.00'): AgentPaymentOrder
+    {
+        return AgentPaymentOrder::query()->create([
+            'agent_id' => $agent->id, 'type' => PaymentOrderService::TYPE_PAY, 'member_id' => AgentPaymentService::payerId($agent),
+            'order_no' => PaymentOrderService::createOrderNo('agjd1_', AgentPaymentService::payerId($agent)),
+            'amount' => $amount, 'channel' => JdPayService::CHANNEL, 'bank_name' => 'JD钱包',
+            'status' => PaymentOrderService::STATUS_PROCESS,
+        ]);
+    }
+
+    private function jdCallback(AgentPaymentOrder $order, string $status = JdPayService::PAY_STATUS_SUCCESS): array
+    {
+        $amount = JdPayService::amount($order->amount);
+        $params = ['userCode' => 'JD-MCH', 'orderCode' => $order->order_no, 'amount' => $amount, 'status' => $status];
+        $params['sign'] = JdPayService::signature([$order->order_no, $amount, 'JD-MCH', $status], 'jd-secret');
+        return $params;
+    }
+
+    public function test_channels_follow_the_configured_group_and_provider_whitelists(): void
+    {
+        $this->payments();
+        $this->useGroup(2);
+        $types = array_column($this->payments->channels(), 'value');
+        $this->assertEqualsCanonicalizing(['usdt', 'rgcz', 'JDpay', 'NOpay12', 'NOpay13', 'ZIMUpay'], $types);
+        config(['app.jd_pay_recharge_user_ids' => '1001,1002']);
+        $this->assertNotContains('JDpay', array_column($this->payments->channels(), 'value'), 'member-only whitelist hides the channel from agents');
+        DB::table('payment_providers')->where('code', 'no')->update(['status' => 0]);
+        $this->assertNotContains('NOpay12', array_column($this->payments->channels(), 'value'));
+    }
+
+    public function test_online_order_rejects_channels_outside_the_group_or_disabled_providers(): void
+    {
+        $this->payments();
+        $this->useGroup(2);
+        $agent = $this->agent();
+        foreach (['wxsm' => '不支持此充值方式', 'rgcz' => '该通道请使用对应的充值接口'] as $type => $message) {
+            try { $this->payments->createOnline($agent, '100', $type); $this->fail($type); }
+            catch (\Exception $e) { $this->assertSame($message, $e->getMessage()); }
+        }
+        DB::table('payment_providers')->where('code', 'zimu')->update(['status' => 0]);
+        $this->expectExceptionMessage('支付公司已禁用');
+        $this->payments->createOnline($agent, '100', 'ZIMUpay');
+    }
+
+    public function test_provider_callback_credits_the_agent_once_and_counts_as_margin(): void
+    {
+        $this->payments();
+        $agent = $this->agent();
+        $order = $this->onlineOrder($agent, '100.00');
+        $this->assertSame($order->id, PaymentOrderService::findPayOrder($order->order_no)->id);
+        $this->assertInstanceOf(AgentPaymentOrder::class, PaymentOrderService::findPayOrder($order->order_no));
+        $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order)));
+        $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order)), 'replayed callback is acknowledged');
+        $agent = $agent->fresh();
+        $this->assertSame('100.0000', $agent->balance);
+        $this->assertSame('100.0000', $agent->margin_amount, 'joint agent recharges are margin');
+        $this->assertSame(PaymentOrderService::STATUS_SUCCESS, $order->fresh()->status);
+        $this->assertSame(1, DB::table('agent_balance_logs')->where('agent_id', $agent->id)->where('type', 'recharge')->count());
+        $this->assertSame(0, DB::table('payment_orders')->count(), 'agent orders never land in member payment_orders');
+        $this->assertSame(0, DB::table('wallets')->count());
+    }
+
+    public function test_bad_signature_and_failed_payment_do_not_credit(): void
+    {
+        $this->payments();
+        $agent = $this->agent();
+        $order = $this->onlineOrder($agent);
+        $forged = $this->jdCallback($order);
+        $forged['sign'] = 'X';
+        $this->assertFalse(PaymentOrderService::receivePay($forged));
+        $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order, JdPayService::PAY_STATUS_FAIL)));
+        $this->assertSame(PaymentOrderService::STATUS_FAIL, $order->fresh()->status);
+        $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order)), 'late success after failure is ignored');
+        $this->assertSame('0.0000', $agent->fresh()->balance);
+    }
+
+    public function test_paid_online_recharge_opens_a_pending_equity_order(): void
+    {
+        $this->payments();
+        AgentSetting::current()->update(['equity_enabled' => 1, 'equity_price_per_percent' => 1000,
+            'equity_min_percent' => 0.1, 'equity_max_percent' => 1]);
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $equity = (new AgentEquityService(new AgentWalletService()))->order($agent, '0.1');
+        $this->assertSame(AgentEquityOrder::STATUS_PENDING, $equity->status);
+        PaymentOrderService::receivePay($this->jdCallback($this->onlineOrder($agent, '100.00')));
+        $this->assertSame(AgentEquityOrder::STATUS_COMPLETED, $equity->fresh()->status);
+        $this->assertSame('0.1000', $agent->fresh()->equity_percent);
+        $this->assertSame('0.0000', $agent->fresh()->balance);
+    }
+
+    public function test_manual_recharge_needs_pay_data_proof_and_audit(): void
+    {
+        $this->payments();
+        $this->useGroup(2);
+        $agent = $this->agent();
+        $order = $this->payments->createManual($agent, '500', 1);
+        $this->assertStringStartsWith('agrgcz_', $order->order_no);
+        try { $this->payments->submitImage($agent, $order->id, 'https://example.test/a.png'); $this->fail('proof before pay data'); }
+        catch (\RuntimeException $e) { $this->assertSame('待处理中,请稍后', $e->getMessage()); }
+        $this->assertSame(1, $this->payments->setPayData([$order->id], 1, '工商银行 6222… 张三'));
+        try { $this->payments->manualAudit($order->id, 2, '', 1); $this->fail('audit before proof'); }
+        catch (\RuntimeException $e) { $this->assertSame('代理未提交充值凭证', $e->getMessage()); }
+        $this->payments->submitImage($agent, $order->id, 'https://example.test/a.png');
+        $this->payments->manualAudit($order->id, 2, 'ok', 1);
+        $this->assertSame('500.0000', $agent->fresh()->balance);
+        $this->assertSame(PaymentOrderService::STATUS_SUCCESS, $order->fresh()->status);
+        $this->expectExceptionMessage('订单已完成,不可驳回');
+        $this->payments->manualAudit($order->id, 3, 'late', 1);
+    }
+
+    public function test_manual_recharge_requires_rgcz_in_the_agent_group(): void
+    {
+        $this->payments();
+        $this->expectExceptionMessage('不支持此充值方式');
+        $this->payments->createManual($this->agent(), '500', 1); // group 1 has no rgcz
+    }
+
+    /** 模拟 JD 下单返回,返回记录到的请求。 */
+    private function fakeJd(array $response): \ArrayObject
+    {
+        $history = new \ArrayObject();
+        $stack = HandlerStack::create(new MockHandler([new Response(200, [], json_encode($response))]));
+        $stack->push(Middleware::history($history));
+        JdPayService::$handler = $stack;
+        config(['app.jd_pay_gateway' => 'https://jd.example.test/pay']);
+        return $history;
+    }
+
+    protected function tearDown(): void
+    {
+        JdPayService::$handler = null;
+        parent::tearDown();
+    }
+
+    public function test_online_order_is_created_in_agent_table_and_hides_raw_provider_response(): void
+    {
+        $this->payments();
+        $this->useGroup(2);
+        $agent = $this->agent();
+        $history = $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-1', 'url' => '']]);
+        $result = $this->payments->createOnline($agent, '100.50', 'JDpay');
+        $order = $result['order'];
+        $this->assertStringStartsWith('agjd1_', $order->order_no);
+        parse_str((string) $history[0]['request']->getBody(), $sent);
+        $this->assertSame($order->order_no, $sent['orderCode']);
+        $this->assertSame('100.50', $sent['amount']);
+        $this->assertSame(PaymentOrderService::STATUS_PROCESS, $order->status);
+        $this->assertSame('JD-PAY-1', $order->pay_no);
+        $this->assertArrayNotHasKey('pay_data', $order->toArray(), 'raw provider response is not returned to the agent');
+        $this->assertArrayNotHasKey('pay_data', $this->payments->paginate([], $agent)['list'][0]->toArray());
+        $this->assertNotEmpty($this->payments->paginate([])['list'][0]->toArray()['pay_data'], 'admin still sees it');
+        $this->assertSame(0, DB::table('payment_orders')->count());
+        $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order)));
+        $this->assertSame('100.5000', $agent->fresh()->balance);
+    }
+
+    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->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-2', 'url' => '']]);
+        $result = PaymentOrderService::createPayOrder(PaymentOrder::class, 'M1001', 100, 'JDpay', 2,
+            RechargeChannel::checkRechargeChannel('JDpay', 2));
+        $this->assertSame(0, $result['code']);
+        $this->assertStringStartsWith('jd1_', $result['order_no']);
+        $found = PaymentOrderService::findPayOrder($result['order_no']);
+        $this->assertSame(PaymentOrder::class, get_class($found));
+        $this->assertSame('M1001', $found->member_id);
+        $this->assertSame(0, DB::table('agent_payment_orders')->count());
+        // 前缀约定被打破(会员订单号以 ag 开头)时回退到另一张表,不丢回调
+        DB::table('payment_orders')->where('id', $found->id)->update(['order_no' => 'ag-member-1']);
+        $this->assertSame($found->id, PaymentOrderService::findPayOrder('ag-member-1')->id);
+        $this->assertInstanceOf(PaymentOrder::class, PaymentOrderService::findPayOrder('ag-member-1'));
+        $this->assertNotInstanceOf(AgentPaymentOrder::class, PaymentOrderService::findPayOrder('ag-member-1'));
+    }
+
+    public function test_manual_recharge_respects_channel_limits_and_can_be_cancelled_to_free_a_slot(): void
+    {
+        $this->payments();
+        $this->useGroup(2);
+        $agent = $this->agent();
+        try { $this->payments->createManual($agent, '5', 1); $this->fail('below rgcz min'); }
+        catch (\RuntimeException $e) { $this->assertStringContainsString('10-500000', $e->getMessage()); }
+        $orders = [];
+        for ($i = 0; $i < 5; $i++) $orders[] = $this->payments->createManual($agent, '100', 1);
+        $this->payments->setPayData([$orders[0]->id], 1, '收款信息');
+        try { $this->payments->createManual($agent, '100', 1); $this->fail('open limit'); }
+        catch (\RuntimeException $e) { $this->assertSame('未完成的人工充值过多,请等待处理', $e->getMessage()); }
+        $cancelled = $this->payments->cancelManual($agent, $orders[0]->id); // 待提交凭证可撤回
+        $this->assertSame(PaymentOrderService::STATUS_CANCEL, $cancelled->status);
+        $this->payments->createManual($agent, '100', 1);
+        $this->payments->setPayData([$orders[1]->id], 1, '收款信息');
+        $this->payments->submitImage($agent, $orders[1]->id, 'https://example.test/a.png');
+        $this->expectExceptionMessage('当前状态不可撤回');
+        $this->payments->cancelManual($agent, $orders[1]->id);
+    }
+}

+ 2 - 0
tests/Support/AgentTestEnvironment.php

@@ -42,6 +42,8 @@ trait AgentTestEnvironment
         $db->setEventDispatcher(new Dispatcher($this->app));
         $db->setAsGlobal();
         $db->bootEloquent();
+        // 生产环境由 DatabaseServiceProvider 绑定;DB::afterCommit 依赖它。
+        $this->app->instance('db.transactions', new \Illuminate\Database\DatabaseTransactionsManager());
         $this->app->instance('db', $db->getDatabaseManager());
         $this->app->bind('db.schema', fn () => $db->schema());
         $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));

+ 1 - 1
tests/fixtures/review_agent_application.php

@@ -23,6 +23,6 @@ $app->instance('log', new Psr\Log\NullLogger());
 $request = Illuminate\Http\Request::create('/admin/agent/applicationAudit', 'POST');
 $request->user = (object) ['id' => 1, 'username' => 'test-admin'];
 $app->instance('request', $request);
-$agents = new App\Services\Agent\AgentService(new App\Services\Agent\AgentFlowCommissionAssignmentService(), new App\Services\Agent\AgentProfitCommissionAssignmentService());
+$agents = new App\Services\Agent\AgentService();
 $result = (new App\Services\Agent\AgentApplicationService())->audit(['id' => $id, 'status' => 'approved'], $agents);
 echo json_encode($result, JSON_THROW_ON_ERROR);