doge před 2 dny
rodič
revize
5788363f2b

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

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

+ 58 - 0
app/Services/Agent/AgentPageConfigurationService.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Services\OperationAuditService;
+use App\Support\RichTextContent;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+class AgentPageConfigurationService
+{
+    private const FIELDS = ['commission_plan_html', 'contact_tg_url', 'agent_backend_url'];
+
+    public function get(): array
+    {
+        return $this->format(DB::table('agent_page_settings')->where('id', 1)->first());
+    }
+
+    public function save(array $input): array
+    {
+        $data = Validator::make($input, [
+            'commission_plan_html' => ['present', 'nullable', 'string', 'max:100000'],
+            'contact_tg_url' => ['present', 'nullable', 'string', 'max:2048'],
+            'agent_backend_url' => ['present', 'nullable', 'string', 'max:2048'],
+        ], ['commission_plan_html.present' => '请提交佣金方案', 'contact_tg_url.present' => '请提交联系TG地址',
+            'agent_backend_url.present' => '请提交代理后台地址'])->validate();
+        $data['commission_plan_html'] = RichTextContent::clean((string)($data['commission_plan_html'] ?? ''));
+        foreach (['contact_tg_url', 'agent_backend_url'] as $field) {
+            $url = trim((string)($data[$field] ?? ''));
+            if ($url !== '') {
+                $parts = parse_url($url);
+                if (preg_match('/[\x00-\x20\x7f\\\\]/', $url) || !filter_var($url, FILTER_VALIDATE_URL) || !$parts
+                    || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+                    || isset($parts['user']) || isset($parts['pass'])) {
+                    throw ValidationException::withMessages([$field => '请填写完整的HTTP/HTTPS地址']);
+                }
+            }
+            $data[$field] = $url;
+        }
+        return DB::transaction(function () use ($data) {
+            DB::table('agent_page_settings')->insertOrIgnore(['id' => 1, 'created_at' => now(), 'updated_at' => now()]);
+            $before = $this->format(DB::table('agent_page_settings')->where('id', 1)->lockForUpdate()->first());
+            if ($before !== $data) {
+                DB::table('agent_page_settings')->where('id', 1)->update($data + ['updated_at' => now()]);
+                OperationAuditService::record('agent_page_configuration', 1, 'update', $before, $data);
+            }
+            return $data;
+        }, 3);
+    }
+
+    private function format(?object $row): array
+    {
+        $data = [];
+        foreach (self::FIELDS as $field) $data[$field] = (string)($row->$field ?? '');
+        return $data;
+    }
+}

