doge 4 dagen geleden
bovenliggende
commit
41de20fb02

+ 67 - 0
app/Http/Controllers/admin/AgentDomainPool.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\Agent\AgentDomainPoolService;
+use Illuminate\Http\Request;
+
+/** 总后台「代理域名管理」:可用域名、使用情况、绑定到哪个代理。 */
+class AgentDomainPool extends AgentApiController
+{
+    public function index(Request $request, AgentDomainPoolService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'keyword' => ['nullable', 'string', 'max:255'], 'type' => ['nullable', 'in:pc,h5,common'],
+                'status' => ['nullable', 'in:0,1'], 'usage' => ['nullable', 'in:in_use,available'],
+                'agent_id' => ['nullable', 'integer'], 'agent_username' => ['nullable', 'string', 'max:64'],
+            ]);
+            if (($data['type'] ?? null) === 'common') $data['type'] = '';
+            return $service->paginate($data);
+        });
+    }
+
+    public function options(Request $request, AgentDomainPoolService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['type' => ['nullable', 'in:pc,h5']]);
+            return $service->options($data['type'] ?? null);
+        });
+    }
+
+    public function create(Request $request, AgentDomainPoolService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                // 数组,或多行文本(每行一个)
+                'domains' => ['required'], 'type' => ['nullable', 'in:pc,h5,common'], 'remark' => ['nullable', 'string', 'max:255'],
+            ]);
+            $domains = is_array($data['domains']) ? $data['domains'] : preg_split('/[\r\n,,]+/u', (string) $data['domains']);
+            $type = ($data['type'] ?? 'common') === 'common' ? '' : (string) $data['type'];
+            return $service->create($domains, $type, trim((string) ($data['remark'] ?? '')), (int) request()->user->id);
+        });
+    }
+
+    public function update(Request $request, AgentDomainPoolService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'id' => ['required', 'integer'], 'domain' => ['nullable', 'string', 'max:255'],
+                'type' => ['nullable', 'in:pc,h5,common'], 'status' => ['nullable', 'in:0,1'], 'remark' => ['nullable', 'string', 'max:255'],
+            ]);
+            if (($data['type'] ?? null) === 'common') $data['type'] = '';
+            return $service->update((int) $data['id'], $data);
+        });
+    }
+
+    public function delete(Request $request, AgentDomainPoolService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer']]);
+            $service->delete((int) $data['id']);
+            return [];
+        });
+    }
+}

+ 16 - 0
app/Models/Agent/AgentDomainPool.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+/** 代理域名池:总后台维护的可用域名,按 host 唯一。 */
+class AgentDomainPool extends BaseModel
+{
+    public const TYPE_TEXT = ['' => '通用', 'pc' => 'PC', 'h5' => 'H5'];
+
+    protected $table = 'agent_domain_pool';
+    protected $guarded = ['id'];
+    protected $hidden = [];
+    protected $casts = ['status' => 'integer', 'created_by' => 'integer'];
+}

+ 189 - 0
app/Services/Agent/AgentDomainPoolService.php

