doge 4 часов назад
Родитель
Сommit
f9eb2f4046

+ 25 - 0
app/Http/Controllers/admin/ContactConfiguration.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\ContactConfigurationService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class ContactConfiguration extends Controller
+{
+    public function show(ContactConfigurationService $service) { return $this->run(fn () => $service->get()); }
+    public function save(ContactConfigurationService $service) { return $this->run(fn () => $service->save(request()->all())); }
+
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback())->header('Cache-Control', 'no-store'); }
+        catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (\Throwable $e) {
+            Log::error('contact_configuration_admin_failed', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '联系我们配置暂不可用,请稍后重试');
+        }
+    }
+}

+ 84 - 0
app/Services/ContactConfigurationService.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+/** 联系我们:后台维护多个邮箱与多个域名,前台原样展示 */
+class ContactConfigurationService
+{
+    public const MAX_ITEMS = 50;
+
+    public function get(): array
+    {
+        return $this->format(DB::table('contact_settings')->where('id', 1)->first());
+    }
+
+    public function save(array $input): array
+    {
+        $data = Validator::make($input, [
+            'emails' => ['present', 'array', 'max:' . self::MAX_ITEMS],
+            'emails.*' => ['nullable', 'string', 'max:254'],
+            'domains' => ['present', 'array', 'max:' . self::MAX_ITEMS],
+            'domains.*' => ['nullable', 'string', 'max:2048'],
+        ], [
+            'emails.present' => '请提交邮箱数组', 'emails.array' => '邮箱必须为数组', 'emails.max' => '邮箱最多' . self::MAX_ITEMS . '个',
+            'emails.*.string' => '每个邮箱必须为字符串', 'emails.*.max' => '每个邮箱最多254个字符',
+            'domains.present' => '请提交域名数组', 'domains.array' => '域名必须为数组', 'domains.max' => '域名最多' . self::MAX_ITEMS . '个',
+            'domains.*.string' => '每个域名必须为字符串', 'domains.*.max' => '每个域名最多2048个字符',
+        ])->validate();
+        foreach (['emails' => '邮箱', 'domains' => '域名'] as $field => $label) {
+            if (array_values($data[$field]) !== $data[$field]) {
+                throw ValidationException::withMessages([$field => $label . '必须为顺序数组']);
+            }
+        }
+
+        $emails = [];
+        foreach ($data['emails'] as $index => $email) {
+            $email = trim((string) $email);
+            if ($email === '') continue;
+            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+                throw ValidationException::withMessages(['emails.' . $index => '邮箱格式不正确:' . $email]);
+            }
+            if (!in_array(strtolower($email), array_map('strtolower', $emails), true)) $emails[] = $email;
+        }
+        $domains = [];
+        foreach ($data['domains'] as $index => $domain) {
+            if (trim((string) $domain) === '') continue;
+            $url = SiteConfigurationService::normalizeDomain((string) $domain, 'domains.' . $index);
+            if (!in_array($url, $domains, true)) $domains[] = $url;
+        }
+
+        $payload = ['emails' => $emails, 'domains' => $domains];
+        return DB::transaction(function () use ($payload) {
+            DB::table('contact_settings')->insertOrIgnore(['id' => 1, 'emails' => '[]', 'domains' => '[]', 'created_at' => now(), 'updated_at' => now()]);
+            $before = $this->format(DB::table('contact_settings')->where('id', 1)->lockForUpdate()->first());
+            if ($before !== $payload) {
+                DB::table('contact_settings')->where('id', 1)->update([
+                    'emails' => json_encode($payload['emails'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
+                    'domains' => json_encode($payload['domains'], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
+                    'updated_at' => now(),
+                ]);
+                OperationAuditService::record('contact_configuration', 1, 'update', $before, $payload);
+            }
+            return $payload;
+        }, 3);
+    }
+
+    private function format(?object $row): array
+    {
+        return ['emails' => $this->list($row->emails ?? null), 'domains' => $this->list($row->domains ?? null)];
+    }
+
+    private function list(?string $value): array
+    {
+        if ($value === null || trim($value) === '') return [];
+        $items = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
+        if (!is_array($items) || array_values($items) !== $items || count(array_filter($items, 'is_string')) !== count($items)) {
+            throw new \RuntimeException('联系我们配置格式异常');
+        }
+        return $items;
+    }
+}

+ 4 - 3
app/Services/SiteConfigurationService.php

@@ -36,7 +36,7 @@ class SiteConfigurationService
         }
         $domains = [];
         foreach ($data['domains'] as $index => $domain) {
-            $url = $this->domain($domain, $index);
+            $url = self::normalizeDomain($domain, 'domains.' . $index);
             if (!in_array($url, $domains, true)) $domains[] = $url;
         }
         $translations = [];
@@ -89,7 +89,8 @@ class SiteConfigurationService
         return array_values(array_filter(array_map('trim', $items), static fn ($item) => $item !== ''));
     }
 
