Изменения в backend: - Обновления в n8n_proxy.py - Изменения в SMS API - Обновления конфигурации - Улучшения SMS сервиса Изменения в frontend: - Обновления Step1Phone компонента - Изменения в Step3Payment - Улучшения generateConfirmationFormHTML - Обновления ClaimForm страницы - Изменения в vite.config.ts Статистика: +242 строки, -81 строка
461 lines
21 KiB
Python
461 lines
21 KiB
Python
"""
|
||
N8N Webhook Proxy Router
|
||
Безопасное проксирование запросов к n8n webhooks.
|
||
Frontend не знает прямых URL webhooks!
|
||
"""
|
||
import httpx
|
||
import logging
|
||
from fastapi import APIRouter, HTTPException, File, UploadFile, Form, Request
|
||
from fastapi.responses import JSONResponse
|
||
from typing import Optional
|
||
|
||
from ..config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/n8n", tags=["n8n-proxy"])
|
||
|
||
|
||
# URL webhooks - берём из settings (defaults в config.py)
|
||
N8N_POLICY_CHECK_WEBHOOK = settings.n8n_policy_check_webhook or None
|
||
N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
|
||
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
|
||
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
|
||
|
||
|
||
@router.post("/policy/check")
|
||
async def proxy_policy_check(request: Request):
|
||
"""
|
||
Проксирует проверку полиса к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/policy/check
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/{uuid}
|
||
"""
|
||
if not N8N_POLICY_CHECK_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N webhook не настроен")
|
||
|
||
try:
|
||
# Получаем JSON body от фронтенда
|
||
body = await request.json()
|
||
body.setdefault('form_id', 'ticket_form')
|
||
|
||
logger.info(f"🔄 Proxy policy check: {body.get('policy_number', 'unknown')}")
|
||
|
||
# Проксируем запрос к n8n
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
response = await client.post(
|
||
N8N_POLICY_CHECK_WEBHOOK,
|
||
json=body,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Policy check success. Response: {response_text[:500]}")
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse JSON: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||
except Exception as e:
|
||
logger.error(f"❌ Error proxying to n8n: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка проверки полиса: {str(e)}")
|
||
|
||
|
||
@router.post("/contact/create")
|
||
async def proxy_create_contact(request: Request):
|
||
"""
|
||
Проксирует создание контакта к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/contact/create
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27
|
||
"""
|
||
if not N8N_CREATE_CONTACT_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N contact webhook не настроен")
|
||
|
||
try:
|
||
body = await request.json()
|
||
|
||
logger.info(
|
||
"🔄 Proxy create contact: phone=%s, session_id=%s, form_id=%s",
|
||
body.get('phone', 'unknown'),
|
||
body.get('session_id', 'unknown'),
|
||
body.get('form_id', 'missing')
|
||
)
|
||
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
response = await client.post(
|
||
N8N_CREATE_CONTACT_WEBHOOK,
|
||
json=body,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Contact created successfully. Response: {response_text[:500]}")
|
||
|
||
if not response_text or response_text.strip() == '':
|
||
logger.error(f"❌ N8N returned empty response")
|
||
raise HTTPException(status_code=500, detail="N8N вернул пустой ответ")
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse JSON: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||
except Exception as e:
|
||
import traceback
|
||
logger.error(f"❌ Error proxying to n8n: {e}")
|
||
logger.error(f"❌ Traceback: {traceback.format_exc()}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка создания контакта: {str(e)}")
|
||
|
||
|
||
@router.post("/upload/file")
|
||
async def proxy_file_upload(
|
||
file: UploadFile = File(...),
|
||
claim_id: Optional[str] = Form(None),
|
||
voucher: Optional[str] = Form(None),
|
||
session_id: Optional[str] = Form(None),
|
||
file_type: Optional[str] = Form(None),
|
||
filename: Optional[str] = Form(None),
|
||
upload_timestamp: Optional[str] = Form(None)
|
||
):
|
||
"""
|
||
Проксирует загрузку файла к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/upload/file (multipart/form-data)
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/{uuid}
|
||
"""
|
||
if not N8N_FILE_UPLOAD_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N upload webhook не настроен")
|
||
|
||
try:
|
||
logger.info(f"🔄 Proxy file upload: {file.filename} for claim {claim_id}")
|
||
|
||
# Читаем файл
|
||
file_content = await file.read()
|
||
|
||
# Формируем multipart/form-data для n8n
|
||
files = {
|
||
'file': (file.filename, file_content, file.content_type)
|
||
}
|
||
|
||
data = {}
|
||
if claim_id:
|
||
data['claim_id'] = claim_id
|
||
if voucher:
|
||
data['voucher'] = voucher
|
||
if session_id:
|
||
data['session_id'] = session_id
|
||
if file_type:
|
||
data['file_type'] = file_type
|
||
if filename:
|
||
data['filename'] = filename
|
||
if upload_timestamp:
|
||
data['upload_timestamp'] = upload_timestamp
|
||
|
||
# Проксируем запрос к n8n
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
response = await client.post(
|
||
N8N_FILE_UPLOAD_WEBHOOK,
|
||
files=files,
|
||
data=data
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ File upload success")
|
||
|
||
if not response_text or response_text.strip() == '':
|
||
# n8n может вернуть пустой ответ, возвращаем заглушку
|
||
logger.warning("⚠️ N8N upload webhook вернул пустой ответ, подставляю default payload")
|
||
return {"success": True, "message": "n8n: empty response"}
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Не удалось распарсить JSON от n8n: {e}. Response: {response_text[:500]}")
|
||
# Возвращаем текстовое содержимое чтобы фронт мог показать пользователю
|
||
return JSONResponse(
|
||
status_code=200,
|
||
content={
|
||
"success": True,
|
||
"message": "n8n upload returned non-JSON response",
|
||
"raw": response_text
|
||
}
|
||
)
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут загрузки файла")
|
||
except Exception as e:
|
||
logger.error(f"❌ Error proxying file to n8n: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка загрузки файла: {str(e)}")
|
||
|
||
|
||
@router.post("/claim/create")
|
||
async def proxy_create_claim(request: Request):
|
||
"""
|
||
Проксирует создание черновика заявки к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/claim/create
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d
|
||
"""
|
||
if not N8N_CREATE_CLAIM_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N claim webhook не настроен")
|
||
|
||
try:
|
||
# Получаем JSON body от фронтенда
|
||
body = await request.json()
|
||
|
||
logger.info(f"🔄 Proxy create claim: event_type={body.get('event_type', 'unknown')}, claim_id={body.get('claim_id', 'unknown')}")
|
||
|
||
# Проксируем запрос к n8n
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
response = await client.post(
|
||
N8N_CREATE_CLAIM_WEBHOOK,
|
||
json=body,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Claim created successfully. Response: {response_text[:200]}")
|
||
|
||
# Проверяем что ответ не пустой
|
||
if not response_text or response_text.strip() == '':
|
||
logger.error(f"❌ N8N returned empty response")
|
||
raise HTTPException(status_code=500, detail="N8N вернул пустой ответ")
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse JSON: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||
except Exception as e:
|
||
logger.error(f"❌ Error proxying to n8n: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка создания заявки: {str(e)}")
|
||
|
||
|
||
@router.post("/documents/attach")
|
||
async def attach_document_to_crm(request: Request):
|
||
"""
|
||
Привязывает загруженные файлы к проекту или заявке в vTiger CRM
|
||
|
||
Входные данные (массив документов):
|
||
[
|
||
{
|
||
"claim_id": "CLM-2025-11-02-WNRZZZ",
|
||
"contact_id": "320096",
|
||
"project_id": "396868",
|
||
"ticket_id": "396936", // Опционально
|
||
"filename": "boarding_pass.pdf",
|
||
"file_type": "flight_delay_boarding_or_ticket",
|
||
"file": "/bucket/path/to/file.pdf" // Без хоста, добавим https://s3.twcstorage.ru
|
||
}
|
||
]
|
||
|
||
Логика:
|
||
- Если указан ticket_id → привязываем к HelpDesk (заявке)
|
||
- Иначе → привязываем к Project (проекту)
|
||
"""
|
||
CRM_UPLOAD_ENDPOINT = "https://crm.clientright.ru/upload_documents_to_crm.php"
|
||
S3_HOST = "https://s3.twcstorage.ru"
|
||
|
||
try:
|
||
body = await request.json()
|
||
|
||
# Поддерживаем как массив, так и одиночный объект
|
||
documents_array = body if isinstance(body, list) else [body]
|
||
|
||
logger.info(f"📎 Attaching {len(documents_array)} document(s)")
|
||
|
||
# Обрабатываем каждый документ
|
||
processed_documents = []
|
||
for idx, doc in enumerate(documents_array):
|
||
contact_id = doc.get('contact_id')
|
||
project_id = doc.get('project_id')
|
||
ticket_id = doc.get('ticket_id') # Опционально
|
||
|
||
# Поддерживаем оба формата: file_url и file
|
||
file_path = doc.get('file') or doc.get('file_url')
|
||
if not file_path:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Document #{idx}: отсутствует поле 'file' или 'file_url'"
|
||
)
|
||
|
||
# Строим полный S3 URL если это путь без хоста
|
||
if file_path.startswith('/'):
|
||
file_url = S3_HOST + file_path
|
||
elif not file_path.startswith('http'):
|
||
file_url = S3_HOST + '/' + file_path
|
||
else:
|
||
file_url = file_path
|
||
|
||
# Поддерживаем оба формата: file_name и filename
|
||
file_name = doc.get('filename') or doc.get('file_name')
|
||
if not file_name:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Document #{idx}: отсутствует поле 'filename' или 'file_name'"
|
||
)
|
||
|
||
file_type = doc.get('file_type', 'Документ')
|
||
|
||
# Валидация обязательных полей
|
||
if not all([contact_id, project_id]):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Document #{idx}: обязательные поля: contact_id, project_id"
|
||
)
|
||
|
||
logger.info(f" [{idx+1}/{len(documents_array)}] {file_name} (type: {file_type})")
|
||
logger.info(f" Contact: {contact_id}, Project: {project_id}, Ticket: {ticket_id or 'N/A'}")
|
||
logger.info(f" File URL: {file_url}")
|
||
|
||
processed_documents.append({
|
||
"file_url": file_url,
|
||
"file_name": file_name,
|
||
"upload_description": file_type,
|
||
"contactid": int(contact_id),
|
||
"pages": 1
|
||
})
|
||
|
||
# Берем общие параметры из первого документа
|
||
first_doc = documents_array[0]
|
||
|
||
# Формируем payload для upload_documents_to_crm.php
|
||
upload_payload = {
|
||
"documents": processed_documents,
|
||
"projectid": int(first_doc.get('project_id')),
|
||
"ticket_id": int(first_doc.get('ticket_id')) if first_doc.get('ticket_id') else None,
|
||
"user_id": 1
|
||
}
|
||
|
||
logger.info(f"📤 Sending to CRM: {upload_payload}")
|
||
|
||
# Отправляем запрос к CRM
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
response = await client.post(
|
||
CRM_UPLOAD_ENDPOINT,
|
||
json=upload_payload,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Document attached successfully. Response: {response_text[:300]}")
|
||
|
||
try:
|
||
result = response.json()
|
||
|
||
# Проверяем успешность
|
||
if result.get('success') and result.get('results'):
|
||
results_array = result['results']
|
||
|
||
# Обрабатываем результаты для каждого документа
|
||
processed_results = []
|
||
errors = []
|
||
|
||
for idx, res in enumerate(results_array):
|
||
if res.get('status') == 'success':
|
||
crm_result = res.get('crm_result', {})
|
||
|
||
processed_results.append({
|
||
"document_id": crm_result.get('document_id'),
|
||
"document_numeric_id": crm_result.get('document_numeric_id'),
|
||
"attached_to": "ticket" if res.get('ticket_id') else "project",
|
||
"attached_to_id": res.get('ticket_id') or res.get('projectid'),
|
||
"file_name": res.get('file_name'),
|
||
"file_type": res.get('description'),
|
||
"s3_bucket": crm_result.get('s3_bucket'),
|
||
"s3_key": crm_result.get('s3_key'),
|
||
"file_size": crm_result.get('file_size'),
|
||
"message": crm_result.get('message')
|
||
})
|
||
|
||
logger.info(f" ✅ [{idx+1}] {res.get('file_name')} → {crm_result.get('document_id')}")
|
||
else:
|
||
# Ошибка для конкретного документа
|
||
error_msg = res.get('crm_result', {}).get('message', 'Unknown error')
|
||
errors.append({
|
||
"file_name": res.get('file_name'),
|
||
"error": error_msg
|
||
})
|
||
logger.error(f" ❌ [{idx+1}] {res.get('file_name')}: {error_msg}")
|
||
|
||
# Если есть хотя бы один успешный результат - считаем успехом
|
||
if processed_results:
|
||
return {
|
||
"success": True,
|
||
"total_processed": len(results_array),
|
||
"successful": len(processed_results),
|
||
"failed": len(errors),
|
||
"results": processed_results,
|
||
"errors": errors if errors else None
|
||
}
|
||
else:
|
||
# Все документы упали с ошибкой
|
||
logger.error(f"❌ All documents failed: {errors}")
|
||
raise HTTPException(status_code=500, detail=f"Все документы не удалось привязать: {errors}")
|
||
else:
|
||
logger.error(f"❌ Unexpected CRM response: {result}")
|
||
raise HTTPException(status_code=500, detail="Неожиданный ответ от CRM")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse CRM response: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа CRM: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ CRM returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"CRM error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ CRM upload timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут загрузки в CRM")
|
||
except HTTPException:
|
||
raise # Пробрасываем HTTPException как есть
|
||
except Exception as e:
|
||
logger.error(f"❌ Error attaching document: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка привязки документа: {str(e)}")
|
||
|