+ 89 - 0
app/Support/RichTextContent.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Support;
+
+/** Table-capable HTML fragment for public editorial content. */
+final class RichTextContent
+{
+    private const TAGS = ['p', 'br', 'div', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'del',
+        'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr', 'sup', 'sub',
+        'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'a'];
+    private const DROP = ['script', 'style', 'iframe', 'object', 'embed', 'svg', 'math', 'template',
+        'form', 'input', 'button', 'textarea', 'select', 'link', 'meta', 'base', 'noscript'];
+    private const STYLES = ['color', 'background-color', 'font-size', 'font-weight', 'font-style', 'font-family',
+        'text-align', 'text-decoration', 'line-height', 'vertical-align', 'white-space',
+        'width', 'height', 'min-width', 'max-width', 'table-layout', 'border', 'border-collapse', 'border-spacing',
+        'border-width', 'border-style', 'border-color', 'border-top', 'border-right', 'border-bottom', 'border-left',
+        'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
+        'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left'];
+
+    public static function clean(string $html): string
+    {
+        if (trim($html) === '') return '';
+        $doc = new \DOMDocument('1.0', 'UTF-8');
+        $previous = libxml_use_internal_errors(true);
+        try {
+            $doc->loadHTML('<?xml encoding="UTF-8" ?><!DOCTYPE html><html><body>' . $html . '</body></html>', LIBXML_NONET);
+            $body = $doc->getElementsByTagName('body')->item(0);
+            if (!$body) return '';
+            self::children($body);
+            $output = '';
+            foreach ($body->childNodes as $node) $output .= $doc->saveHTML($node);
+            return trim($output);
+        } finally { libxml_clear_errors(); libxml_use_internal_errors($previous); }
+    }
+
+    private static function children(\DOMNode $parent): void
+    {
+        foreach (iterator_to_array($parent->childNodes) as $node) {
+            if ($node instanceof \DOMComment || $node instanceof \DOMProcessingInstruction) { $parent->removeChild($node); continue; }
+            if (!$node instanceof \DOMElement) continue;
+            $tag = strtolower($node->tagName);
+            if (in_array($tag, self::DROP, true)) { $parent->removeChild($node); continue; }
+            self::children($node);
+            if (!in_array($tag, self::TAGS, true)) {
+                while ($node->firstChild) $parent->insertBefore($node->firstChild, $node);
+                $parent->removeChild($node);
+                continue;
+            }
+            foreach (iterator_to_array($node->attributes) as $attribute) {
+                $name = strtolower($attribute->name); $value = $attribute->value;
+                $allowed = in_array($name, ['title', 'dir'], true);
+                if ($name === 'style') { $value = self::style($value); $allowed = $value !== ''; }
+                if (in_array($name, ['colspan', 'rowspan'], true)) $allowed = in_array($tag, ['td', 'th'], true) && (bool)preg_match('/^[1-9]\d{0,2}$/D', $value);
+                if (in_array($name, ['width', 'height', 'border', 'cellpadding', 'cellspacing', 'span'], true)) $allowed = (bool)preg_match('/^\d{1,4}%?$/D', $value);
+                if ($name === 'align') $allowed = in_array($value, ['left', 'right', 'center', 'justify'], true);
+                if ($name === 'valign') $allowed = in_array($value, ['top', 'middle', 'bottom', 'baseline'], true);
+                if ($name === 'scope') $allowed = $tag === 'th' && in_array($value, ['row', 'col', 'rowgroup', 'colgroup'], true);
+                if ($name === 'href') $allowed = $tag === 'a' && self::link($value);
+                if ($name === 'target') $allowed = $tag === 'a' && in_array($value, ['_self', '_blank'], true);
+                if (!$allowed) $node->removeAttributeNode($attribute);
+                else $node->setAttribute($name, $value);
+            }
+            if ($tag === 'a' && $node->getAttribute('target') === '_blank') $node->setAttribute('rel', 'noopener noreferrer');
+        }
+    }
+
+    private static function style(string $style): string
+    {
+        $result = [];
+        foreach (explode(';', $style) as $declaration) {
+            $parts = explode(':', $declaration, 2);
+            if (count($parts) !== 2) continue;
+            $property = strtolower(trim($parts[0])); $value = trim($parts[1]);
+            if (!in_array($property, self::STYLES, true) || !preg_match('/^[#a-zA-Z0-9\s.,()%+\-\x27"]+$/D', $value)
+                || preg_match('/(?:url|expression|image-set|attr)\s*\(/i', $value)) continue;
+            $result[] = $property . ': ' . $value;
+        }
+        return implode('; ', $result);
+    }
+
+    private static function link(string $value): bool
+    {
+        if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false;
+        if (str_starts_with($value, '#') || (str_starts_with($value, '/') && !str_starts_with($value, '//'))) return true;
+        $parts = parse_url($value);
+        return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+            && !isset($parts['user']) && !isset($parts['pass']);
+    }
+}

+ 20 - 0
database/migrations/2026_10_30_180000_create_agent_page_settings.php

@@ -0,0 +1,20 @@
+<?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
+    {
+        Schema::create('agent_page_settings', function (Blueprint $t) {
+            $t->id();
+            $t->mediumText('commission_plan_html')->nullable();
+            $t->string('contact_tg_url', 2048)->default('');
+            $t->string('agent_backend_url', 2048)->default('');
+            $t->timestamps();
+        });
+    }
+
+    public function down(): void { Schema::dropIfExists('agent_page_settings'); }
+};

