فهرست منبع

可以绑定多个域名

doge 4 روز پیش
والد
کامیت
4200df2536

+ 8 - 1
app/Http/Controllers/admin/Agent.php

@@ -100,8 +100,15 @@ class Agent extends AgentApiController
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate([
                 'id' => ['required', 'integer', 'exists:agents,id'],
-                'pc' => ['nullable', 'string', 'max:255'], 'h5' => ['nullable', 'string', 'max:255'],
+                // 字符串(一个域名,兼容旧写法)或数组(多个域名);空字符串 / 空数组为清空
+                'pc' => ['nullable', $one = 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'],
             ]);
+            $data = array_intersect_key($request->all(), array_flip(['pc', 'h5'])) + ['id' => $data['id']];
             return $service->save(AgentModel::query()->findOrFail($data['id']), $data);
         });
     }

+ 49 - 23
app/Services/Agent/AgentDomainService.php

@@ -8,51 +8,77 @@ use Illuminate\Support\Facades\DB;
 
 /**
  * 代理推广域名:总后台绑定什么就保存什么(含路径和参数,原样返回作为推广链接),不再自动拼注册路径和邀请码。
+ * 每个代理的 PC、H5 都可以绑定多个域名;一个主机名只能属于一个代理。
  * 主机名(host)用于判断域名是否已被其他代理绑定,以及注册时按访问域名识别代理。
  */
 class AgentDomainService
 {
+    public const MAX_PER_TYPE = 50;
+
+    /**
+     * 保存代理的 PC / H5 域名。每种类型传字符串(一个域名,兼容旧写法)或数组(多个);
+     * 传了的类型整体替换为本次的列表,空字符串 / 空数组表示清空;没传的类型不变。
+     */
     public function save(Agent $agent, array $domains): array
     {
         return DB::transaction(function () use ($agent, $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;
                 }
-                $url = $this->normalize((string) $domains[$type]);
-                if ($url === '') {
-                    AgentDomain::query()->where('agent_id', $agent->id)->where('type', $type)->delete();
-                    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);
                 }
-                $host = $this->host($url);
-                $owner = AgentDomain::query()->where('host', $host)->lockForUpdate()->first();
-                if ($owner && (int) $owner->agent_id !== (int) $agent->id) {
-                    throw new \RuntimeException('该域名已绑定其他代理');
+                $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();
                 }
-                // 登记到域名池(已停用的不能绑定);迁移未执行前跳过
-                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);
+                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]);
+                    }
                 }
-                AgentDomain::query()->updateOrCreate(
-                    ['agent_id' => $agent->id, 'type' => $type],
-                    ['domain' => $url, 'host' => $host, 'status' => 1]
-                );
             }
             return $this->format($agent->fresh());
         });
     }
 
+    /**
+     * 域名信息。pc_domains / h5_domains 为全部绑定(按绑定先后);
+     * pc_domain / h5_domain 和 *_register_url 为第一个,兼容只显示一个的旧页面。推广链接 = 绑定的地址本身。
+     */
     public function format(Agent $agent): array
     {
-        $domains = $agent->domains()->where('status', 1)->pluck('domain', 'type');
-        $pc = (string) ($domains['pc'] ?? '');
-        $h5 = (string) ($domains['h5'] ?? '');
+        $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' => $pc,
-            'h5_domain' => $h5,
-            // 推广链接 = 绑定的地址本身
-            'pc_register_url' => $pc,
-            'h5_register_url' => $h5,
+            '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,
         ];
     }
 

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

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 代理可以绑定多个 PC / H5 域名:去掉 (agent_id, type) 唯一,改为同一代理同一类型下主机名不重复。
+ * 一个主机名只能属于一个代理,由 AgentDomainService 保存时校验。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('agent_domains', function (Blueprint $t) {
+            $t->dropUnique('uniq_agent_domain_type');
+            $t->unique(['agent_id', 'type', 'host'], 'uniq_agent_domain_host');
+            $t->index(['agent_id', 'type'], 'idx_agent_domain_type');
+        });
+    }
+
+    public function down(): void
+    {
+        // 回滚前每个代理每种类型只保留一个域名,否则唯一索引建不起来
+        Schema::table('agent_domains', function (Blueprint $t) {
+            $t->dropUnique('uniq_agent_domain_host');
+            $t->dropIndex('idx_agent_domain_type');
+            $t->unique(['agent_id', 'type'], 'uniq_agent_domain_type');
+        });
+    }
+};

