2026-02-24 16:17:59 +03:00
|
|
|
|
"""
|
|
|
|
|
|
Профиль пользователя: контактные данные из 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
|
2026-02-27 08:34:27 +03:00
|
|
|
|
from typing import Optional, Tuple
|
2026-02-24 16:17:59 +03:00
|
|
|
|
|
|
|
|
|
|
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"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 08:34:27 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
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)")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 08:34:27 +03:00
|
|
|
|
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)")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
@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 не настроен",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-27 08:34:27 +03:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-02-24 16:17:59 +03:00
|
|
|
|
|
|
|
|
|
|
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": []}
|
2026-02-27 08:34:27 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|