fix(chat): счётчики переезжают на атомарный Redis — файловый кэш терял прибавки

Файловый Cache::increment() под параллельной нагрузкой (10 x 30) досчитал 77 из 300 —
защита от накрутки счёта (пауза/час/IP/сутки) продавливалась параллельным скриптом.
ChatRateLimiter теперь берёт своё хранилище (services.jivo_bot.limiter_store, по
умолчанию redis, в тестах array) и считает через атомарные add()+increment(), а не
через фасад RateLimiter на общем кэше. Ручная проверка на живом Redis: 300 из 300.
This commit is contained in:
Дмитрий
2026-07-13 07:47:20 +03:00
parent 09b980f5ac
commit 9e0614c8a5
4 changed files with 34 additions and 15 deletions
+19 -11
View File
@@ -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));
+4
View File
@@ -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' => [
+1
View File
@@ -22,6 +22,7 @@
</source>
<php>
<env name="JIVO_BOT_LLM" value="yandex"/>
<env name="BOT_LIMITER_STORE" value="array"/>
<env name="AITUNNEL_API_KEY" value=""/>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
+10 -4
View File
@@ -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');
});