Files
aiform_prod/backend/app/api/profile.py
Fedor c39b12630e Профиль: валидация, календарь, ИНН 12 цифр, email, DaData адреса, банки из BANK_IP, подсказка ИНН (ФНС)
- Backend: N8N_AUTH_WEBHOOK из env (fallback), банки из BANK_IP, эндпоинт
  /api/v1/profile/dadata/address для подсказок адресов (FORMA_DADATA_*).
- Config: bank_ip, bank_api_url, forma_dadata_api_key, forma_dadata_secret.
- Frontend Profile: DatePicker для даты рождения, ИНН 12 цифр + ссылка на ФНС,
  валидация email, чекбокс «Совпадает с адресом регистрации», AutoComplete
  адресов через DaData, Select банков из /api/v1/banks/nspk (bankId/bankName).

Подробности в CHANGELOG_PROFILE_VALIDATION.md.
2026-02-27 18:32:06 +03:00

323 lines
14 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.

"""
Профиль пользователя: контактные данные из CRM через n8n webhook.
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env).
"""
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)")
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
@router.get("/dadata/address")
async def get_dadata_address_suggestions(
query: str = Query(..., min_length=1, description="Строка поиска адреса"),
count: int = Query(10, ge=1, le=20),
):
"""
Подсказки адресов через DaData (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env).
Возвращает список { value, unrestricted_value } для подстановки в форму профиля.
"""
api_key = (getattr(settings, "forma_dadata_api_key", None) or "").strip()
secret = (getattr(settings, "forma_dadata_secret", None) or "").strip()
if not api_key or not secret:
raise HTTPException(status_code=503, detail="DaData не настроен (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET)")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
DADATA_SUGGEST_URL,
json={"query": query.strip(), "count": count},
headers={
"Authorization": f"Token {api_key}",
"X-Secret": secret,
"Content-Type": "application/json",
},
)
if response.status_code != 200:
logger.warning("DaData address suggest вернул %s: %s", response.status_code, response.text[:300])
return {"suggestions": []}
data = response.json()
suggestions = data.get("suggestions") or []
return {"suggestions": [{"value": s.get("value", ""), "unrestricted_value": s.get("unrestricted_value", "")} for s in suggestions]}
except httpx.TimeoutException:
return {"suggestions": []}
except Exception as e:
logger.exception("Ошибка DaData suggest: %s", e)
return {"suggestions": []}
@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