-    private function domain(string $value, int $index): string
+    /** 规范为 http(s)://host[:port];不带协议时补 https:// */
+    public static function normalizeDomain(string $value, string $field): string
     {
         $url = trim($value);
         if (!preg_match('~^[a-z][a-z0-9+.-]*://~i', $url)) $url = 'https://' . $url;
@@ -100,7 +101,7 @@ class SiteConfigurationService
             || isset($parts['query']) || isset($parts['fragment'])
             || !in_array($parts['path'] ?? '', ['', '/'], true)
             || (isset($parts['port']) && $parts['port'] < 1)) {
-            throw ValidationException::withMessages(['domains.' . $index => '域名仅支持HTTP/HTTPS站点地址,不带账号、路径、查询参数或片段']);
+            throw ValidationException::withMessages([$field => '域名仅支持HTTP/HTTPS站点地址,不带账号、路径、查询参数或片段']);
         }
         return strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : '');
     }

+ 23 - 0
database/migrations/2026_11_04_130000_create_contact_settings.php

@@ -0,0 +1,23 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        // 联系我们:单行配置(id=1),邮箱与域名均为JSON字符串数组
+        Schema::create('contact_settings', function (Blueprint $table) {
+            $table->unsignedInteger('id')->primary();
+            $table->json('emails');
+            $table->json('domains');
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('contact_settings');
+    }
+};

+ 53 - 0
docs/前端接口/联系我们-前台接入.md

@@ -0,0 +1,53 @@
+# 联系我们:前台接入
+
+接口项目:`melbet_sport-api`。一次请求获取后台配置的邮箱列表和域名列表。无需会员登录,使用当前前台 API 域名及现有公共请求封装。
+
+## 请求
+
+```http
+GET /api/contact/config
+```
+
+无请求参数,无需 Token;不区分语言(各语言返回同一组数据)。
+
+## 成功响应
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "emails": [
+      "support@example.com",
+      "business@example.com"
+    ],
+    "domains": [
+      "https://www.example.com",
+      "https://backup.example.com"
+    ]
+  }
+}
+```
+
+| 字段 | 类型 | 用途 |
+|---|---|---|
+| `code` | integer | `1` 成功,`0` 失败 |
+| `show` | integer | 沿用公共请求封装处理 |
+| `msg` | string | 失败时的提示文案 |
+| `data.emails` | string[] | 联系邮箱列表,按后台顺序展示;可做 `mailto:` 链接或点击复制 |
+| `data.domains` | string[] | 官方域名列表,均为 `http(s)://主机[:端口]` 完整地址,按后台顺序展示;可直接作为链接打开或点击复制 |
+
+两个数组均已在后台去重、去空白,前端无需再处理。未配置时返回空数组:
+
+```json
+{"code":1,"show":0,"msg":"","data":{"emails":[],"domains":[]}}
+```
+
+## 页面交互
+
+- 进入“联系我们”页面时请求一次;后台修改后重新请求即可获取最新配置(接口返回 `Cache-Control: no-store`)。
+- `emails` 为空时隐藏邮箱区块,`domains` 为空时隐藏域名区块;两者都为空时可显示“暂无联系方式”。
+- 邮箱建议展示为 `<a href="mailto:support@example.com">`,并提供复制按钮。
+- 域名可显示为去掉协议的文本(如 `www.example.com`),点击时用完整地址新窗口打开。
+- `code=0` 时展示 `msg`(如“联系我们配置暂不可用,请稍后重试”),沿用公共请求组件的失败处理。

+ 98 - 0
docs/总后台/联系我们-后台接入.md

