Профиль: валидация, календарь, ИНН 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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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="Токен сессии"),
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user