+ 7 - 0
docs/代理/推广注册.md

@@ -78,6 +78,13 @@ Content-Type: application/json
 
 该接口只给当前登录的新会员绑定,默认必须在注册后 30 分钟内完成;相同代理重复请求保持幂等,不能自行换绑其他代理。超时时间可由后端 `AGENT_REFERRAL_BIND_MINUTES` 调整。
 
+**注册时自动关联(2026-11-09 起)**:会员前台注册接口(melbet_sport-api `POST /api/user/register`)会在注册时自动关联代理,不需要前端再调用下面的绑定接口:
+
+1. 请求参数里带了代理邀请码 `agent_code`:按邀请码关联;
+2. 否则按注册页的域名(请求头 `Referer` / `Origin` 的主机名)匹配总后台给代理绑定的域名。
+
+只关联启用中的代理;匹配不到就正常注册、不挂代理。下面的解析和绑定接口保留,用于注册后补绑(30 分钟内)。
+
 注册接入顺序:
 
 1. 注册页读取 URL 中的 `agent_code`;**没有时把当前页面地址(`location.href`)作为 `domain`**。代理的推广链接就是总后台绑定的域名本身,默认不带 `agent_code`,靠域名识别代理。

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

@@ -2,6 +2,10 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 代理绑定多个域名(2026-11-09)
+
+- [代理绑定多个域名-前端修改单](代理绑定多个域名-前端修改单.md):代理的 PC、H5 都可以绑定多个域名。`POST /admin/agent/domains` 的 `pc`、`h5` 可以传数组,返回和代理信息新增 `pc_domains`、`h5_domains`。需要修改总后台的绑定弹窗和列表,以及代理后台的邀请链接。
+
 ## 代理域名管理(2026-11-09)
 
 - [代理域名管理-前端接入](../总后台/代理域名管理-前端接入.md):总后台「代理」菜单下新增「代理域名管理」,可以批量添加可用域名、启用 / 停用 / 删除,列表显示使用情况和绑定的代理。代理列表「绑定域名」可以改为从可用域名下拉选择(`GET /admin/agent/domain-pool/options`)。

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

@@ -0,0 +1,85 @@
+# 代理绑定多个域名-前端修改单(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. 编辑时删掉一个域名并保存,该域名被解绑,「代理域名管理」里显示为可用。

+ 6 - 2
docs/前端接口/活动/余额宝持仓列表-利率与可转入金额-前台接入.md

@@ -2,7 +2,9 @@
 
 接口项目:`melbet_sport-api` 会员前台 API,需要登录。
 