@@ -0,0 +1,98 @@
+# 联系我们:后台前端接入
+
+接口项目:`bot-28`,使用总后台 API 域名。邮箱列表与域名列表在同一表单、同一次请求中读取或保存,两者都支持多个。
+
+## 接口
+
+| 方法 | 路径 | 用途 | 权限 URI |
+|---|---|---|---|
+| GET | `/admin/contact/config` | 读取邮箱数组和域名数组 | `admin/contact/config` |
+| POST | `/admin/contact/config` | 一次保存全部内容 | `admin/contact/config/save` |
+
+请求头使用 `Authorization: Bearer <admin_token>`;POST 使用 `Content-Type: application/json`。保存需要运营配置权限(与“跑马灯与域名配置”相同)。
+
+> 如需按钮级权限控制,在菜单管理中“联系我们”页面下新增按钮,`uri` 填 `admin/contact/config/save`;未配置该按钮时所有可进入页面的管理员均可保存。
+
+## 表单字段
+
+| 字段 | 类型 | 规则 |
+|---|---|---|
+| `emails` | string[] | 必须提交,最多50项;空数组清空邮箱列表 |
+| `emails[]` | string | 邮箱地址,最多254个字符,需为合法邮箱格式 |
+| `domains` | string[] | 必须提交,最多50项;空数组清空域名列表 |
+| `domains[]` | string | HTTP/HTTPS站点根地址,最多2048个字符;不带协议时自动补 `https://` |
+
+保存规则:
+
+- 空白项自动忽略(输入框留空不会报错)。
+- 邮箱去除首尾空格,按不区分大小写去重,保留第一次出现的写法。
+- 域名不含路径、查询参数、片段或账号密码;协议和主机名转为小写、去掉末尾 `/`,规范化后重复的只保留第一项。
+- 两个列表都按提交顺序保存和返回。
+
+## 读取
+
+```http
+GET /admin/contact/config
+```
+
+读取和保存成功均返回以下结构:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1793760000,
+  "msg": "OK",
+  "data": {
+    "emails": [
+      "support@example.com",
+      "business@example.com"
+    ],
+    "domains": [
+      "https://www.example.com",
+      "https://backup.example.com"
+    ]
+  }
+}
+```
+
+未配置时返回 `{"emails":[],"domains":[]}`。
+
+## 保存
+
+```http
+POST /admin/contact/config
+Content-Type: application/json
+```
+
+```json
+{
+  "emails": ["support@example.com", "business@example.com"],
+  "domains": ["www.example.com", "https://backup.example.com"]
+}
+```
+
+整体替换:提交的数组即为最终列表。保存成功后用返回的 `data`(已规范化,如 `www.example.com` → `https://www.example.com`)回填表单。
+
+清空全部:
+
+```json
+{"emails":[],"domains":[]}
+```
+
+## 校验失败示例
+
+```json
+{"code": -3, "timestamp": 1793760000, "msg": "邮箱格式不正确:abc", "data": []}
+```
+
+常见提示:`请提交邮箱数组`、`邮箱最多50个`、`邮箱格式不正确:xxx`、`域名最多50个`、`域名仅支持HTTP/HTTPS站点地址,不带账号、路径、查询参数或片段`。
+
+## 页面交互
+
+- 两个可增删的列表:“邮箱”“域名”,每行一个输入框,支持新增行、删除行、调整顺序。
+- 保存按钮一次发送 `emails` 和 `domains` 两个完整数组;取消编辑时重新读取。
+- `code=0` 成功;`code=-3` 展示 `msg`;登录失效及权限不足沿用后台公共处理。
+
+## 前台读取
+
+会员前台通过 `melbet_sport-api` 的 `GET /api/contact/config` 读取,见《联系我们-前台接入》。

+ 4 - 0
routes/admin.php