@@ -0,0 +1,189 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentDomain;
+use App\Models\Agent\AgentDomainPool;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 代理域名池(总后台「代理域名管理」):
+ * - 批量添加可用域名,按主机名去重;
+ * - 列表显示使用情况:绑定到哪个代理、PC 还是 H5(按 host 和 agent_domains 匹配);
+ * - 使用中的域名不能删除;停用的域名不能再绑定(已绑定的不受影响,需要先到代理列表解绑);
+ * - 代理列表直接输入并绑定的域名会自动登记到域名池(AgentDomainService::save)。
+ */
+class AgentDomainPoolService
+{
+    private const MAX_BATCH = 100;
+
+    public function __construct(private AgentDomainService $domains)
+    {
+    }
+
+    public function paginate(array $params): array
+    {
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = AgentDomainPool::query();
+        if (!empty($params['keyword'])) $query->where('domain', 'like', '%' . trim((string) $params['keyword']) . '%');
+        if (isset($params['type']) && $params['type'] !== null) $query->where('type', (string) $params['type']);
+        if (isset($params['status']) && $params['status'] !== '') $query->where('status', (int) $params['status']);
+        $bound = fn ($q) => $q->select(DB::raw(1))->from('agent_domains')->whereColumn('agent_domains.host', 'agent_domain_pool.host');
+        if (($params['usage'] ?? '') === 'in_use') $query->whereExists($bound);
+        if (($params['usage'] ?? '') === 'available') $query->whereNotExists($bound);
+        if (!empty($params['agent_id']) || !empty($params['agent_username'])) {
+            $query->whereIn('host', AgentDomain::query()->select('host')->whereIn('agent_id', function ($q) use ($params) {
+                $q->select('id')->from('agents');
+                if (!empty($params['agent_id'])) $q->where('id', (int) $params['agent_id']);
+                if (!empty($params['agent_username'])) $q->where('username', 'like', '%' . trim((string) $params['agent_username']) . '%');
+            }));
+        }
+        $total = (clone $query)->count();
+        $rows = $query->orderByDesc('id')->forPage($page, $limit)->get();
+        $bindings = $this->bindings($rows->pluck('host')->all());
+        $list = $rows->map(fn (AgentDomainPool $row) => $this->present($row, $bindings[$row->host] ?? []))->all();
+        return compact('total', 'page', 'limit', 'list') + ['summary' => $this->summary()];
+    }
+
+    /** 绑定代理时的下拉:启用、未被使用、类型匹配(通用的两种都可用)。 */
+    public function options(?string $type): array
+    {
+        $query = AgentDomainPool::query()->where('status', 1)
+            ->whereNotExists(fn ($q) => $q->select(DB::raw(1))->from('agent_domains')->whereColumn('agent_domains.host', 'agent_domain_pool.host'));
+        if ($type) $query->whereIn('type', ['', $type]);
+        return $query->orderByDesc('id')->limit(500)->get(['id', 'domain', 'type', 'remark'])
+            ->map(fn (AgentDomainPool $row) => ['id' => $row->id, 'domain' => $row->domain, 'type' => $row->type,
+                'type_text' => AgentDomainPool::TYPE_TEXT[$row->type] ?? '', 'remark' => $row->remark])->all();
+    }
+
+    /** 批量添加:每行一个域名;已存在或重复的跳过并说明原因。 */
+    public function create(array $domains, string $type, string $remark, ?int $adminId): array
+    {
+        $domains = array_values(array_filter(array_map(fn ($d) => trim((string) $d), $domains), fn ($d) => $d !== ''));
+        if ($domains === []) throw new \InvalidArgumentException('请输入域名');
+        if (count($domains) > self::MAX_BATCH) throw new \InvalidArgumentException('一次最多添加 ' . self::MAX_BATCH . ' 个域名');
+        $created = [];
+        $skipped = [];
+        DB::transaction(function () use ($domains, $type, $remark, $adminId, &$created, &$skipped) {
+            $seen = [];
+            foreach ($domains as $domain) {
+                try {
+                    $value = $this->domains->normalize($domain);
+                } catch (\InvalidArgumentException $e) {
+                    $skipped[] = ['domain' => $domain, 'reason' => '域名格式错误'];
+                    continue;
+                }
+                $host = $this->domains->host($value);
+                if (isset($seen[$host])) { $skipped[] = ['domain' => $domain, 'reason' => '本次输入重复']; continue; }
+                $seen[$host] = true;
+                if (AgentDomainPool::query()->where('host', $host)->lockForUpdate()->exists()) {
+                    $skipped[] = ['domain' => $domain, 'reason' => '域名已存在']; continue;
+                }
+                $created[] = AgentDomainPool::query()->create(['domain' => $value, 'host' => $host, 'type' => $type,
+                    'status' => 1, 'remark' => $remark, 'created_by' => $adminId])->id;
+            }
+        });
+        return ['created' => count($created), 'skipped' => $skipped];
+    }
+
+    public function update(int $id, array $data): array
+    {
+        return DB::transaction(function () use ($id, $data) {
+            $row = AgentDomainPool::query()->lockForUpdate()->findOrFail($id);
+            if (array_key_exists('domain', $data) && $data['domain'] !== null && trim((string) $data['domain']) !== $row->domain) {
+                $value = $this->domains->normalize((string) $data['domain']);
+                $host = $this->domains->host($value);
+                if ($host !== $row->host) {
+                    if ($this->inUse($row->host)) throw new \RuntimeException('域名使用中,不能修改为其他域名,请先到代理列表解绑');
+                    if (AgentDomainPool::query()->where('host', $host)->whereKeyNot($row->id)->exists()) throw new \RuntimeException('域名已存在');
+                }
+                $row->domain = $value;
+                $row->host = $host;
+            }
+            foreach (['type', 'remark'] as $field) {
+                if (array_key_exists($field, $data) && $data[$field] !== null) $row->{$field} = (string) $data[$field];
+            }
+            if (array_key_exists('status', $data) && $data['status'] !== null) $row->status = (int) $data['status'];
+            $row->save();
+            return $this->present($row, $this->bindings([$row->host])[$row->host] ?? []);
+        });
+    }
+
+    public function delete(int $id): void
+    {
+        DB::transaction(function () use ($id) {
+            $row = AgentDomainPool::query()->lockForUpdate()->findOrFail($id);
+            if ($this->inUse($row->host)) throw new \RuntimeException('域名使用中,不能删除,请先到代理列表解绑');
+            $row->delete();
+        });
+    }
+
+    /** 代理列表绑定域名前调用:停用的不能绑定;没登记的自动登记。调用方已在事务内。 */
+    public function register(string $domain, string $host, string $slotType, ?int $adminId): void
+    {
+        $row = AgentDomainPool::query()->where('host', $host)->lockForUpdate()->first();
+        if ($row) {
+            if ((int) $row->status !== 1) throw new \RuntimeException('该域名已停用,不能绑定');
+            return;
+        }
+        AgentDomainPool::query()->create(['domain' => $domain, 'host' => $host, 'type' => $slotType, 'status' => 1,
+            'remark' => '代理列表绑定时自动登记', 'created_by' => $adminId]);
+    }
+
+    private function inUse(string $host): bool
+    {
+        return AgentDomain::query()->where('host', $host)->exists();
+    }
+
+    /** host => [绑定信息…](同一代理的 PC 和 H5 可能绑定同一主机名)。 */
+    private function bindings(array $hosts): array
+    {
+        if ($hosts === []) return [];
+        $agents = [];
+        $result = [];
+        $rows = AgentDomain::query()->whereIn('host', $hosts)->orderBy('id')->get();
+        foreach (Agent::query()->whereIn('id', $rows->pluck('agent_id')->unique())->get(['id', 'username', 'real_name', 'type', 'status']) as $agent) {
+            $agents[$agent->id] = $agent;
+        }
+        foreach ($rows as $row) {
+            $agent = $agents[$row->agent_id] ?? null;
+            $result[$row->host][] = [
+                'agent_id' => (int) $row->agent_id,
+                'agent_username' => $agent?->username ?? '',
+                'agent_real_name' => $agent?->real_name ?? '',
+                'agent_type' => $agent ? (int) $agent->type : null,
+                'agent_type_text' => $agent?->type_text ?? '',
+                'agent_status' => $agent ? (int) $agent->status : null,
+                'slot' => $row->type,
+                'slot_text' => AgentDomainPool::TYPE_TEXT[$row->type] ?? $row->type,
+                'bound_domain' => $row->domain,
+                'bound_at' => $row->created_at ? (string) $row->created_at : null,
+            ];
+        }
+        return $result;
+    }
+
+    private function present(AgentDomainPool $row, array $bindings): array
+    {
+        $inUse = $bindings !== [];
+        return [
+            'id' => (int) $row->id, 'domain' => $row->domain, 'host' => $row->host,
+            'type' => $row->type, 'type_text' => AgentDomainPool::TYPE_TEXT[$row->type] ?? '',
+            'status' => (int) $row->status, 'status_text' => (int) $row->status === 1 ? '启用' : '停用',
+            'remark' => $row->remark, 'in_use' => $inUse, 'usage_text' => $inUse ? '使用中' : ((int) $row->status === 1 ? '可用' : '停用'),
+            'bindings' => $bindings, 'created_by' => $row->created_by,
+            'created_at' => $row->created_at ? (string) $row->created_at : null, 'updated_at' => $row->updated_at ? (string) $row->updated_at : null,
+        ];
+    }
+
+    private function summary(): array
+    {
+        $bound = fn ($q) => $q->select(DB::raw(1))->from('agent_domains')->whereColumn('agent_domains.host', 'agent_domain_pool.host');
+        $total = AgentDomainPool::query()->count();
+        $inUse = AgentDomainPool::query()->whereExists($bound)->count();
+        $disabled = AgentDomainPool::query()->where('status', 0)->whereNotExists($bound)->count();
+        return ['total' => $total, 'in_use' => $inUse, 'available' => $total - $inUse - $disabled, 'disabled' => $disabled];
+    }
+}

