Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h
This commit is contained in:
@@ -85,14 +85,23 @@ async def login(request: Auth2LoginRequest):
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
|
||||
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[AUTH2] TG: n8n need_contact — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
|
||||
logger.info("[AUTH2] TG: n8n не вернул unified_id — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
@@ -100,6 +109,7 @@ async def login(request: Auth2LoginRequest):
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(tg_user["telegram_user_id"]) if tg_user.get("telegram_user_id") is not None else None,
|
||||
))
|
||||
|
||||
first_name = tg_user.get("first_name") or ""
|
||||
@@ -143,18 +153,23 @@ async def login(request: Auth2LoginRequest):
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
|
||||
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
|
||||
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
|
||||
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[AUTH2] MAX: n8n need_contact — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
|
||||
logger.info("[AUTH2] MAX: n8n не вернул unified_id — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
@@ -162,6 +177,7 @@ async def login(request: Auth2LoginRequest):
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(max_user["max_user_id"]) if max_user.get("max_user_id") is not None else None,
|
||||
))
|
||||
|
||||
first_name = max_user.get("first_name") or ""
|
||||
|
||||
204
backend/app/api/auth_universal.py
Normal file
204
backend/app/api/auth_universal.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Универсальный auth: один endpoint для TG и MAX.
|
||||
Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK,
|
||||
пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import settings
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth-universal"])
|
||||
|
||||
|
||||
class AuthUniversalRequest(BaseModel):
|
||||
channel: str # tg | max
|
||||
init_data: str
|
||||
|
||||
|
||||
class AuthUniversalResponse(BaseModel):
|
||||
success: bool
|
||||
need_contact: Optional[bool] = None
|
||||
message: Optional[str] = None
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
|
||||
|
||||
@router.post("", response_model=AuthUniversalResponse)
|
||||
async def auth_universal(request: AuthUniversalRequest):
|
||||
"""
|
||||
Универсальная авторизация: channel (tg|max) + init_data.
|
||||
Валидируем init_data, получаем channel_user_id, вызываем N8N_AUTH_WEBHOOK,
|
||||
при успехе пишем сессию в Redis по session:{channel}:{channel_user_id}.
|
||||
"""
|
||||
logger.info("[AUTH] POST /api/v1/auth вызван: channel=%s", request.channel)
|
||||
channel = (request.channel or "").strip().lower()
|
||||
if channel not in ("tg", "telegram", "max"):
|
||||
channel = "telegram" if channel.startswith("tg") else "max"
|
||||
# В n8n и Redis всегда передаём telegram, не tg
|
||||
if channel == "tg":
|
||||
channel = "telegram"
|
||||
|
||||
init_data = (request.init_data or "").strip()
|
||||
if not init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
# 1) Извлечь channel_user_id из init_data
|
||||
channel_user_id: Optional[str] = None
|
||||
if channel == "telegram":
|
||||
try:
|
||||
user = extract_telegram_user(init_data)
|
||||
channel_user_id = user.get("telegram_user_id")
|
||||
except TelegramAuthError as e:
|
||||
logger.warning("[TG] Ошибка валидации init_data: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
else:
|
||||
try:
|
||||
user = extract_max_user(init_data)
|
||||
channel_user_id = user.get("max_user_id")
|
||||
except MaxAuthError as e:
|
||||
logger.warning("[MAX] Ошибка валидации init_data: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not channel_user_id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось получить channel_user_id из init_data")
|
||||
|
||||
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or "").strip()
|
||||
if not webhook_url:
|
||||
logger.error("N8N_AUTH_WEBHOOK не задан в .env")
|
||||
raise HTTPException(status_code=503, detail="Сервис авторизации не настроен")
|
||||
|
||||
# 2) Вызвать n8n
|
||||
payload = {
|
||||
"channel": channel,
|
||||
"channel_user_id": channel_user_id,
|
||||
"init_data": init_data,
|
||||
}
|
||||
logger.info("[AUTH] Вызов N8N_AUTH_WEBHOOK: channel=%s, channel_user_id=%s", channel, channel_user_id)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[AUTH] Таймаут N8N_AUTH_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Таймаут сервиса авторизации")
|
||||
except Exception as e:
|
||||
logger.exception("[AUTH] Ошибка вызова N8N_AUTH_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Ошибка сервиса авторизации")
|
||||
|
||||
# Лог: что пришло от n8n (сырой ответ)
|
||||
try:
|
||||
_body = response.text or ""
|
||||
logger.info("[AUTH] n8n ответ: status=%s, body_len=%s, body_preview=%s", response.status_code, len(_body), _body[:500] if _body else "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
raw = response.json()
|
||||
logger.info("[AUTH] raw type=%s, is_list=%s, len=%s", type(raw).__name__, isinstance(raw, list), len(raw) if isinstance(raw, (list, dict)) else 0)
|
||||
if isinstance(raw, list) and len(raw) > 0:
|
||||
logger.info("[AUTH] raw[0] keys=%s", list(raw[0].keys()) if isinstance(raw[0], dict) else type(raw[0]).__name__)
|
||||
|
||||
# n8n может вернуть: массив [{ json: { ... } }] или массив объектов напрямую [{ success, unified_id, ... }]
|
||||
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
|
||||
first = raw[0]
|
||||
if "json" in first:
|
||||
data = first["json"]
|
||||
logger.info("[AUTH] парсинг: взяли first['json'], data keys=%s", list(data.keys()) if isinstance(data, dict) else "?")
|
||||
elif "success" in first or "unified_id" in first:
|
||||
data = first
|
||||
logger.info("[AUTH] парсинг: взяли first как data, keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = {}
|
||||
logger.warning("[AUTH] парсинг: first без json/success/unified_id, data={}")
|
||||
elif isinstance(raw, dict):
|
||||
data = raw
|
||||
logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = {}
|
||||
logger.warning("[AUTH] парсинг: неизвестный формат raw, data={}")
|
||||
except Exception as e:
|
||||
logger.warning("[AUTH] Ответ n8n не JSON: %s", (response.text or "")[:300])
|
||||
raise HTTPException(status_code=502, detail="Некорректный ответ сервиса авторизации")
|
||||
|
||||
logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id"))
|
||||
|
||||
# 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате)
|
||||
need_contact = (
|
||||
data.get("need_contact") is True
|
||||
or data.get("need_contact") == 1
|
||||
or (isinstance(data.get("need_contact"), str) and data.get("need_contact", "").strip().lower() in ("true", "1"))
|
||||
)
|
||||
if need_contact:
|
||||
logger.info("[AUTH] ответ: need_contact=true → закрыть приложение")
|
||||
return AuthUniversalResponse(
|
||||
success=False,
|
||||
need_contact=True,
|
||||
message=(data.get("message") or "Пользователь не найден. Поделитесь контактом в чате с ботом."),
|
||||
)
|
||||
if data.get("success") is False:
|
||||
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
|
||||
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку")
|
||||
return AuthUniversalResponse(
|
||||
success=False,
|
||||
need_contact=False,
|
||||
message=(data.get("message") or "Ошибка авторизации."),
|
||||
)
|
||||
|
||||
# 4) Успех: unified_id и т.д.
|
||||
unified_id = data.get("unified_id")
|
||||
if not unified_id and isinstance(data.get("result"), dict):
|
||||
unified_id = (data.get("result") or {}).get("unified_id")
|
||||
if not unified_id:
|
||||
logger.warning("[AUTH] n8n не вернул unified_id: %s", data)
|
||||
logger.info("[AUTH] ответ: нет unified_id → need_contact=true, закрыть приложение")
|
||||
return AuthUniversalResponse(success=False, need_contact=True, message="Контакт не найден.")
|
||||
|
||||
# 5) Записать сессию в Redis по session:{channel}:{channel_user_id} и session:{session_token}
|
||||
session_data = {
|
||||
"unified_id": unified_id,
|
||||
"phone": data.get("phone") or (data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None,
|
||||
"contact_id": data.get("contact_id") or (data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None,
|
||||
"has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False,
|
||||
"chat_id": channel_user_id,
|
||||
}
|
||||
try:
|
||||
await session_api.set_session_by_channel_user(channel, channel_user_id, session_data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[AUTH] Ошибка записи сессии в Redis: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Ошибка сохранения сессии")
|
||||
|
||||
session_token = str(uuid.uuid4())
|
||||
try:
|
||||
await session_api.set_session_by_token(session_token, session_data)
|
||||
except Exception as e:
|
||||
logger.warning("[AUTH] Двойная запись session_token в Redis: %s", e)
|
||||
|
||||
logger.info("[AUTH] ответ: success=true, session_token=%s..., unified_id=%s", session_token[:8] if session_token else "", unified_id)
|
||||
return AuthUniversalResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=session_data.get("phone"),
|
||||
contact_id=session_data.get("contact_id"),
|
||||
has_drafts=session_data.get("has_drafts", False),
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
from ..services.redis_service import redis_service
|
||||
from ..services.database import db
|
||||
from ..services.crm_mysql_service import crm_mysql_service
|
||||
@@ -23,7 +24,10 @@ from ..config import settings
|
||||
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
|
||||
def _get_ticket_form_webhook() -> str:
|
||||
"""URL webhook n8n для wizard и create. Менять в .env: N8N_TICKET_FORM_FINAL_WEBHOOK"""
|
||||
return (getattr(settings, "n8n_ticket_form_final_webhook", None) or "").strip() or "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
|
||||
|
||||
@router.post("/wizard")
|
||||
@@ -59,16 +63,32 @@ async def submit_wizard(request: Request):
|
||||
},
|
||||
)
|
||||
|
||||
webhook_url = _get_ticket_form_webhook()
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||
webhook_url,
|
||||
data=data,
|
||||
files=files or None,
|
||||
)
|
||||
|
||||
text = response.text or ""
|
||||
|
||||
logger.info(
|
||||
"n8n wizard response: status=%s, body_length=%s, body_preview=%s",
|
||||
response.status_code,
|
||||
len(text),
|
||||
text[:1500] if len(text) > 1500 else text,
|
||||
extra={"claim_id": data.get("claim_id"), "session_id": data.get("session_id")},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
logger.info(
|
||||
"n8n wizard response (parsed): keys=%s",
|
||||
list(parsed.keys()) if isinstance(parsed, dict) else type(parsed).__name__,
|
||||
extra={"session_id": data.get("session_id")},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"✅ TicketForm wizard webhook OK",
|
||||
extra={"response_preview": text[:500]},
|
||||
@@ -121,9 +141,10 @@ async def create_claim(request: Request):
|
||||
)
|
||||
|
||||
# Проксируем запрос к n8n
|
||||
webhook_url = _get_ticket_form_webhook()
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||
webhook_url,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
@@ -962,12 +983,44 @@ async def load_wizard_data(claim_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
||||
|
||||
|
||||
# Актуальный webhook для описания проблемы (n8n.clientright.ru). Старый aiform_description на .pro больше не используем.
|
||||
DESCRIPTION_WEBHOOK_DEFAULT = "https://n8n.clientright.ru/webhook/ticket_form_description"
|
||||
|
||||
DEBUG_LOG_PATH = "/app/logs/debug-2a4d38.log"
|
||||
|
||||
|
||||
def _debug_log(hy: str, msg: str, data: dict):
|
||||
try:
|
||||
import time
|
||||
line = json.dumps({
|
||||
"sessionId": "2a4d38",
|
||||
"hypothesisId": hy,
|
||||
"location": "claims.py:publish_ticket_form_description",
|
||||
"message": msg,
|
||||
"data": data,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}, ensure_ascii=False) + "\n"
|
||||
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_description_webhook_url() -> str:
|
||||
"""URL webhook для описания проблемы: только env N8N_DESCRIPTION_WEBHOOK или константа (старый .pro не используем)."""
|
||||
url = (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "").strip()
|
||||
if url:
|
||||
return url
|
||||
return DESCRIPTION_WEBHOOK_DEFAULT
|
||||
|
||||
|
||||
async def _send_buffered_messages_to_webhook():
|
||||
"""
|
||||
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
|
||||
"""
|
||||
try:
|
||||
if not settings.n8n_description_webhook:
|
||||
description_webhook_url = _get_description_webhook_url()
|
||||
if not description_webhook_url:
|
||||
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
|
||||
return
|
||||
|
||||
@@ -998,7 +1051,7 @@ async def _send_buffered_messages_to_webhook():
|
||||
]
|
||||
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
description_webhook_url,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
@@ -1059,27 +1112,53 @@ async def publish_ticket_form_description(
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub)
|
||||
Отправляет описание проблемы в n8n через webhook. URL: N8N_DESCRIPTION_WEBHOOK из env или константа (n8n.clientright.ru).
|
||||
"""
|
||||
# #region agent log
|
||||
_debug_log("H1_H4", "POST /description handler entered", {"session_id": getattr(payload, "session_id", None)})
|
||||
# #endregion
|
||||
try:
|
||||
if not settings.n8n_description_webhook:
|
||||
description_webhook_url = _get_description_webhook_url()
|
||||
# #region agent log
|
||||
_debug_log("H3_H5", "description webhook URL resolved", {"url": description_webhook_url[:80] if description_webhook_url else "", "env_N8N": (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "")[:80]})
|
||||
# #endregion
|
||||
if not description_webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="N8N description webhook не настроен"
|
||||
)
|
||||
|
||||
|
||||
# Если unified_id не передан — подставляем из сессии в Redis (tg/max auth создают сессию с unified_id)
|
||||
unified_id = payload.unified_id
|
||||
contact_id = payload.contact_id
|
||||
phone = payload.phone
|
||||
if not unified_id and payload.session_id:
|
||||
try:
|
||||
session_key = f"session:{payload.session_id}"
|
||||
session_raw = await redis_service.client.get(session_key)
|
||||
if session_raw:
|
||||
session_data = json.loads(session_raw)
|
||||
unified_id = unified_id or session_data.get("unified_id")
|
||||
contact_id = contact_id or session_data.get("contact_id")
|
||||
phone = phone or session_data.get("phone")
|
||||
if unified_id:
|
||||
logger.info("📝 unified_id/contact_id/phone подставлены из сессии Redis: session_key=%s", session_key)
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось прочитать сессию из Redis для подстановки unified_id: %s", e)
|
||||
|
||||
# Формируем данные в формате, который ожидает n8n workflow
|
||||
channel = payload.channel or f"{settings.redis_prefix}description"
|
||||
message = {
|
||||
"type": "ticket_form_description",
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id, # Опционально - может быть None
|
||||
"phone": payload.phone,
|
||||
"phone": phone,
|
||||
"email": payload.email,
|
||||
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
|
||||
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
|
||||
"unified_id": unified_id, # из запроса или из сессии Redis
|
||||
"contact_id": contact_id,
|
||||
"description": payload.problem_description.strip(),
|
||||
"source": payload.source,
|
||||
"entry_channel": (payload.entry_channel or "web").strip() or "web", # telegram | max | web — для роутинга в n8n
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -1092,13 +1171,11 @@ async def publish_ticket_form_description(
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"📝 TicketForm description received",
|
||||
"📝 TicketForm description received → webhook=%s",
|
||||
description_webhook_url[:80] + ("..." if len(description_webhook_url) > 80 else ""),
|
||||
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,
|
||||
},
|
||||
@@ -1114,23 +1191,44 @@ async def publish_ticket_form_description(
|
||||
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
|
||||
# #region agent log
|
||||
_debug_log("H2_H4", "about to POST to n8n webhook", {"attempt": attempt, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
|
||||
# #endregion
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
description_webhook_url,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
# #region agent log
|
||||
_debug_log("H4", "n8n webhook response", {"status": response.status_code, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
|
||||
# #endregion
|
||||
if response.status_code == 200:
|
||||
response_body = response.text or ""
|
||||
logger.info(
|
||||
f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})",
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
"✅ Описание успешно отправлено в n8n webhook (попытка %s), ответ n8n (length=%s): %s",
|
||||
attempt,
|
||||
len(response_body),
|
||||
response_body[:2000] if len(response_body) > 2000 else response_body,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
try:
|
||||
parsed_n8n = json.loads(response_body)
|
||||
logger.info(
|
||||
"n8n description response (parsed): keys=%s",
|
||||
list(parsed_n8n.keys()) if isinstance(parsed_n8n, dict) else type(parsed_n8n).__name__,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# После описания фронт подписывается на SSE — логируем, на что именно
|
||||
logger.info(
|
||||
"📡 После описания в n8n клиент подпишется на: "
|
||||
"channel_ocr=ocr_events:%s (GET /api/v1/events/%s), "
|
||||
"channel_plan=claim:plan:%s (GET /api/v1/claim-plan/%s)",
|
||||
payload.session_id, payload.session_id, payload.session_id, payload.session_id,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
|
||||
# Успешно отправили - возвращаем успех
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -9,12 +9,108 @@ from pydantic import BaseModel
|
||||
from typing import Dict, Any
|
||||
from app.services.redis_service import redis_service
|
||||
from app.services.database import db
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
||||
|
||||
# Типы для единого отображения на фронте: тип + текст (+ data для consumer_complaint)
|
||||
DISPLAY_EVENT_TYPES = ("trash_message", "out_of_scope", "consumer_consultation", "consumer_complaint")
|
||||
|
||||
|
||||
def _normalize_display_event(actual_event: dict) -> dict:
|
||||
"""
|
||||
Приводит событие к формату { event_type, message [, data] } для единого отображения.
|
||||
event_type — один из: trash_message (красный), out_of_scope (жёлтый),
|
||||
consumer_consultation (синий), consumer_complaint (зелёный).
|
||||
"""
|
||||
raw_type = actual_event.get("event_type") or actual_event.get("type")
|
||||
payload = actual_event.get("payload") or actual_event.get("data") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload) if payload else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
msg = (actual_event.get("message") or payload.get("message") or "").strip() or "Ответ получен"
|
||||
|
||||
# Если n8n уже прислал один из четырёх типов — не перезаписываем, отдаём как есть (синий/зелёный не превращаем в жёлтый)
|
||||
if raw_type in DISPLAY_EVENT_TYPES:
|
||||
return {
|
||||
"event_type": raw_type,
|
||||
"message": msg or "Ответ получен",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": (actual_event.get("suggested_actions") or payload.get("suggested_actions")) if raw_type == "out_of_scope" else None,
|
||||
}
|
||||
|
||||
if raw_type == "trash_message" or payload.get("intent") == "trash":
|
||||
return {
|
||||
"event_type": "trash_message",
|
||||
"message": msg or "К сожалению, это обращение не по тематике.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "out_of_scope":
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg or "К сожалению, мы не можем помочь с этим вопросом.",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions") or payload.get("suggested_actions"),
|
||||
}
|
||||
if raw_type == "consumer_intent":
|
||||
intent = payload.get("intent") or actual_event.get("intent")
|
||||
if intent == "consultation":
|
||||
return {
|
||||
"event_type": "consumer_consultation",
|
||||
"message": msg or "Понял. Это похоже на консультацию.",
|
||||
"data": {},
|
||||
}
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Обращение принято.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "documents_list_ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Подготовлен список документов.",
|
||||
"data": {
|
||||
**actual_event.get("data", {}),
|
||||
"documents_required": actual_event.get("documents_required"),
|
||||
"claim_id": actual_event.get("claim_id"),
|
||||
},
|
||||
}
|
||||
if raw_type in ("wizard_ready", "wizard_plan_ready", "claim_plan_ready"):
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "План готов.",
|
||||
"data": actual_event.get("data", actual_event),
|
||||
}
|
||||
if raw_type == "ocr_status" and actual_event.get("status") == "ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Данные подтверждены.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
# Если есть текст сообщения, но тип неизвестен — считаем out_of_scope, чтобы фронт точно показал ответ
|
||||
if msg and msg.strip() and raw_type not in (
|
||||
"documents_list_ready", "document_uploaded", "document_ocr_completed",
|
||||
"ocr_status", "claim_ready", "claim_plan_ready", "claim_plan_error",
|
||||
):
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg.strip(),
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions"),
|
||||
}
|
||||
# Остальные события — прозрачно, только дополняем message
|
||||
out = dict(actual_event)
|
||||
if "message" not in out or not out.get("message"):
|
||||
out["message"] = msg
|
||||
return out
|
||||
|
||||
|
||||
class EventPublish(BaseModel):
|
||||
"""Модель для публикации события"""
|
||||
@@ -84,7 +180,10 @@ async def stream_events(task_id: str):
|
||||
Returns:
|
||||
StreamingResponse с событиями
|
||||
"""
|
||||
logger.info(f"🚀 SSE connection requested for session_token: {task_id}")
|
||||
logger.info(
|
||||
"🚀 SSE connection requested for session_token: %s → channel=ocr_events:%s (Redis %s:%s)",
|
||||
task_id, task_id, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def event_generator():
|
||||
"""Генератор событий из Redis Pub/Sub"""
|
||||
@@ -95,7 +194,10 @@ async def stream_events(task_id: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (проверка: redis-cli -h %s PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, settings.redis_host, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n"
|
||||
@@ -298,10 +400,14 @@ async def stream_events(task_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
|
||||
|
||||
# Единый формат для фронта: событие с полями event_type и message (и data при необходимости)
|
||||
raw_event_type = actual_event.get("event_type")
|
||||
raw_status = actual_event.get("status")
|
||||
actual_event = _normalize_display_event(actual_event)
|
||||
# Отправляем событие клиенту (плоский формат)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
|
||||
event_type_sent = actual_event.get('event_type', 'unknown')
|
||||
event_status = actual_event.get('status', 'unknown')
|
||||
event_type_sent = actual_event.get("event_type", "unknown")
|
||||
event_status = actual_event.get("status") or (actual_event.get("data") or {}).get("status") or "unknown"
|
||||
# Логируем размер и наличие данных
|
||||
data_info = actual_event.get('data', {})
|
||||
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
|
||||
@@ -310,18 +416,21 @@ async def stream_events(task_id: str):
|
||||
|
||||
# Если обработка завершена - закрываем соединение
|
||||
# НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события)
|
||||
if event_status in ['completed', 'error'] and event_type_sent not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
if event_status in ['completed', 'error'] and (raw_event_type or event_type_sent) not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
logger.info(f"✅ Task {task_id} finished, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для финальных событий
|
||||
# Закрываем для финальных событий (raw_event_type до нормализации)
|
||||
if raw_event_type in ['claim_ready', 'claim_plan_ready', 'wizard_ready', 'wizard_plan_ready']:
|
||||
logger.info(f"✅ Final event {raw_event_type} sent, closing SSE")
|
||||
break
|
||||
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для ocr_status ready (форма заявления готова)
|
||||
if event_type_sent == 'ocr_status' and event_status == 'ready':
|
||||
logger.info(f"✅ OCR ready event sent, closing SSE")
|
||||
if raw_event_type == "ocr_status" and raw_status == "ready":
|
||||
logger.info("✅ OCR ready event sent, closing SSE")
|
||||
break
|
||||
else:
|
||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
||||
@@ -369,7 +478,10 @@ async def stream_claim_plan(session_token: str):
|
||||
}
|
||||
}
|
||||
"""
|
||||
logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}")
|
||||
logger.info(
|
||||
"🚀 Claim plan SSE: session_token=%s → channel=claim:plan:%s (Redis %s:%s)",
|
||||
session_token, session_token, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def claim_plan_generator():
|
||||
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
|
||||
@@ -379,7 +491,10 @@ async def stream_claim_plan(session_token: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n"
|
||||
|
||||
@@ -106,19 +106,28 @@ async def max_auth(request: MaxAuthRequest):
|
||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
|
||||
logger.info("[MAX] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
_raw = (
|
||||
n8n_data.get("need_contact")
|
||||
or _result_dict.get("need_contact")
|
||||
or n8n_data.get("needContact")
|
||||
or _result_dict.get("needContact")
|
||||
)
|
||||
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[MAX] n8n: need_contact — юзер не в базе, закрываем приложение")
|
||||
logger.info("[MAX] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
|
||||
return MaxAuthResponse(success=False, need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone_res = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone_res = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.error("[MAX] n8n не вернул unified_id. Ответ: %s", n8n_data)
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для пользователя MAX")
|
||||
logger.info("[MAX] n8n не вернул unified_id (юзер не в базе) — возвращаем need_contact=true. Ответ: %s", n8n_data)
|
||||
return MaxAuthResponse(success=False, need_contact=True)
|
||||
|
||||
session_request = session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
@@ -126,6 +135,7 @@ async def max_auth(request: MaxAuthRequest):
|
||||
phone=phone_res or phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(max_user_id) if max_user_id else None,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -75,4 +75,5 @@ class TicketFormDescriptionRequest(BaseModel):
|
||||
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
||||
source: str = Field("ticket_form", description="Источник события")
|
||||
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
||||
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web — для роутинга в n8n")
|
||||
|
||||
|
||||
208
backend/app/api/profile.py
Normal file
208
backend/app/api/profile.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Профиль пользователя: контактные данные из CRM через n8n webhook.
|
||||
|
||||
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
|
||||
unified_id берётся из сессии по session_token или передаётся явно.
|
||||
|
||||
----- Что уходит на N8N_CONTACT_WEBHOOK (POST body) -----
|
||||
- unified_id (str): идентификатор пользователя в CRM
|
||||
- entry_channel (str): "telegram" | "max" | "web"
|
||||
- chat_id (str, опционально): Telegram user id или Max user id
|
||||
- session_token, contact_id, phone (опционально)
|
||||
|
||||
----- Как n8n должен возвращать ответ -----
|
||||
|
||||
1) Ничего не нашло (контакт не найден в CRM или нет данных):
|
||||
- HTTP 200
|
||||
- Тело: пустой массив [] ИЛИ объект {"items": []}
|
||||
Пример: [] или {"items": []}
|
||||
|
||||
2) Нашло контакт(ы):
|
||||
- HTTP 200
|
||||
- Тело: массив контактов ИЛИ объект с полем items/contact/data:
|
||||
• [] → нормализуется в {"items": []}
|
||||
• {"items": [...]} → как есть
|
||||
• {"contact": {...}} → один контакт в items
|
||||
• {"contact": [...]} → массив в items
|
||||
• {"data": [...]} → массив в items
|
||||
• один объект {...} → один элемент в items
|
||||
|
||||
Поля контакта (snake_case или camelCase, фронт смотрит оба):
|
||||
last_name/lastName, first_name/firstName, middle_name/middleName,
|
||||
birth_date/birthDate, birth_place/birthPlace, inn, email,
|
||||
registration_address/address/mailingstreet, mailing_address/postal_address,
|
||||
bank_for_compensation/bank, phone/mobile/mobile_phone.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
|
||||
|
||||
|
||||
class ProfileContactRequest(BaseModel):
|
||||
"""Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id."""
|
||||
session_token: Optional[str] = Field(None, description="Токен сессии (unified_id подставится из Redis)")
|
||||
unified_id: Optional[str] = Field(None, description="Unified ID пользователя в CRM")
|
||||
channel: Optional[str] = Field(None, description="Канал: tg | max (для поиска сессии в Redis)")
|
||||
channel_user_id: Optional[str] = Field(None, description="ID пользователя в канале (tg/max)")
|
||||
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web")
|
||||
chat_id: Optional[str] = Field(None, description="Telegram user id или Max user id (для передачи в n8n)")
|
||||
|
||||
|
||||
@router.get("/contact")
|
||||
async def get_profile_contact(
|
||||
session_token: Optional[str] = Query(None, description="Токен сессии"),
|
||||
unified_id: Optional[str] = Query(None, description="Unified ID"),
|
||||
channel: Optional[str] = Query(None, description="Канал: tg | max"),
|
||||
channel_user_id: Optional[str] = Query(None, description="ID пользователя в канале"),
|
||||
entry_channel: Optional[str] = Query(None, description="Канал: telegram | max | web"),
|
||||
chat_id: Optional[str] = Query(None, description="Telegram/Max user id"),
|
||||
):
|
||||
"""
|
||||
Получить контактные данные из CRM через n8n webhook.
|
||||
Передайте session_token, (channel + channel_user_id) или unified_id.
|
||||
"""
|
||||
return await _fetch_contact(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
entry_channel=entry_channel,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/contact")
|
||||
async def post_profile_contact(body: ProfileContactRequest):
|
||||
"""То же по телу запроса."""
|
||||
return await _fetch_contact(
|
||||
session_token=body.session_token,
|
||||
unified_id=body.unified_id,
|
||||
channel=body.channel,
|
||||
channel_user_id=body.channel_user_id,
|
||||
entry_channel=body.entry_channel,
|
||||
chat_id=body.chat_id,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_contact(
|
||||
session_token: Optional[str] = None,
|
||||
unified_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
channel_user_id: Optional[str] = None,
|
||||
entry_channel: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
webhook_url = getattr(settings, "n8n_contact_webhook", None) or ""
|
||||
if not webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_CONTACT_WEBHOOK не настроен",
|
||||
)
|
||||
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
# Сессия по channel + channel_user_id (универсальный auth пишет в Redis по этому ключу)
|
||||
if not unified_id and channel and channel_user_id:
|
||||
try:
|
||||
from app.api.session import get_session_by_channel_user
|
||||
session_data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
|
||||
if session_data:
|
||||
unified_id = session_data.get("unified_id")
|
||||
contact_id = session_data.get("contact_id")
|
||||
phone = session_data.get("phone")
|
||||
if chat_id is None:
|
||||
chat_id = session_data.get("chat_id")
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка чтения сессии по channel: %s", e)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
|
||||
# Сессия по session_token
|
||||
if not unified_id and session_token:
|
||||
try:
|
||||
from app.api.session import SessionVerifyRequest, verify_session
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if getattr(verify_res, "valid", False):
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
if chat_id is None:
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка верификации сессии для профиля: %s", e)
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Укажите session_token, (channel + channel_user_id) или unified_id",
|
||||
)
|
||||
|
||||
# В хук уходит всё, что нужно для обработки в n8n: канал, unified_id, chat_id, contact_id, phone, session_token
|
||||
payload: dict = {
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (entry_channel or "web").strip() or "web",
|
||||
}
|
||||
if session_token:
|
||||
payload["session_token"] = session_token
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_CONTACT_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис контактов временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("N8N contact webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Сервис контактов вернул ошибку",
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
data = response.text or ""
|
||||
|
||||
# Нормализация ответа n8n в единый формат { "items": [...] }
|
||||
if isinstance(data, list):
|
||||
return {"items": data if data else []}
|
||||
if isinstance(data, dict):
|
||||
if "items" in data and isinstance(data["items"], list):
|
||||
return {"items": data["items"]}
|
||||
if "contact" in data:
|
||||
c = data["contact"]
|
||||
return {"items": c if isinstance(c, list) else [c] if c else []}
|
||||
if "data" in data and isinstance(data["data"], list):
|
||||
return {"items": data["data"]}
|
||||
# Один объект-контакт без обёртки (если есть хоть одно поле контакта — считаем контактом)
|
||||
if data and isinstance(data, dict):
|
||||
return {"items": [data]}
|
||||
return {"items": []}
|
||||
return {"items": []}
|
||||
@@ -2,7 +2,8 @@
|
||||
Session management API endpoints
|
||||
|
||||
Обеспечивает управление сессиями пользователей через Redis:
|
||||
- Верификация существующей сессии
|
||||
- Верификация по session_token или по (channel, channel_user_id)
|
||||
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
|
||||
- Logout (удаление сессии)
|
||||
"""
|
||||
|
||||
@@ -22,13 +23,83 @@ router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
||||
# Redis connection (используем существующее подключение)
|
||||
redis_client: Optional[redis.Redis] = None
|
||||
|
||||
# TTL для сессии по channel+channel_user_id (секунды). 0 = без TTL.
|
||||
SESSION_BY_CHANNEL_TTL_HOURS = 24
|
||||
|
||||
def init_redis(redis_conn: redis.Redis):
|
||||
"""Initialize Redis connection"""
|
||||
|
||||
def init_redis(redis_conn: Optional[redis.Redis]):
|
||||
"""Initialize Redis connection (локальный Redis для сессий). None при shutdown."""
|
||||
global redis_client
|
||||
redis_client = redis_conn
|
||||
|
||||
|
||||
def _session_key_by_channel(channel: str, channel_user_id: str) -> str:
|
||||
"""Ключ Redis для сессии по каналу и id пользователя в канале."""
|
||||
return f"session:{channel}:{channel_user_id}"
|
||||
|
||||
|
||||
async def set_session_by_channel_user(
|
||||
channel: str,
|
||||
channel_user_id: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Записать сессию в Redis по ключу session:{channel}:{channel_user_id}.
|
||||
data: unified_id, phone, contact_id, chat_id, has_drafts, ...
|
||||
"""
|
||||
if not redis_client:
|
||||
raise HTTPException(status_code=500, detail="Redis connection not initialized")
|
||||
key = _session_key_by_channel(channel, channel_user_id)
|
||||
payload = {
|
||||
"unified_id": data.get("unified_id") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"contact_id": data.get("contact_id") or "",
|
||||
"chat_id": str(channel_user_id),
|
||||
"has_drafts": data.get("has_drafts", False),
|
||||
"verified_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
|
||||
if ttl:
|
||||
await redis_client.setex(key, ttl, json.dumps(payload))
|
||||
else:
|
||||
await redis_client.set(key, json.dumps(payload))
|
||||
logger.info("Сессия записана: %s, unified_id=%s", key, payload.get("unified_id"))
|
||||
|
||||
|
||||
async def get_session_by_channel_user(channel: str, channel_user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Прочитать сессию из Redis по channel и channel_user_id. Если нет — None."""
|
||||
if not redis_client:
|
||||
return None
|
||||
key = _session_key_by_channel(channel, channel_user_id)
|
||||
raw = await redis_client.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
async def set_session_by_token(session_token: str, data: Dict[str, Any]) -> None:
|
||||
"""Записать сессию в Redis по ключу session:{session_token} (для совместимости с profile/claims)."""
|
||||
if not redis_client:
|
||||
return
|
||||
key = f"session:{session_token}"
|
||||
payload = {
|
||||
"unified_id": data.get("unified_id") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"contact_id": data.get("contact_id") or "",
|
||||
"chat_id": data.get("chat_id") or "",
|
||||
"has_drafts": data.get("has_drafts", False),
|
||||
"verified_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
|
||||
if ttl:
|
||||
await redis_client.setex(key, ttl, json.dumps(payload))
|
||||
else:
|
||||
await redis_client.set(key, json.dumps(payload))
|
||||
|
||||
|
||||
class SessionVerifyRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
@@ -39,10 +110,16 @@ class SessionVerifyResponse(BaseModel):
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
chat_id: Optional[str] = None # telegram_user_id или max_user_id
|
||||
verified_at: Optional[str] = None
|
||||
expires_in_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class SessionVerifyByChannelRequest(BaseModel):
|
||||
channel: str # tg | max
|
||||
channel_user_id: str
|
||||
|
||||
|
||||
class SessionLogoutRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
@@ -92,6 +169,7 @@ async def verify_session(request: SessionVerifyRequest):
|
||||
unified_id=session_data.get('unified_id'),
|
||||
phone=session_data.get('phone'),
|
||||
contact_id=session_data.get('contact_id'),
|
||||
chat_id=session_data.get('chat_id'),
|
||||
verified_at=session_data.get('verified_at'),
|
||||
expires_in_seconds=ttl if ttl > 0 else None
|
||||
)
|
||||
@@ -143,20 +221,47 @@ async def logout_session(request: SessionLogoutRequest):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при выходе: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/verify-by-channel", response_model=SessionVerifyResponse)
|
||||
async def verify_session_by_channel(request: SessionVerifyByChannelRequest):
|
||||
"""
|
||||
Проверить сессию по channel и channel_user_id (ключ Redis: session:{channel}:{channel_user_id}).
|
||||
Используется, когда клиент не хранит session_token и передаёт channel + channel_user_id.
|
||||
"""
|
||||
try:
|
||||
data = await get_session_by_channel_user(request.channel, request.channel_user_id)
|
||||
if not data:
|
||||
return SessionVerifyResponse(success=True, valid=False)
|
||||
ttl = await redis_client.ttl(_session_key_by_channel(request.channel, request.channel_user_id)) if redis_client else 0
|
||||
return SessionVerifyResponse(
|
||||
success=True,
|
||||
valid=True,
|
||||
unified_id=data.get("unified_id"),
|
||||
phone=data.get("phone"),
|
||||
contact_id=data.get("contact_id"),
|
||||
chat_id=data.get("chat_id"),
|
||||
verified_at=data.get("verified_at"),
|
||||
expires_in_seconds=ttl if ttl > 0 else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка verify-by-channel: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Ошибка проверки сессии")
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
session_token: str
|
||||
unified_id: str
|
||||
phone: str
|
||||
contact_id: str
|
||||
ttl_hours: int = 24
|
||||
chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_session(request: SessionCreateRequest):
|
||||
"""
|
||||
Создать новую сессию (вызывается после успешной SMS верификации)
|
||||
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
|
||||
|
||||
Обычно вызывается из Step1Phone после получения данных от n8n.
|
||||
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
|
||||
"""
|
||||
try:
|
||||
if not redis_client:
|
||||
@@ -171,6 +276,8 @@ async def create_session(request: SessionCreateRequest):
|
||||
'verified_at': datetime.utcnow().isoformat(),
|
||||
'expires_at': (datetime.utcnow() + timedelta(hours=request.ttl_hours)).isoformat()
|
||||
}
|
||||
if request.chat_id is not None:
|
||||
session_data['chat_id'] = str(request.chat_id).strip()
|
||||
|
||||
# Сохраняем в Redis с TTL
|
||||
await redis_client.setex(
|
||||
|
||||
@@ -31,11 +31,12 @@ class TelegramAuthRequest(BaseModel):
|
||||
|
||||
class TelegramAuthResponse(BaseModel):
|
||||
success: bool
|
||||
session_token: str
|
||||
unified_id: str
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
@@ -114,15 +115,35 @@ async def telegram_auth(request: TelegramAuthRequest):
|
||||
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
# Ожидаем от n8n как минимум unified_id
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
# Логируем сырой ответ n8n для отладки (ключи и need_contact/unified_id)
|
||||
logger.info("[TG] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
if _result_dict:
|
||||
logger.info("[TG] n8n result ключи: %s", list(_result_dict.keys()))
|
||||
|
||||
# Если n8n вернул need_contact — пользователя нет в базе, мини-апп должен закрыться
|
||||
_raw = (
|
||||
n8n_data.get("need_contact")
|
||||
or _result_dict.get("need_contact")
|
||||
or n8n_data.get("needContact")
|
||||
or _result_dict.get("needContact")
|
||||
)
|
||||
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[TG] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
|
||||
return TelegramAuthResponse(success=False, need_contact=True)
|
||||
|
||||
# Ожидаем от n8n как минимум unified_id
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
|
||||
|
||||
# Нет unified_id = пользователь не найден в базе → тоже возвращаем need_contact, чтобы фронт закрыл мини-апп
|
||||
if not unified_id:
|
||||
logger.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data)
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя")
|
||||
logger.info("[TG] n8n не вернул unified_id (пользователь не в базе) — возвращаем need_contact=true. Ответ n8n: %s", n8n_data)
|
||||
return TelegramAuthResponse(success=False, need_contact=True)
|
||||
|
||||
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
|
||||
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
|
||||
@@ -132,6 +153,7 @@ async def telegram_auth(request: TelegramAuthRequest):
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(telegram_user_id) if telegram_user_id else None,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -62,20 +62,33 @@ class Settings(BaseSettings):
|
||||
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
|
||||
# ============================================
|
||||
# REDIS
|
||||
# REDIS (внешний — события, буферы, SMS и т.д.)
|
||||
# ============================================
|
||||
redis_host: str = "localhost"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = "CRM_Redis_Pass_2025_Secure!"
|
||||
redis_db: int = 0
|
||||
redis_prefix: str = "ticket_form:"
|
||||
|
||||
# Redis для сессий (локальный в Docker — miniapp_redis; снаружи — localhost:6383 или свой)
|
||||
redis_session_host: str = "localhost"
|
||||
redis_session_port: int = 6383
|
||||
redis_session_password: str = ""
|
||||
redis_session_db: int = 0
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Формирует URL для подключения к Redis"""
|
||||
"""Формирует URL для подключения к Redis (внешний)"""
|
||||
if self.redis_password:
|
||||
return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def redis_session_url(self) -> str:
|
||||
"""URL для локального Redis сессий"""
|
||||
if self.redis_session_password:
|
||||
return f"redis://:{self.redis_session_password}@{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
|
||||
return f"redis://{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
|
||||
|
||||
# ============================================
|
||||
# RABBITMQ
|
||||
@@ -184,9 +197,14 @@ class Settings(BaseSettings):
|
||||
n8n_file_upload_webhook: str = ""
|
||||
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
|
||||
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
|
||||
n8n_description_webhook: str = "https://n8n.clientright.pro/webhook/aiform_description" # Webhook для обработки описания проблемы
|
||||
n8n_description_webhook: str = "https://n8n.clientright.ru/webhook/ticket_form_description" # Webhook для описания проблемы (переопределяется через N8N_DESCRIPTION_WEBHOOK в .env)
|
||||
# Wizard и финальная отправка заявки (create) — один webhook, меняется через .env
|
||||
n8n_ticket_form_final_webhook: str = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
|
||||
|
||||
# Контактные данные из CRM для раздела «Профиль» (массив или пусто)
|
||||
n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env
|
||||
|
||||
# ============================================
|
||||
# TELEGRAM BOT
|
||||
# ============================================
|
||||
@@ -197,6 +215,7 @@ class Settings(BaseSettings):
|
||||
# ============================================
|
||||
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp
|
||||
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
|
||||
n8n_auth_webhook: str = "" # Универсальный auth: channel + channel_user_id + init_data → unified_id, phone, contact_id, has_drafts
|
||||
|
||||
# ============================================
|
||||
# LOGGING
|
||||
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import redis.asyncio as redis
|
||||
from .config import settings, get_cors_origins_live, get_settings
|
||||
from .services.database import db
|
||||
from .services.redis_service import redis_service
|
||||
@@ -17,7 +18,7 @@ from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .services.crm_mysql_service import crm_mysql_service
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, documents_draft_open
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, auth_universal, documents_draft_open, profile
|
||||
from .api import debug_session
|
||||
|
||||
# Настройка логирования
|
||||
@@ -119,12 +120,23 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(f"⚠️ PostgreSQL not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем Redis
|
||||
# Подключаем внешний Redis (события, буферы, SMS и т.д.)
|
||||
await redis_service.connect()
|
||||
# Инициализируем session API с Redis connection
|
||||
session.init_redis(redis_service.client)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Redis not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем локальный Redis для сессий (отдельно от внешнего)
|
||||
session_redis = await redis.from_url(
|
||||
settings.redis_session_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
await session_redis.ping()
|
||||
session.init_redis(session_redis)
|
||||
logger.info(f"✅ Session Redis connected: {settings.redis_session_host}:{settings.redis_session_port}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Session Redis not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем RabbitMQ
|
||||
@@ -159,6 +171,9 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
await db.disconnect()
|
||||
await redis_service.disconnect()
|
||||
if session.redis_client:
|
||||
await session.redis_client.close()
|
||||
session.init_redis(None)
|
||||
await rabbitmq_service.disconnect()
|
||||
await policy_service.close()
|
||||
await crm_mysql_service.close()
|
||||
@@ -226,6 +241,8 @@ app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
|
||||
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
|
||||
app.include_router(max_auth.router) # 📱 MAX Mini App auth
|
||||
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
|
||||
app.include_router(auth_universal.router) # Универсальный auth: channel + init_data → N8N_AUTH_WEBHOOK, Redis session:{channel}:{channel_user_id}
|
||||
app.include_router(profile.router) # 👤 Профиль: контакты из CRM через N8N_CONTACT_WEBHOOK
|
||||
app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
|
||||
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user