""" Профиль пользователя: контактные данные из 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, Tuple 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"]) async def _resolve_profile_identity( 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, ) -> Tuple[str, Optional[str], Optional[str], Optional[str]]: """Возвращает (unified_id, contact_id, phone, chat_id). При ошибке — HTTPException(401/400).""" contact_id: Optional[str] = None phone: Optional[str] = None 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="Сессия недействительна или истекла") 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", ) return unified_id, contact_id, phone, chat_id 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)") class ProfileContactUpdateRequest(BaseModel): """Обновление контакта: session_token обязателен; остальные поля — редактируемые (все обязательны на фронте, кроме phone).""" session_token: str = Field(..., description="Токен сессии") entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web") last_name: str = Field("", description="Фамилия") first_name: str = Field("", description="Имя") middle_name: str = Field("", description="Отчество") birth_date: str = Field("", description="Дата рождения") birth_place: str = Field("", description="Место рождения") inn: str = Field("", description="ИНН") email: str = Field("", description="Email") registration_address: str = Field("", description="Адрес регистрации") mailing_address: str = Field("", description="Почтовый адрес") bank_for_compensation: str = Field("", description="Банк для возмещения") phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в 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 не настроен", ) unified_id, contact_id, phone, chat_id = await _resolve_profile_identity( session_token=session_token, unified_id=unified_id, channel=channel, channel_user_id=channel_user_id, entry_channel=entry_channel, chat_id=chat_id, ) 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 "" 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": []} @router.post("/contact/update") async def post_profile_contact_update(body: ProfileContactUpdateRequest): """ Обновить контакт в CRM через N8N_PROFILE_UPDATE_WEBHOOK. Вызывается с фронта при verification="0". Сессия проверяется по session_token. """ webhook_url = (getattr(settings, "n8n_profile_update_webhook", None) or "").strip() if not webhook_url: raise HTTPException( status_code=503, detail="N8N_PROFILE_UPDATE_WEBHOOK не настроен", ) unified_id, contact_id, phone, chat_id = await _resolve_profile_identity( session_token=body.session_token, entry_channel=body.entry_channel, chat_id=None, ) payload: dict = { "unified_id": unified_id, "entry_channel": (body.entry_channel or "web").strip() or "web", "session_token": body.session_token, "last_name": (body.last_name or "").strip(), "first_name": (body.first_name or "").strip(), "middle_name": (body.middle_name or "").strip(), "birth_date": (body.birth_date or "").strip(), "birth_place": (body.birth_place or "").strip(), "inn": (body.inn or "").strip(), "email": (body.email or "").strip(), "registration_address": (body.registration_address or "").strip(), "mailing_address": (body.mailing_address or "").strip(), "bank_for_compensation": (body.bank_for_compensation or "").strip(), } if contact_id is not None: payload["contact_id"] = contact_id if body.phone is not None and str(body.phone).strip(): payload["phone"] = str(body.phone).strip() elif 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_PROFILE_UPDATE_WEBHOOK: %s", e) raise HTTPException(status_code=502, detail="Не удалось сохранить профиль, попробуйте позже") if response.status_code < 200 or response.status_code >= 300: logger.warning("N8N profile update webhook вернул %s: %s", response.status_code, response.text[:500]) raise HTTPException( status_code=502, detail="Не удалось сохранить профиль, попробуйте позже", ) result: dict = {"success": True} try: data = response.json() if isinstance(data, dict) and data: result.update(data) except Exception: pass return result