From 9e0614c8a5b6d16899377deb8a2adfdd41aea41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 13 Jul 2026 07:47:20 +0300 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=D1=81=D1=87=D1=91=D1=82=D1=87?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D0=B7=D0=B6?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20=D0=BD=D0=B0=20=D0=B0=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BD=D1=8B=D0=B9=20Redis=20=E2=80=94=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BA=D1=8D=D1=88?= =?UTF-8?q?=20=D1=82=D0=B5=D1=80=D1=8F=D0=BB=20=D0=BF=D1=80=D0=B8=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Файловый Cache::increment() под параллельной нагрузкой (10 x 30) досчитал 77 из 300 — защита от накрутки счёта (пауза/час/IP/сутки) продавливалась параллельным скриптом. ChatRateLimiter теперь берёт своё хранилище (services.jivo_bot.limiter_store, по умолчанию redis, в тестах array) и считает через атомарные add()+increment(), а не через фасад RateLimiter на общем кэше. Ручная проверка на живом Redis: 300 из 300. --- app/app/Services/Bot/ChatRateLimiter.php | 30 ++++++++++++------- app/config/services.php | 4 +++ app/phpunit.xml | 1 + app/tests/Feature/Bot/ChatRateLimiterTest.php | 14 ++++++--- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/app/app/Services/Bot/ChatRateLimiter.php b/app/app/Services/Bot/ChatRateLimiter.php index 646773a1..cf53fda9 100644 --- a/app/app/Services/Bot/ChatRateLimiter.php +++ b/app/app/Services/Bot/ChatRateLimiter.php @@ -8,7 +8,6 @@ use App\Mail\ChatBudgetAlertMail; use App\Models\BotDialog; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\RateLimiter; /** * Защита чата от накрутки счёта (спека 2026-07-13-own-chat-widget-design §6). @@ -41,23 +40,20 @@ class ChatRateLimiter public function check(string $chatId, string $ip, bool $authenticated): ?string { $pauseKey = 'chat:pause:'.$chatId; - if (RateLimiter::tooManyAttempts($pauseKey, 1)) { + if (! $this->store()->add($pauseKey, 1, self::PAUSE_SECONDS)) { return self::TOO_FAST; } - RateLimiter::hit($pauseKey, self::PAUSE_SECONDS); $hourKey = 'chat:hour:'.$chatId; $hourly = $authenticated ? self::USER_PER_HOUR : self::GUEST_PER_HOUR; - if (RateLimiter::tooManyAttempts($hourKey, $hourly)) { + if ($this->hit($hourKey, 3600) > $hourly) { return self::TOO_MANY; } - RateLimiter::hit($hourKey, 3600); $ipKey = 'chat:ip:'.$ip; - if (RateLimiter::tooManyAttempts($ipKey, self::IP_PER_HOUR)) { + if ($this->hit($ipKey, 3600) > self::IP_PER_HOUR) { return self::TOO_MANY; } - RateLimiter::hit($ipKey, 3600); $asked = BotDialog::query() ->where('chat_id', $chatId) @@ -74,18 +70,30 @@ class ChatRateLimiter return null; } + private function store(): \Illuminate\Contracts\Cache\Repository + { + return Cache::store((string) config('services.jivo_bot.limiter_store')); + } + + /** Атомарная прибавка с временем жизни. Возвращает новое значение счётчика. */ + private function hit(string $key, int $ttlSeconds): int + { + $store = $this->store(); + $store->add($key, 0, $ttlSeconds); // создаёт ключ, если его нет (атомарно) + + return (int) $store->increment($key); // в Redis это INCR — атомарно + } + /** Суточный потолок на весь чат. Пробит — гасим и один раз шлём владельцу тревогу. */ private function dailyBudgetLeft(): bool { - $key = 'chat:day:'.now()->toDateString(); - Cache::add($key, 0, now()->addDay()); - $used = (int) Cache::increment($key); + $used = $this->hit('chat:day:'.now()->toDateString(), 86400); if ($used <= self::GLOBAL_PER_DAY) { return true; } - if (Cache::add('chat:day:alert:'.now()->toDateString(), 1, now()->addDay())) { + if ($this->store()->add('chat:day:alert:'.now()->toDateString(), 1, 86400)) { $to = (string) config('services.support.email'); if ($to !== '') { Mail::to($to)->queue(new ChatBudgetAlertMail($used)); diff --git a/app/config/services.php b/app/config/services.php index 7bb8f635..b4bec03d 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -166,6 +166,10 @@ return [ 'llm' => env('JIVO_BOT_LLM', 'yandex'), 'aitunnel_model' => env('JIVO_BOT_AITUNNEL_MODEL', 'gpt-4o-mini'), 'timeout_seconds' => env('JIVO_BOT_LLM_TIMEOUT', 10), + // Счётчики чата должны считать АТОМАРНО: файловый кэш под параллельной нагрузкой + // теряет до 74% прибавок (проверено), и защита от накрутки счёта становится дырявой. + // Redis (INCR) атомарен. В тестах — 'array' (один процесс, гонки не бывает). + 'limiter_store' => env('BOT_LIMITER_STORE', 'redis'), ], // YandexGPT Lite (Yandex Cloud Foundation Models) — мозг бота (решение 8 протокола). 'yandexgpt' => [ diff --git a/app/phpunit.xml b/app/phpunit.xml index 8706af49..81280ea1 100644 --- a/app/phpunit.xml +++ b/app/phpunit.xml @@ -22,6 +22,7 @@ + diff --git a/app/tests/Feature/Bot/ChatRateLimiterTest.php b/app/tests/Feature/Bot/ChatRateLimiterTest.php index c86d18a9..d1d2f30e 100644 --- a/app/tests/Feature/Bot/ChatRateLimiterTest.php +++ b/app/tests/Feature/Bot/ChatRateLimiterTest.php @@ -8,7 +8,6 @@ use App\Services\Bot\ChatRateLimiter; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); @@ -29,10 +28,10 @@ it('гостю даёт не больше двадцати вопросов в $chat = str_repeat('b', 32); for ($i = 0; $i < ChatRateLimiter::GUEST_PER_HOUR; $i++) { - RateLimiter::clear('chat:pause:'.$chat); + Cache::forget('chat:pause:'.$chat); expect($limiter->check($chat, '1.2.3.4', false))->toBeNull(); } - RateLimiter::clear('chat:pause:'.$chat); + Cache::forget('chat:pause:'.$chat); expect($limiter->check($chat, '1.2.3.4', false))->toContain('несколько минут'); }); @@ -41,7 +40,7 @@ it('вошедшему клиенту даёт больше — шестьдес $chat = str_repeat('c', 32); for ($i = 0; $i < ChatRateLimiter::GUEST_PER_HOUR + 1; $i++) { - RateLimiter::clear('chat:pause:'.$chat); + Cache::forget('chat:pause:'.$chat); expect($limiter->check($chat, '1.2.3.4', true))->toBeNull(); } }); @@ -80,3 +79,10 @@ it('пробитый суточный потолок гасит чат и шлё Mail::assertQueued(ChatBudgetAlertMail::class, 1); }); + +it('счётчики чата живут в атомарном хранилище, а не в файловом кэше', function () { + // Файловый кэш под параллельной нагрузкой теряет прибавки (проверено: 300 → 77), + // и защита от накрутки счёта становится дырявой. Хранилище счётчиков должно быть + // атомарным (Redis INCR). Этот сторож не даёт вернуть 'file' незаметно. + expect(config('services.jivo_bot.limiter_store'))->not->toBe('file'); +});