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:
@@ -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));
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user