-`GET /api/yuebao/itemList` 原有的 `count`、`list` 不变,新增 `extend` 字段,返回当前余额宝配置的利率和当前会员最多可以转入多少钱。转入弹窗可以直接用这些数据,不用再单独请求。
+`GET /api/yuebao/itemList` 原有的 `count`、`list` 不变,新增 `extend` 字段,返回当前余额宝配置的利率、活动说明内容,以及当前会员最多可以转入多少钱。余额宝页面和转入弹窗可以直接用这些数据,不用再单独请求。
+
+> **余额宝已不在活动列表中展示**:`/api/index/activity` 不再返回余额宝,无论传不传 `type`、传 `type=3` 还是 `type=6`。`/api/index/activityInfo` 用余额宝的活动 ID 查询会返回“参数错误”。余额宝的说明内容改为从本接口的 `extend.content` 获取。
 
 ```json
 {
@@ -16,7 +18,8 @@
       "min_amount": "1.00",
       "max_amount": "100.00",
       "can_deposit": true,
-      "reason": null
+      "reason": null,
+      "content": "<div>...</div>"
     }
   }
 }
@@ -29,6 +32,7 @@
 | `min_amount` | string | 单笔最少转入金额,目前是 `1.00` |
 | `max_amount` | string | 当前最多可转入的金额,两位小数,计算方法见下 |
 | `can_deposit` | bool | 现在能不能转入(`max_amount ≥ min_amount`,并且符合活动条件) |
+| `content` | string\|null | 后台“余额宝配置”里编辑器填写的活动内容(HTML),用富文本方式渲染即可。后台没有上架的余额宝配置时为 `null` |
 | `reason` | string\|null | 不能转入时的原因,已按请求头 `Lang`(`zh`/`en`/`vi`)翻译,可以直接展示 |
 
 `max_amount` = min(可用余额,今日有效投注 − 当前余额宝持仓),结果小于 0 时按 0 算。这个规则和转入接口 `POST /api/yuebao/add` 的校验一致。

+ 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_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`。**需要和代码一起上线**(充值入账会写这张表)。上线前已到账的充值不锁定 |
 | `2026_11_08_180000_add_agent_equity_order_admin.php` | 总后台「入股购买记录」菜单 `agentEquityOrders` 和取消按钮;`agent_equity_orders` 新增 `cancelled_by`、`cancel_remark`。**代理取消入股订单也会写这两列,需要和代码一起上线** |

+ 2 - 0
docs/总后台/代理列表.md

@@ -245,6 +245,8 @@ GET /admin/agent/show?id=12
 }
 ```
 
+**可以绑定多个**:`pc`、`h5` 可以传数组(也兼容字符串),传了就整体替换该类型的绑定,返回新增 `pc_domains`、`h5_domains`(全部域名),详见 [代理绑定多个域名-前端修改单](../前端交付/代理绑定多个域名-前端修改单.md)。
+
 **绑定什么就保存什么**:输入的地址(包括路径和参数)只去掉首尾空格后原样保存,推广链接 `pc_register_url`/`h5_register_url` 就是绑定的地址本身,后端不再自动拼注册路径和邀请码。需要指向注册页时,直接绑定完整地址,例如 `https://pc.example.com/#/user/register`。
 
 一个域名(按主机名判断,不区分路径)不能绑定给不同代理。

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

@@ -152,7 +152,7 @@
 [{"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` 原样传过去。手动输入的新域名会自动登记到域名池。绑定停用的域名时返回 `该域名已停用,不能绑定`。
+代理列表的「绑定域名」弹窗:PC、H5 改为多选(选项来自本接口,也可手动输入),提交仍然是原接口 `POST /admin/agent/domains`,`pc`、`h5` 传完整的域名数组(每个代理每种类型可以绑定多个)。手动输入的新域名会自动登记到域名池。绑定停用的域名时返回 `该域名已停用,不能绑定`。
 
 ## 7. 页面建议
 

+ 48 - 0
tests/Integration/AgentMultipleDomainsTest.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Agent as AdminAgent;
+use App\Services\Agent\AgentDomainService;
+use Illuminate\Http\Request;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 代理可以绑定多个 PC / H5 域名;字符串写法兼容;一个主机名只能属于一个代理。 */
+class AgentMultipleDomainsTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    private function bind(int $agentId, array $params): array
+    {
+        $request = Request::create('/admin/agent/domains', 'POST', ['id' => $agentId] + $params);
+        $request->user = (object) ['id' => 1];
+        $this->app->instance('request', $request);
+        return (new AdminAgent())->domains($request, new AgentDomainService())->getData(true);
+    }
+
+    public function test_agent_binds_several_domains_per_type(): 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']);
+        $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');
+
+        $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']]);
+        $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');
+    }
+}

+ 1 - 0
tests/Support/AgentTestEnvironment.php

@@ -92,6 +92,7 @@ trait AgentTestEnvironment
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_120000_agent_joint_venture_and_equity.php')->up();
         (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();
 
         foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
             Schema::create($table, function (Blueprint $t) {