+ 6 - 2
app/Services/Agent/AgentDomainService.php

@@ -29,6 +29,10 @@ class AgentDomainService
                 if ($owner && (int) $owner->agent_id !== (int) $agent->id) {
                     throw new \RuntimeException('该域名已绑定其他代理');
                 }
+                // 登记到域名池(已停用的不能绑定);迁移未执行前跳过
+                if (\Illuminate\Support\Facades\Schema::hasTable('agent_domain_pool')) {
+                    (new AgentDomainPoolService($this))->register($url, $host, $type, isset(request()->user->id) ? (int) request()->user->id : null);
+                }
                 AgentDomain::query()->updateOrCreate(
                     ['agent_id' => $agent->id, 'type' => $type],
                     ['domain' => $url, 'host' => $host, 'status' => 1]
@@ -67,7 +71,7 @@ class AgentDomainService
     }
 
     /** 只去掉首尾空白,其余原样保留;校验能解析出 http/https 主机名。 */
-    private function normalize(string $value): string
+    public function normalize(string $value): string
     {
         $value = trim($value);
         if ($value === '') {
@@ -80,7 +84,7 @@ class AgentDomainService
     }
 
     /** 主机名(小写);没写协议时按 https 解析。只允许 http/https。 */
-    private function host(string $url): string
+    public function host(string $url): string
     {
         $candidate = preg_match('#^[a-z][a-z0-9+.-]*://#i', $url) ? $url : 'https://' . $url;
         $scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME));

