doge 1 dzień temu
rodzic
commit
dc60bef62f

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

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\SiteConfigurationService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class SiteConfiguration extends Controller
+{
+    public function show(SiteConfigurationService $service) { return $this->run(fn () => $service->get()); }
+    public function save(SiteConfigurationService $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('site_configuration_admin_failed', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '站点配置暂不可用,请稍后重试');
+        }
+    }
+}

+ 87 - 0
app/Services/SiteConfigurationService.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+class SiteConfigurationService
+{
+    public function get(): array
+    {
+        return DB::transaction(fn () => $this->format(DB::table('site_settings')->where('id', 1)->sharedLock()->first()));
+    }
+
+    public function save(array $input): array
+    {
+        $data = Validator::make($input, [
+            'translations' => ['present', 'array:zh,en,vi'],
+            'translations.*' => ['array:content'],
+            'translations.*.content' => ['nullable', 'string', 'max:2000'],
+            'domains' => ['present', 'array', 'max:100'],
+            'domains.*' => ['required', 'string', 'max:2048'],
+        ], [
+            'translations.present' => '请提交三语言跑马灯内容', 'translations.array' => '跑马灯仅支持zh/en/vi三种语言',
+            'translations.*.array' => '每种语言请提交content对象', 'translations.*.content.string' => '跑马灯内容必须为文本',
+            'translations.*.content.max' => '每种语言的跑马灯最多2000个字符',
+            'domains.present' => '请提交域名数组', 'domains.array' => '域名必须为数组', 'domains.max' => '域名最多100个',
+            'domains.*.required' => '域名不能为空', 'domains.*.string' => '每个域名必须为字符串', 'domains.*.max' => '每个域名最多2048个字符',
+        ])->validate();
+        if (array_values($data['domains']) !== $data['domains']) {
+            throw ValidationException::withMessages(['domains' => '域名必须为顺序数组']);
+        }
+        $domains = [];
+        foreach ($data['domains'] as $index => $domain) {
+            $url = $this->domain($domain, $index);
+            if (!in_array($url, $domains, true)) $domains[] = $url;
+        }
+        $translations = [];
+        foreach (['zh', 'en', 'vi'] as $language) $translations[$language] = ['content' => trim((string) ($data['translations'][$language]['content'] ?? ''))];
+        $payload = ['translations' => $translations, 'domains' => $domains];
+        return DB::transaction(function () use ($payload) {
+            DB::table('site_settings')->insertOrIgnore(['id' => 1, 'domains' => '[]', 'created_at' => now(), 'updated_at' => now()]);
+            $before = $this->format(DB::table('site_settings')->where('id', 1)->lockForUpdate()->first());
+            if ($before !== $payload) {
+                DB::table('site_settings')->where('id', 1)->update([
+                    'domains' => json_encode($payload['domains'], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'updated_at' => now()]);
+                DB::table('site_setting_translations')->where('site_setting_id', 1)->delete();
+                foreach ($payload['translations'] as $language => $text) {
+                    if ($text['content'] !== '') DB::table('site_setting_translations')->insert([
+                        'site_setting_id' => 1, 'language' => $language, 'content' => $text['content']]);
+                }
+                OperationAuditService::record('site_configuration', 1, 'update', $before, $payload);
+            }
+            return $payload;
+        }, 3);
+    }
+
+    private function format(?object $row): array
+    {
+        $domains = $row ? json_decode($row->domains, true, 512, JSON_THROW_ON_ERROR) : [];
+        if (!is_array($domains) || array_values($domains) !== $domains || count(array_filter($domains, 'is_string')) !== count($domains)) {
+            throw new \RuntimeException('站点域名配置格式异常');
+        }
+        $translations = ['zh' => ['content' => ''], 'en' => ['content' => ''], 'vi' => ['content' => '']];
+        if ($row) foreach (DB::table('site_setting_translations')->where('site_setting_id', 1)->get(['language', 'content']) as $text) {
+            if (isset($translations[$text->language])) $translations[$text->language] = ['content' => (string) $text->content];
+        }
+        return ['translations' => $translations, 'domains' => $domains];
+    }
+
+    private function domain(string $value, int $index): string
+    {
+        $url = trim($value);
+        if (!preg_match('~^[a-z][a-z0-9+.-]*://~i', $url)) $url = 'https://' . $url;
+        $parts = parse_url($url);
+        if (!filter_var($url, FILTER_VALIDATE_URL) || !$parts
+            || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+            || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
+            || isset($parts['query']) || isset($parts['fragment'])
+            || !in_array($parts['path'] ?? '', ['', '/'], true)
+            || (isset($parts['port']) && $parts['port'] < 1)) {
+            throw ValidationException::withMessages(['domains.' . $index => '域名仅支持HTTP/HTTPS站点地址,不带账号、路径、查询参数或片段']);
+        }
+        return strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : '');
+    }
+}

