Ver Fonte

代理域名无pc、h5

doge há 1 dia atrás
pai
commit
821226c8c8

+ 6 - 6
app/Http/Controllers/admin/Agent.php

@@ -100,16 +100,16 @@ class Agent extends AgentApiController
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate([
                 'id' => ['required', 'integer', 'exists:agents,id'],
-                // 字符串(一个域名,兼容旧写法)或数组(多个域名);空字符串 / 空数组为清空
-                'pc' => ['nullable', $one = function ($attribute, $value, $fail) {
+                // domains:域名数组(整体替换,空数组为清空),也可以是字符串(一个域名)
+                'domains' => ['present', function ($attribute, $value, $fail) {
                     if (!is_string($value) && !is_array($value)) $fail('域名格式错误');
                     elseif (is_string($value) && mb_strlen($value) > 255) $fail('域名不能超过255个字符');
                 }],
-                'h5' => ['nullable', $one],
-                'pc.*' => ['nullable', 'string', 'max:255'], 'h5.*' => ['nullable', 'string', 'max:255'],
+                'domains.*' => ['nullable', 'string', 'max:255'],
             ]);
-            $data = array_intersect_key($request->all(), array_flip(['pc', 'h5'])) + ['id' => $data['id']];
-            return $service->save(AgentModel::query()->findOrFail($data['id']), $data);
+            $domains = $request->input('domains');
+            $values = is_array($domains) ? $domains : ($domains === null ? [] : [(string) $domains]);
+            return $service->save(AgentModel::query()->findOrFail($data['id']), $values);
         });
     }
 

+ 6 - 12
app/Http/Controllers/admin/AgentDomainPool.php

@@ -14,21 +14,17 @@ class AgentDomainPool extends AgentApiController
         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'],
+                'keyword' => ['nullable', 'string', 'max:255'],
                 '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)
+    public function options(AgentDomainPoolService $service)
     {
-        return $this->execute(function () use ($request, $service) {
-            $data = $request->validate(['type' => ['nullable', 'in:pc,h5']]);
-            return $service->options($data['type'] ?? null);
-        });
+        return $this->execute(fn () => $service->options());
     }
 
     public function create(Request $request, AgentDomainPoolService $service)
@@ -36,11 +32,10 @@ class AgentDomainPool extends AgentApiController
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate([
                 // 数组,或多行文本(每行一个)
-                'domains' => ['required'], 'type' => ['nullable', 'in:pc,h5,common'], 'remark' => ['nullable', 'string', 'max:255'],
+                'domains' => ['required'], '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);
+            return $service->create($domains, trim((string) ($data['remark'] ?? '')), (int) request()->user->id);
         });
     }
 
@@ -49,9 +44,8 @@ class AgentDomainPool extends AgentApiController
         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'],
+                'status' => ['nullable', 'in:0,1'], 'remark' => ['nullable', 'string', 'max:255'],
             ]);
-            if (($data['type'] ?? null) === 'common') $data['type'] = '';
             return $service->update((int) $data['id'], $data);
         });
     }

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

@@ -7,8 +7,6 @@ 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 = [];

+ 11 - 17
app/Services/Agent/AgentDomainPoolService.php

@@ -28,7 +28,6 @@ class AgentDomainPoolService
         $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);
@@ -47,26 +46,24 @@ class AgentDomainPoolService
         return compact('total', 'page', 'limit', 'list') + ['summary' => $this->summary()];
     }
 
-    /** 绑定代理时的下拉:启用、未被使用、类型匹配(通用的两种都可用)。 */
-    public function options(?string $type): array
+    /** 绑定代理时的下拉:启用且未被使用的域名。 */
+    public function options(): 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();
+        return $query->orderByDesc('id')->limit(500)->get(['id', 'domain', 'remark'])
+            ->map(fn (AgentDomainPool $row) => ['id' => $row->id, 'domain' => $row->domain, 'remark' => $row->remark])->all();
     }
 
     /** 批量添加:每行一个域名;已存在或重复的跳过并说明原因。 */
