Files
aiform_prod/backend/app/api/n8n_proxy.py
2026-02-20 09:31:13 +03:00

577 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
N8N_MAX_AUTH_WEBHOOK = getattr(settings, "n8n_max_auth_webhook", None) or None
@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("/tg/auth")
async def proxy_telegram_auth(request: Request):
"""
Проксирует авторизацию Telegram WebApp (Mini App) в n8n webhook.
Используется backend-эндпоинтом /api/v1/tg/auth:
- backend валидирует initData
- затем вызывает этот роут для маппинга telegram_user_id → unified_id в n8n
"""
if not N8N_TG_AUTH_WEBHOOK:
logger.error("[TG] N8N_TG_AUTH_WEBHOOK не задан в .env — webhook не вызывается")
raise HTTPException(status_code=500, detail="N8N Telegram auth webhook не настроен")
try:
body = await request.json()
logger.info(
"[TG] Proxy → n8n webhook %s: telegram_user_id=%s, session_token=%s",
N8N_TG_AUTH_WEBHOOK[:50] + "...",
body.get("telegram_user_id", "unknown"),
body.get("session_token", "unknown"),
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
N8N_TG_AUTH_WEBHOOK,
json=body,
headers={"Content-Type": "application/json"},
)
response_text = response.text or ""
logger.info("[TG] n8n webhook ответ: status=%s, body длина=%s", response.status_code, len(response_text))
if response.status_code == 200:
logger.info(
"[TG] n8n webhook success. Response: %s",
response_text[:500],
)
try:
return response.json()
except Exception as e:
logger.error(
"❌ Failed to parse Telegram auth JSON: %s. Response: %s",
e,
response_text[:500],
)
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
logger.error(
"[TG] n8n webhook вернул ошибку %s: %s",
response.status_code,
response_text[:500],
)
raise HTTPException(
status_code=response.status_code,
detail=f"N8N Telegram auth error: {response_text}",
)
except httpx.TimeoutException:
logger.error("[TG] Таймаут при вызове n8n Telegram auth webhook")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (Telegram auth)")
except Exception as e:
logger.exception("[TG] Ошибка при вызове n8n Telegram auth: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
@router.post("/max/auth")
async def proxy_max_auth(request: Request):
"""
Проксирует авторизацию MAX WebApp в n8n webhook.
Используется /api/v1/max/auth: backend валидирует initData, затем вызывает этот роут.
"""
if not N8N_MAX_AUTH_WEBHOOK:
logger.error("[MAX] N8N_MAX_AUTH_WEBHOOK не задан в .env")
raise HTTPException(status_code=500, detail="N8N MAX auth webhook не настроен")
try:
body = await request.json()
logger.info(
"[MAX] Proxy → n8n: max_user_id=%s, session_token=%s",
body.get("max_user_id", "unknown"),
body.get("session_token", "unknown"),
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
N8N_MAX_AUTH_WEBHOOK,
json=body,
headers={"Content-Type": "application/json"},
)
response_text = response.text or ""
logger.info("[MAX] n8n webhook ответ: status=%s, len=%s", response.status_code, len(response_text))
if response.status_code == 200:
try:
return response.json()
except Exception as e:
logger.error("[MAX] Парсинг JSON: %s. Response: %s", e, response_text[:500])
raise HTTPException(status_code=500, detail="Ошибка парсинга ответа n8n")
logger.error("[MAX] n8n вернул %s: %s", response.status_code, response_text[:500])
raise HTTPException(
status_code=response.status_code,
detail=f"N8N MAX auth error: {response_text}",
)
except httpx.TimeoutException:
logger.error("[MAX] Таймаут n8n MAX auth webhook")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (MAX auth)")
except Exception as e:
logger.exception("[MAX] Ошибка вызова n8n MAX auth: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка авторизации MAX: {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)}")