feat(redis): кэш, rate limit, баны IP и pub/sub через Redis

Добавлен сервис redis:7-alpine (AOF, requirepass, maxmemory + allkeys-lru,
healthcheck, том redis-data, порт только на 127.0.0.1) и абстракция redis.js
по образцу storage.js.

Переведено на Redis:
- кэш ответов API и настроек (было Map в памяти), инвалидация по префиксу
  через SCAN + DEL;
- rate limit для api/entry/file — общие счётчики вместо MemoryStore;
- баны IP и счётчики неудачных входа — с TTL, вместо опроса БД каждую минуту;
- кэш сессий (30 с) с invalidateSessions() на каждой мутации users/sessions/
  user_branches, иначе деактивированный пользователь сохранил бы доступ;
- pub/sub для SSE-событий и мгновенного пробуждения фоновых воркеров вместо
  ожидания цикла опроса БД.

Отказоустойчивость: при недоступном Redis все операции уходят в in-memory
backend с той же семантикой, приложение стартует и работает без Redis и
возвращается в Redis автоматически. Первое подключение ограничено по времени
(REDIS_CONNECT_TIMEOUT_MS, 5 с) — node-redis не отклоняет connect() при
недоступном сервере, а повторяет попытки бесконечно.

Добавлены тесты: redis.selftest.js (в т.ч. поведение при недоступном
сервере) и api.smoketest.js (сквозная проверка API, включая инвалидацию
кэша и мгновенную смерть сессии после logout).
This commit is contained in:
dev
2026-09-26 15:26:00 +03:00
parent e4d58d6525
commit 0e38a280d7
11 changed files with 1098 additions and 74 deletions
+18 -4
View File
@@ -9,7 +9,7 @@ const crypto = require('crypto');
const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000;
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage }) {
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus }) {
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
const IDLE_MIN = 2000;
const IDLE_MAX = 30000;
@@ -37,10 +37,17 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
});
}
function notify() {
function wakeLocal() {
if (wake) wake();
}
function notify() {
wakeLocal();
if (bus) bus.publish();
}
if (bus) bus.subscribe(wakeLocal);
async function isEnabled() {
const v = await getSetting('photo_worker_enabled', 'true');
return String(v) !== 'false';
@@ -233,7 +240,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
return { start, notify, getInfo };
}
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model, bus }) {
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
@@ -266,10 +273,17 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
});
}
function notify() {
function wakeLocal() {
if (wake) wake();
}
function notify() {
wakeLocal();
if (bus) bus.publish();
}
if (bus) bus.subscribe(wakeLocal);
async function isEnabled() {
const v = await getSetting('ai_autocheck_enabled', 'true');
return String(v) !== 'false';