-    public function create(array $domains, string $type, string $remark, ?int $adminId): array
+    public function create(array $domains, 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) {
+        DB::transaction(function () use ($domains, $remark, $adminId, &$created, &$skipped) {
             $seen = [];
             foreach ($domains as $domain) {
                 try {
@@ -81,7 +78,7 @@ class AgentDomainPoolService
                 if (AgentDomainPool::query()->where('host', $host)->lockForUpdate()->exists()) {
                     $skipped[] = ['domain' => $domain, 'reason' => '域名已存在']; continue;
                 }
-                $created[] = AgentDomainPool::query()->create(['domain' => $value, 'host' => $host, 'type' => $type,
+                $created[] = AgentDomainPool::query()->create(['domain' => $value, 'host' => $host, 'type' => '',
                     'status' => 1, 'remark' => $remark, 'created_by' => $adminId])->id;
             }
         });
@@ -102,7 +99,7 @@ class AgentDomainPoolService
                 $row->domain = $value;
                 $row->host = $host;
             }
-            foreach (['type', 'remark'] as $field) {
+            foreach (['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'];
@@ -121,14 +118,14 @@ class AgentDomainPoolService
     }
 
     /** 代理列表绑定域名前调用:停用的不能绑定;没登记的自动登记。调用方已在事务内。 */
-    public function register(string $domain, string $host, string $slotType, ?int $adminId): void
+    public function register(string $domain, string $host, ?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,
+        AgentDomainPool::query()->create(['domain' => $domain, 'host' => $host, 'type' => '', 'status' => 1,
             'remark' => '代理列表绑定时自动登记', 'created_by' => $adminId]);
     }
 
@@ -137,7 +134,7 @@ class AgentDomainPoolService
         return AgentDomain::query()->where('host', $host)->exists();
     }
 
-    /** host => [绑定信息…](同一代理的 PC 和 H5 可能绑定同一主机名)。 */
+    /** host => [绑定信息](一个主机名只属于一个代理,数组里最多一项)。 */
     private function bindings(array $hosts): array
     {
         if ($hosts === []) return [];
@@ -156,8 +153,6 @@ class AgentDomainPoolService
                 '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,
             ];
@@ -170,7 +165,6 @@ class AgentDomainPoolService
         $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,

+ 32 - 53
app/Services/Agent/AgentDomainService.php

@@ -8,78 +8,57 @@ use Illuminate\Support\Facades\DB;
 
 /**
  * 代理推广域名:总后台绑定什么就保存什么(含路径和参数,原样返回作为推广链接),不再自动拼注册路径和邀请码。
- * 每个代理的 PC、H5 都可以绑定多个域名;一个主机名只能属于一个代理。
+ * 每个代理可以绑定多个域名,不区分 PC / H5;一个主机名只能属于一个代理。
  * 主机名(host)用于判断域名是否已被其他代理绑定,以及注册时按访问域名识别代理。
  */
 class AgentDomainService
 {
-    public const MAX_PER_TYPE = 50;
+    public const MAX_DOMAINS = 50;
 
     /**
-     * 保存代理的 PC / H5 域名。每种类型传字符串(一个域名,兼容旧写法)或数组(多个);
-     * 传了的类型整体替换为本次的列表,空字符串 / 空数组表示清空;没传的类型不变。
+     * 保存代理的域名列表(不区分 PC / H5),整体替换:传空数组表示清空。
+     * 同一主机名只保留第一个;一个主机名只能属于一个代理。
      */
-    public function save(Agent $agent, array $domains): array
+    public function save(Agent $agent, array $values): array
     {
-        return DB::transaction(function () use ($agent, $domains) {
+        return DB::transaction(function () use ($agent, $values) {
+            $wanted = [];
+            foreach ($values as $value) {
+                $url = $this->normalize((string) $value);
+                if ($url === '') continue;
+                $wanted[$this->host($url)] ??= $url;
+            }
+            if (count($wanted) > self::MAX_DOMAINS) {
+                throw new \InvalidArgumentException('每个代理最多绑定 ' . self::MAX_DOMAINS . ' 个域名');
+            }
             $poolEnabled = \Illuminate\Support\Facades\Schema::hasTable('agent_domain_pool');
             $adminId = isset(request()->user->id) ? (int) request()->user->id : null;
-            foreach (['pc', 'h5'] as $type) {
-                if (!array_key_exists($type, $domains)) {
-                    continue;
-                }
-                $values = is_array($domains[$type]) ? $domains[$type] : [$domains[$type]];
-                $wanted = [];
-                foreach ($values as $value) {
-                    $url = $this->normalize((string) $value);
-                    if ($url === '') continue;
-                    $host = $this->host($url);
-                    $wanted[$host] ??= $url; // 同一主机名只保留第一个
-                }
-                if (count($wanted) > self::MAX_PER_TYPE) {
-                    throw new \InvalidArgumentException('每种类型最多绑定 ' . self::MAX_PER_TYPE . ' 个域名');
-                }
-                foreach ($wanted as $host => $url) {
-                    $owner = AgentDomain::query()->where('host', $host)->where('agent_id', '<>', $agent->id)->lockForUpdate()->first();
-                    if ($owner) {
-                        throw new \RuntimeException('该域名已绑定其他代理:' . $host);
-                    }
-                    // 登记到域名池(已停用的不能绑定);迁移未执行前跳过
-                    if ($poolEnabled) (new AgentDomainPoolService($this))->register($url, $host, $type, $adminId);
+            foreach ($wanted as $host => $url) {
+                if (AgentDomain::query()->where('host', $host)->where('agent_id', '<>', $agent->id)->lockForUpdate()->exists()) {
+                    throw new \RuntimeException('该域名已绑定其他代理:' . $host);
                 }
-                $existing = AgentDomain::query()->where('agent_id', $agent->id)->where('type', $type)->lockForUpdate()->get()->keyBy('host');
-                foreach ($existing as $host => $row) {
-                    if (!isset($wanted[$host])) $row->delete();
-                }
-                foreach ($wanted as $host => $url) {
-                    if (isset($existing[$host])) {
-                        $existing[$host]->fill(['domain' => $url, 'status' => 1])->save();
-                    } else {
-                        AgentDomain::query()->create(['agent_id' => $agent->id, 'type' => $type, 'domain' => $url, 'host' => $host, 'status' => 1]);
-                    }
+                // 登记到域名池(已停用的不能绑定);迁移未执行前跳过
+                if ($poolEnabled) (new AgentDomainPoolService($this))->register($url, $host, $adminId);
+            }
+            $existing = AgentDomain::query()->where('agent_id', $agent->id)->lockForUpdate()->get()->keyBy('host');
+            foreach ($existing as $host => $row) {
+                if (!isset($wanted[$host])) $row->delete();
+            }
+            foreach ($wanted as $host => $url) {
+                if (isset($existing[$host])) {
+                    $existing[$host]->fill(['domain' => $url, 'type' => '', 'status' => 1])->save();
+                } else {
+                    AgentDomain::query()->create(['agent_id' => $agent->id, 'type' => '', 'domain' => $url, 'host' => $host, 'status' => 1]);
                 }
             }
             return $this->format($agent->fresh());
         });
     }
 
-    /**
-     * 域名信息。pc_domains / h5_domains 为全部绑定(按绑定先后);
-     * pc_domain / h5_domain 和 *_register_url 为第一个,兼容只显示一个的旧页面。推广链接 = 绑定的地址本身。
-     */
+    /** 域名信息:domains 为全部绑定的域名(按绑定先后),每个都是推广链接(绑定什么就是什么)。 */
     public function format(Agent $agent): array
     {
-        $rows = $agent->domains()->where('status', 1)->orderBy('id')->get(['type', 'domain']);
-        $pc = $rows->where('type', 'pc')->pluck('domain')->values()->all();
-        $h5 = $rows->where('type', 'h5')->pluck('domain')->values()->all();
-        return [
-            'pc_domain' => (string) ($pc[0] ?? ''),
-            'h5_domain' => (string) ($h5[0] ?? ''),
-            'pc_register_url' => (string) ($pc[0] ?? ''),
-            'h5_register_url' => (string) ($h5[0] ?? ''),
-            'pc_domains' => $pc,
-            'h5_domains' => $h5,
-        ];
+        return ['domains' => $agent->domains()->where('status', 1)->orderBy('id')->pluck('domain')->values()->all()];
     }
 
     public function resolve(?string $invitationCode, ?string $domain): ?Agent

+ 37 - 0
database/migrations/2026_11_09_140000_merge_agent_domain_types.php

@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 代理域名不再区分 PC / H5:每个代理就是一组域名。
+ * - agent_domains:同一代理同一主机名只留最早的一条,type 统一为空;主机名全局唯一(一个域名只属于一个代理);
+ * - agent_domain_pool:type 统一为空(不再分类型)。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        $keep = DB::table('agent_domains')->groupBy('agent_id', 'host')->selectRaw('MIN(id) AS id')->pluck('id')->all();
+        if ($keep !== []) DB::table('agent_domains')->whereNotIn('id', $keep)->delete();
+        DB::table('agent_domains')->update(['type' => '']);
+        Schema::table('agent_domains', function (Blueprint $t) {
+            $t->dropUnique('uniq_agent_domain_host');
+            $t->dropIndex('idx_agent_domain_type');
+            $t->unique('host', 'uniq_agent_domain_host_only');
+        });
+        if (Schema::hasTable('agent_domain_pool')) DB::table('agent_domain_pool')->update(['type' => '']);
+    }
+
+    public function down(): void
+    {
+        // 旧数据的 PC / H5 区分已合并,回滚只恢复索引;回滚后的域名都视为 PC
+        DB::table('agent_domains')->update(['type' => 'pc']);
+        Schema::table('agent_domains', function (Blueprint $t) {
+            $t->dropUnique('uniq_agent_domain_host_only');
+            $t->unique(['agent_id', 'type', 'host'], 'uniq_agent_domain_host');
+            $t->index(['agent_id', 'type'], 'idx_agent_domain_type');
+        });
+    }
+};

+ 1 - 4
docs/代理/个人信息.md

@@ -56,10 +56,7 @@
     },
     "last_login_at": "2026-08-24 18:00:00",
     "last_login_ip": "1.2.3.4",
-    "pc_domain": "https://pc.example.com",
-    "h5_domain": "https://h5.example.com",
-    "pc_register_url": "https://pc.example.com",
-    "h5_register_url": "https://h5.example.com"
+    "domains": ["https://pc.example.com", "https://h5.example.com"]
   }
 }
 ```

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

@@ -39,8 +39,7 @@
 |---|---|
 | `id`、`username`、`real_name`、`language`、`invitation_code` | 不变 |
 | `type` / `type_text` | `1` 合营 / `2` 入股 |
-| `pc_domain`、`h5_domain` | 总后台分配给代理的推广域名 |
-| `pc_register_url`、`h5_register_url` | **平台邀请地址**:总后台绑定的地址原样返回(绑定什么就是什么),没分配域名时为空字符串 |
+| `domains` | **推广链接**:总后台给代理绑定的全部域名(不分 PC / H5,原样返回),没分配时为空数组 |
 | `balance` | 总余额 |
 | `frozen_balance` | 提现冻结中 |
 | `withdrawable_amount` | 当前可提现 |
@@ -58,8 +57,7 @@
 {"token":"…","token_type":"Bearer","expires_at":"2026-11-14 12:00:00","agent":{
   "id":12,"username":"agent12","real_name":"","language":"zh","invitation_code":"ABCD123456",
   "type":1,"type_text":"合营","withdraw_fee_rate":"0.0000","has_withdraw_password":true,
-  "pc_domain":"https://a.example.com","h5_domain":"https://m.example.com",
-  "pc_register_url":"https://a.example.com","h5_register_url":"https://m.example.com",
+  "domains":["https://a.example.com","https://m.example.com"],
   "balance":"21500.0000","frozen_balance":"0.0000","withdrawable_amount":"1500.0000",
   "margin_amount":"20000.0000","margin_balance":"20000.0000","profit_amount":"1500.0000",
   "profit_commission_profile_id":2,"profit_commission_profile":{"id":2,"name":"合营A","margin_amount":"20000.0000","f1_lottery_rate":"10.0000","f1_sport_rate":"10.0000","live_rate":"10.0000","slot_rate":"10.0000","sport_rate":"10.0000","esports_rate":"10.0000","fishing_rate":"10.0000","chess_rate":"10.0000"},

+ 1 - 1
docs/代理/代理首页.md

@@ -104,7 +104,7 @@ GET /agent/dashboard?start_date=2026-11-08&end_date=2026-11-08
 |---|---|
 | 欢迎语里的账号 | `username` |
 | 类型标签(合营 / 入股) | `type`(`1` 合营 / `2` 入股)、`type_text` |
-| 邀请链接 | `pc_register_url`、`h5_register_url` |
+| 邀请链接 | `domains`(数组,每个都是推广链接) |
 
 欢迎语里的称呼建议按 `type` 区分(例如合营代理显示「尊敬的合营代理」、入股代理显示「尊敬的股东」),不要固定写「股东」。
 

+ 1 - 4
docs/代理/所有代理.md

@@ -53,10 +53,7 @@ GET /agent/sub-agents?page=1&limit=20&direct_only=1
         "last_login_at": null,
         "last_login_ip": "",
         "parent": {"id":12,"username":"agent001"},
-        "pc_domain": "",
-        "h5_domain": "",
-        "pc_register_url": "",
-        "h5_register_url": ""
+        "domains": []
       }
     ]
   }

+ 2 - 5
docs/代理/推广域名.md

@@ -16,12 +16,9 @@
   "timestamp": 1787568000,
   "msg": "OK",
   "data": {
-    "pc_domain": "https://pc.example.com",
-    "h5_domain": "https://h5.example.com",
-    "pc_register_url": "https://pc.example.com",
-    "h5_register_url": "https://h5.example.com"
+    "domains": ["https://pc.example.com", "https://h5.example.com"]
   }
 }
 ```
 
-前端复制推广链接时使用返回的 `pc_register_url`/`h5_register_url`。推广链接就是总后台绑定的地址本身(绑定什么就是什么),后端不再拼接注册路径和邀请码;没有绑定域名时为空字符串。
+`domains` 是总后台给代理绑定的全部域名(不分 PC / H5),每一个都是推广链接,前端逐个列出供复制。推广链接就是绑定的地址本身(绑定什么就是什么),后端不拼注册路径和邀请码;没有绑定时为空数组。

+ 2 - 2
docs/前端交付/README.md

@@ -2,9 +2,9 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
-## 代理绑定多个域名(2026-11-09)
+## 代理域名(2026-11-09,取代「代理绑定多个域名」)
 
-- [代理绑定多个域名-前端修改单](代理绑定多个域名-前端修改单.md):代理的 PC、H5 都可以绑定多个域名。`POST /admin/agent/domains` 的 `pc`、`h5` 可以传数组,返回和代理信息新增 `pc_domains`、`h5_domains`。需要修改总后台的绑定弹窗和列表,以及代理后台的邀请链接。
+- [代理域名-前端修改单](代理域名-前端修改单.md):代理域名**不分 PC 和 H5**,每个代理可以绑定多个域名。绑定接口改为 `domains` 数组,代理信息改为返回 `domains`。旧的 `pc`/`h5` 参数和 `pc_domain`/`h5_domain`/`pc_register_url`/`h5_register_url` 字段**已删除,不兼容**。「代理域名管理」去掉了类型。
 
 ## 代理域名管理(2026-11-09)
 

+ 100 - 0
docs/前端交付/代理域名-前端修改单.md

@@ -0,0 +1,100 @@
+# 代理域名-前端修改单(2026-11-09)
+
+本单**取代**之前的《代理绑定多个域名-前端修改单》,以本单为准。
+
+## 改动
+
+1. **不再区分 PC 和 H5**:每个代理就是一组域名(可以绑定多个,最多 50 个)。
+2. **绑定什么就是什么**:域名原样保存,每个域名本身就是推广链接,后端不再拼注册路径和邀请码。
+3. **不兼容旧字段**:
+   - 绑定接口的 `pc`、`h5` 参数已删除,改为 `domains`;
+   - 返回里的 `pc_domain`、`h5_domain`、`pc_register_url`、`h5_register_url` 已删除,改为 `domains`。
+4. 会员从任意一个绑定的域名注册,后端按注册页域名自动关联代理。会员前台不用改。
+
+规则:
+
+- 一个域名(按主机名判断,例如 `https://a.com/x` 和 `a.com` 算同一个)只能属于一个代理。
+- 同一次提交里主机名相同的只保留第一个。
+- 「代理域名管理」里停用的域名不能绑定。手动输入的新域名会自动登记到「代理域名管理」。
+
+## 一、总后台
+
+### 1. 绑定域名 `POST /admin/agent/domains`
+
+请用 JSON 提交:
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 代理 ID |
+| `domains` | 是 | 域名数组,**整体替换**这个代理的全部域名;`[]` 表示全部解绑 |
+
+```json
+{"id": 6, "domains": ["https://a.example.com", "https://b.example.com/#/user/register", "https://m.example.com"]}
+```
+
+返回:
+
+```json
+{"domains": ["https://a.example.com", "https://b.example.com/#/user/register", "https://m.example.com"]}
+```
+
+错误提示:
+
+| `msg` | 原因 |
+|---|---|
+| `该域名已绑定其他代理:b.example.com` | 列表里有域名属于其他代理,这次提交整体不生效 |
+| `该域名已停用,不能绑定` | 域名在「代理域名管理」里被停用 |
+| `每个代理最多绑定 50 个域名` | |
+| `域名格式错误` | 不是 http/https 地址,或含空格 |
+
+### 2. 绑定域名弹窗
+
+- PC、H5 两个输入框合并为**一个多选 / 标签输入**:
+  - 可以从下拉选择,选项来自 `GET /admin/agent/domain-pool/options`(启用且未被使用的域名,**不再传 `type`**),返回 `[{id, domain, remark}]`;
+  - 也可以手动输入新域名。
+- 打开弹窗时用代理信息里的 `domains` 回显。
+- 提交时传**完整列表**,删掉的域名不传就会被解绑。
+
+### 3. 代理列表 / 详情
+
+`GET /admin/agent`、`GET /admin/agent/show` 的代理信息:删除 `pc_domain`、`h5_domain`、`pc_register_url`、`h5_register_url`,新增 `domains`(数组)。列表的域名列改为展示 `domains`,多个时换行或显示「a.com 等 3 个」。
+
+### 4. 代理域名管理(去掉类型)
+
+- 列表 `GET /admin/agent/domain-pool`:删除筛选参数 `type`;每行删除 `type`、`type_text`;`bindings` 里删除 `slot`、`slot_text`。
+- 新增 `POST /admin/agent/domain-pool/create`:删除参数 `type`。
+- 修改 `POST /admin/agent/domain-pool/update`:删除参数 `type`。
+- 下拉 `GET /admin/agent/domain-pool/options`:删除参数 `type`,返回删除 `type`、`type_text`。
+
+页面上去掉「类型」列、类型筛选和新增 / 编辑表单里的类型选择。
+
+## 二、代理独立后台
+
+`POST /agent/auth/login` 和 `GET /agent/profile` 返回的代理信息、`GET /agent/profile/domains`:删除 `pc_domain`、`h5_domain`、`pc_register_url`、`h5_register_url`,新增 `domains`(数组)。
+
+```json
+{"domains": ["https://a.example.com", "https://b.example.com/#/user/register"]}
+```
+
+「邀请链接」改为列出 `domains` 里的每一个,各自可以复制;数组为空时显示「暂未分配推广域名,请联系平台」。
+
+## 三、会员前台(PC / H5)
+
+不需要改。
+
+## 上线
+
+后端执行迁移:
+
+- `2026_11_09_130000_allow_multiple_agent_domains`
+- `2026_11_09_140000_merge_agent_domain_types`
+
+后一个迁移会把已有的 PC、H5 域名合并到同一个列表(同一个域名只留一条),并去掉「代理域名管理」里的类型。
+
+## 验收
+
+1. 给代理绑定 3 个域名并保存,总后台列表和代理后台都显示这 3 个。
+2. 分别从其中两个域名注册新会员,都挂在这个代理下。
+3. 把其中一个域名绑给另一个代理,提示「该域名已绑定其他代理:…」,两边都不变。
+4. 编辑时删掉一个域名并保存,它被解绑,「代理域名管理」里显示为可用。
+5. 「代理域名管理」页面没有类型列和类型筛选。

+ 0 - 85
docs/前端交付/代理绑定多个域名-前端修改单.md

@@ -1,85 +0,0 @@
-# 代理绑定多个域名-前端修改单(2026-11-09)
-
-## 改动
-
-代理的 PC 域名、H5 域名**都可以绑定多个**(原来每种只能一个)。每个绑定的域名都是推广链接,会员从其中任意一个域名注册,都会自动关联到这个代理。
-
-规则:
-
-- 绑定什么就保存什么(原样保存,不拼注册路径和邀请码)。
-- 一个域名(按主机名判断)只能属于一个代理;同一代理同一类型下,主机名相同的只保留第一个。
-- 每种类型最多 50 个。
-- 「代理域名管理」里停用的域名不能绑定;新域名绑定时会自动登记到「代理域名管理」。
-
-## 一、总后台
-
-### 1. 绑定域名 `POST /admin/agent/domains`(地址不变,参数可传数组)
-
-| 参数 | 必填 | 说明 |
-|---|---:|---|
-| `id` | 是 | 代理 ID |
-| `pc` | 否 | **数组**(多个域名),也兼容字符串(一个域名)。传了就**整体替换**该类型的全部绑定;空数组或空字符串表示清空;不传则不改 |
-| `h5` | 否 | 同 `pc` |
-
-```json
-{"id": 6, "pc": ["https://a.example.com", "https://b.example.com/#/user/register"], "h5": ["https://m.example.com"]}
-```
-
-返回(新增 `pc_domains`、`h5_domains`):
-
-```json
-{
-  "pc_domain": "https://a.example.com",
-  "h5_domain": "https://m.example.com",
-  "pc_register_url": "https://a.example.com",
-  "h5_register_url": "https://m.example.com",
-  "pc_domains": ["https://a.example.com", "https://b.example.com/#/user/register"],
-  "h5_domains": ["https://m.example.com"]
-}
-```
-
-- `pc_domains` / `h5_domains`:全部绑定的域名,按绑定先后排序。**页面请改用这两个字段。**
-- `pc_domain` / `h5_domain` / `pc_register_url` / `h5_register_url`:只是第一个域名,保留给旧页面,不要再依赖。
-
-错误提示:
-
-| `msg` | 原因 |
-|---|---|
-| `该域名已绑定其他代理:b.example.com` | 列表里有域名属于其他代理(这次提交整体不生效) |
-| `该域名已停用,不能绑定` | 域名在「代理域名管理」里被停用 |
-| `每种类型最多绑定 50 个域名` | |
-| `域名格式错误` | 不是 http/https 地址,或含空格 |
-
-### 2. 绑定域名弹窗
-
-- PC、H5 各改成**多选 / 标签输入**:
-  - 可以从下拉选择,选项用 `GET /admin/agent/domain-pool/options?type=pc`(或 `h5`),返回启用且未被使用的域名;
-  - 也可以手动输入新域名。
-- 打开弹窗时用代理详情或列表里的 `pc_domains` / `h5_domains` 回显当前绑定。
-- 提交时把**完整列表**传给 `pc` / `h5`(删掉的域名不传就会被解绑)。
-
-### 3. 代理列表 / 详情
-
-`GET /admin/agent`、`GET /admin/agent/show` 返回的代理信息新增 `pc_domains`、`h5_domains`。列表的「域名」列改为展示全部(多个时换行或「a.com 等 3 个」)。
-
-### 4. 代理域名管理
-
-不需要改。列表的 `bindings` 本来就是数组,一个代理绑定多个域名时,每个域名各自显示「使用中」和绑定的代理。
-
-## 二、代理独立后台
-
-`GET /agent/profile`、`POST /agent/auth/login` 返回的代理信息、`GET /agent/profile/domains` 都新增 `pc_domains`、`h5_domains`。
-
-- 「邀请链接」改为列出全部域名,每个都可以复制(`pc_domains`、`h5_domains`)。
-- 原来的 `pc_register_url` / `h5_register_url` 只是第一个,保留兼容。
-
-## 三、会员前台(PC / H5)
-
-不需要改。会员从任意一个绑定的域名注册,后端都会按注册页域名自动关联代理。
-
-## 验收
-
-1. 给代理绑定 2 个 PC 域名、1 个 H5 域名,列表和代理后台都能看到全部 3 个。
-2. 分别从两个 PC 域名注册新会员,都挂在这个代理下。
-3. 把其中一个域名绑给另一个代理,提示「该域名已绑定其他代理:…」。
-4. 编辑时删掉一个域名并保存,该域名被解绑,「代理域名管理」里显示为可用。

+ 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_140000_merge_agent_domain_types.php` | 代理域名不分 PC / H5:已有的 PC、H5 域名合并为同一个列表(同一代理同一主机名只留一条),`agent_domains.host` 改为全局唯一;「代理域名管理」去掉类型 |
 | `2026_11_09_130000_allow_multiple_agent_domains.php` | 代理可以绑定多个域名:`agent_domains` 去掉 `(agent_id, type)` 唯一索引,改为 `(agent_id, type, host)` 唯一。**回滚前**每个代理每种类型只能保留一个域名 |
 | `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`。**需要和代码一起上线**(充值入账会写这张表)。上线前已到账的充值不锁定 |

+ 8 - 17
docs/总后台/代理列表.md

@@ -75,10 +75,7 @@ GET /admin/agent?page=1&limit=20&username=agent&status=1
         "flow_commission_profile": {"id":1,"name":"官方默认流水比例","live_rate":"0.3000","slot_rate":"0.2000","lottery_rate":"0.1000","sport_rate":"0.1500","esports_rate":"0.1200","fishing_rate":"0.1800","chess_rate":"0.1600","is_default":1},
         "profit_commission_profile": {"id":1,"name":"官方默认比例","live_rate":"30.0000","slot_rate":"30.0000","lottery_rate":"30.0000","sport_rate":"30.0000","esports_rate":"30.0000","fishing_rate":"30.0000","chess_rate":"30.0000","is_default":1},
         "parent": null,
-        "pc_domain": "https://pc.example.com",
-        "h5_domain": "https://h5.example.com",
-        "pc_register_url": "https://pc.example.com",
-        "h5_register_url": "https://h5.example.com"
+        "domains": ["https://pc.example.com", "https://h5.example.com"]
       }
     ]
   }
@@ -112,8 +109,7 @@ GET /admin/agent/show?id=12
     "balance": "5000.0000",
     "children_count": 3,
     "members_count": 25,
-    "pc_domain": "https://pc.example.com",
-    "h5_domain": "https://h5.example.com"
+    "domains": ["https://pc.example.com", "https://h5.example.com"]
   }
 }
 ```
@@ -234,24 +230,22 @@ GET /admin/agent/show?id=12
 | 参数 | 类型 | 必填 | 说明 |
 |---|---|---:|---|
 | `id` | integer | 是 | 代理 ID |
-| `pc` | string/null | 否 | PC 域名或 URL,最长 255;传空字符串解绑 |
-| `h5` | string/null | 否 | H5 域名或 URL,最长 255;传空字符串解绑 |
+| `domains` | array | 是 | 域名数组(不分 PC / H5,最多 50 个),整体替换该代理的全部域名;`[]` 为全部解绑 |
 
 ```json
 {
   "id": 12,
-  "pc": "https://pc.example.com",
-  "h5": "https://h5.example.com"
+  "domains": ["https://pc.example.com", "https://h5.example.com"]
 }
 ```
 
-**可以绑定多个**:`pc`、`h5` 可以传数组(也兼容字符串),传了就整体替换该类型的绑定,返回新增 `pc_domains`、`h5_domains`(全部域名),详见 [代理绑定多个域名-前端修改单](../前端交付/代理绑定多个域名-前端修改单.md)。
+**不分 PC / H5,可以绑定多个**,详见 [代理域名-前端修改单](../前端交付/代理域名-前端修改单.md)。
 
-**绑定什么就保存什么**:输入的地址(包括路径和参数)只去掉首尾空格后原样保存,推广链接 `pc_register_url`/`h5_register_url` 就是绑定的地址本身,后端不再自动拼注册路径和邀请码。需要指向注册页时,直接绑定完整地址,例如 `https://pc.example.com/#/user/register`。
+**绑定什么就保存什么**:输入的地址(包括路径和参数)只去掉首尾空格后原样保存,`domains` 里的每个地址就是推广链接,后端不再自动拼注册路径和邀请码。需要指向注册页时,直接绑定完整地址,例如 `https://pc.example.com/#/user/register`。
 
 一个域名(按主机名判断,不区分路径)不能绑定给不同代理。
 
-域名来源:输入框可以改为下拉,选项用 `GET /admin/agent/domain-pool/options?type=pc|h5`(启用且未被使用的域名),也可以手动输入。手动输入的新域名会自动登记到「代理域名管理」;在那里停用的域名绑定时返回 `该域名已停用,不能绑定`。详见 [代理域名管理-前端接入.md](代理域名管理-前端接入.md)。会员从这个域名注册时,注册页把当前页面地址作为 `domain` 传给 `/agent/referral/bind`,就能识别代理。
+域名来源:多选下拉,选项用 `GET /admin/agent/domain-pool/options`(启用且未被使用的域名),也可以手动输入。手动输入的新域名会自动登记到「代理域名管理」;在那里停用的域名绑定时返回 `该域名已停用,不能绑定`。详见 [代理域名管理-前端接入.md](代理域名管理-前端接入.md)。会员从这个域名注册时,注册页把当前页面地址作为 `domain` 传给 `/agent/referral/bind`,就能识别代理。
 
 ```json
 {
@@ -259,10 +253,7 @@ GET /admin/agent/show?id=12
   "timestamp": 1787568000,
   "msg": "OK",
   "data": {
-    "pc_domain": "https://pc.example.com",
-    "h5_domain": "https://h5.example.com",
-    "pc_register_url": "https://pc.example.com",
-    "h5_register_url": "https://h5.example.com"
+    "domains": ["https://pc.example.com", "https://h5.example.com"]
   }
 }
 ```

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

@@ -19,11 +19,11 @@
 
 - 域名按**主机名**唯一:`https://a.com/#/reg` 和 `a.com` 算同一个域名。
 - 域名**原样保存**(只去掉首尾空格),可以是纯域名,也可以是带路径的完整地址。
-- **使用中** = 已绑定给某个代理(在「代理列表 → 绑定域名」绑定)。一个域名只能绑定给一个代理;同一个代理的 PC 和 H5 可以绑同一个域名。
+- **使用中** = 已绑定给某个代理(在「代理列表 → 绑定域名」绑定)。一个域名只能绑定给一个代理;一个代理可以绑定多个域名。
 - 在代理列表直接输入一个不在域名池里的域名并绑定时,会**自动登记**到域名池(备注「代理列表绑定时自动登记」)。上线时,已有的代理绑定域名由迁移导入。
 - **停用**的域名不能再绑定给代理;已经绑定的不受影响(要换下来请到代理列表解绑)。
 - **使用中**的域名不能删除,也不能改成另一个主机名,要先解绑。
-- 类型 `type`:`pc` / `h5` / 空(通用)。只影响「可用域名」下拉的筛选,通用的两边都会出现。
+- 域名不分 PC / H5。
 
 ## 2. 域名列表
 
@@ -33,7 +33,6 @@
 |---|---|---:|---|
 | `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 | 否 | 绑定在该代理的域名 |
@@ -50,8 +49,6 @@
       "id": 1,
       "domain": "https://a.example.com",
       "host": "a.example.com",
-      "type": "pc",
-      "type_text": "PC",
       "status": 1,
       "status_text": "启用",
       "remark": "第一批",
@@ -65,8 +62,6 @@
           "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"
         }
@@ -85,10 +80,9 @@
 |---|---|---|
 | `domain` | 域名 | 添加时的原样内容 |
 | `host` | 主机名 | 小写,可不展示 |
-| `type` / `type_text` | 类型 | `pc` PC / `h5` H5 / 空 通用 |
 | `status` / `status_text` | 状态 | 启用 / 停用 |
 | `usage_text` | 使用情况 | `使用中` / `可用` / `停用` |
-| `bindings` | 绑定代理 | 未使用时为空数组。每项:代理 ID、账号、真实姓名、代理类型(合营 / 入股)、代理状态、绑定位置 `slot`(pc / h5)、代理实际绑定的地址 `bound_domain`、绑定时间 `bound_at` |
+| `bindings` | 绑定代理 | 未使用时为空数组。每项:代理 ID、账号、真实姓名、代理类型(合营 / 入股)、代理状态、代理实际绑定的地址 `bound_domain`、绑定时间 `bound_at` |
 | `remark` | 备注 | |
 | `created_at` | 添加时间 | |
 
@@ -101,11 +95,10 @@
 | 参数 | 必填 | 说明 |
 |---|---:|---|
 | `domains` | 是 | 域名数组,或多行文本(每行一个,也可以用逗号分隔),一次最多 100 个 |
-| `type` | 否 | `pc` / `h5` / `common`,默认 `common`(通用) |
 | `remark` | 否 | 备注,最多 255 字 |
 
 ```json
-{"domains": "a.example.com\nhttps://b.example.com/#/user/register", "type": "pc", "remark": "第一批"}
+{"domains": "a.example.com\nhttps://b.example.com/#/user/register", "remark": "第一批"}
 ```
 
 返回:
@@ -124,7 +117,6 @@
 |---|---:|---|
 | `id` | 是 | 域名 ID |
 | `domain` | 否 | 修改域名。使用中的域名只能改路径,不能改成其他主机名 |
-| `type` | 否 | `pc` / `h5` / `common` |
 | `status` | 否 | `1` 启用 / `0` 停用 |
 | `remark` | 否 | 备注 |
 
@@ -144,19 +136,18 @@
 
 | 参数 | 必填 | 说明 |
 |---|---:|---|
-| `type` | 否 | `pc` 或 `h5`:返回该类型和通用的域名;不传返回全部 |
 
 返回**启用且未被使用**的域名(最多 500 个):
 
 ```json
-[{"id": 2, "domain": "https://b.example.com/#/user/register", "type": "pc", "type_text": "PC", "remark": "第一批"}]
+[{"id": 2, "domain": "https://b.example.com/#/user/register", "remark": "第一批"}]
 ```
 
-代理列表的「绑定域名」弹窗:PC、H5 改为多选(选项来自本接口,也可手动输入),提交仍然是原接口 `POST /admin/agent/domains`,`pc`、`h5` 传完整的域名数组(每个代理每种类型可以绑定多个)。手动输入的新域名会自动登记到域名池。绑定停用的域名时返回 `该域名已停用,不能绑定`。
+代理列表的「绑定域名」弹窗:一个多选(选项来自本接口,也可手动输入),提交 `POST /admin/agent/domains` 的 `domains` 传完整的域名数组(不分 PC / H5,每个代理可以绑定多个),见 [代理域名-前端修改单](../前端交付/代理域名-前端修改单.md)。手动输入的新域名会自动登记到域名池。绑定停用的域名时返回 `该域名已停用,不能绑定`。
 
 ## 7. 页面建议
 
 - 顶部:`summary` 四个数字(总数 / 使用中 / 可用 / 停用)。
-- 筛选:关键字、类型、状态、使用情况、代理账号。
-- 表格:域名、类型、状态、使用情况、绑定代理(账号 + 类型 + PC / H5,多个时换行)、备注、添加时间、操作(修改、启用 / 停用、删除;使用中的行隐藏删除按钮)。
-- 「新增」按钮:多行文本框 + 类型 + 备注。
+- 筛选:关键字、状态、使用情况、代理账号。
+- 表格:域名、状态、使用情况、绑定代理(账号 + 合营 / 入股)、备注、添加时间、操作(修改、启用 / 停用、删除;使用中的行隐藏删除按钮)。
+- 「新增」按钮:多行文本框 + 备注。

+ 10 - 10
tests/Integration/AgentDomainPoolTest.php

@@ -35,38 +35,38 @@ class AgentDomainPoolTest extends TestCase
     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' => '第一批']);
+        $created = $this->call('create', ['domains' => "a.example.com\nhttps://A.example.com/#/reg\nftp://bad.example.com\nhttps://b.example.com/#/user/register", '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']);
+        $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']);
+        $this->domains->save($agent, ['https://a.example.com/#/user/register', '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([$agent->username, 'https://a.example.com/#/user/register'],
+            [$rows['a.example.com']['bindings'][0]['agent_username'], $rows['a.example.com']['bindings'][0]['bound_domain']]);
+        $this->assertSame('代理列表绑定时自动登记', $rows['c.example.com']['remark']);
+        $this->assertArrayNotHasKey('type', $rows['a.example.com']);
         $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');
+        $this->assertSame(['https://b.example.com/#/user/register'], array_column($this->pool->options(), 'domain'), 'options exclude domains in use');
 
         $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'); }
+        try { $this->domains->save($this->agent(), ['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->domains->save($agent, ['https://c.example.com']);
         $this->assertSame(0, $this->call('delete', ['id' => $a])['code'], 'free after unbinding');
         $this->assertSame(2, $this->call('index', [], 'GET')['data']['total']);
     }

+ 26 - 17
tests/Integration/AgentMultipleDomainsTest.php

@@ -8,7 +8,7 @@ use Illuminate\Http\Request;
 use PHPUnit\Framework\TestCase;
 use Tests\Support\AgentTestEnvironment;
 
-/** 代理可以绑定多个 PC / H5 域名;字符串写法兼容;一个主机名只能属于一个代理。 */
+/** 代理绑定多个域名,不区分 PC / H5;一个主机名只能属于一个代理。 */
 class AgentMultipleDomainsTest extends TestCase
 {
     use AgentTestEnvironment;
@@ -21,28 +21,37 @@ class AgentMultipleDomainsTest extends TestCase
         return (new AdminAgent())->domains($request, new AgentDomainService())->getData(true);
     }
 
-    public function test_agent_binds_several_domains_per_type(): void
+    public function test_agent_binds_a_single_list_of_domains(): void
     {
         $agent = $this->agent();
         $other = $this->agent();
-        $data = $this->bind($agent->id, ['pc' => ['https://a.com/#/reg', 'b.com', 'https://A.com/other'], 'h5' => 'https://m.a.com']);
+        $data = $this->bind($agent->id, ['domains' => ['https://a.com/#/reg', 'b.com', 'https://A.com/other', 'https://m.a.com']]);
         $this->assertSame(0, $data['code'], (string) $data['msg']);
-        $this->assertSame(['https://a.com/#/reg', 'b.com'], $data['data']['pc_domains'], 'same host kept once, first one wins');
-        $this->assertSame(['https://m.a.com'], $data['data']['h5_domains']);
-        $this->assertSame(['https://a.com/#/reg', 'https://a.com/#/reg', 'https://m.a.com'],
-            [$data['data']['pc_domain'], $data['data']['pc_register_url'], $data['data']['h5_domain']], 'single fields keep the first domain');
+        $this->assertSame(['https://a.com/#/reg', 'b.com', 'https://m.a.com'], $data['data']['domains'], 'same host kept once');
+        $this->assertSame(['domains'], array_keys($data['data']), 'no pc / h5 fields');
+        $this->assertSame($agent->id, (new AgentDomainService())->resolve(null, 'https://m.a.com/x')->id);
 
-        $resolver = new AgentDomainService();
-        $this->assertSame($agent->id, $resolver->resolve(null, 'https://b.com/register')->id, 'every bound domain links the agent');
-
-        $taken = $this->bind($other->id, ['pc' => ['c.com', 'b.com']]);
+        $taken = $this->bind($other->id, ['domains' => ['c.com', 'b.com']]);
         $this->assertSame('该域名已绑定其他代理:b.com', $taken['msg']);
-        $this->assertSame([], $this->bind($other->id, ['h5' => []])['data']['pc_domains'], 'failed bind changed nothing');
 
-        $data = $this->bind($agent->id, ['pc' => ['b.com', 'd.com']]);
-        $this->assertSame(['b.com', 'd.com'], $data['data']['pc_domains'], 'list replaced; a.com released');
-        $this->assertSame(['https://m.a.com'], $data['data']['h5_domains'], 'type not sent stays');
-        $this->assertSame(0, $this->bind($other->id, ['pc' => 'a.com'])['code'], 'released domain can be bound by another agent');
-        $this->assertSame([], $this->bind($agent->id, ['pc' => ''])['data']['pc_domains'], 'empty string clears');
+        $this->assertSame(['b.com'], $this->bind($agent->id, ['domains' => 'b.com'])['data']['domains'], 'string is one domain; list replaced');
+        $this->assertSame(['c.com', 'a.com'], $this->bind($other->id, ['domains' => ['c.com', 'a.com']])['data']['domains'], 'released a.com can move');
+        $this->assertNotSame(0, $this->bind($other->id, ['pc' => 'x.com'])['code'], 'old pc / h5 params are not accepted');
+        $this->assertSame(['c.com', 'a.com'], $this->bind($other->id, ['domains' => ['c.com', 'a.com']])['data']['domains']);
+    }
+
+    public function test_migration_merges_old_pc_and_h5_rows(): void
+    {
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_140000_merge_agent_domain_types.php';
+        $migration->down();
+        $agent = $this->agent();
+        \Illuminate\Support\Facades\DB::table('agent_domains')->insert([
+            ['agent_id' => $agent->id, 'type' => 'pc', 'domain' => 'https://a.com', 'host' => 'a.com', 'status' => 1],
+            ['agent_id' => $agent->id, 'type' => 'h5', 'domain' => 'https://a.com/m', 'host' => 'a.com', 'status' => 1],
+            ['agent_id' => $agent->id, 'type' => 'h5', 'domain' => 'https://m.b.com', 'host' => 'm.b.com', 'status' => 1],
+        ]);
+        $migration->up();
+        $this->assertSame(['https://a.com', 'https://m.b.com'], (new AgentDomainService())->format($agent)['domains']);
+        $this->assertSame([''], \Illuminate\Support\Facades\DB::table('agent_domains')->distinct()->pluck('type')->all());
     }
 }

+ 1 - 0
tests/Support/AgentTestEnvironment.php

@@ -93,6 +93,7 @@ trait AgentTestEnvironment
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_180000_add_agent_equity_order_admin.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_190000_add_agent_recharge_locks.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_130000_allow_multiple_agent_domains.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_140000_merge_agent_domain_types.php')->up();
 
         foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
             Schema::create($table, function (Blueprint $t) {