@@ -90,6 +90,10 @@ Route::middleware(['admin.jwt'])->group(function () {
         Route::get('/site/config', [\App\Http\Controllers\admin\SiteConfiguration::class, 'show']);
         Route::post('/site/config', [\App\Http\Controllers\admin\SiteConfiguration::class, 'save'])
             ->middleware('operations.admin')->defaults('permission_uri', 'admin/site/config/save');
+        // 联系我们:多个邮箱 + 多个域名
+        Route::get('/contact/config', [\App\Http\Controllers\admin\ContactConfiguration::class, 'show']);
+        Route::post('/contact/config', [\App\Http\Controllers\admin\ContactConfiguration::class, 'save'])
+            ->middleware('operations.admin')->defaults('permission_uri', 'admin/contact/config/save');
 
         Route::prefix('/promotion')->group(function () {
             Route::get('/', [\App\Http\Controllers\admin\Promotion::class, 'index']);

+ 111 - 0
tests/Integration/ContactConfigurationTest.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\ContactConfiguration;
+use App\Services\ContactConfigurationService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class ContactConfigurationTest extends TestCase
+{
+    private Application $app;
+    private ContactConfigurationService $service;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
+        $request = Request::create('/admin/contact/config'); $request->user = (object) ['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request);
+        DB::statement('CREATE TABLE bot_operation_audits (id INTEGER PRIMARY KEY AUTOINCREMENT,resource_type TEXT,resource_id INTEGER,action TEXT,changes TEXT,operator_id INTEGER,operator_name TEXT,created_at TEXT)');
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_04_130000_create_contact_settings.php')->up();
+        $this->service = new ContactConfigurationService();
+    }
+
+    protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+
+    public function test_empty_by_default(): void
+    {
+        $this->assertSame(['emails' => [], 'domains' => []], $this->service->get());
+    }
+
+    public function test_save_normalizes_dedupes_skips_blanks_and_keeps_order(): void
+    {
+        $result = $this->service->save([
+            'emails' => [' support@example.com ', '', null, 'Biz@Example.com', 'SUPPORT@example.com'],
+            'domains' => ['example.com', 'https://EXAMPLE.com/', ' ', 'HTTP://backup.example:8080'],
+        ]);
+        $this->assertSame(['emails' => ['support@example.com', 'Biz@Example.com'], 'domains' => ['https://example.com', 'http://backup.example:8080']], $result);
+        $this->assertSame($result, $this->service->get());
+        $this->assertSame(1, DB::table('operation_audits')->count());
+        $this->service->save($result);
+        $this->assertSame(1, DB::table('operation_audits')->count(), 'unchanged save is not audited');
+        $this->assertSame(['emails' => [], 'domains' => []], $this->service->save(['emails' => [], 'domains' => []]));
+    }
+
+    #[DataProvider('invalidPayloads')]
+    public function test_invalid_payload_does_not_save(array $payload): void
+    {
+        $before = $this->service->save(['emails' => ['a@example.com'], 'domains' => ['example.com']]);
+        try { $this->service->save($payload); $this->fail('Expected validation failure'); }
+        catch (ValidationException $e) { $this->assertNotEmpty($e->validator->errors()->first()); }
+        $this->assertSame($before, $this->service->get());
+    }
+
+    public static function invalidPayloads(): array
+    {
+        return [
+            [['domains' => []]], [['emails' => []]],
+            [['emails' => 'a@example.com', 'domains' => []]],
+            [['emails' => ['not-an-email'], 'domains' => []]],
+            [['emails' => ['x' => 'a@example.com'], 'domains' => []]],
+            [['emails' => [123], 'domains' => []]],
+            [['emails' => array_fill(0, 51, 'a@example.com'), 'domains' => []]],
+            [['emails' => [], 'domains' => ['ftp://example.com']]],
+            [['emails' => [], 'domains' => ['https://example.com/path']]],
+            [['emails' => [], 'domains' => array_fill(0, 51, 'example.com')]],
+        ];
+    }
+
+    public function test_routes_and_controller(): void
+    {
+        $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        $get = $router->getRoutes()->match(Request::create('/admin/contact/config', 'GET'));
+        $post = $router->getRoutes()->match(Request::create('/admin/contact/config', 'POST'));
+        foreach ([$get, $post] as $route) {
+            $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
+        }
+        $this->assertContains('operations.admin', $post->middleware());
+        $this->assertSame('admin/contact/config/save', $post->defaults['permission_uri']);
+
+        $this->app->instance('request', Request::create('/admin/contact/config', 'POST', ['emails' => ['bad'], 'domains' => []]));
+        $response = (new ContactConfiguration())->save($this->service);
+        $this->assertSame(-3, $response->getData(true)['code']);
+        $this->assertStringContainsString('邮箱格式不正确', $response->getData(true)['msg']);
+
+        $response = (new ContactConfiguration())->show($this->service);
+        $this->assertSame(0, $response->getData(true)['code']);
+        $this->assertSame(['emails' => [], 'domains' => []], $response->getData(true)['data']);
+    }
+}