+ 26 - 0
docs/前端接口/代理页面配置-前台接入.md

@@ -0,0 +1,26 @@
+# 代理页面配置:前台展示
+
+`GET /api/agent/config`
+
+无需登录、无需参数,沿用现有公共请求封装。
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "commission_plan_html": "<p><strong>佣金方案</strong></p><table border=\"1\"><tbody><tr><th>等级</th><th>比例</th></tr><tr><td>一级代理</td><td>30%</td></tr></tbody></table>",
+    "contact_tg_url": "https://t.me/customer_service",
+    "agent_backend_url": "https://agent.example.com/login"
+  }
+}
+```
+
+| 字段 | 页面用途 |
+| --- | --- |
+| `commission_plan_html` | 按 HTML 富文本展示佣金方案,保留表格;窄屏可横向滚动 |
+| `contact_tg_url` | 「联系我们」按钮打开此地址 |
+| `agent_backend_url` | 「代理后台」按钮打开此地址 |
+
+页面进入时请求。字段为空字符串时,隐藏对应内容或按钮。`code=0` 时显示 `msg`。

+ 14 - 2
docs/前端接口/落地页浮动数字-WebSocket接入.md

@@ -3,9 +3,21 @@
 复用现有 WebSocket 地址,无需登录、无需发送订阅消息。监听 `type=floating_number`:
 
 ```json