+ 31 - 0
database/migrations/2026_10_27_120000_create_site_settings.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('site_settings', function (Blueprint $table) {
+            $table->unsignedInteger('id')->primary();
+            $table->json('domains');
+            $table->timestamps();
+        });
+        Schema::create('site_setting_translations', function (Blueprint $table) {
+            $table->unsignedInteger('site_setting_id');
+            $table->string('language', 8);
+            $table->text('content');
+            $table->primary(['site_setting_id', 'language']);
+            $table->foreign('site_setting_id')->references('id')->on('site_settings')->onDelete('cascade');
+        });
+        DB::table('site_settings')->insert(['id' => 1, 'domains' => '[]', 'created_at' => now(), 'updated_at' => now()]);
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('site_setting_translations');
+        Schema::dropIfExists('site_settings');
+    }
+};

+ 104 - 0
docs/总后台/跑马灯与域名配置-后台接入.md

@@ -0,0 +1,104 @@
+# 跑马灯与域名配置:后台前端接入
+
+接口项目:`bot-28`,使用总后台 API 域名。三种语言的跑马灯和一组共用域名在同一表单、同一次请求中读取或保存。
+
+## 接口
+
+| 方法 | 路径 | 用途 | 权限 URI |
+|---|---|---|---|
+| GET | `/admin/site/config` | 读取三种语言及域名数组 | `admin/site/config` |
+| POST | `/admin/site/config` | 一次保存全部内容 | `admin/site/config/save` |
+
+请求头使用 `Authorization: Bearer <admin_token>`;POST 使用 `Content-Type: application/json`。保存需要运营配置权限。
+
+## 表单字段
+
+| 字段 | 类型 | 规则 |
+|---|---|---|
+| `translations` | object | 必须提交,支持 `zh/en/vi` 三种语言 |
+| `translations.zh.content` | string | 中文跑马灯,最多2000个字符,可为空 |
+| `translations.en.content` | string | 英文跑马灯,最多2000个字符,可为空 |
+| `translations.vi.content` | string | 越南文跑马灯,最多2000个字符,可为空 |
+| `domains` | string[] | 必须提交,三种语言共用一组,最多100项;空数组清空域名列表 |
+| `domains[]` | string | 每项最多2048个字符,填写HTTP/HTTPS站点根地址;不带协议时补 `https://` |
+
+跑马灯按纯文本编辑。某语言内容为空时,前台不显示该语言跑马灯,也不使用其他语言替代。
+
+域名使用站点根地址,如 `https://example.com`;不含路径、查询参数、片段或账号密码。保存后协议及主机名转为小写、移除末尾 `/`,规范化后重复地址只保留第一项,其余按提交顺序返回。
+
+## 读取
+
+```http
+GET /admin/site/config
+```
+
+读取和保存成功均返回以下结构:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1793066400,
+  "msg": "OK",
+  "data": {
+    "translations": {
+      "zh": {
+        "content": "欢迎体验,最新活动请查看活动页面。"
+      },
+      "en": {
+        "content": "Welcome. Visit the promotions page for the latest offers."
+      },
+      "vi": {
+        "content": "Chào mừng bạn. Xem trang khuyến mãi để biết ưu đãi mới nhất."
+      }
+    },
+    "domains": [
+      "https://www.example.com",
+      "https://backup.example.com"
+    ]
+  }
+}
+```
+
+读取始终包含 `zh/en/vi` 三个对象;未填写语言的 `content` 为 `""`,未设置域名时 `domains=[]`。
+
+## 保存
+
+```http
+POST /admin/site/config
+Content-Type: application/json
+```
+
+```json
+{
+  "translations": {
+    "zh": {
+      "content": "欢迎体验,最新活动请查看活动页面。"
+    },
+    "en": {
+      "content": "Welcome. Visit the promotions page for the latest offers."
+    },
+    "vi": {
+      "content": "Chào mừng bạn. Xem trang khuyến mãi để biết ưu đãi mới nhất."
+    }
+  },
+  "domains": [
+    "https://www.example.com",
+    "https://backup.example.com"
+  ]
+}
+```
+
+每次同时提交三种语言及共用域名数组。`translations` 完整替换:未包含的语言会清空。保存成功后用返回的 `data` 回填表单。
+
+清空全部内容的请求为:
+
+```json
+{"translations":{},"domains":[]}
+```
+
+## 页面交互
+
+- 使用中文、英文、越南文三个页签分别编辑对应 `content`。
+- 域名列表只编辑一组;切换语言时保留共用列表,支持增删及调整顺序。
+- 保存按钮一次发送完整三语言表单和域名数组;取消编辑时重新读取当前配置。
+- `code=0` 成功;`code=-3` 展示 `msg`;登录失效及权限不足沿用后台公共处理。

+ 4 - 0
routes/admin.php

@@ -83,6 +83,10 @@ Route::middleware(['admin.jwt'])->group(function () {
 
 
     Route::middleware(['check.button.uri'])->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::prefix('/promotion')->group(function () {
             Route::get('/', [\App\Http\Controllers\admin\Promotion::class, 'index']);
             Route::get('/options', [\App\Http\Controllers\admin\Promotion::class, 'options'])->defaults('permission_uri', 'admin/promotion');

+ 137 - 0
tests/Integration/SiteConfigurationTest.php

@@ -0,0 +1,137 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\SiteConfiguration;
+use App\Services\SiteConfigurationService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+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\TestCase;
+
+class SiteConfigurationTest extends TestCase
+{
+    private Application $app;
+    private SiteConfigurationService $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 class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        $request = Request::create('/admin/site/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)');
+        DB::statement('CREATE TABLE bot_menus (id INTEGER PRIMARY KEY,title TEXT)');
+        DB::table('menus')->insert(['id' => 1, 'title' => '前端已有菜单']);
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_120000_create_site_settings.php')->up();
+        $this->service = new SiteConfigurationService();
+    }
+
+    protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+
+    private function emptyConfig(): array { return ['translations' => ['zh' => ['content' => ''], 'en' => ['content' => ''], 'vi' => ['content' => '']], 'domains' => []]; }
+
+    public function test_defaults_and_migration_do_not_require_a_separate_configuration_record_or_touch_menus(): void
+    {
+        $this->assertSame($this->emptyConfig(), $this->service->get());
+        $this->assertSame(1, DB::table('menus')->count());
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_120000_create_site_settings.php';
+        $migration->down();
+        $this->assertSame('前端已有菜单', DB::table('menus')->value('title'));
+        $migration->up();
+        $this->assertSame($this->emptyConfig(), $this->service->get());
+    }
+
+    public function test_both_fields_save_together_preserve_domain_order_and_return_normalized_values(): void
+    {
+        $result = $this->service->save(['translations' => ['zh' => ['content' => ' 欢迎体验 '], 'en' => ['content' => ' Welcome '], 'vi' => ['content' => ' Chào mừng ']], 'domains' => ['example.com', 'https://EXAMPLE.com/', 'HTTP://BACKUP.EXAMPLE:8080/']]);
+        $this->assertSame(['translations' => ['zh' => ['content' => '欢迎体验'], 'en' => ['content' => 'Welcome'], 'vi' => ['content' => 'Chào mừng']], '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());
+        $this->assertSame($this->emptyConfig(), $this->service->save(['translations' => [], 'domains' => []]));
+    }
+
+    /** @dataProvider invalidPayloads */
+    public function test_invalid_configuration_does_not_partially_save(array $payload): void
+    {
+        try { $this->service->save($payload); $this->fail('Expected validation failure'); }
+        catch (ValidationException $e) { $this->assertNotEmpty($e->validator->errors()->all()); }
+        $this->assertSame($this->emptyConfig(), $this->service->get());
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+
+    public static function invalidPayloads(): array
+    {
+        $base = ['translations' => ['zh' => ['content' => '测试']], 'domains' => []];
+        return [[['domains' => []]], [['translations' => []]],
+            [array_replace($base, ['translations' => ['zh' => ['content' => str_repeat('字', 2001)]]])],
+            [array_replace($base, ['domains' => ['primary' => 'https://example.com']])],
+            [array_replace($base, ['domains' => array_fill(0, 101, 'https://example.com')])],
+            [array_replace($base, ['domains' => ['']])], [array_replace($base, ['domains' => [123]])],
+            [array_replace($base, ['domains' => ['ftp://example.com']])],
+            [array_replace($base, ['domains' => ['javascript:alert(1)']])],
+            [array_replace($base, ['domains' => ['https://user:pass@example.com']])],
+            [array_replace($base, ['domains' => ['https://example.com/path']])],
+            [array_replace($base, ['domains' => ['https://example.com?x=1']])],
+            [array_replace($base, ['domains' => ['https://example.com/#fragment']])],
+            [array_replace($base, ['translations' => ['fr' => ['content' => 'French']]])],
+            [array_replace($base, ['translations' => ['zh' => '不是对象']])],
+            [array_replace($base, ['translations' => ['zh' => ['title' => '未知字段']]])]];
+    }
+
+    public function test_translations_are_independent_and_full_replacement_clears_omitted_languages(): void
+    {
+        $this->service->save(['translations' => ['zh' => ['content' => '中文'], 'en' => ['content' => 'English'], 'vi' => ['content' => 'Tiếng Việt']], 'domains' => ['example.com']]);
+        $this->assertSame(3, DB::table('site_setting_translations')->count());
+        $next = $this->service->save(['translations' => ['zh' => ['content' => '新中文']], 'domains' => ['shared.example']]);
+        $this->assertSame(['zh' => ['content' => '新中文'], 'en' => ['content' => ''], 'vi' => ['content' => '']], $next['translations']);
+        $this->assertSame(['https://shared.example'], $next['domains']);
+        $this->assertSame(1, DB::table('site_setting_translations')->count());
+    }
+
+    public function test_audit_failure_rolls_back_both_configuration_fields(): void
+    {
+        $before = $this->service->save(['translations' => ['zh' => ['content' => '原内容']], 'domains' => ['example.com']]);
+        DB::statement('DROP TABLE bot_operation_audits');
+        try { $this->service->save(['translations' => ['zh' => ['content' => '新内容']], 'domains' => ['new.example']]); $this->fail('Expected audit failure'); }
+        catch (\Illuminate\Database\QueryException $e) { $this->assertSame($before, $this->service->get()); }
+    }
+
+    public function test_one_admin_path_supports_read_and_write_with_authentication_and_permission_checks(): 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/site/config', 'GET'));
+        $post = $router->getRoutes()->match(Request::create('/admin/site/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/site/config/save', $post->defaults['permission_uri']);
+        $response = (new SiteConfiguration())->show($this->service);
+        $this->assertSame(0, $response->getData(true)['code']);
+        $this->assertSame($this->emptyConfig(), $response->getData(true)['data']);
+        $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+    }
+}