+ 80 - 0
database/migrations/2026_11_09_120000_create_agent_domain_pool.php

@@ -0,0 +1,80 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 代理域名池:总后台维护可用域名,查看哪些在使用、绑定到哪个代理。
+ * - agent_domain_pool:按主机名 host 唯一;type 为 pc / h5 / 空(通用);status 1 启用、0 停用(停用后不能再绑定);
+ * - 导入已有的代理绑定域名(agent_domains);
+ * - 菜单 agentDomainPool(挂在「代理充值记录」同一父菜单下)和新增 / 修改 / 删除按钮。
+ */
+return new class extends Migration {
+    private const BUTTONS = [
+        'admin/agent/domain-pool/create' => '代理域名池-新增',
+        'admin/agent/domain-pool/update' => '代理域名池-修改',
+        'admin/agent/domain-pool/delete' => '代理域名池-删除',
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('agent_domain_pool')) {
+            Schema::create('agent_domain_pool', function (Blueprint $t) {
+                $t->id();
+                $t->string('domain', 255)->comment('域名或完整地址,原样保存');
+                $t->string('host', 255)->unique()->comment('主机名(小写),用于唯一和匹配绑定');
+                $t->string('type', 16)->default('')->comment('pc / h5 / 空=通用');
+                $t->unsignedTinyInteger('status')->default(1)->comment('1 启用 0 停用');
+                $t->string('remark', 255)->default('');
+                $t->unsignedBigInteger('created_by')->nullable();
+                $t->timestamps();
+                $t->index(['status', 'type']);
+            });
+        }
+        if (Schema::hasTable('agent_domains')) {
+            $now = date('Y-m-d H:i:s');
+            foreach (DB::table('agent_domains')->orderBy('id')->get(['domain', 'host', 'type']) as $row) {
+                if ($row->host === '' || DB::table('agent_domain_pool')->where('host', $row->host)->exists()) continue;
+                DB::table('agent_domain_pool')->insert(['domain' => $row->domain, 'host' => $row->host, 'type' => (string) $row->type,
+                    'status' => 1, 'remark' => '', 'created_at' => $now, 'updated_at' => $now]);
+            }
+        }
+        $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', 'agentDomainPool')->where('type', 1)->value('id');
+        if (!$menuId) {
+            $menuId = DB::table('menus')->insertGetId([
+                'parent_id' => $parentId, 'title' => '代理域名管理', 'icon' => null, 'uri' => 'agentDomainPool',
+                'permission_name' => 'agent.agentDomainPool', 'sort' => 25, '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(['agentDomainPool'], 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();
+        }
+        Schema::dropIfExists('agent_domain_pool');
+    }
+};

+ 4 - 0
docs/前端交付/README.md

@@ -2,6 +2,10 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 代理域名管理(2026-11-09)
+
+- [代理域名管理-前端接入](../总后台/代理域名管理-前端接入.md):总后台「代理」菜单下新增「代理域名管理」,可以批量添加可用域名、启用 / 停用 / 删除,列表显示使用情况和绑定的代理。代理列表「绑定域名」可以改为从可用域名下拉选择(`GET /admin/agent/domain-pool/options`)。
+
 ## 入股配置:代理充值通道(2026-11-09)
 
 - [入股配置-代理充值通道-前端修正单](入股配置-代理充值通道-前端修正单.md):总后台「入股配置」页面新增「代理充值通道组合」下拉,选项来自 `GET /admin/rechargeChannel/groupList`。

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

@@ -14,6 +14,7 @@ php artisan migrate --force
 | `2026_11_07_130000_add_agent_joint_venture_admin_menus.php` | 总后台菜单:入股配置、分成方案申请、代理充值记录、会员盈亏日报,以及保存/审核按钮。代理菜单挂在标题含「代理」的一级菜单下;找不到时挂到根节点,需要手动调整 |
 | `2026_11_08_150000_add_type_to_agent_applications.php` | `agent_applications.type`(1 合营,默认 / 2 入股):会员申请时选择的代理类型,由 melbet_sport-api 的 `POST /api/agent/apply` 写入。**需要先于 melbet 新代码执行**,否则会员提交申请会因缺少该列报错 |
 | `2026_11_08_170000_add_agent_withdrawal_channels.php` | 代理提现支持全部提现通道:`agent_withdrawals` 新增 `pay_no`、`pay_data`、`callback_data`(三方代付)。通道存在原有的 `account_type`,提现账户沿用 `agent_withdraw_accounts`(`type` = 通道,`details` 存开户行/收款码/收款方式) |
+| `2026_11_09_120000_create_agent_domain_pool.php` | 代理域名池:新表 `agent_domain_pool`(按 `host` 唯一),导入已有的 `agent_domains`;菜单「代理域名管理」`agentDomainPool` 和新增 / 修改 / 删除按钮。代理列表绑定域名时会自动登记到域名池,停用的域名不能绑定 |
 | `2026_11_08_190000_add_agent_recharge_locks.php` | 入股代理充值锁定:新表 `agent_recharge_locks`(每笔充值一条,`remaining` 仍锁定的金额,`unlock_at` 到期时间),`agent_settings.equity_recharge_lock_days`(默认 7)。`AgentWalletService::adjust` 在入股代理 `recharge` 时写入、`equity_purchase` 时按到期时间先后消耗;可提现 = 余额 − 未到期的 `remaining`。**需要和代码一起上线**(充值入账会写这张表)。上线前已到账的充值不锁定 |
 | `2026_11_08_180000_add_agent_equity_order_admin.php` | 总后台「入股购买记录」菜单 `agentEquityOrders` 和取消按钮;`agent_equity_orders` 新增 `cancelled_by`、`cancel_remark`。**代理取消入股订单也会写这两列,需要和代码一起上线** |
 | `2026_11_08_120000_create_agent_payment_orders.php` | 代理人民币充值:新表 `agent_payment_orders`(字段和状态码同 `payment_orders`)、`agent_settings.recharge_channel_group_id`(默认 1),以及「代理人民币充值」菜单和 4 个按钮(挂在「代理充值记录」同一个父菜单下) |

+ 3 - 1
docs/总后台/代理列表.md

@@ -247,7 +247,9 @@ GET /admin/agent/show?id=12
 
 **绑定什么就保存什么**:输入的地址(包括路径和参数)只去掉首尾空格后原样保存,推广链接 `pc_register_url`/`h5_register_url` 就是绑定的地址本身,后端不再自动拼注册路径和邀请码。需要指向注册页时,直接绑定完整地址,例如 `https://pc.example.com/#/user/register`。
 
-一个域名(按主机名判断,不区分路径)不能绑定给不同代理。会员从这个域名注册时,注册页把当前页面地址作为 `domain` 传给 `/agent/referral/bind`,就能识别代理。
+一个域名(按主机名判断,不区分路径)不能绑定给不同代理。
+
+域名来源:输入框可以改为下拉,选项用 `GET /admin/agent/domain-pool/options?type=pc|h5`(启用且未被使用的域名),也可以手动输入。手动输入的新域名会自动登记到「代理域名管理」;在那里停用的域名绑定时返回 `该域名已停用,不能绑定`。详见 [代理域名管理-前端接入.md](代理域名管理-前端接入.md)。会员从这个域名注册时,注册页把当前页面地址作为 `domain` 传给 `/agent/referral/bind`,就能识别代理。
 
 ```json
 {

+ 162 - 0
docs/总后台/代理域名管理-前端接入.md

@@ -0,0 +1,162 @@
+# 总后台-代理域名管理(前端接入)
+
+「代理」菜单下新增子菜单「代理域名管理」,用于维护可分配给代理的域名,查看每个域名是否在使用、绑定在哪个代理。
+
+沿用总后台登录凭证,`/admin/agent/*` 只对 `AGENT_ADMIN_IDS` 里的管理员开放。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。
+
+## 0. 菜单与权限
+
+迁移 `2026_11_09_120000_create_agent_domain_pool.php` 已写入:
+
+| 类型 | uri | 标题 |
+|---|---|---|
+| 菜单 | `agentDomainPool` | 代理域名管理(和「代理充值记录」在同一个父菜单下) |
+| 按钮 | `admin/agent/domain-pool/create` | 代理域名池-新增 |
+| 按钮 | `admin/agent/domain-pool/update` | 代理域名池-修改(含启用 / 停用) |
+| 按钮 | `admin/agent/domain-pool/delete` | 代理域名池-删除 |
+
+## 1. 规则
+
+- 域名按**主机名**唯一:`https://a.com/#/reg` 和 `a.com` 算同一个域名。
+- 域名**原样保存**(只去掉首尾空格),可以是纯域名,也可以是带路径的完整地址。
+- **使用中** = 已绑定给某个代理(在「代理列表 → 绑定域名」绑定)。一个域名只能绑定给一个代理;同一个代理的 PC 和 H5 可以绑同一个域名。
+- 在代理列表直接输入一个不在域名池里的域名并绑定时,会**自动登记**到域名池(备注「代理列表绑定时自动登记」)。上线时,已有的代理绑定域名由迁移导入。
+- **停用**的域名不能再绑定给代理;已经绑定的不受影响(要换下来请到代理列表解绑)。
+- **使用中**的域名不能删除,也不能改成另一个主机名,要先解绑。
+- 类型 `type`:`pc` / `h5` / 空(通用)。只影响「可用域名」下拉的筛选,通用的两边都会出现。
+
+## 2. 域名列表
+
+### `GET /admin/agent/domain-pool`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `page` / `limit` | int | 否 | 默认 1 / 20,`limit` 最大 100 |
+| `keyword` | string | 否 | 域名模糊查询 |
+| `type` | string | 否 | `pc` / `h5` / `common`(通用) |
+| `status` | int | 否 | `1` 启用 / `0` 停用 |
+| `usage` | string | 否 | `in_use` 使用中 / `available` 未使用 |
+| `agent_id` | int | 否 | 绑定在该代理的域名 |
+| `agent_username` | string | 否 | 绑定代理的账号,模糊查询 |
+
+```json
+{
+  "total": 3,
+  "page": 1,
+  "limit": 20,
+  "summary": {"total": 3, "in_use": 2, "available": 1, "disabled": 0},
+  "list": [
+    {
+      "id": 1,
+      "domain": "https://a.example.com",
+      "host": "a.example.com",
+      "type": "pc",
+      "type_text": "PC",
+      "status": 1,
+      "status_text": "启用",
+      "remark": "第一批",
+      "in_use": true,
+      "usage_text": "使用中",
+      "bindings": [
+        {
+          "agent_id": 6,
+          "agent_username": "test1",
+          "agent_real_name": "simple",
+          "agent_type": 2,
+          "agent_type_text": "入股",
+          "agent_status": 1,
+          "slot": "pc",
+          "slot_text": "PC",
+          "bound_domain": "https://a.example.com/#/user/register",
+          "bound_at": "2026-11-09 12:00:00"
+        }
+      ],
+      "created_by": 1,
+      "created_at": "2026-11-09 11:00:00",
+      "updated_at": "2026-11-09 11:00:00"
+    }
+  ]
+}
+```
+
+列表字段:
+
+| 字段 | 页面列 | 说明 |
+|---|---|---|
+| `domain` | 域名 | 添加时的原样内容 |
+| `host` | 主机名 | 小写,可不展示 |
+| `type` / `type_text` | 类型 | `pc` PC / `h5` H5 / 空 通用 |
+| `status` / `status_text` | 状态 | 启用 / 停用 |
+| `usage_text` | 使用情况 | `使用中` / `可用` / `停用` |
+| `bindings` | 绑定代理 | 未使用时为空数组。每项:代理 ID、账号、真实姓名、代理类型(合营 / 入股)、代理状态、绑定位置 `slot`(pc / h5)、代理实际绑定的地址 `bound_domain`、绑定时间 `bound_at` |
+| `remark` | 备注 | |
+| `created_at` | 添加时间 | |
+
+`summary`:域名总数、使用中、可用(启用且未使用)、停用(未使用的停用域名),不受筛选影响,可放在列表上方。
+
+## 3. 添加域名(批量)
+
+### `POST /admin/agent/domain-pool/create`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `domains` | 是 | 域名数组,或多行文本(每行一个,也可以用逗号分隔),一次最多 100 个 |
+| `type` | 否 | `pc` / `h5` / `common`,默认 `common`(通用) |
+| `remark` | 否 | 备注,最多 255 字 |
+
+```json
+{"domains": "a.example.com\nhttps://b.example.com/#/user/register", "type": "pc", "remark": "第一批"}
+```
+
+返回:
+
+```json
+{"created": 1, "skipped": [{"domain": "a.example.com", "reason": "域名已存在"}]}
+```
+
+不能添加的会跳过,并在 `skipped` 里说明原因:`域名格式错误`、`本次输入重复`、`域名已存在`。页面可以提示「成功 N 个,跳过 M 个」并列出原因。
+
+## 4. 修改 / 启用 / 停用
+
+### `POST /admin/agent/domain-pool/update`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 域名 ID |
+| `domain` | 否 | 修改域名。使用中的域名只能改路径,不能改成其他主机名 |
+| `type` | 否 | `pc` / `h5` / `common` |
+| `status` | 否 | `1` 启用 / `0` 停用 |
+| `remark` | 否 | 备注 |
+
+只传要修改的字段。返回修改后的这一行(字段同列表)。
+
+常见错误:`域名使用中,不能修改为其他域名,请先到代理列表解绑`、`域名已存在`、`域名格式错误`。
+
+## 5. 删除
+
+### `POST /admin/agent/domain-pool/delete`
+
+参数:`id`。使用中返回 `域名使用中,不能删除,请先到代理列表解绑`。
+
+## 6. 可用域名下拉(给「代理列表 → 绑定域名」用)
+
+### `GET /admin/agent/domain-pool/options`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `type` | 否 | `pc` 或 `h5`:返回该类型和通用的域名;不传返回全部 |
+
+返回**启用且未被使用**的域名(最多 500 个):
+
+```json
+[{"id": 2, "domain": "https://b.example.com/#/user/register", "type": "pc", "type_text": "PC", "remark": "第一批"}]
+```
+
+代理列表的「绑定域名」弹窗:PC、H5 两个输入框改为「下拉选择(来自本接口)+ 可手动输入」,提交仍然是原接口 `POST /admin/agent/domains`(`id`、`pc`、`h5`),把选中的 `domain` 原样传过去。手动输入的新域名会自动登记到域名池。绑定停用的域名时返回 `该域名已停用,不能绑定`。
+
+## 7. 页面建议
+
+- 顶部:`summary` 四个数字(总数 / 使用中 / 可用 / 停用)。
+- 筛选:关键字、类型、状态、使用情况、代理账号。
+- 表格:域名、类型、状态、使用情况、绑定代理(账号 + 类型 + PC / H5,多个时换行)、备注、添加时间、操作(修改、启用 / 停用、删除;使用中的行隐藏删除按钮)。
+- 「新增」按钮:多行文本框 + 类型 + 备注。

+ 5 - 0
routes/admin.php

@@ -183,6 +183,11 @@ Route::middleware(['admin.jwt'])->group(function () {
             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');
             Route::get('/equity-orders', [AgentAdmin::class, 'equityOrders'])->defaults('permission_uri', 'admin/agent/equity-orders');
+            Route::get('/domain-pool', [\App\Http\Controllers\admin\AgentDomainPool::class, 'index'])->defaults('permission_uri', 'admin/agent/domain-pool');
+            Route::get('/domain-pool/options', [\App\Http\Controllers\admin\AgentDomainPool::class, 'options'])->defaults('permission_uri', 'admin/agent/domain-pool');
+            Route::post('/domain-pool/create', [\App\Http\Controllers\admin\AgentDomainPool::class, 'create'])->defaults('permission_uri', 'admin/agent/domain-pool/create');
+            Route::post('/domain-pool/update', [\App\Http\Controllers\admin\AgentDomainPool::class, 'update'])->defaults('permission_uri', 'admin/agent/domain-pool/update');
+            Route::post('/domain-pool/delete', [\App\Http\Controllers\admin\AgentDomainPool::class, 'delete'])->defaults('permission_uri', 'admin/agent/domain-pool/delete');
             Route::post('/equity-orders/cancel', [AgentAdmin::class, 'cancelEquityOrder'])->defaults('permission_uri', 'admin/agent/equity-orders/cancel');
         });
 

+ 73 - 0
tests/Integration/AgentDomainPoolTest.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\AgentDomainPool as PoolController;
+use App\Services\Agent\AgentDomainPoolService;
+use App\Services\Agent\AgentDomainService;
+use Illuminate\Http\Request;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 总后台代理域名管理:添加可用域名、使用情况、绑定到哪个代理、停用和删除规则。 */
+class AgentDomainPoolTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    private AgentDomainPoolService $pool;
+    private AgentDomainService $domains;
+
+    private function boot(): void
+    {
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_120000_create_agent_domain_pool.php')->up();
+        $this->domains = new AgentDomainService();
+        $this->pool = new AgentDomainPoolService($this->domains);
+    }
+
+    private function call(string $method, array $params, string $http = 'POST'): array
+    {
+        $request = Request::create('/admin/agent/domain-pool', $http, $params);
+        $request->user = (object) ['id' => 9];
+        $this->app->instance('request', $request);
+        return (new PoolController())->{$method}($request, $this->pool)->getData(true);
+    }
+
+    public function test_pool_lifecycle_with_bindings(): void
+    {
+        $this->boot();
+        $created = $this->call('create', ['domains' => "a.example.com\nhttps://A.example.com/#/reg\nftp://bad.example.com\nhttps://b.example.com/#/user/register", 'type' => 'pc', 'remark' => '第一批']);
+        $this->assertSame(0, $created['code'], (string) $created['msg']);
+        $this->assertSame(2, $created['data']['created']);
+        $this->assertSame(['本次输入重复', '域名格式错误'], array_column($created['data']['skipped'], 'reason'));
+        $this->assertSame('域名已存在', $this->pool->create(['a.example.com'], '', '', 1)['skipped'][0]['reason']);
+
+        $agent = $this->agent();
+        $this->domains->save($agent, ['pc' => 'https://a.example.com/#/user/register', 'h5' => 'https://c.example.com']);
+        $list = $this->call('index', [], 'GET')['data'];
+        $this->assertSame(['total' => 3, 'in_use' => 2, 'available' => 1, 'disabled' => 0], $list['summary'], 'c.example.com auto-registered on bind');
+        $rows = array_column($list['list'], null, 'host');
+        $this->assertTrue($rows['a.example.com']['in_use']);
+        $this->assertSame('a.example.com', $rows['a.example.com']['domain'], 'pool keeps what was added');
+        $this->assertSame([$agent->username, 'pc', 'https://a.example.com/#/user/register'],
+            [$rows['a.example.com']['bindings'][0]['agent_username'], $rows['a.example.com']['bindings'][0]['slot'], $rows['a.example.com']['bindings'][0]['bound_domain']]);
+        $this->assertSame(['h5', '代理列表绑定时自动登记'], [$rows['c.example.com']['type'], $rows['c.example.com']['remark']]);
+        $this->assertSame(['可用', '使用中'], [$rows['b.example.com']['usage_text'], $rows['a.example.com']['usage_text']]);
+
+        $this->assertSame(['b.example.com'], array_column($this->call('index', ['usage' => 'available'], 'GET')['data']['list'], 'host'));
+        $this->assertSame(2, $this->call('index', ['agent_username' => $agent->username], 'GET')['data']['total']);
+        $this->assertSame(['https://b.example.com/#/user/register'], array_column($this->pool->options('pc'), 'domain'), 'options exclude domains in use');
+        $this->assertSame([], $this->pool->options('h5'), 'pc-only domain is not offered for h5');
+
+        $b = $rows['b.example.com']['id'];
+        $this->call('update', ['id' => $b, 'status' => 0]);
+        try { $this->domains->save($this->agent(), ['pc' => 'b.example.com']); $this->fail('disabled'); }
+        catch (\RuntimeException $e) { $this->assertSame('该域名已停用,不能绑定', $e->getMessage()); }
+
+        $a = $rows['a.example.com']['id'];
+        $this->assertSame('域名使用中,不能删除,请先到代理列表解绑', $this->call('delete', ['id' => $a])['msg']);
+        $this->assertSame('域名使用中,不能修改为其他域名,请先到代理列表解绑', $this->call('update', ['id' => $a, 'domain' => 'z.example.com'])['msg']);
+        $this->domains->save($agent, ['pc' => '']);
+        $this->assertSame(0, $this->call('delete', ['id' => $a])['code'], 'free after unbinding');
+        $this->assertSame(2, $this->call('index', [], 'GET')['data']['total']);
+    }
+}