-{"type":"floating_number","data":{"value":"1000000000.00"}}
+{"type":"floating_number","data":{"value":"1284174638.57"}}
 ```
 
-连接成功立即收到当前值,之后每 5 秒更新一次。数字在 9.9 亿至 10.1 亿之间上下浮动,各客户端收到同一数值。
+初始值为 `1284174638.57`。连接成功立即收到当前值,之后每 5 秒更新一次,数字在 12.7 亿至 13 亿之间上下浮动,各客户端收到同一数值。
 
 `data.value` 是固定两位小数的字符串,直接用于展示。断线后按现有方式重连,即可收到最新值;保留原有心跳。
+
+连接成功后的 `welcome` 消息包含 `features:["floating_number"]`,用于确认当前连接支持该数字推送。
+
+如果页面是在连接建立后才开始监听,可以主动获取一次当前值:
+
+```json
+{"type":"get_floating_number"}
+```
+
+服务端仍以 `floating_number` 消息返回。没有收到 `welcome` 时,先检查连接是否成功;证书错误会导致连接失败,无法收到推送。
+
+可直接打开同域名下的 `/websocket-test.html` 测试。填入前端实际连接地址,点击“连接”,即可查看原始消息和最新数字;“获取当前数字”会主动读取一次当前值。

+ 24 - 0
docs/总后台/代理页面配置-后台接入.md

@@ -0,0 +1,24 @@
+# 代理页面配置:后台编辑
+
+沿用后台登录请求封装。
+
+- 读取:`GET /admin/agent/pageConfig`
+- 保存:`POST /admin/agent/pageConfig`
+
+| 字段 | 表单 |
+| --- | --- |
+| `commission_plan_html` | 佣金方案富文本编辑器,支持表格、合并单元格和常用样式,最多 100000 字符 |
+| `contact_tg_url` | 联系 TG 的完整 HTTP/HTTPS 地址,最多 2048 字符 |
+| `agent_backend_url` | 代理后台的完整 HTTP/HTTPS 地址,最多 2048 字符 |
+
+读取成功时 `code=0`,从 `data` 回填三个字段。保存时三个字段一起提交,空字符串表示清空:
+
+```json
+{
+  "commission_plan_html": "<p><strong>佣金方案</strong></p><table border=\"1\"><tbody><tr><th>等级</th><th>比例</th></tr><tr><td>一级代理</td><td>30%</td></tr></tbody></table>",
+  "contact_tg_url": "https://t.me/customer_service",
+  "agent_backend_url": "https://agent.example.com/login"
+}
+```
+
+保存成功时 `code=0`,`data` 返回保存后的三个字段,用于回填编辑器和地址输入框。保存失败显示 `msg`。不要把富文本转成纯文本后提交。

+ 2 - 0
routes/admin.php

@@ -142,6 +142,8 @@ Route::middleware(['admin.jwt'])->group(function () {
         });
 
         Route::prefix('/agent')->middleware('agent.admin')->group(function () {
+            Route::get('/pageConfig', [\App\Http\Controllers\admin\AgentPageConfiguration::class, 'show'])->defaults('permission_uri', 'admin/agent');
+            Route::post('/pageConfig', [\App\Http\Controllers\admin\AgentPageConfiguration::class, 'save'])->defaults('permission_uri', 'admin/agent/update');
             Route::get('/applications', [\App\Http\Controllers\admin\AgentApplication::class, 'index'])->defaults('permission_uri', 'admin/agent');
             Route::post('/applicationAudit', [\App\Http\Controllers\admin\AgentApplication::class, 'audit'])->defaults('permission_uri', 'admin/agent/update');
             Route::get('/', [AgentAdmin::class, 'index']);

+ 139 - 0
tests/Integration/AgentPageConfigurationTest.php

@@ -0,0 +1,139 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\AgentPageConfiguration;
+use App\Services\Agent\AgentPageConfigurationService;
+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 AgentPageConfigurationTest extends TestCase
+{
+    private Application $app;
+    private AgentPageConfigurationService $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/agent/pageConfig'); $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)');
+        $this->migration()->up();
+        $this->service = new AgentPageConfigurationService();
+    }
+
+    protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+    private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_30_180000_create_agent_page_settings.php'; }
+    private function emptyConfig(): array { return ['commission_plan_html' => '', 'contact_tg_url' => '', 'agent_backend_url' => '']; }
+    private function payload(): array
+    {
+        return ['commission_plan_html' => '<p><strong>佣金方案 / Tiếng Việt</strong></p><table style="width:100%;border-collapse:collapse" border="1"><caption>代理比例</caption><colgroup><col width="50%"></colgroup><thead><tr><th colspan="2">等级</th></tr></thead><tbody><tr><td rowspan="2" style="text-align:center;color:#f00">一级代理</td><td>30%</td></tr><tr><td>40%</td></tr></tbody></table>',
+            'contact_tg_url' => ' https://t.me/customer_service ', 'agent_backend_url' => 'https://agent.example.com/login?from=member#start'];
+    }
+
+    public function test_defaults_and_migration_can_be_rolled_back_and_reapplied(): void
+    {
+        $this->assertSame($this->emptyConfig(), $this->service->get());
+        $this->migration()->down(); $this->migration()->up();
+        $this->assertSame($this->emptyConfig(), $this->service->get());
+    }
+
+    public function test_rich_table_and_two_urls_save_together_and_only_public_fields_are_returned(): void
+    {
+        $this->app['request']->merge($this->payload());
+        $response = (new AgentPageConfiguration())->save($this->service);
+        $result = $response->getData(true); $this->assertSame(0, $result['code']);
+        $data = $result['data'];
+        $this->assertSame(array_keys($this->emptyConfig()), array_keys($data));
+        $this->assertSame('https://t.me/customer_service', $data['contact_tg_url']);
+        $this->assertSame($this->payload()['agent_backend_url'], $data['agent_backend_url']);
+        foreach (['<table', '<thead>', '<tbody>', '<caption>', '<colgroup>', 'colspan="2"', 'rowspan="2"', 'width: 100%', 'text-align: center', 'color: #f00', '佣金方案', 'Tiếng Việt', '30%', '40%'] as $text) $this->assertStringContainsString($text, $data['commission_plan_html']);
+        $this->assertSame($data, $this->service->get());
+        $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+        $this->assertSame(1, DB::table('agent_page_settings')->count());
+        $this->assertSame(1, DB::table('operation_audits')->count());
+        $this->assertSame($data, $this->service->save($data));
+        $this->assertSame(1, DB::table('operation_audits')->count());
+        $this->assertSame($this->emptyConfig(), $this->service->save($this->emptyConfig()));
+    }
+
+    public function test_active_markup_and_unsafe_styles_are_removed_without_flattening_tables(): void
+    {
+        $input = $this->emptyConfig();
+        $input['commission_plan_html'] = '<script>alert(1)</script><svg><script>alert(2)</script></svg><iframe src="https://evil.example"></iframe><p onclick="alert(3)">中文</p><table><tr><td style="color:red;position:fixed;background-color:url(https://evil.example);width:expression(alert(4))" onmouseover="alert(5)">30%</td></tr></table><a href="jav&#x61;script:alert(6)">错误链接</a><a href="https://t.me/customer_service" target="_blank">TG</a>';
+        $html = $this->service->save($input)['commission_plan_html'];
+        foreach (['<script', '<svg', '<iframe', 'onclick', 'onmouseover', 'javascript:', 'expression(', 'url(', 'position:', 'alert('] as $danger) $this->assertStringNotContainsString($danger, $html);
+        foreach (['<table>', '<td style="color: red">30%</td>', '中文', 'href="https://t.me/customer_service"', 'rel="noopener noreferrer"'] as $safe) $this->assertStringContainsString($safe, $html);
+    }
+
+    /** @dataProvider invalidValues */
+    public function test_invalid_values_do_not_partially_save(string $field, $invalid): void
+    {
+        $before = $this->service->save($this->payload());
+        $input = $before; $input[$field] = $invalid;
+        try { $this->service->save($input); $this->fail('Expected validation failure'); }
+        catch (ValidationException $e) { $this->assertArrayHasKey($field, $e->errors()); }
+        $this->assertSame($before, $this->service->get()); $this->assertSame(1, DB::table('operation_audits')->count());
+    }
+
+    public static function invalidValues(): array
+    {
+        $cases = [['commission_plan_html', []], ['commission_plan_html', str_repeat('字', 100001)]];
+        foreach (['contact_tg_url', 'agent_backend_url'] as $field) {
+            foreach (['javascript:alert(1)', '//example.com', 'https://user:password@example.com', "https://example.com/\na", 'https://example.com/\\evil', str_repeat('x', 2049), ['url']] as $value) $cases[] = [$field, $value];
+        }
+        return $cases;
+    }
+
+    public function test_save_is_atomic_when_audit_fails_and_missing_form_fields_are_rejected(): void
+    {
+        $before = $this->service->save($this->payload());
+        foreach (array_keys($before) as $field) {
+            $input = $before; unset($input[$field]);
+            try { $this->service->save($input); $this->fail('Missing field accepted'); }
+            catch (ValidationException $e) { $this->assertArrayHasKey($field, $e->errors()); }
+        }
+        DB::statement('DROP TABLE bot_operation_audits');
+        try { $this->service->save($this->emptyConfig()); $this->fail('Audit failure was swallowed'); }
+        catch (\Illuminate\Database\QueryException $e) { $this->assertSame($before, $this->service->get()); }
+    }
+
+    public function test_admin_routes_keep_login_agent_admin_and_read_write_permissions(): 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'; });
+        foreach (['GET' => ['show', 'admin/agent'], 'POST' => ['save', 'admin/agent/update']] as $method => [$action, $permission]) {
+            $route = $router->getRoutes()->match(Request::create('/admin/agent/pageConfig', $method));
+            $this->assertSame(AgentPageConfiguration::class . '@' . $action, $route->getAction('uses'));
+            foreach (['admin.jwt', 'check.button.uri', 'agent.admin'] as $middleware) $this->assertContains($middleware, $route->middleware());
+            $this->assertSame($permission, $route->defaults['permission_uri']);
+        }
+        $result = (new AgentPageConfiguration())->show($this->service)->getData(true);
+        $this->assertSame($this->emptyConfig(), $result['data']);
+        DB::statement('DROP TABLE bot_agent_page_settings');
+        $failed = (new AgentPageConfiguration())->show($this->service)->getData(true);
+        $this->assertSame('代理页面配置暂不可用,请稍后重试', $failed['msg']);
+    }
+}