Профиль: валидация, календарь, ИНН 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.
This commit is contained in:
Fedor
2026-02-27 18:31:41 +03:00
parent b5c31b43dd
commit c39b12630e
6 changed files with 379 additions and 56 deletions

View File

@@ -7,7 +7,7 @@
import logging
import os
import uuid
from typing import Optional, Any, Dict
from typing import Optional, Any, Dict, Union
import httpx
from fastapi import APIRouter, HTTPException
@@ -37,6 +37,27 @@ class AuthUniversalResponse(BaseModel):
phone: Optional[str] = None
contact_id: Optional[str] = None
has_drafts: Optional[bool] = None
need_profile_confirm: Optional[bool] = None
profile_needs_attention: Optional[bool] = None
def _to_bool(v: Any) -> Optional[bool]:
if v is None:
return None
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
if v == 1:
return True
if v == 0:
return False
if isinstance(v, str):
s = v.strip().lower()
if s in ("1", "true", "yes", "y", "да"):
return True
if s in ("0", "false", "no", "n", "нет", ""):
return False
return None
@router.post("", response_model=AuthUniversalResponse)
@@ -152,6 +173,20 @@ async def auth_universal(request: AuthUniversalRequest):
logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id"))
# Флаг «профиль требует внимания»: приходит из n8n, прокидываем в сессию и на фронт
need_profile_confirm = _to_bool(
data.get("need_profile_confirm")
if "need_profile_confirm" in data
else data.get("needProfileConfirm")
)
profile_needs_attention = _to_bool(
data.get("profile_needs_attention")
if "profile_needs_attention" in data
else data.get("profileNeedsAttention")
)
if profile_needs_attention is None:
profile_needs_attention = need_profile_confirm
# 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате)
need_contact = (
data.get("need_contact") is True
@@ -198,6 +233,8 @@ async def auth_universal(request: AuthUniversalRequest):
"contact_id": _contact_id,
"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,
"need_profile_confirm": need_profile_confirm,
"profile_needs_attention": profile_needs_attention,
}
logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone"))
try:
@@ -222,4 +259,6 @@ async def auth_universal(request: AuthUniversalRequest):
phone=session_data.get("phone"),
contact_id=session_data.get("contact_id"),
has_drafts=session_data.get("has_drafts", False),
need_profile_confirm=need_profile_confirm,
profile_needs_attention=profile_needs_attention,
)

View File

@@ -14,13 +14,10 @@ router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
@router.get("/nspk")
async def get_nspk_banks():
"""
Получить список банков СБП из внешнего API
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
Получить список банков из внешнего API (BANK_IP в .env или nspk_banks_api_url).
"""
external_api_url = (getattr(settings, "bank_ip", None) or getattr(settings, "bank_api_url", None) or "").strip() or "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
try:
# URL внешнего API
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(external_api_url)

View File

@@ -2,36 +2,7 @@
Профиль пользователя: контактные данные из 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.
GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env).
"""
import logging
@@ -128,6 +99,46 @@ class ProfileContactUpdateRequest(BaseModel):
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="Токен сессии"),

View File

@@ -2,6 +2,7 @@
Конфигурация приложения
"""
import os
import json
from pathlib import Path
from pydantic_settings import BaseSettings
from typing import List, Optional
@@ -138,9 +139,17 @@ class Settings(BaseSettings):
aviationstack_base_url: str = "http://api.aviationstack.com/v1"
# ============================================
# NSPK BANKS API
# NSPK BANKS API (и альтернативный BANK_IP из .env)
# ============================================
nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json"
bank_ip: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
bank_api_url: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
# ============================================
# DADATA (подсказки адресов в форме профиля)
# ============================================
forma_dadata_api_key: str = "" # FORMA_DADATA_API_KEY
forma_dadata_secret: str = "" # FORMA_DADATA_SECRET
# ============================================
# SMS SERVICE (SigmaSMS)
@@ -221,11 +230,21 @@ class Settings(BaseSettings):
# ============================================
# MAX (мессенджер) — Mini App auth
# ============================================
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp
max_bot_token: str = "" # Токен бота MAX (один бот)
max_bot_tokens: str = "" # Мультибот: JSON {"bot_id": "token", ...}. Если задан — используется вместо max_bot_token.
def get_max_bot_tokens(self) -> List[tuple]:
"""Список (bot_id, token) для проверки подписи MAX initData. Один токен — [('default', token)]."""
token = (self.max_bot_token or "").strip()
"""Список (bot_id, token) для проверки подписи MAX initData. Из MAX_BOT_TOKENS (JSON) или [('default', MAX_BOT_TOKEN)]."""
s = (self.max_bot_tokens or os.environ.get("MAX_BOT_TOKENS") or "").strip()
if s:
try:
d = json.loads(s)
out = [(k, str(v).strip()) for k, v in d.items() if v and str(v).strip()]
if out:
return out
except Exception:
pass
token = (self.max_bot_token or os.environ.get("MAX_BOT_TOKEN") or "").strip()
if token:
return [("default", token)]
return []