feat(ticket_form): unified_id/contact_id передача, исправлен мерж сессии, новая сессия для жалобы

- Добавлены unified_id и contact_id в TicketFormDescriptionRequest
- Исправлен CODE_MERGE_PROJECT_TO_SESSION.js - теперь сохраняются ВСЕ данные из body.other
- Добавлен fallback на получение other из Webhook напрямую
- Генерация новой session_id при создании новой жалобы (сохраняя авторизацию)
- Добавлен SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql для CRM контактов
- Создан SESSION_LOG_2025-11-25.md с документацией сессии
This commit is contained in:
Fedor
2025-11-25 20:02:21 +03:00
parent a20a4d0e09
commit 52fe013375
39 changed files with 4155 additions and 21 deletions

View File

@@ -343,3 +343,4 @@ TTL: 86400 секунд
**Статус:** ✅ Завершено

View File

@@ -0,0 +1,135 @@
# Лог сессии 25.11.2025
## Основные задачи
### 1. Передача unified_id и contact_id в описание проблемы
**Файлы:**
- `backend/app/api/models.py` — добавлены поля `unified_id` и `contact_id` в `TicketFormDescriptionRequest`
- `backend/app/api/claims.py` — добавлена передача `unified_id` и `contact_id` в Redis событие
- `frontend/src/components/form/StepDescription.tsx` — добавлена передача `unified_id` и `contact_id` при отправке описания
**Результат:** При отправке описания проблемы теперь передаются `unified_id` и `contact_id` пользователя.
---
### 2. Структура таблиц CRM MySQL для контактов
**Основные таблицы:**
- `vtiger_contactdetails` — основные данные (firstname, lastname, email, mobile, phone)
- `vtiger_contactscf` — кастомные поля:
- `cf_1157` — Отчество (middle_name)
- `cf_1263` — Место рождения (birthplace)
- `cf_1257` — ИНН (inn)
- `cf_1849` — Реквизиты (requisites)
- `cf_1580` — Код (code)
- `vtiger_contactsubdetails` — дополнительные данные (birthday, homephone)
- `vtiger_contactaddress` — адреса (mailingstreet, mailingcity, и т.д.)
**Создан файл:** `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — правильный SQL запрос для получения всех данных контакта
---
### 3. Исправление Code Node: Мерж данных проекта в сессию
**Проблема:** Данные из `body.other` (sessionData) не сохранялись в Redis — терялись все данные пользователя.
**Причина:** К моменту выполнения Code Node структура данных менялась (`body_keys: ["success", "result"]`), и `body.other` был недоступен.
**Решение:** Добавлен fallback на получение `other` напрямую из Webhook:
```javascript
// ✅ Пробуем также достать other из Webhook напрямую
if (!rawOther) {
try {
const webhookJson = $('Webhook').first()?.json;
if (webhookJson?.body?.other) {
rawOther = webhookJson.body.other;
}
} catch (e) {}
}
```
**Файл:** `docs/CODE_MERGE_PROJECT_TO_SESSION.js`
**Результат:** Теперь в Redis сохраняются ВСЕ данные:
- session_id, phone, unified_id, contact_id
- lastname, firstname, middle_name
- birthday, birthplace, inn
- mailingzip, mailingstreet, email, tg_id
- description
- claim_id, project_id, project_name
- is_new_project, current_step
---
### 4. Генерация новой сессии для новой жалобы
**Проблема:** При создании новой жалобы использовалась та же сессия, что и для предыдущей.
**Решение:**
- Добавлена функция `generateUUIDv4()` в `ClaimForm.tsx`
- При создании новой жалобы генерируется новый `session_id`
- `session_token` в localStorage (авторизация) остаётся прежним
- `unified_id`, `phone`, `contact_id` сохраняются
**Файл:** `frontend/src/pages/ClaimForm.tsx`
---
## Созданные/обновлённые файлы
### Новые файлы:
- `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — SQL запрос для контактов с кастомными полями
### Обновлённые файлы:
- `backend/app/api/models.py` — добавлены unified_id, contact_id
- `backend/app/api/claims.py` — передача unified_id, contact_id в Redis
- `frontend/src/components/form/StepDescription.tsx` — передача unified_id, contact_id
- `frontend/src/pages/ClaimForm.tsx` — генерация новой сессии для новой жалобы
- `docs/CODE_MERGE_PROJECT_TO_SESSION.js` — исправлен мерж данных в сессию
---
## Технические детали
### Redis канал для описания проблемы
- Канал: `ticket_form:description`
- Передаваемые данные: session_id, phone, email, unified_id, contact_id, problem_description
### Redis канал для подтверждения формы
- Канал: `clientright:webform:approve`
- Включает SMS код для верификации
### Структура сессии в Redis
```json
{
"session_id": "sess_...",
"phone": "79262306381",
"unified_id": "usr_...",
"contact_id": "320096",
"lastname": "Коробков",
"firstname": "Федор",
"middle_name": "Владимирович",
"birthday": "1981-09-18",
"birthplace": "Москва",
"inn": "123456789012",
"mailingstreet": "...",
"email": "help@clientright.ru",
"tg_id": "295410106",
"description": "...",
"claim_id": "...",
"project_id": "399171",
"project_name": "Коробков_КлиентПрав",
"is_new_project": false,
"current_step": 2
}
```
---
## Статус
Все задачи выполнены
✅ Backend пересобран и перезапущен
✅ Frontend обновлён через HMR
✅ Тестирование успешно

View File

@@ -201,6 +201,8 @@ async def list_drafts(
c.updated_at
FROM clpr_claims c
WHERE c.unified_id = $1
AND (c.status_code != 'approved' OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
"""
@@ -227,6 +229,8 @@ async def list_drafts(
AND ua.channel_user_id = $1
LIMIT 1
)
AND (c.status_code != 'approved' OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
"""
@@ -246,6 +250,8 @@ async def list_drafts(
c.updated_at
FROM clpr_claims c
WHERE c.session_token = $1
AND (c.status_code != 'approved' OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
"""
@@ -350,6 +356,7 @@ async def get_draft(claim_id: str):
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
# Убираем фильтры по channel и status_code, чтобы находить черновики из всех каналов
# ✅ Сортируем по updated_at DESC, чтобы получить самую свежую запись (которая может иметь send_to_form_approve)
query = """
SELECT
id,
@@ -362,6 +369,7 @@ async def get_draft(claim_id: str):
updated_at
FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
ORDER BY updated_at DESC
LIMIT 1
"""
@@ -449,6 +457,133 @@ async def delete_draft(claim_id: str):
raise HTTPException(status_code=500, detail=f"Ошибка при удалении черновика: {str(e)}")
@router.post("/approve")
async def publish_form_approval(request: Request):
"""
Публикация данных подтвержденной формы в Redis канал
После SMS-апрува отправляет данные формы в Redis канал clientright:webform:approve
для обработки в n8n workflow.
В будущем можно подключить RabbitMQ для очереди и защиты от дублей.
"""
try:
body = await request.json()
# Детальное логирование всего body для отладки
logger.info(
f"📥 Получен запрос на публикацию формы подтверждения",
extra={
"body_keys": list(body.keys()) if isinstance(body, dict) else "not_dict",
"body_type": type(body).__name__,
"sms_code_in_body": "sms_code" in body if isinstance(body, dict) else False,
"sms_code_value": body.get("sms_code", "NOT_FOUND") if isinstance(body, dict) else "NOT_DICT",
},
)
claim_id = body.get("claim_id")
session_token = body.get("session_token") or body.get("session_id")
sms_code = body.get("sms_code", "")
# Логируем полученные данные для отладки
logger.info(
f"📥 Извлеченные данные из запроса",
extra={
"claim_id": claim_id,
"sms_code": sms_code if sms_code else "(пусто)",
"sms_code_length": len(sms_code) if sms_code else 0,
"has_sms_code": bool(sms_code),
},
)
if not claim_id:
raise HTTPException(status_code=400, detail="claim_id обязателен")
# Генерируем idempotency key для защиты от дублей (для будущей интеграции с RabbitMQ)
import time
idempotency_key = f"{claim_id}_{int(time.time() * 1000)}_{body.get('user_id', 'unknown')}"
# Формируем событие для Redis
event_data = {
"event_type": "form_approve",
"status": "approved",
"message": "Форма подтверждена после SMS-верификации",
"claim_id": claim_id,
"session_token": session_token,
"unified_id": body.get("unified_id"),
"phone": body.get("phone"),
"sms_code": sms_code, # SMS код для верификации
"sms_verified": True,
"idempotency_key": idempotency_key, # Для защиты от дублей в RabbitMQ
"timestamp": datetime.utcnow().isoformat(),
# Данные формы подтверждения
"form_data": body.get("form_data", {}),
"user": body.get("user", {}),
"project": body.get("project", {}),
"offenders": body.get("offenders", []),
"meta": body.get("meta", {}),
# Оригинальные данные для сравнения
"original_data": body.get("original_data", {}),
}
# Публикуем в Redis канал clientright:webform:approve
channel = "clientright:webform:approve"
# Логируем event_data перед сериализацией
logger.info(
f"📢 Формируем событие для Redis канала {channel}",
extra={
"claim_id": claim_id,
"idempotency_key": idempotency_key,
"sms_code": sms_code if sms_code else "(пусто)",
"has_sms_code": bool(sms_code),
"sms_code_in_event_data": "sms_code" in event_data,
"event_data_sms_code_value": event_data.get("sms_code", "NOT_FOUND"),
"event_data_keys": list(event_data.keys()),
},
)
event_json = json.dumps(event_data, ensure_ascii=False)
# Логируем после сериализации
logger.info(
f"📢 JSON для публикации готов",
extra={
"json_length": len(event_json),
"sms_code_in_json": '"sms_code"' in event_json,
},
)
await redis_service.publish(channel, event_json)
logger.info(
f"✅ Form approval published to {channel}",
extra={
"claim_id": claim_id,
"idempotency_key": idempotency_key,
"sms_code_included": bool(sms_code),
},
)
return {
"success": True,
"channel": channel,
"idempotency_key": idempotency_key,
"message": "Данные формы отправлены на обработку",
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Failed to publish form approval")
raise HTTPException(
status_code=500,
detail=f"Ошибка при отправке данных формы: {str(e)}",
)
@router.get("/{claim_id}")
async def get_claim(claim_id: str):
"""Получить информацию о заявке по ID"""
@@ -553,18 +688,64 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
"claim_id": payload.claim_id, # Опционально - может быть None
"phone": payload.phone,
"email": payload.email,
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
"description": payload.problem_description.strip(),
"source": payload.source,
"timestamp": datetime.utcnow().isoformat(),
}
event_json = json.dumps(event, ensure_ascii=False)
logger.info(
"📝 TicketForm description received",
extra={"session_id": payload.session_id, "claim_id": payload.claim_id or "not_set"},
extra={
"session_id": payload.session_id,
"claim_id": payload.claim_id or "not_set",
"phone": payload.phone,
"unified_id": payload.unified_id or "not_set",
"contact_id": payload.contact_id or "not_set",
"description_length": len(payload.problem_description),
"channel": channel,
},
)
await redis_service.publish(channel, json.dumps(event, ensure_ascii=False))
logger.info(
"📡 TicketForm description published",
extra={"channel": channel, "session_id": payload.session_id},
"📡 Publishing to Redis channel",
extra={
"channel": channel,
"event_type": event["type"],
"event_keys": list(event.keys()),
"json_length": len(event_json),
},
)
subscribers_count = await redis_service.publish(channel, event_json)
logger.info(
"✅ TicketForm description published to Redis",
extra={
"channel": channel,
"session_id": payload.session_id,
"subscribers_count": subscribers_count,
"event_json_preview": event_json[:500],
},
)
if subscribers_count == 0:
logger.warning(
f"⚠️ WARNING: No subscribers on channel {channel}! "
f"n8n workflow is not listening to this channel. "
f"Event was published but will be lost."
)
# Дополнительная проверка: логируем полный event для отладки
logger.debug(
"🔍 Full event data published",
extra={
"channel": channel,
"event": event,
},
)
return {
"success": True,

View File

@@ -238,3 +238,129 @@ async def stream_events(task_id: str):
}
)
@router.get("/claim-plan/{session_token}")
async def stream_claim_plan(session_token: str):
"""
SSE стрим для получения данных заявления из канала claim:plan:{session_token}
Используется после отправки формы визарда для получения данных заявления
от n8n workflow, которые затем отображаются в форме подтверждения.
Args:
session_token: Session token (например, sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34)
Используется для формирования канала claim:plan:{session_token}
Returns:
StreamingResponse с данными заявления в формате:
{
"event_type": "claim_plan_ready",
"status": "ready",
"data": {
"propertyName": {...}, // Данные заявления из n8n
...
}
}
"""
logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}")
async def claim_plan_generator():
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
channel = f"claim:plan:{session_token}"
# Подписываемся на канал Redis
pubsub = redis_service.client.pubsub()
await pubsub.subscribe(channel)
logger.info(f"📡 Client subscribed to {channel}")
# Отправляем начальное событие
yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n"
try:
# Слушаем события (таймаут 5 минут для обработки в n8n)
while True:
logger.info(f"⏳ Waiting for claim plan data on {channel}...")
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=300.0)
if message:
logger.info(f"📥 Received claim plan message type: {message['type']}")
if message['type'] == 'message':
event_data_raw = message['data'] # Уже строка (decode_responses=True)
logger.info(f"📦 Raw claim plan data length: {len(event_data_raw)}")
try:
# Парсим данные от n8n
claim_data = json.loads(event_data_raw)
# Формируем событие в стандартном формате
event = {
"event_type": "claim_plan_ready",
"status": "ready",
"message": "Данные заявления готовы",
"data": claim_data, # Весь объект от n8n
"timestamp": None
}
logger.info(f"✅ Claim plan data received for session {session_token}")
# Отправляем событие клиенту
event_json = json.dumps(event, ensure_ascii=False)
yield f"data: {event_json}\n\n"
# После получения данных закрываем соединение
logger.info(f"✅ Claim plan sent to client, closing SSE")
break
except json.JSONDecodeError as e:
logger.error(f"❌ Failed to parse claim plan JSON: {e}")
error_event = {
"event_type": "claim_plan_error",
"status": "error",
"message": f"Ошибка парсинга данных: {str(e)}",
"data": {},
"timestamp": None
}
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
break
else:
logger.info(f"⏰ Timeout waiting for claim plan on {channel}")
# Отправляем timeout событие
timeout_event = {
"event_type": "claim_plan_timeout",
"status": "timeout",
"message": "Превышено время ожидания данных заявления",
"data": {},
"timestamp": None
}
yield f"data: {json.dumps(timeout_event, ensure_ascii=False)}\n\n"
break
await asyncio.sleep(0.1)
except asyncio.CancelledError:
logger.info(f"❌ Client disconnected from {channel}")
except Exception as e:
logger.error(f"❌ Error in claim plan stream: {e}")
error_event = {
"event_type": "claim_plan_error",
"status": "error",
"message": f"Ошибка получения данных: {str(e)}",
"data": {},
"timestamp": None
}
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
finally:
await pubsub.unsubscribe(channel)
await pubsub.close()
return StreamingResponse(
claim_plan_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Отключаем буферизацию nginx
}
)

View File

@@ -69,6 +69,8 @@ class TicketFormDescriptionRequest(BaseModel):
claim_id: Optional[str] = Field(None, description="ID заявки (если уже создана)")
phone: Optional[str] = Field(None, description="Номер телефона заявителя")
email: Optional[str] = Field(None, description="Email заявителя")
unified_id: Optional[str] = Field(None, description="Unified ID пользователя из PostgreSQL")
contact_id: Optional[str] = Field(None, description="Contact ID пользователя в CRM")
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
source: str = Field("ticket_form", description="Источник события")
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")

View File

@@ -54,9 +54,18 @@ class RedisService:
async def publish(self, channel: str, message: str):
"""Публикация сообщения в канал Redis Pub/Sub"""
try:
await self.client.publish(channel, message)
subscribers_count = await self.client.publish(channel, message)
logger.info(
f"📢 Redis publish: channel={channel}, message_length={len(message)}, subscribers={subscribers_count}"
)
if subscribers_count == 0:
logger.warning(
f"⚠️ No subscribers on channel {channel}. Message published but no one is listening!"
)
return subscribers_count
except Exception as e:
logger.error(f"❌ Redis publish error: {e}")
raise
async def delete(self, key: str) -> bool:
"""Удалить ключ"""

View File

@@ -209,3 +209,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.

View File

@@ -102,3 +102,4 @@ function mapCombinedDocs(cds = []) {
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.

View File

@@ -211,3 +211,4 @@ const results = arr
return results.length ? results : [{ json: null }];

View File

@@ -1,5 +1,6 @@
// ========================================
// Code Node: Мерж данных проекта в сессию
// v2.0 - с расширенным логированием для отладки
// ========================================
// 1. Берём первый item
@@ -12,25 +13,62 @@ if (!inputItem || !inputItem.json) {
// root — то, что реально пришло в эту ноду
const root = inputItem.json;
// ✅ ОТЛАДКА: смотрим что пришло
console.log('🔍 DEBUG: root keys:', Object.keys(root));
console.log('🔍 DEBUG: root.body exists:', !!root.body);
console.log('🔍 DEBUG: root.other exists:', !!root.other);
// 2. Универсально получаем body
// - если нода стоит сразу после Webhook → данные лежат в root.body
// - если кто-то выше уже отдал только body → root и есть body
const body = root.body || root;
console.log('🔍 DEBUG: body keys:', Object.keys(body));
console.log('🔍 DEBUG: body.other exists:', !!body.other);
console.log('🔍 DEBUG: body.other type:', typeof body.other);
// 3. Парсим body.other (если есть) как сессию
// ✅ ВАЖНО: Также проверяем root.other напрямую (если данные пришли не через body)
let sessionData = {};
const rawOther = body.other;
let rawOther = body.other || root.other;
// ✅ Пробуем также достать other из Webhook напрямую
if (!rawOther) {
try {
const webhookJson = $('Webhook').first()?.json;
if (webhookJson?.body?.other) {
rawOther = webhookJson.body.other;
console.log('✅ Взяли other напрямую из Webhook');
}
} catch (e) {
console.log('⚠️ Не удалось достать other из Webhook:', e.message);
}
}
console.log('🔍 DEBUG: rawOther exists:', !!rawOther);
console.log('🔍 DEBUG: rawOther type:', typeof rawOther);
if (rawOther) {
console.log('🔍 DEBUG: rawOther preview:', typeof rawOther === 'string' ? rawOther.substring(0, 200) : JSON.stringify(rawOther).substring(0, 200));
}
if (rawOther) {
if (typeof rawOther === 'string') {
try {
sessionData = JSON.parse(rawOther);
console.log('✅ Распарсили other как JSON. Ключи:', Object.keys(sessionData));
console.log('✅ sessionData.session_id:', sessionData.session_id);
console.log('✅ sessionData.phone:', sessionData.phone);
console.log('✅ sessionData.firstname:', sessionData.firstname);
} catch (e) {
throw new Error('Не смог распарсить body.other как JSON: ' + e.message + '. rawOther: ' + rawOther);
throw new Error('Не смог распарсить other как JSON: ' + e.message + '. rawOther: ' + rawOther.substring(0, 500));
}
} else if (typeof rawOther === 'object') {
sessionData = rawOther;
console.log('✅ other уже объект. Ключи:', Object.keys(sessionData));
}
} else {
console.log('⚠️ other отсутствует или пустой. Проверьте структуру данных!');
console.log('⚠️ root:', JSON.stringify(root).substring(0, 500));
}
// 4. Определяем claimId (основной путь)
@@ -94,19 +132,75 @@ if (!projectResult || !projectResult.project_id) {
}
// 8. Собираем обновлённую сессию
// ✅ Используем spread оператор, но с фильтрацией undefined значений
// Сначала создаём базовый объект из sessionData, фильтруя undefined
const baseSession = Object.keys(sessionData).reduce((acc, key) => {
if (sessionData[key] !== undefined && sessionData[key] !== null) {
acc[key] = sessionData[key];
}
return acc;
}, {});
console.log('📦 baseSession после фильтрации:', Object.keys(baseSession));
console.log('📦 baseSession sample:', {
session_id: baseSession.session_id,
phone: baseSession.phone,
unified_id: baseSession.unified_id,
contact_id: baseSession.contact_id,
firstname: baseSession.firstname,
lastname: baseSession.lastname,
});
const updatedSession = {
...sessionData, // всё, что было в other
claim_id: claimId, // актуальный claim_id
// ✅ Шаг 1: Все данные из sessionData (body.other) - базовая сессия
...baseSession,
// ✅ Шаг 2: Дополняем данными из body (если их нет в sessionData)
...(body.phone && !baseSession.phone ? { phone: body.phone } : {}),
...(body.unified_id && !baseSession.unified_id ? { unified_id: body.unified_id } : {}),
...(body.contact_id && !baseSession.contact_id ? { contact_id: body.contact_id } : {}),
...(body.email && !baseSession.email ? { email: body.email } : {}),
// ✅ Шаг 3: Данные проекта (новые, всегда перезаписываем)
claim_id: claimId, // актуальный claim_id (перезаписываем null из sessionData)
project_id: projectResult.project_id, // id проекта из CRM
project_name: projectResult.project_name || null, // название проекта из CRM (новое поле)
project_name: projectResult.project_name || null, // название проекта из CRM
is_new_project: projectResult.is_new, // флаг новый/старый
current_step: 2, // двигаем визард на шаг 2
// ✅ Шаг 4: Данные анализа из body (приоритет body)
problem: body.problem || baseSession.problem || null,
last_analysis_output: body.output || baseSession.last_analysis_output || null,
// ✅ Шаг 5: Метаданные (всегда обновляем)
updated_at: new Date().toISOString(),
// опционально дотащим полезные поля из body:
problem: body.problem ?? sessionData.problem,
last_analysis_output: body.output ?? sessionData.last_analysis_output,
};
// ✅ Логируем результат для отладки
console.log('📦 sessionData keys:', Object.keys(sessionData));
console.log('📦 sessionData sample:', {
session_id: sessionData.session_id,
phone: sessionData.phone,
unified_id: sessionData.unified_id,
contact_id: sessionData.contact_id,
firstname: sessionData.firstname,
lastname: sessionData.lastname,
middle_name: sessionData.middle_name,
});
console.log('📦 updatedSession keys:', Object.keys(updatedSession));
console.log('📦 updatedSession sample:', {
session_id: updatedSession.session_id,
phone: updatedSession.phone,
unified_id: updatedSession.unified_id,
contact_id: updatedSession.contact_id,
firstname: updatedSession.firstname,
lastname: updatedSession.lastname,
middle_name: updatedSession.middle_name,
claim_id: updatedSession.claim_id,
project_id: updatedSession.project_id,
});
console.log('📦 updatedSession FULL:', JSON.stringify(updatedSession, null, 2));
// 9. Возвращаем один item для Redis SET
return [
{

View File

@@ -182,3 +182,4 @@ clpr_users (id)
```

View File

@@ -37,3 +37,4 @@ return {
};

View File

@@ -46,3 +46,4 @@ return {
};

View File

@@ -0,0 +1,150 @@
# Настройка n8n Workflow для обработки описания проблемы
## Проблема
После отправки описания проблемы форма "тупит" на шаге рекомендаций. Это происходит потому, что n8n не обрабатывает событие из Redis канала.
## Текущий поток данных
1. **Frontend** отправляет описание на `/api/v1/claims/description`
2. **Backend** публикует событие в Redis канал `ticket_form:description`
3. **Frontend** подписывается на SSE `/api/v1/events/{session_id}` (слушает канал `ocr_events:{session_id}`)
4. **n8n** должен:
- Подписаться на канал `ticket_form:description` (или получить событие из него)
- Обработать описание и сгенерировать `wizard_plan`
- Опубликовать `wizard_plan` в канал `ocr_events:{session_id}` через POST `/api/v1/events/{session_id}`
## Структура события в Redis канале `ticket_form:description`
```json
{
"type": "ticket_form_description",
"session_id": "sess_xxx",
"claim_id": "claim_id_xxx" или null,
"phone": "79262306381",
"email": "user@example.com",
"description": "Описание проблемы...",
"source": "ticket_form",
"timestamp": "2025-11-25T12:30:36.262855"
}
```
## Настройка n8n Workflow
### Шаг 1: Redis Subscribe Node
1. Добавьте **Redis Subscribe** node
2. Настройте подключение к Redis:
- Host: `crm.clientright.ru` (или IP вашего Redis)
- Port: `6379`
- Password: `CRM_Redis_Pass_2025_Secure!`
3. Channel: `ticket_form:description`
4. Output: `JSON`
### Шаг 2: Обработка описания
После получения события из Redis:
1. Извлеките `session_id` из события: `{{ $json.session_id }}`
2. Извлеките `description` из события: `{{ $json.description }}`
3. Обработайте описание (AI, RAG и т.д.)
4. Сгенерируйте `wizard_plan`
### Шаг 3: Сохранение wizard_plan в PostgreSQL
Сохраните `wizard_plan` в таблицу `clpr_claims` используя SQL скрипт (например, `SQL_CLAIMSAVE_UPSERT_SIMPLE.sql`).
### Шаг 4: Публикация wizard_plan обратно в Redis
**ВАЖНО:** После генерации `wizard_plan` нужно опубликовать событие обратно в Redis канал `ocr_events:{session_id}`.
Используйте **HTTP Request** node:
- **Method:** POST
- **URL:** `http://147.45.146.17:8200/api/v1/events/{{ $json.session_id }}`
- **Headers:**
```json
{
"Content-Type": "application/json"
}
```
- **Body (JSON):**
```json
{
"event_type": "wizard_ready",
"status": "ready",
"message": "Wizard plan готов",
"data": {
"claim_id": "{{ $json.claim_id }}",
"wizard_plan": {{ $json.wizard_plan }},
"answers_prefill": {{ $json.answers_prefill }},
"coverage_report": {{ $json.coverage_report }}
},
"timestamp": "{{ $now.toISO() }}"
}
```
**Альтернатива:** Используйте **Redis Publish** node напрямую:
- Channel: `ocr_events:{{ $json.session_id }}`
- Message (JSON):
```json
{
"event_type": "wizard_ready",
"status": "ready",
"message": "Wizard plan готов",
"data": {
"claim_id": "{{ $json.claim_id }}",
"wizard_plan": {{ $json.wizard_plan }},
"answers_prefill": {{ $json.answers_prefill }},
"coverage_report": {{ $json.coverage_report }}
},
"timestamp": "{{ $now.toISO() }}"
}
```
## Проверка работы
1. Откройте консоль браузера (F12)
2. Отправьте описание проблемы
3. Проверьте логи backend:
```bash
docker-compose logs -f ticket_form_backend | grep -E "📝|📡|description"
```
4. Проверьте, что событие опубликовано в Redis:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB CHANNELS "ticket_form:*"
```
5. Проверьте, что n8n получил событие (в логах n8n workflow)
6. Проверьте, что n8n опубликовал `wizard_plan` обратно в канал `ocr_events:{session_id}`
## Типичные проблемы
### Проблема 1: n8n не получает события из Redis
**Решение:** Проверьте, что Redis Subscribe node правильно настроен и подключен к правильному каналу `ticket_form:description`.
### Проблема 2: Frontend не получает wizard_plan
**Решение:** Проверьте, что n8n публикует событие в правильный канал `ocr_events:{session_id}` (не `ocr_events:session_id`, а `ocr_events:{session_id}` где `{session_id}` - это значение из события).
### Проблема 3: Неправильный формат события
**Решение:** Убедитесь, что событие содержит поле `event_type: "wizard_ready"` и `status: "ready"`. Backend ожидает этот формат.
## Пример полного workflow в n8n
```
Redis Subscribe (ticket_form:description)
Code Node (обработка описания)
AI/RAG Node (генерация wizard_plan)
PostgreSQL Node (сохранение wizard_plan)
HTTP Request Node (POST /api/v1/events/{session_id})
или
Redis Publish Node (ocr_events:{session_id})
```

View File

@@ -0,0 +1,120 @@
# Настройка n8n Workflow для обработки подтвержденных форм
## Описание
После того, как пользователь подтвердил форму и прошел SMS-верификацию, данные публикуются в Redis канал `clientright:webform:approve`. n8n workflow должен:
1. Подписаться на Redis канал `clientright:webform:approve`
2. Обработать данные формы
3. Отметить форму как подтвержденную в PostgreSQL (чтобы она больше не показывалась в черновиках)
## Структура данных в Redis канале
```json
{
"event_type": "form_approve",
"status": "approved",
"message": "Форма подтверждена после SMS-верификации",
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
"session_token": "sess_xxx",
"unified_id": "usr_xxx",
"phone": "79262306381",
"sms_code": "123456",
"sms_verified": true,
"idempotency_key": "claim_id_timestamp_user_id",
"timestamp": "2025-11-25T12:30:36.262855",
"form_data": { /* данные формы */ },
"user": { /* данные пользователя */ },
"project": { /* данные проекта */ },
"offenders": [ /* нарушители */ ],
"meta": { /* метаданные */ }
}
```
## Настройка n8n Workflow
### Шаг 1: Redis Subscribe Node
1. Добавьте **Redis Subscribe** node
2. Настройте подключение к Redis:
- Host: `crm.clientright.ru` (или IP вашего Redis)
- Port: `6379`
- Password: `CRM_Redis_Pass_2025_Secure!`
3. Channel: `clientright:webform:approve`
4. Output: `JSON`
### Шаг 2: Обработка данных
После получения данных из Redis канала:
1. **Parse JSON** (если нужно)
2. **Обработайте данные формы** (сохранение в CRM, отправка уведомлений и т.д.)
3. **Отметьте форму как подтвержденную** (см. Шаг 3)
### Шаг 3: Отметка формы как подтвержденной
Используйте **PostgreSQL** node с SQL скриптом из `SQL_MARK_FORM_APPROVED.sql`:
```sql
-- Используйте claim_id из данных Redis события
WITH claim_lookup AS (
SELECT
c.id,
c.payload,
c.status_code,
c.is_confirmed
FROM clpr_claims c
WHERE c.id::text = '{{ $json.claim_id }}'::text
OR c.payload->>'claim_id' = '{{ $json.claim_id }}'::text
ORDER BY
CASE WHEN c.id::text = '{{ $json.claim_id }}'::text THEN 1 ELSE 2 END,
c.updated_at DESC
LIMIT 1
)
UPDATE clpr_claims c
SET
status_code = 'approved',
is_confirmed = true,
updated_at = now()
FROM claim_lookup cl
WHERE c.id = cl.id
RETURNING
c.id,
c.payload->>'claim_id' AS claim_id,
c.status_code,
c.is_confirmed,
c.updated_at;
```
**Параметры:**
- `{{ $json.claim_id }}` - claim_id из данных Redis события
**Результат:**
- Форма помечается как `status_code = 'approved'`
- Устанавливается `is_confirmed = true`
- Форма больше не будет показываться в списке черновиков (`/api/v1/claims/drafts/list`)
## Проверка работы
После обработки события в n8n:
1. Проверьте, что запись в `clpr_claims` обновлена:
```sql
SELECT id, status_code, is_confirmed, updated_at
FROM clpr_claims
WHERE payload->>'claim_id' = 'YOUR_CLAIM_ID';
```
2. Проверьте, что форма не показывается в черновиках:
```bash
curl "http://localhost:8200/api/v1/claims/drafts/list?unified_id=YOUR_UNIFIED_ID"
```
## Важные поля из Redis события
- `claim_id` - ID заявки (используется для обновления статуса)
- `sms_code` - SMS код, использованный для верификации (для аудита)
- `form_data` - данные формы подтверждения
- `user`, `project`, `offenders` - структурированные данные формы
- `idempotency_key` - ключ для защиты от дублей (для будущей интеграции с RabbitMQ)

View File

@@ -259,3 +259,4 @@
}
}

View File

@@ -399,3 +399,4 @@ IF "проверка наличия файлов"
**Дата:** 2025-11-21
**Статус:** Готово к внедрению ✅

View File

@@ -0,0 +1,225 @@
# 🐛 Проблемы с памятью в n8n
## 🔍 Симптомы
- UI n8n не отвечает (нельзя сохранить workflow, включить/выключить)
- Workflow не обрабатывает события
- Страница зависает при попытке редактирования
- Требуется перезагрузка сервера для восстановления
## 💾 Возможные причины
### 1. **Переполнение памяти (OOM)**
- n8n процесс исчерпал доступную память
- Система убивает процесс (OOM Killer)
- Или процесс зависает в ожидании освобождения памяти
**Диагностика:**
```bash
# Проверка использования памяти n8n
docker stats n8n_container --no-stream
# Проверка логов OOM Killer
dmesg | grep -i "out of memory"
dmesg | grep -i "killed process"
# Проверка использования памяти системой
free -h
```
### 2. **Утечки памяти в workflow**
- Workflow накапливает данные в памяти
- Большие массивы данных не освобождаются
- Долгие операции держат данные в памяти
**Диагностика:**
- Проверить Execution History - сколько данных хранится
- Проверить размер данных в workflow (большие JSON объекты)
- Проверить количество активных executions
### 3. **Слишком много активных workflows**
- Много workflows работают одновременно
- Каждый workflow держит соединения и данные в памяти
- Redis Trigger для каждого workflow = отдельное соединение
**Диагностика:**
```bash
# Количество активных workflows (через n8n API или БД)
# Проверить количество Redis подписок
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" CLIENT LIST | grep -c "SUBSCRIBE"
```
### 4. **Большие данные в workflow**
- Workflow обрабатывает большие файлы/JSON
- Данные хранятся в памяти между нодами
- Нет очистки промежуточных данных
**Диагностика:**
- Проверить размер данных в Execution History
- Проверить размер JSON payload между нодами
- Проверить использование диска для execution data
### 5. **Проблемы с базой данных n8n**
- База данных n8n переполнена старыми executions
- Медленные запросы блокируют работу
- Блокировки таблиц
**Диагностика:**
```bash
# Размер базы данных n8n
# Проверить количество executions
# Проверить медленные запросы
```
## 🛠️ Решения
### 1. **Ограничить использование памяти**
В `docker-compose.yml` для n8n:
```yaml
services:
n8n:
mem_limit: 2g # Ограничить память до 2GB
mem_reservation: 1g # Резервировать минимум 1GB
oom_kill_disable: false # Разрешить OOM Killer убивать процесс
```
Или через переменные окружения:
```bash
NODE_OPTIONS="--max-old-space-size=1536" # Ограничить heap до 1.5GB
```
### 2. **Очистить старые executions**
Настроить автоматическую очистку в n8n:
- Settings → Workflows → Execution Data Retention
- Установить срок хранения (например, 7 дней)
- Включить автоматическую очистку
Или через SQL (если используете PostgreSQL):
```sql
-- Удалить executions старше 7 дней
DELETE FROM execution_entity
WHERE "stoppedAt" < NOW() - INTERVAL '7 days';
-- Удалить execution_data для удалённых executions
DELETE FROM execution_data
WHERE "executionId" NOT IN (SELECT id FROM execution_entity);
```
### 3. **Оптимизировать workflow**
- **Не хранить большие данные между нодами**
- Использовать `Set` node для очистки ненужных полей
- Не передавать большие файлы через workflow data
- **Использовать streaming для больших данных**
- Обрабатывать данные порциями
- Не загружать всё в память сразу
- **Ограничить размер данных в Redis Trigger**
- Проверять размер сообщения перед обработкой
- Отклонять слишком большие сообщения
### 4. **Мониторинг памяти**
Создать скрипт для мониторинга:
```bash
#!/bin/bash
# monitor_n8n_memory.sh
CONTAINER="n8n_container"
THRESHOLD=80 # Процент использования памяти
MEMORY_USAGE=$(docker stats $CONTAINER --no-stream --format "{{.MemPerc}}" | sed 's/%//')
if (( $(echo "$MEMORY_USAGE > $THRESHOLD" | bc -l) )); then
echo "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_USAGE}% памяти!"
# Можно добавить отправку алерта
fi
```
### 5. **Настроить swap**
Если сервер имеет swap, убедиться что он настроен:
```bash
# Проверить swap
swapon --show
# Если нет swap, создать (осторожно - может замедлить работу)
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
```
### 6. **Ограничить количество активных workflows**
- Отключить неиспользуемые workflows
- Использовать один workflow вместо нескольких для похожих задач
- Разделить сложные workflows на несколько простых
### 7. **Оптимизировать Redis Trigger**
- Использовать один Redis Trigger для нескольких каналов (если возможно)
- Ограничить количество одновременных подписок
- Использовать Redis Streams вместо Pub/Sub для больших объёмов данных
## 📊 Диагностика после перезагрузки
После перезагрузки сервера проверить:
```bash
# 1. Использование памяти n8n
docker stats n8n_container --no-stream
# 2. Логи n8n на ошибки памяти
docker logs n8n_container 2>&1 | grep -i "memory\|oom\|heap"
# 3. Системные логи OOM Killer
dmesg | grep -i "out of memory" | tail -20
# 4. Использование памяти системой
free -h
# 5. Топ процессов по использованию памяти
ps aux --sort=-%mem | head -10
```
## 🔄 Профилактика
1. **Регулярная очистка executions**
- Настроить автоматическую очистку старых данных
- Ограничить срок хранения execution data
2. **Мониторинг ресурсов**
- Настроить алерты при высоком использовании памяти
- Регулярно проверять использование ресурсов
3. **Оптимизация workflows**
- Избегать хранения больших данных в памяти
- Использовать streaming для больших файлов
- Очищать промежуточные данные
4. **Ограничения ресурсов**
- Установить лимиты памяти для n8n контейнера
- Настроить OOM Killer для корректной обработки
5. **Резервирование**
- Рассмотреть использование нескольких инстансов n8n
- Использовать load balancer для распределения нагрузки
## 📝 Рекомендации для продакшена
1. **Мониторинг**: Настроить Prometheus/Grafana для мониторинга памяти
2. **Алерты**: Настроить уведомления при превышении порога памяти
3. **Автоматическая очистка**: Настроить cron для очистки старых executions
4. **Лимиты**: Установить жёсткие лимиты памяти для n8n
5. **Логирование**: Включить детальное логирование использования памяти
## 🔗 Полезные ссылки
- [n8n Memory Management](https://docs.n8n.io/hosting/configuration/environment-variables/#memory-management)
- [Docker Memory Limits](https://docs.docker.com/config/containers/resource_constraints/#memory)
- [Node.js Memory Management](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-megabytes)

View File

@@ -0,0 +1,167 @@
# 🔧 Troubleshooting: Redis Trigger в n8n зависает
## 🐛 Проблема
Redis Trigger в n8n перестаёт слушать канал `ticket_form:description`, хотя workflow активен.
## 🔍 Возможные причины
### 1. **Потеря соединения с Redis**
- Соединение оборвалось из-за сетевых проблем
- Redis перезапустился, но n8n не переподключился
- Таймаут соединения
**Решение:**
- Проверить логи n8n на ошибки подключения
- Убедиться, что Redis доступен: `redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING`
- Перезапустить workflow в n8n (отключить → включить)
### 2. **Проблемы с памятью/ресурсами**
- n8n исчерпал память
- Слишком много активных workflows
**Решение:**
- Проверить использование памяти: `docker stats n8n_container`
- Увеличить лимиты памяти для n8n
- Перезапустить n8n контейнер
### 3. **Долгие операции в workflow**
- Workflow обрабатывает сообщение слишком долго
- Блокирует обработку новых сообщений
**Решение:**
- Оптимизировать workflow (убрать долгие операции)
- Использовать асинхронную обработку
- Разбить workflow на несколько этапов
### 4. **Проблемы с сетью**
- Временные сбои сети между n8n и Redis
- Firewall блокирует соединение
**Решение:**
- Проверить сетевую связность: `ping crm.clientright.ru`
- Проверить firewall правила
- Использовать retry-логику в workflow
## 🛠️ Решения для предотвращения
### 1. **Мониторинг подписчиков**
Запустить скрипт мониторинга:
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
python3 monitor_n8n_redis_trigger.py
```
Или добавить в cron для автоматической проверки:
```bash
# Проверка каждые 5 минут
*/5 * * * * cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form && python3 monitor_n8n_redis_trigger.py >> logs/n8n_monitor_cron.log 2>&1
```
### 2. **Health Check для Redis Trigger**
Добавить в workflow n8n:
- **Schedule Trigger** (каждые 5 минут)
- **Redis Publish** (отправить тестовое сообщение)
- **If Node** (проверить, обработалось ли сообщение)
- **Send Alert** (если нет - отправить уведомление)
### 3. **Автоматический перезапуск workflow**
Создать скрипт для автоматического перезапуска:
```bash
#!/bin/bash
# Проверка и перезапуск workflow если нет подписчиков
SUBS=$(redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description" | tail -1)
if [ "$SUBS" -eq "0" ]; then
echo "⚠️ Нет подписчиков! Требуется перезапуск workflow"
# Здесь можно добавить API вызов для перезапуска workflow через n8n API
fi
```
### 4. **Настройка Redis для стабильности**
В `redis.conf`:
```conf
# Таймаут для неактивных соединений (0 = отключить)
timeout 0
# Keepalive для TCP соединений
tcp-keepalive 60
# Максимальное количество клиентов
maxclients 10000
```
### 5. **Логирование в n8n**
Включить детальное логирование для Redis Trigger:
- Settings → Logging → Level: `debug`
- Проверить логи на ошибки подключения
## 📊 Диагностика
### Проверка подписчиков
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
```
### Проверка подключения n8n к Redis
```bash
# Из контейнера n8n
docker exec -it n8n_container redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
```
### Тестовая публикация
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" \
PUBLISH "ticket_form:description" '{"type":"test","session_id":"test123"}'
```
### Проверка логов n8n
```bash
docker logs n8n_container | grep -i redis
docker logs n8n_container | grep -i "ticket_form:description"
```
## ✅ Быстрое решение
Если workflow завис:
1. **Отключить workflow** в n8n (кнопка "Active")
2. **Сохранить** изменения
3. **Включить обратно** (кнопка "Active")
4. **Проверить подписчиков**: `PUBSUB NUMSUB "ticket_form:description"`
Если не помогло:
1. **Перезапустить n8n контейнер**:
```bash
docker restart n8n_container
```
2. **Проверить Redis**:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
```
3. **Проверить сеть** между n8n и Redis
## 🔄 Рекомендации для продакшена
1. **Мониторинг**: Настроить автоматический мониторинг подписчиков
2. **Алерты**: Настроить уведомления при отсутствии подписчиков
3. **Health Checks**: Регулярные проверки работоспособности
4. **Логирование**: Детальное логирование всех операций с Redis
5. **Резервирование**: Рассмотреть использование Redis Sentinel для высокой доступности
## 📝 Логи для анализа
Проверить логи:
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log` - мониторинг
- `docker logs n8n_container` - логи n8n
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend/logs/` - логи backend

View File

@@ -93,3 +93,4 @@ updateFormData({
5. **Response** → возвращает полный ответ с `unified_id`

View File

@@ -143,3 +143,4 @@ return {
```

View File

@@ -132,3 +132,4 @@ WHERE ua.channel = 'web_form'
Должна быть запись с `unified_id` в формате `usr_...`.

View File

@@ -430,3 +430,4 @@ return claim;
- ✅ Быстрая загрузка состояния формы

View File

@@ -190,3 +190,4 @@ if (channel === 'telegram') {
Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`).

View File

@@ -0,0 +1,163 @@
# Redis канал для подтверждения формы (form_approve)
## 📋 Описание
После SMS-апрува данные подтвержденной формы публикуются в Redis канал `clientright:webform:approve` для обработки в n8n workflow.
## 🔄 Архитектура
```
Frontend (StepClaimConfirmation)
→ POST /api/v1/claims/approve
→ Backend публикует в Redis канал clientright:webform:approve
→ n8n подписывается на канал и обрабатывает данные
```
## 📡 Endpoint
**POST** `/api/v1/claims/approve`
### Request Body
```json
{
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
"session_token": "sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34",
"session_id": "sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34",
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
"user_id": "user_123",
"phone": "79262306381",
"form_data": {
"user": {...},
"project": {...},
"offenders": [...],
"meta": {...}
},
"user": {...},
"project": {...},
"offenders": [...],
"meta": {...},
"original_data": {...}
}
```
### Response
```json
{
"success": true,
"channel": "clientright:webform:approve",
"idempotency_key": "0eb051ec-23a6-4e06-8b98-f02d20d35f68_1735123456789_user_123",
"message": "Данные формы отправлены на обработку"
}
```
## 📢 Redis канал
**Канал:** `clientright:webform:approve`
**Формат сообщения:**
```json
{
"event_type": "form_approve",
"status": "approved",
"message": "Форма подтверждена после SMS-верификации",
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
"session_token": "sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34",
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
"phone": "79262306381",
"sms_verified": true,
"idempotency_key": "0eb051ec-23a6-4e06-8b98-f02d20d35f68_1735123456789_user_123",
"timestamp": "2025-11-24T14:15:26.03297+03:00",
"form_data": {...},
"user": {...},
"project": {...},
"offenders": [...],
"meta": {...},
"original_data": {...}
}
```
## 🔐 Idempotency Key
Для защиты от дублей генерируется `idempotency_key`:
```
{claim_id}_{timestamp_ms}_{user_id}
```
Этот ключ можно использовать в будущем для интеграции с RabbitMQ:
- Проверка дублей перед обработкой
- Дедупликация в очереди
- Гарантия идемпотентности
## 🚀 Настройка n8n
### 1. Redis Subscribe Node
**Operation:** `Subscribe`
**Channel:** `clientright:webform:approve`
### 2. Обработка события
После получения события из Redis:
1. Проверить `idempotency_key` (для защиты от дублей)
2. Обработать данные формы
3. Сохранить в БД через SQL запрос
4. Отправить уведомления (если нужно)
### 3. Пример workflow
```
[Redis Subscribe] → [Check Idempotency] → [Process Form Data] → [Save to DB] → [Send Notifications]
```
## 🔮 Будущая интеграция с RabbitMQ
При необходимости можно подключить RabbitMQ для:
- **Очереди:** Гарантированная обработка всех событий
- **Защита от дублей:** Проверка `idempotency_key` перед добавлением в очередь
- **Retry механизм:** Автоматические повторы при ошибках
- **Масштабирование:** Несколько воркеров для обработки
### Структура для RabbitMQ
```json
{
"queue": "form_approve",
"message": {
"idempotency_key": "...",
"claim_id": "...",
"data": {...}
},
"headers": {
"idempotency-key": "...",
"retry-count": 0
}
}
```
## 📊 Мониторинг
### Проверка канала в Redis
```bash
redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \
MONITOR | grep clientright:webform:approve
```
### Подписка на канал (тест)
```bash
redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \
SUBSCRIBE clientright:webform:approve
```
## ✅ Преимущества
1. **Высокая производительность:** Redis Pub/Sub очень быстрый
2. **Не блокирует фронтенд:** Fire-and-forget подход
3. **Масштабируемость:** Можно добавить несколько подписчиков
4. **Готовность к RabbitMQ:** Idempotency key уже включен
5. **Простота отладки:** Можно мониторить через Redis MONITOR

View File

@@ -197,3 +197,4 @@ if (channel === 'web_form' && enable_cache === true) {
Но это опционально и не обязательно для веб-формы.

View File

@@ -71,3 +71,4 @@
4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров

View File

@@ -113,3 +113,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload
3. Убедиться, что все данные корректно восстанавливаются в форму

View File

@@ -130,3 +130,4 @@ WITH existing AS (
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`

View File

@@ -210,3 +210,4 @@ SELECT
- ✅ Правильное слияние `answers` и `documents_meta`

View File

@@ -0,0 +1,27 @@
// Code23 — помещаем в n8n-nodes-base.code (JS), Mode = Run Once for All Items
// Берём все входные элементы
const items = $input.all();
// Предполагаем, что нас интересует первый элемент массива
const data = items[0].json;
// Всегда возвращаем сообщение об ошибке
const answerText = 'Извините, произошла ошибка, мы уже работаем над ее устранением, попробуйте задать ваш вопрос еще раз через некоторое время';
// Собираем единый объект для следующего узла
return [
{
json: {
...data,
respound: {
type: 'text',
text: answerText,
replyMarkup: {
remove_keyboard: true
}
}
}
}
];

View File

@@ -112,3 +112,4 @@
Выполни задачу прямо сейчас и верни JSON согласно схеме.

View File

@@ -0,0 +1,489 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Card, Spin, message, Modal, Input, Button, Form } from 'antd';
import { generateConfirmationFormHTML } from './generateConfirmationFormHTML';
interface Props {
claimPlanData: any; // Данные заявления от n8n
onNext: () => void;
onPrev: () => void;
}
export default function StepClaimConfirmation({
claimPlanData,
onNext,
onPrev,
}: Props) {
const [loading, setLoading] = useState(true);
const iframeRef = useRef<HTMLIFrameElement>(null);
const [htmlContent, setHtmlContent] = useState<string>('');
// SMS Approval state
const [smsModalVisible, setSmsModalVisible] = useState(false);
const [smsCodeSent, setSmsCodeSent] = useState(false);
const [smsLoading, setSmsLoading] = useState(false);
const [smsVerifyLoading, setSmsVerifyLoading] = useState(false);
const [pendingFormData, setPendingFormData] = useState<any>(null);
const [smsForm] = Form.useForm();
useEffect(() => {
if (!claimPlanData) {
message.error('Данные заявления не получены');
return;
}
console.log('📋 StepClaimConfirmation: получены данные claimPlanData:', claimPlanData);
console.log('📋 claimPlanData.claim_id:', claimPlanData?.claim_id);
console.log('📋 claimPlanData.unified_id:', claimPlanData?.unified_id);
console.log('📋 claimPlanData.propertyName?.meta?.claim_id:', claimPlanData?.propertyName?.meta?.claim_id);
console.log('📋 claimPlanData.propertyName?.meta?.unified_id:', claimPlanData?.propertyName?.meta?.unified_id);
// Формируем данные для формы подтверждения
// Формат должен соответствовать тому, что ожидает HTML форма
const claimId = claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '';
const unifiedId = claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '';
console.log('📋 Извлечённые ID:', { claimId, unifiedId });
// Преобразуем данные из propertyName в формат для формы
const applicant = claimPlanData?.propertyName?.applicant || {};
const caseData = claimPlanData?.propertyName?.case || {};
const contract = claimPlanData?.propertyName?.contract_or_service || {};
const claimData = claimPlanData?.propertyName?.claim || {};
const offenders = claimPlanData?.propertyName?.offenders || [];
// Передаем данные в формате propertyName, чтобы функция normalizeData могла их правильно обработать
const formData = {
propertyName: claimPlanData?.propertyName || {
applicant: applicant,
case: caseData,
contract_or_service: contract,
claim: claimData,
offenders: offenders,
meta: {
...claimPlanData?.propertyName?.meta,
claim_id: claimId,
unified_id: unifiedId,
user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '',
},
attachments_names: claimPlanData?.propertyName?.attachments_names || [],
},
session_token: claimPlanData?.session_token || '',
telegram_id: claimPlanData?.telegram_id || '',
prefix: claimPlanData?.prefix || '',
claim_id: claimId,
token: claimPlanData?.token || '',
sms_meta: {
session_token: claimPlanData?.session_token || '',
prefix: claimPlanData?.prefix || '',
telegram_id: claimPlanData?.telegram_id || '',
claim_id: claimId,
unified_id: unifiedId,
user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '',
},
};
console.log('📋 Сформированные formData:', formData);
console.log('📋 formData.propertyName:', formData.propertyName);
console.log('📋 formData.propertyName?.meta:', formData.propertyName?.meta);
// Генерируем HTML форму здесь, на нашей стороне
const html = generateConfirmationFormHTML(formData);
setHtmlContent(html);
setLoading(false);
}, [claimPlanData]);
// Функция сохранения данных формы - публикация в Redis канал
// ⚠️ ВАЖНО: Эта функция должна вызываться ТОЛЬКО после SMS-верификации!
const saveFormData = useCallback(async (formData: any, smsCode?: string) => {
console.log('💾 Публикуем данные формы в Redis канал:', formData);
console.log('📱 SMS код для публикации:', smsCode || '(не передан)');
// Защита: если SMS код не передан, это ошибка (данные не должны отправляться без верификации)
if (!smsCode || smsCode.trim() === '') {
console.error('❌ ОШИБКА: saveFormData вызван БЕЗ SMS кода! Данные не должны отправляться без верификации.');
message.error('Ошибка: данные не могут быть отправлены без SMS-верификации');
return;
}
// Получаем данные из claimPlanData для формирования payload
const claimId = claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '';
const unifiedId = claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '';
const sessionToken = claimPlanData?.session_token || '';
const userId = claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '';
const phone = claimPlanData?.propertyName?.applicant?.phone ||
claimPlanData?.propertyName?.user?.mobile ||
claimPlanData?.phone || '';
// Формируем payload для Redis канала
const payload = {
claim_id: claimId,
session_token: sessionToken,
session_id: sessionToken,
unified_id: unifiedId,
user_id: userId,
phone: phone,
sms_code: smsCode || '', // SMS код для верификации
// Данные формы подтверждения
form_data: formData,
user: formData?.user || {},
project: formData?.project || {},
offenders: formData?.offenders || [],
meta: formData?.meta || {},
// Оригинальные данные для сравнения (если есть)
original_data: formData?.originalData || {},
};
console.log('📦 Payload для Redis:', { ...payload, sms_code: smsCode ? '***' : '(пусто)' });
// Публикуем в Redis канал через backend endpoint (fire-and-forget)
// Канал: clientright:webform:approve
fetch('/api/v1/claims/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
keepalive: true, // Продолжить отправку даже если страница закрывается
}).catch((error) => {
// Тихо логируем ошибки, но не блокируем пользователя
console.error('Ошибка публикации данных формы в Redis:', error);
});
console.log('✅ Данные формы опубликованы в Redis канал clientright:webform:approve');
}, [claimPlanData]);
// Функция отправки SMS-кода
const sendSMSCode = useCallback(async (phone: string) => {
try {
setSmsLoading(true);
// SMS API ожидает телефон в формате +79001234567
const phoneWithPlus = phone.startsWith('+') ? phone : `+${phone}`;
const response = await fetch('/api/v1/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: phoneWithPlus }),
});
const result = await response.json();
if (response.ok) {
message.success('Код отправлен на ваш телефон');
setSmsCodeSent(true);
if (result.debug_code) {
message.info(`DEBUG: Код ${result.debug_code}`);
}
} else {
message.error(result.detail || 'Ошибка отправки кода');
}
} catch (error) {
message.error('Ошибка соединения с сервером');
} finally {
setSmsLoading(false);
}
}, []);
// Функция проверки SMS-кода
const verifySMSCode = useCallback(async (phone: string, code: string) => {
try {
setSmsVerifyLoading(true);
// SMS API ожидает телефон в формате +79001234567
const phoneWithPlus = phone.startsWith('+') ? phone : `+${phone}`;
const response = await fetch('/api/v1/sms/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: phoneWithPlus, code }),
});
const result = await response.json();
if (response.ok) {
message.success('Код подтвержден!');
console.log('✅ SMS код успешно проверен:', code);
// Закрываем модалку
setSmsModalVisible(false);
setSmsCodeSent(false);
smsForm.resetFields();
// Отправляем данные в Redis канал с SMS кодом
console.log('📤 Вызываем saveFormData с SMS кодом:', code);
saveFormData(pendingFormData, code);
// Показываем сообщение об успешной отправке
message.success('Ваше заявление отправлено!');
// Переходим дальше
onNext();
} else {
message.error(result.detail || 'Неверный код');
}
} catch (error) {
message.error('Ошибка соединения с сервером');
} finally {
setSmsVerifyLoading(false);
}
}, [pendingFormData, saveFormData, smsForm, onNext]);
useEffect(() => {
// Слушаем сообщения от iframe
const handleMessage = (event: MessageEvent) => {
console.log('📨 Message from iframe:', event.data);
if (event.data.type === 'claim_confirmed') {
console.log('✅ Заявление подтверждено с данными:', event.data.data);
// Сохраняем данные формы для последующего сохранения после SMS-апрува
setPendingFormData(event.data.data);
// Получаем телефон пользователя для отправки SMS
const phone =
claimPlanData?.propertyName?.applicant?.phone ||
claimPlanData?.propertyName?.user?.mobile ||
claimPlanData?.phone ||
'';
if (!phone) {
message.error('Не удалось определить номер телефона для подтверждения');
return;
}
// Показываем модалку SMS-апрува
setSmsModalVisible(true);
setSmsCodeSent(false);
// Не вызываем resetFields() здесь, т.к. форма еще не отрендерена
// Форма будет сброшена при первом рендере
// Автоматически отправляем SMS-код
sendSMSCode(phone);
} else if (event.data.type === 'claim_cancelled') {
message.info('Подтверждение отменено');
onPrev();
} else if (event.data.type === 'claim_form_loaded') {
setLoading(false);
// Автоматически подстраиваем высоту iframe после загрузки
if (iframeRef.current) {
try {
const iframe = iframeRef.current;
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDoc) {
const height = Math.max(
iframeDoc.body.scrollHeight,
iframeDoc.body.offsetHeight,
iframeDoc.documentElement.clientHeight,
iframeDoc.documentElement.scrollHeight,
iframeDoc.documentElement.offsetHeight
);
iframe.style.height = Math.max(height + 50, 800) + 'px';
}
} catch (e) {
console.warn('Не удалось автоматически подстроить высоту iframe:', e);
}
}
} else if (event.data.type === 'iframe_resize') {
// Обработка запроса на изменение размера от iframe
if (iframeRef.current && event.data.height) {
iframeRef.current.style.height = Math.max(event.data.height + 50, 800) + 'px';
}
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [onNext, onPrev, sendSMSCode, claimPlanData]);
// Обработчик отправки SMS-кода из модалки
const handleSendCode = useCallback(async () => {
const phone =
claimPlanData?.propertyName?.applicant?.phone ||
claimPlanData?.propertyName?.user?.mobile ||
claimPlanData?.phone ||
'';
if (!phone) {
message.error('Не удалось определить номер телефона');
return;
}
await sendSMSCode(phone);
}, [claimPlanData, sendSMSCode]);
// Обработчик проверки SMS-кода из модалки
const handleVerifyCode = useCallback(async () => {
try {
const values = await smsForm.validateFields();
const phone =
claimPlanData?.propertyName?.applicant?.phone ||
claimPlanData?.propertyName?.user?.mobile ||
claimPlanData?.phone ||
'';
if (!phone) {
message.error('Не удалось определить номер телефона');
return;
}
await verifySMSCode(phone, values.code);
} catch (error) {
// Валидация не прошла
}
}, [claimPlanData, smsForm, verifySMSCode]);
// Обработчик отмены SMS-апрува
const handleCancelSMS = useCallback(() => {
setSmsModalVisible(false);
setSmsCodeSent(false);
setPendingFormData(null);
// Сбрасываем форму только если она была отрендерена (smsCodeSent был true)
if (smsCodeSent) {
smsForm.resetFields();
}
message.info('Подтверждение отменено');
}, [smsForm, smsCodeSent]);
// Вычисляем телефон для отображения (до условного рендера)
const phone =
claimPlanData?.propertyName?.applicant?.phone ||
claimPlanData?.propertyName?.user?.mobile ||
claimPlanData?.phone ||
'';
const displayPhone = phone ? (phone.length > 4 ? `${phone.slice(0, -4)}****` : '****') : '****';
if (loading) {
return (
<Card>
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
<p style={{ marginTop: '16px' }}>Загрузка формы подтверждения...</p>
</div>
</Card>
);
}
return (
<>
<Card
styles={{
body: {
padding: 0,
height: 'calc(100vh - 200px)',
minHeight: '800px',
display: 'flex',
flexDirection: 'column',
}
}}
>
<iframe
ref={iframeRef}
srcDoc={htmlContent}
style={{
width: '100%',
height: '100%',
minHeight: '800px',
border: 'none',
borderRadius: '8px',
flex: 1,
}}
title="Форма подтверждения заявления"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
</Card>
{/* Модальное окно SMS-апрува */}
<Modal
title="Подтверждение отправки заявления"
open={smsModalVisible}
onCancel={handleCancelSMS}
footer={null}
closable={!smsCodeSent}
maskClosable={!smsCodeSent}
width={400}
>
<div style={{ padding: '16px 0' }}>
<p style={{ marginBottom: '16px', fontSize: '14px', color: '#666' }}>
Для завершения отправки заявления необходимо подтвердить номер телефона.
</p>
{phone && (
<p style={{ marginBottom: '16px', fontSize: '14px' }}>
Код отправлен на номер: <strong>{displayPhone}</strong>
</p>
)}
{!smsCodeSent ? (
<div style={{ textAlign: 'center' }}>
<Button
type="primary"
loading={smsLoading}
onClick={handleSendCode}
block
>
Отправить код подтверждения
</Button>
</div>
) : (
<Form
form={smsForm}
layout="vertical"
onFinish={handleVerifyCode}
preserve={false}
>
<Form.Item
name="code"
label="Введите код из SMS"
rules={[
{ required: true, message: 'Введите код' },
{ len: 6, message: 'Код должен состоять из 6 цифр' },
{ pattern: /^\d+$/, message: 'Код должен содержать только цифры' },
]}
>
<Input
placeholder="000000"
maxLength={6}
style={{ fontSize: '18px', textAlign: 'center', letterSpacing: '4px' }}
autoFocus
/>
</Form.Item>
<Form.Item>
<div style={{ display: 'flex', gap: '8px' }}>
<Button
onClick={handleCancelSMS}
style={{ flex: 1 }}
>
Отмена
</Button>
<Button
type="primary"
htmlType="submit"
loading={smsVerifyLoading}
style={{ flex: 1 }}
>
Подтвердить
</Button>
</div>
</Form.Item>
<div style={{ textAlign: 'center', marginTop: '8px' }}>
<Button
type="link"
onClick={handleSendCode}
loading={smsLoading}
size="small"
>
Отправить код повторно
</Button>
</div>
</Form>
)}
</div>
</Modal>
</>
);
}

View File

@@ -74,6 +74,16 @@ export default function StepDescription({
return;
}
console.log('📝 Отправка описания проблемы на сервер:', {
session_id: formData.session_id,
phone: formData.phone,
email: formData.email,
unified_id: formData.unified_id,
contact_id: formData.contact_id,
description_length: safeDescription.length,
description_preview: safeDescription.substring(0, 100),
});
const response = await fetch('/api/v1/claims/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -81,14 +91,29 @@ export default function StepDescription({
session_id: formData.session_id,
phone: formData.phone,
email: formData.email,
unified_id: formData.unified_id, // ✅ Unified ID пользователя
contact_id: formData.contact_id, // ✅ Contact ID пользователя
problem_description: safeDescription,
}),
});
console.log('📝 Ответ сервера:', {
status: response.status,
ok: response.ok,
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Ошибка отправки описания:', {
status: response.status,
error: errorText,
});
throw new Error(`Ошибка API: ${response.status}`);
}
const responseData = await response.json();
console.log('✅ Описание успешно отправлено:', responseData);
message.success('Описание отправлено, подбираем рекомендации...');
updateFormData({
problemDescription: safeDescription,

View File

@@ -371,10 +371,21 @@ export default function StepWizardPlan({
useEffect(() => {
if (!isWaiting || !formData.session_id || plan) {
console.log('⏭️ StepWizardPlan: пропускаем подписку SSE', {
isWaiting,
hasSessionId: !!formData.session_id,
hasPlan: !!plan,
});
return;
}
const sessionId = formData.session_id;
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
session_id: sessionId,
sse_url: `/events/${sessionId}`,
redis_channel: `ocr_events:${sessionId}`,
});
const source = new EventSource(`/events/${sessionId}`);
eventSourceRef.current = source;
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
@@ -761,17 +772,106 @@ export default function StepWizardPlan({
response: parsed ?? text,
});
message.success('Мы изучаем ваш вопрос и документы.');
// Подписываемся на канал claim:plan для получения данных заявления
if (formData.session_id) {
subscribeToClaimPlan(formData.session_id);
} else {
console.warn('⚠️ session_id отсутствует, не можем подписаться на claim:plan');
onNext();
}
} catch (error) {
message.error('Ошибка соединения при отправке визарда.');
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
error: String(error),
});
onNext();
} finally {
setSubmitting(false);
}
onNext();
};
// Функция подписки на канал claim:plan
const subscribeToClaimPlan = useCallback((sessionToken: string) => {
console.log('📡 Подписка на канал claim:plan для session:', sessionToken);
// Закрываем предыдущее соединение, если есть
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// Создаём новое SSE соединение
const eventSource = new EventSource(`/api/v1/claim-plan/${sessionToken}`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('✅ Подключено к каналу claim:plan');
addDebugEvent?.('claim-plan', 'info', '📡 Ожидание данных заявления...');
message.loading('Ожидание данных заявления...', 0);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📥 Получены данные из claim:plan:', data);
if (data.event_type === 'claim_plan_ready' && data.status === 'ready') {
// Данные заявления получены!
message.destroy(); // Убираем loading сообщение
message.success('Данные заявления готовы!');
// Сохраняем данные заявления в formData
updateFormData({
claimPlanData: data.data, // Данные от n8n
showClaimConfirmation: true, // Флаг для показа формы подтверждения
});
// Закрываем SSE соединение
eventSource.close();
eventSourceRef.current = null;
// Переходим к следующему шагу (форма подтверждения)
onNext();
} else if (data.event_type === 'claim_plan_error' || data.status === 'error') {
message.destroy();
message.error(data.message || 'Ошибка получения данных заявления');
eventSource.close();
eventSourceRef.current = null;
onNext(); // Переходим дальше даже при ошибке
} else if (data.event_type === 'claim_plan_timeout' || data.status === 'timeout') {
message.destroy();
message.warning('Превышено время ожидания. Попробуйте обновить страницу.');
eventSource.close();
eventSourceRef.current = null;
onNext();
}
} catch (error) {
console.error('❌ Ошибка парсинга данных из claim:plan:', error);
message.destroy();
message.error('Ошибка обработки данных заявления');
}
};
eventSource.onerror = (error) => {
console.error('❌ Ошибка SSE соединения claim:plan:', error);
message.destroy();
message.error('Ошибка подключения к серверу');
eventSource.close();
eventSourceRef.current = null;
onNext(); // Переходим дальше даже при ошибке
};
// Таймаут на 5 минут
timeoutRef.current = setTimeout(() => {
console.warn('⏰ Таймаут ожидания данных заявления');
message.destroy();
message.warning('Превышено время ожидания данных заявления');
eventSource.close();
eventSourceRef.current = null;
onNext();
}, 300000); // 5 минут
}, [addDebugEvent, updateFormData, onNext]);
const renderQuestionField = (question: WizardQuestion) => {
// Обработка по input_type для более точного определения типа поля

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import StepDescription from '../components/form/StepDescription';
import Step1Policy from '../components/form/Step1Policy';
import StepDraftSelection from '../components/form/StepDraftSelection';
import StepWizardPlan from '../components/form/StepWizardPlan';
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
import Step2EventType from '../components/form/Step2EventType';
import StepDocumentUpload from '../components/form/StepDocumentUpload';
import Step3Payment from '../components/form/Step3Payment';
@@ -16,6 +17,18 @@ import './ClaimForm.css';
const { Step } = Steps;
/**
* Генерация UUID v4
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
*/
function generateUUIDv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
interface FormData {
// Шаг 1: Phone
phone?: string;
@@ -42,6 +55,10 @@ interface FormData {
wizardUploads?: Record<string, any>;
wizardSkippedDocuments?: string[];
// Подтверждение заявления (после получения данных из claim:plan)
showClaimConfirmation?: boolean;
claimPlanData?: any; // Данные заявления от n8n из канала claim:plan
// Шаг 3: Event Type
eventType?: string;
ticket_id?: string; // ✅ ID заявки в vTiger (HelpDesk)
@@ -71,6 +88,8 @@ export default function ClaimForm() {
// session_id будет получен от n8n при создании контакта
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
@@ -204,6 +223,26 @@ export default function ClaimForm() {
fetchClientIp();
}, []);
// Автоматический переход к шагу подтверждения, когда данные готовы
useEffect(() => {
if (formData.showClaimConfirmation && formData.claimPlanData) {
// Вычисляем индекс шага подтверждения динамически
// Шаг подтверждения добавляется после StepWizardPlan
// После выбора черновика showDraftSelection = false, поэтому:
// - Шаг 0 = Step1Phone
// - Шаг 1 = StepDescription
// - Шаг 2 = StepWizardPlan
// - Шаг 3 = StepClaimConfirmation (если showClaimConfirmation=true)
const confirmationStepIndex = 3; // Фиксированный индекс для шага подтверждения
console.log('✅ Данные заявления готовы, переходим к шагу подтверждения:', confirmationStepIndex);
setTimeout(() => {
setCurrentStep(confirmationStepIndex);
}, 100);
}
}, [formData.showClaimConfirmation, formData.claimPlanData]);
// Динамически определяем список шагов на основе выбранного eventType
const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
const totalDocumentSteps = documentConfigs.length;
@@ -244,6 +283,261 @@ export default function ClaimForm() {
});
}, []);
// Преобразование данных черновика в формат propertyName для формы подтверждения
const transformDraftToClaimPlanFormat = useCallback((data: {
claim: any;
payload: any;
body: any;
isTelegramFormat: boolean;
finalClaimId: string;
actualSessionId: string;
currentFormData: FormData;
}) => {
const { claim, payload, body, finalClaimId, actualSessionId, currentFormData } = data;
console.log('🔄 transformDraftToClaimPlanFormat: входные данные:', {
claimId: finalClaimId,
claimUnifiedId: claim.unified_id,
formDataUnifiedId: currentFormData.unified_id,
claimKeys: Object.keys(claim),
});
console.log('🔄 Данные из БД:', {
hasApplicantData: !!(body.applicant || payload.applicant),
hasCaseData: !!(body.case || payload.case),
hasContractData: !!(body.contract_or_service || payload.contract_or_service),
hasWizardAnswers: !!(body.answers || payload.answers || body.wizard_answers || payload.wizard_answers),
hasSendToFormApprove: !!(payload.send_to_form_approve && payload.send_to_form_approve.draft),
payloadKeys: Object.keys(payload),
bodyKeys: Object.keys(body),
});
// ✅ ПРИОРИТЕТ 1: Если есть данные в payload.send_to_form_approve.draft - используем их напрямую!
const sendToFormApproveDraft = payload.send_to_form_approve?.draft;
if (sendToFormApproveDraft) {
console.log('✅ Найдены данные в payload.send_to_form_approve.draft, используем их напрямую!');
console.log('✅ send_to_form_approve.draft:', sendToFormApproveDraft);
// Используем данные из send_to_form_approve.draft напрямую
const draftData = sendToFormApproveDraft;
// Формируем propertyName из draft данных
const propertyName = {
applicant: draftData.applicant || {},
case: draftData.case || {},
contract_or_service: draftData.contract_or_service || {},
offenders: draftData.offenders || [],
claim: draftData.claim || {},
meta: {
...(draftData.meta || {}),
claim_id: finalClaimId,
unified_id: draftData.meta?.unified_id || claim.unified_id || currentFormData.unified_id || null,
},
attachments: draftData.attachments || [],
attachments_count: draftData.attachments_count || 0,
attachments_names: draftData.attachments_names || [],
};
// Возвращаем данные в формате объекта (для компонента StepClaimConfirmation)
const result = {
propertyName: propertyName,
session_token: actualSessionId,
prefix: '',
telegram_id: null,
claim_id: finalClaimId,
unified_id: propertyName.meta.unified_id,
user_id: propertyName.meta.user_id || null,
};
console.log('🔄 transformDraftToClaimPlanFormat: результат из send_to_form_approve:', {
claim_id: result.claim_id,
unified_id: result.unified_id,
hasPropertyName: !!result.propertyName,
hasMeta: !!result.propertyName?.meta,
});
return result;
}
// ✅ ПРИОРИТЕТ 2: Если данных нет в send_to_form_approve, извлекаем из body/payload
// Извлекаем данные из body (telegram) или напрямую из payload (web_form)
const applicantData = body.applicant || payload.applicant || {};
const caseData = body.case || payload.case || {};
const contractData = body.contract_or_service || payload.contract_or_service || {};
const offendersData = body.offenders || payload.offenders || [];
const claimData = body.claim || payload.claim || {};
const metaData = body.meta || payload.meta || {};
const documentsMeta = body.documents_meta || payload.documents_meta || [];
// Извлекаем ответы на вопросы из wizard_answers
const wizardAnswers = body.answers || payload.answers || body.wizard_answers || payload.wizard_answers || {};
let answersParsed = wizardAnswers;
if (typeof wizardAnswers === 'string') {
try {
answersParsed = JSON.parse(wizardAnswers);
} catch (e) {
console.warn('⚠️ Не удалось распарсить answers:', e);
answersParsed = {};
}
}
console.log('🔄 wizard_answers parsed:', answersParsed);
// Преобразуем wizard_answers в формат propertyName, если данных нет в propertyName формате
// Маппинг полей из wizard_answers в propertyName структуру
const hasPropertyNameData = !!(applicantData.first_name || applicantData.last_name || caseData.category || contractData.subject);
if (!hasPropertyNameData && answersParsed && Object.keys(answersParsed).length > 0) {
console.log('🔄 Преобразуем wizard_answers в propertyName формат');
console.log('🔄 wizard_answers keys:', Object.keys(answersParsed));
// Маппинг полей из wizard_answers в contract_or_service
if (answersParsed.item && !contractData.subject) {
contractData.subject = answersParsed.item;
}
if (answersParsed.price && !contractData.amount_paid) {
// Нормализуем цену (убираем "рублей", пробелы и т.д.)
const priceStr = String(answersParsed.price).replace(/\s+/g, '').replace(/руб(лей|ль|\.)?/gi, '').replace(/₽|р\.|р$/gi, '');
contractData.amount_paid = priceStr;
contractData.amount_paid_fmt = priceStr;
}
if (answersParsed.place_date && !contractData.agreement_date) {
contractData.agreement_date = answersParsed.place_date;
contractData.agreement_date_fmt = answersParsed.place_date;
}
if (answersParsed.cancel_date && !contractData.period_start) {
contractData.period_start = answersParsed.cancel_date;
contractData.period_start_fmt = answersParsed.cancel_date;
}
// Маппинг полей из wizard_answers в claim
if (answersParsed.steps_taken && !claimData.description) {
claimData.description = answersParsed.steps_taken;
}
if (answersParsed.expectation && !claimData.reason) {
// expectation может быть "refund", "replacement", "compensation", "other"
claimData.reason = answersParsed.expectation === 'refund' ? 'consumer' : 'consumer';
}
// Маппинг в case
if (!caseData.category) {
caseData.category = 'consumer'; // По умолчанию consumer
}
if (!caseData.direction) {
caseData.direction = 'web_form';
}
// Если есть problem_description, используем его для claim.description
const problemDesc = payload.problem_description || body.problem_description;
if (problemDesc && !claimData.description) {
claimData.description = problemDesc;
}
if (problemDesc && !contractData.subject) {
contractData.subject = problemDesc;
}
}
// Данные заявителя берутся из других источников (phone, email из claim или formData)
// ФИО, дата рождения, ИНН будут заполняться в форме подтверждения
const applicantPhone = claim.phone || payload.phone || body.phone || currentFormData.phone || null;
const applicantEmail = claim.email || payload.email || body.email || currentFormData.email || null;
// Если есть данные заявителя в applicantData, используем их
if (!applicantData.phone && applicantPhone) {
applicantData.phone = applicantPhone;
}
if (!applicantData.email && applicantEmail) {
applicantData.email = applicantEmail;
}
// Формируем attachments_names из documents_meta
const attachmentsNames = documentsMeta.map((doc: any) => {
return doc.original_file_name || doc.file_name || doc.field_name || 'Документ';
});
// Формируем attachments с полной информацией
const attachments = documentsMeta.map((doc: any) => ({
label: doc.field_label || doc.original_file_name || doc.file_name || doc.field_name || 'Документ', // ✅ Используем field_label
field_label: doc.field_label || doc.field_name || doc.original_file_name || doc.file_name || 'Документ', // ✅ Добавляем field_label отдельно
url: doc.file_id ? `https://s3.twcstorage.ru${doc.file_id}` : '',
file_id: doc.file_id || '',
stored_file_name: doc.file_name || '',
original_file_name: doc.original_file_name || doc.file_name || '',
field_name: doc.field_name || '',
uploaded_at: doc.uploaded_at || new Date().toISOString(),
}));
// Формируем propertyName в нужном формате
const propertyName = {
applicant: {
first_name: applicantData.first_name || null,
middle_name: applicantData.middle_name || null,
last_name: applicantData.last_name || null,
full_name: applicantData.full_name || null,
birth_date: applicantData.birth_date || null,
birth_date_fmt: applicantData.birth_date_fmt || null,
birth_place: applicantData.birth_place || null,
inn: applicantData.inn || null,
address: applicantData.address || null,
phone: claim.phone || payload.phone || body.phone || currentFormData.phone || null,
email: claim.email || payload.email || body.email || currentFormData.email || null,
},
case: {
category: caseData.category || payload.case_type || 'consumer',
direction: caseData.direction || 'web_form',
country: caseData.country || null,
},
contract_or_service: {
agreement_date: contractData.agreement_date || null,
agreement_date_fmt: contractData.agreement_date_fmt || null,
amount_paid: contractData.amount_paid || null,
amount_paid_fmt: contractData.amount_paid_fmt || null,
subject: contractData.subject || payload.problem_description || body.problem_description || null,
period_start: contractData.period_start || null,
period_start_fmt: contractData.period_start_fmt || null,
period_end: contractData.period_end || null,
period_end_fmt: contractData.period_end_fmt || null,
period_text: contractData.period_text || null,
},
offenders: offendersData.length > 0 ? offendersData : [],
claim: {
reason: claimData.reason || caseData.category || 'consumer',
description: claimData.description || payload.problem_description || body.problem_description || null,
},
meta: {
claim_id: finalClaimId,
unified_id: claim.unified_id || currentFormData.unified_id || null,
status: claim.status_code || 'draft',
created_at: claim.created_at || new Date().toISOString(),
updated_at: claim.updated_at || new Date().toISOString(),
user_id: metaData.user_id || null,
},
attachments: attachments,
attachments_count: attachments.length,
attachments_names: attachmentsNames,
};
// Возвращаем данные в формате объекта (для компонента StepClaimConfirmation)
const result = {
propertyName: propertyName,
session_token: actualSessionId,
prefix: '',
telegram_id: null,
claim_id: finalClaimId,
unified_id: claim.unified_id || currentFormData.unified_id || null,
user_id: metaData.user_id || null,
};
console.log('🔄 transformDraftToClaimPlanFormat: результат:', {
claim_id: result.claim_id,
unified_id: result.unified_id,
hasPropertyName: !!result.propertyName,
hasMeta: !!result.propertyName?.meta,
});
return result;
}, []);
// Загрузка черновика
const loadDraft = useCallback(async (claimId: string) => {
try {
@@ -272,6 +566,7 @@ export default function ClaimForm() {
console.log('🔍 Claim объект:', claim);
console.log('🔍 claim.claim_id:', claim.claim_id);
console.log('🔍 claim.id:', claim.id);
console.log('🔍 claim.unified_id:', claim.unified_id);
console.log('🔍 Payload черновика:', payload);
console.log('🔍 payload.body:', body);
console.log('🔍 Формат:', isTelegramFormat ? 'telegram (body)' : 'web_form (прямой)');
@@ -279,7 +574,16 @@ export default function ClaimForm() {
// ✅ Извлекаем данные из body (telegram) или напрямую из payload (web_form)
const wizardPlanRaw = body.wizard_plan || payload.wizard_plan;
const answersRaw = body.answers || payload.answers;
const problemDescription = body.problem_description || payload.problem_description || body.description || payload.description;
// Ищем problem_description в разных местах (может быть в разных форматах)
const problemDescription =
body.problem_description ||
payload.problem_description ||
body.description ||
payload.description ||
payload.body?.problem_description || // Для вложенных структур
payload.body?.description ||
null;
const documentsMeta = body.documents_meta || payload.documents_meta || [];
// ✅ Парсим wizard_plan и answers, если они строки (JSON)
let wizardPlan = wizardPlanRaw;
@@ -300,9 +604,34 @@ export default function ClaimForm() {
}
}
console.log('🔍 problem_description:', problemDescription ? 'есть' : 'нет');
// ✅ Проверяем, заполнены ли все шаги
// Для problem_description: если его нет в payload, но есть wizard_plan и answers,
// значит описание уже было введено ранее (wizard_plan генерируется на основе описания)
const hasDescription = !!problemDescription || (!!wizardPlan && !!answers); // Если есть план и ответы - описание было
const hasWizardPlan = !!wizardPlan;
const hasAnswers = !!answers && Object.keys(answers).length > 0;
const hasDocuments = Array.isArray(documentsMeta) && documentsMeta.length > 0;
const isDraft = claim.status_code === 'draft';
const allStepsFilled = hasDescription && hasWizardPlan && hasAnswers && hasDocuments;
const isReadyForConfirmation = allStepsFilled && isDraft;
console.log('🔍 Проверка полноты черновика:', {
hasDescription,
hasWizardPlan,
hasAnswers,
hasDocuments,
isDraft,
allStepsFilled,
isReadyForConfirmation,
problemDescriptionFound: !!problemDescription,
inferredFromPlan: !problemDescription && !!wizardPlan && !!answers,
});
console.log('🔍 problem_description:', problemDescription ? 'есть' : (wizardPlan && answers ? 'выведено из наличия плана и ответов' : 'нет'));
console.log('🔍 wizard_plan:', wizardPlan ? 'есть' : 'нет');
console.log('🔍 answers:', answers ? 'есть' : 'нет');
console.log('🔍 documents_meta:', documentsMeta.length, 'документов');
console.log('🔍 Все ключи payload:', Object.keys(payload));
if (isTelegramFormat) {
console.log('🔍 Все ключи body:', Object.keys(body));
@@ -316,12 +645,21 @@ export default function ClaimForm() {
console.log('🔄 Загрузка черновика: session_id из черновика:', claim.session_token);
console.log('🔄 Загрузка черновика: текущий sessionIdRef.current:', sessionIdRef.current);
console.log('🔄 Загрузка черновика: текущий formData.session_id:', formData.session_id);
const actualSessionId = sessionIdRef.current || formData.session_id;
// ✅ При загрузке черновика используем session_id из черновика (для продолжения работы с той же жалобой)
// Если session_id из черновика есть - используем его, иначе текущий
const actualSessionId = claim.session_token || sessionIdRef.current || formData.session_id;
console.log('🔄 Загрузка черновика: ИСПОЛЬЗУЕМ session_id:', actualSessionId);
// ✅ Обновляем sessionIdRef на сессию из черновика (если есть)
if (claim.session_token && claim.session_token !== sessionIdRef.current) {
sessionIdRef.current = claim.session_token;
console.log('🔄 Обновляем sessionIdRef на сессию из черновика:', claim.session_token);
}
updateFormData({
claim_id: finalClaimId, // ✅ Используем извлечённый claim_id
session_id: actualSessionId, // ✅ Используем ТЕКУЩИЙ session_id, а не старый из черновика
session_id: actualSessionId, // ✅ Используем session_id из черновика (если есть) или текущий
phone: body.phone || payload.phone || formData.phone,
email: body.email || payload.email || formData.email,
problemDescription: problemDescription || formData.problemDescription,
@@ -349,6 +687,34 @@ export default function ClaimForm() {
setSelectedDraftId(finalClaimId);
setShowDraftSelection(false);
// ✅ Если все шаги заполнены и статус = draft → переходим к форме подтверждения
if (isReadyForConfirmation) {
console.log('✅ Все шаги заполнены, преобразуем данные для формы подтверждения');
setIsPhoneVerified(true);
// Преобразуем данные из БД в формат propertyName для формы подтверждения
const claimPlanData = transformDraftToClaimPlanFormat({
claim,
payload,
body,
isTelegramFormat,
finalClaimId,
actualSessionId,
currentFormData: formData,
});
// Сохраняем данные заявления в formData
updateFormData({
claimPlanData: claimPlanData,
showClaimConfirmation: true,
});
// Переход к шагу подтверждения произойдёт автоматически через useEffect
setCurrentStep(2); // StepWizardPlan (временно, useEffect переключит на подтверждение)
return;
}
// ✅ Определяем шаг для перехода на основе данных черновика
// Приоритет: если есть wizard_plan → переходим к визарду (даже если нет problem_description)
// После выбора черновика showDraftSelection = false, поэтому:
@@ -448,12 +814,27 @@ export default function ClaimForm() {
console.log('🆕 Текущий currentStep:', currentStep);
console.log('🆕 isPhoneVerified:', isPhoneVerified);
// ✅ Генерируем НОВУЮ сессию для новой жалобы
const newSessionId = 'sess_' + generateUUIDv4();
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
console.log('🆕 Старая сессия:', sessionIdRef.current);
// ✅ Обновляем sessionIdRef на новую сессию
sessionIdRef.current = newSessionId;
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
const savedSessionToken = localStorage.getItem('session_token');
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
setShowDraftSelection(false);
setSelectedDraftId(null);
setHasDrafts(false); // ✅ Сбрасываем флаг наличия черновиков
// Очищаем данные формы, кроме телефона и session_id
// Очищаем данные формы и устанавливаем НОВЫЙ session_id
// unified_id, phone, contact_id остаются прежними - авторизация сохранена!
updateFormData({
session_id: newSessionId, // ✅ Новая сессия для новой жалобы
claim_id: undefined,
problemDescription: undefined,
wizardPlan: undefined,
@@ -464,6 +845,7 @@ export default function ClaimForm() {
wizardUploads: undefined,
wizardSkippedDocuments: undefined,
eventType: undefined,
// ✅ unified_id, phone, contact_id НЕ очищаем - авторизация сохраняется!
});
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
@@ -474,7 +856,7 @@ export default function ClaimForm() {
// Шаг 1 - Description (сюда переходим)
// Шаг 2 - WizardPlan
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
}, [updateFormData, currentStep, isPhoneVerified]);
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
const handleSubmit = useCallback(async () => {
try {
@@ -687,6 +1069,21 @@ export default function ClaimForm() {
),
});
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
if (formData.showClaimConfirmation && formData.claimPlanData) {
stepsArray.push({
title: 'Подтверждение',
description: 'Проверка данных',
content: (
<StepClaimConfirmation
claimPlanData={formData.claimPlanData}
onPrev={prevStep}
onNext={nextStep}
/>
),
});
}
// Шаг 3: Policy (всегда)
stepsArray.push({
title: 'Проверка полиса',

View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Мониторинг использования памяти n8n
# Проверяет использование памяти и отправляет алерт при превышении порога
N8N_CONTAINER="${N8N_CONTAINER:-n8n}" # Имя контейнера n8n
THRESHOLD="${MEMORY_THRESHOLD:-80}" # Порог использования памяти (%)
LOG_FILE="/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_memory_monitor.log"
# Создать директорию для логов если не существует
mkdir -p "$(dirname "$LOG_FILE")"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# Проверка существования контейнера
if ! docker ps --format "{{.Names}}" | grep -q "^${N8N_CONTAINER}$"; then
log "❌ Контейнер ${N8N_CONTAINER} не найден!"
exit 1
fi
# Получение использования памяти
MEMORY_INFO=$(docker stats "$N8N_CONTAINER" --no-stream --format "{{.MemUsage}}|{{.MemPerc}}")
MEMORY_USAGE=$(echo "$MEMORY_INFO" | cut -d'|' -f1)
MEMORY_PERCENT=$(echo "$MEMORY_INFO" | cut -d'|' -f2 | sed 's/%//')
# Проверка порога
if (( $(echo "$MEMORY_PERCENT > $THRESHOLD" | bc -l 2>/dev/null || echo "0") )); then
log "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_PERCENT}% памяти (${MEMORY_USAGE})"
log " Порог: ${THRESHOLD}%"
# Дополнительная информация
log "📊 Дополнительная информация:"
docker stats "$N8N_CONTAINER" --no-stream --format " CPU: {{.CPUPerc}} | Memory: {{.MemUsage}} | Network: {{.NetIO}}" | tee -a "$LOG_FILE"
# Проверка OOM Killer
OOM_COUNT=$(dmesg | grep -i "out of memory" | grep -i "$N8N_CONTAINER" | wc -l)
if [ "$OOM_COUNT" -gt 0 ]; then
log "🚨 Обнаружены записи OOM Killer для n8n!"
dmesg | grep -i "out of memory" | grep -i "$N8N_CONTAINER" | tail -5 | tee -a "$LOG_FILE"
fi
exit 1
else
log "✅ Память в норме: ${MEMORY_PERCENT}% (${MEMORY_USAGE})"
exit 0
fi

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Мониторинг Redis Trigger в n8n
Проверяет наличие подписчиков на канале ticket_form:description
и отправляет алерт если подписчиков нет
"""
import redis
import time
import logging
from datetime import datetime
import sys
# Настройки
REDIS_HOST = "crm.clientright.ru"
REDIS_PORT = 6379
REDIS_PASSWORD = "CRM_Redis_Pass_2025_Secure!"
CHANNEL = "ticket_form:description"
CHECK_INTERVAL = 60 # Проверка каждую минуту
ALERT_THRESHOLD = 0 # Если подписчиков меньше этого значения - алерт
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
def check_subscribers():
"""Проверка количества подписчиков на канале"""
try:
r = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5
)
# Проверка подключения
r.ping()
# Проверка подписчиков
numsub = r.pubsub_numsub(CHANNEL)
subscribers = numsub[0][1] if numsub else 0
logger.info(f"📊 Канал {CHANNEL}: {subscribers} подписчиков")
if subscribers <= ALERT_THRESHOLD:
logger.warning(
f"⚠️ ВНИМАНИЕ: На канале {CHANNEL} нет подписчиков! "
f"n8n workflow может быть неактивен или завис."
)
return False
return True
except redis.ConnectionError as e:
logger.error(f"❌ Ошибка подключения к Redis: {e}")
return False
except Exception as e:
logger.error(f"❌ Неожиданная ошибка: {e}")
return False
finally:
try:
r.close()
except:
pass
def send_test_message():
"""Отправка тестового сообщения для проверки"""
try:
r = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5
)
test_message = {
"type": "test",
"session_id": "monitor_test",
"timestamp": datetime.utcnow().isoformat(),
"message": "Health check from monitor script"
}
import json
subscribers = r.publish(CHANNEL, json.dumps(test_message))
logger.info(f"📤 Тестовое сообщение отправлено. Получено подписчиками: {subscribers}")
r.close()
return subscribers > 0
except Exception as e:
logger.error(f"❌ Ошибка отправки тестового сообщения: {e}")
return False
def main():
"""Основной цикл мониторинга"""
logger.info("🚀 Запуск мониторинга Redis Trigger для n8n")
logger.info(f"📡 Канал: {CHANNEL}")
logger.info(f"⏱️ Интервал проверки: {CHECK_INTERVAL} секунд")
consecutive_failures = 0
max_failures = 3 # После 3 неудачных проверок подряд - критический алерт
while True:
try:
is_ok = check_subscribers()
if is_ok:
consecutive_failures = 0
else:
consecutive_failures += 1
if consecutive_failures >= max_failures:
logger.critical(
f"🚨 КРИТИЧЕСКОЕ СОСТОЯНИЕ: "
f"Канал {CHANNEL} не имеет подписчиков уже {consecutive_failures} проверок подряд! "
f"Требуется перезапуск n8n workflow!"
)
# Можно добавить отправку уведомления (email, telegram, etc.)
time.sleep(CHECK_INTERVAL)
except KeyboardInterrupt:
logger.info("⏹️ Остановка мониторинга по запросу пользователя")
break
except Exception as e:
logger.error(f"❌ Критическая ошибка в цикле мониторинга: {e}")
time.sleep(CHECK_INTERVAL)
if __name__ == "__main__":
main()