Профиль: валидация, календарь, ИНН 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

@@ -0,0 +1,28 @@
# Изменения: форма профиля, валидация, DaData, банки
## Backend
### auth_universal.py
- Чтение N8N_AUTH_WEBHOOK: fallback на `os.environ.get("N8N_AUTH_WEBHOOK")`, если в config нет поля `n8n_auth_webhook` (чтобы webhook auth_miniapp вызывался при отсутствии config.py на хосте).
### banks.py
- URL списка банков берётся из .env: `BANK_IP` (в config — `bank_ip`), fallback на `bank_api_url` и запасной URL. Прокси запроса к внешнему API для мини-аппа.
### profile.py
- Новый эндпоинт `GET /api/v1/profile/dadata/address?query=...&count=10` — подсказки адресов через DaData API (ключи FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env). Ответ: `{ "suggestions": [ { "value", "unrestricted_value" } ] }`.
### config.py
- Добавлены поля: `bank_ip` (BANK_IP), `bank_api_url`; `forma_dadata_api_key`, `forma_dadata_secret` (FORMA_DADATA_*).
## Frontend (Profile.tsx)
- **Дата рождения:** календарь (DatePicker), формат DD.MM.YYYY, нельзя выбрать будущую дату.
- **ИНН:** строго 12 цифр, валидация и ввод только цифр; подсказка «Узнать свой ИНН вы можете здесь» со ссылкой на сервис ФНС (service.nalog.ru).
- **Email:** валидация формата (type: email).
- **Адрес регистрации / Почтовый адрес:** чекбокс «Совпадает с адресом регистрации» — при включении почтовый подставляется и блокируется; оба поля — AutoComplete с подсказками из DaData (запрос к /api/v1/profile/dadata/address).
- **Банк для возмещения:** выпадающий список (Select) с поиском, данные с /api/v1/banks/nspk (API из BANK_IP); учтён формат ответа с полями bankId, bankName (camelCase).
## .env
- BANK_IP — URL API списка банков (например http://212.193.27.93/api/payouts/dictionaries/nspk-banks).
- FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET — ключи DaData для подсказок адресов.

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 []

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { Button, Card, Descriptions, Form, Input, Spin, Typography, message } from 'antd';
import { useEffect, useState, useCallback } from 'react';
import { Button, Card, Checkbox, Descriptions, Form, Input, Select, DatePicker, AutoComplete, Spin, Typography, message } from 'antd';
import { User } from 'lucide-react';
import dayjs from 'dayjs';
import './Profile.css';
const { Title, Text } = Typography;
const DATE_FORMAT = 'DD.MM.YYYY';
/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [
@@ -28,12 +30,30 @@ function getValue(obj: Record<string, unknown>, keys: string[]): string {
return '';
}
/** Парсим дату из строки (DD.MM.YYYY, YYYY-MM-DD и т.д.) в dayjs или null */
function parseBirthDate(s: string): dayjs.Dayjs | null {
if (!s || typeof s !== 'string') return null;
const trimmed = s.trim();
if (!trimmed) return null;
const d = dayjs(trimmed, [DATE_FORMAT, 'YYYY-MM-DD', 'DD.MM.YYYY'], true);
return d.isValid() ? d : null;
}
/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
function canEditProfile(contact: Record<string, unknown>): boolean {
const v = contact?.verification ?? contact?.Verification;
return v === '0' || v === 0;
}
interface BankOption {
id?: string;
name?: string;
bankid?: string;
bankname?: string;
value?: string;
label?: string;
}
interface ProfileProps {
onNavigate?: (path: string) => void;
}
@@ -44,6 +64,61 @@ export default function Profile({ onNavigate }: ProfileProps) {
const [error, setError] = useState<string | null>(null);
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
const [sameAsRegistration, setSameAsRegistration] = useState(false);
const [banks, setBanks] = useState<BankOption[]>([]);
const [banksLoading, setBanksLoading] = useState(false);
const [addressOptionsReg, setAddressOptionsReg] = useState<{ value: string }[]>([]);
const [addressOptionsMail, setAddressOptionsMail] = useState<{ value: string }[]>([]);
const [dadataLoadingReg, setDadataLoadingReg] = useState(false);
const [dadataLoadingMail, setDadataLoadingMail] = useState(false);
const loadBanks = useCallback(async () => {
setBanksLoading(true);
try {
const res = await fetch('/api/v1/banks/nspk');
if (!res.ok) {
setBanks([]);
return;
}
const data = await res.json().catch(() => []);
const list = Array.isArray(data) ? data : (data?.data || data?.items || []);
const normalized: BankOption[] = list.map((b: Record<string, unknown> | string) => {
if (typeof b === 'string') return { value: b, label: b };
const name = (b?.bankName ?? b?.bankname ?? b?.name ?? b?.title ?? b?.value ?? '').toString().trim();
const id = (b?.bankId ?? b?.bankid ?? b?.id ?? b?.value ?? name).toString().trim();
return { bankid: id, bankname: name, value: name, label: name };
}).filter((b: BankOption) => b.value || b.label);
setBanks(normalized);
} catch {
setBanks([]);
} finally {
setBanksLoading(false);
}
}, []);
const searchAddress = useCallback(async (query: string, setOptions: (o: { value: string }[]) => void, setLoading: (v: boolean) => void) => {
if (!query || query.length < 2) {
setOptions([]);
return;
}
setLoading(true);
try {
const res = await fetch(`/api/v1/profile/dadata/address?query=${encodeURIComponent(query)}&count=10`);
const data = await res.json().catch(() => ({}));
const suggestions = (data?.suggestions || []).map((s: { value?: string; unrestricted_value?: string }) => ({
value: (s.unrestricted_value || s.value || '').trim(),
})).filter((s: { value: string }) => s.value);
setOptions(suggestions);
} catch {
setOptions([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (canEditProfile(contact || {})) loadBanks();
}, [contact, loadBanks]);
useEffect(() => {
let cancelled = false;
@@ -90,10 +165,18 @@ export default function Profile({ onNavigate }: ProfileProps) {
: null;
setContact(first);
if (first && canEditProfile(first)) {
const initial: Record<string, string> = {};
const initial: Record<string, string | dayjs.Dayjs | null> = {};
PROFILE_FIELDS.forEach(({ key, keys }) => {
initial[key] = getValue(first, keys) || '';
const raw = getValue(first, keys) || '';
if (key === 'birth_date') {
initial[key] = parseBirthDate(raw) || (raw ? dayjs(raw) : null);
} else {
initial[key] = raw;
}
});
const regAddr = (initial.registration_address as string) || '';
const mailAddr = (initial.mailing_address as string) || '';
setSameAsRegistration(!!regAddr && regAddr === mailAddr);
form.setFieldsValue(initial);
}
})
@@ -106,6 +189,22 @@ export default function Profile({ onNavigate }: ProfileProps) {
return () => { cancelled = true; };
}, [onNavigate, form]);
const handleSameAsRegistrationChange = (e: { target: { checked: boolean } }) => {
const checked = e.target.checked;
setSameAsRegistration(checked);
if (checked) {
const reg = form.getFieldValue('registration_address') || '';
form.setFieldsValue({ mailing_address: reg });
}
};
const handleRegistrationAddressChange = () => {
if (sameAsRegistration) {
const reg = form.getFieldValue('registration_address') || '';
form.setFieldsValue({ mailing_address: reg });
}
};
const handleSave = async () => {
if (!contact || !canEditProfile(contact)) return;
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
@@ -124,6 +223,8 @@ export default function Profile({ onNavigate }: ProfileProps) {
} catch {
return;
}
const birthDateVal = values.birth_date;
const birthDateStr = dayjs.isDayjs(birthDateVal) ? birthDateVal.format(DATE_FORMAT) : (birthDateVal && String(birthDateVal).trim()) || '';
setSaving(true);
try {
const res = await fetch('/api/v1/profile/contact/update', {
@@ -135,7 +236,7 @@ export default function Profile({ onNavigate }: ProfileProps) {
last_name: values.last_name ?? '',
first_name: values.first_name ?? '',
middle_name: values.middle_name ?? '',
birth_date: values.birth_date ?? '',
birth_date: birthDateStr,
birth_place: values.birth_place ?? '',
inn: values.inn ?? '',
email: values.email ?? '',
@@ -152,7 +253,7 @@ export default function Profile({ onNavigate }: ProfileProps) {
return;
}
message.success('Профиль сохранён');
setContact({ ...contact, ...values });
setContact({ ...contact, ...values, birth_date: birthDateStr });
} catch (e) {
message.error('Не удалось сохранить профиль, попробуйте позже');
} finally {
@@ -209,16 +310,144 @@ export default function Profile({ onNavigate }: ProfileProps) {
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
>
<Form form={form} layout="vertical" onFinish={handleSave}>
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => (
<Form.Item
key={key}
name={key}
label={label}
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
>
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
</Form.Item>
))}
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => {
if (key === 'birth_date') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Укажите дату рождения' }]}
>
<DatePicker
format={DATE_FORMAT}
placeholder="Выберите дату"
style={{ width: '100%' }}
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
);
}
if (key === 'inn') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[
{ required: true, message: 'Введите ИНН' },
{ pattern: /^\d{12}$/, message: 'ИНН должен содержать ровно 12 цифр' },
]}
help={
<span style={{ fontSize: 12, color: 'var(--ant-color-text-secondary, rgba(0,0,0,0.45))' }}>
Узнать свой ИНН вы можете{' '}
<a href="https://service.nalog.ru/static/personal-data.html?svc=inn&from=%2Finn.do" target="_blank" rel="noopener noreferrer">
здесь
</a>
{' '}(сервис ФНС России).
</span>
}
>
<Input
maxLength={12}
placeholder="12 цифр"
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 12);
form.setFieldValue('inn', v);
}}
/>
</Form.Item>
);
}
if (key === 'email') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[
{ required: true, message: 'Введите электронную почту' },
{ type: 'email', message: 'Введите корректный email' },
]}
>
<Input type="email" placeholder="example@mail.ru" />
</Form.Item>
);
}
if (key === 'registration_address') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Обязательное поле' }]}
>
<AutoComplete
options={addressOptionsReg}
placeholder="Начните вводить адрес"
onSearch={(q) => searchAddress(q, setAddressOptionsReg, setDadataLoadingReg)}
onChange={handleRegistrationAddressChange}
notFoundContent={dadataLoadingReg ? 'Загрузка...' : null}
/>
</Form.Item>
);
}
if (key === 'mailing_address') {
return (
<>
<Form.Item style={{ marginBottom: 8 }}>
<Checkbox checked={sameAsRegistration} onChange={handleSameAsRegistrationChange}>
Совпадает с адресом регистрации
</Checkbox>
</Form.Item>
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Обязательное поле' }]}
>
<AutoComplete
options={addressOptionsMail}
placeholder="Начните вводить адрес"
onSearch={(q) => searchAddress(q, setAddressOptionsMail, setDadataLoadingMail)}
disabled={sameAsRegistration}
notFoundContent={dadataLoadingMail ? 'Загрузка...' : null}
/>
</Form.Item>
</>
);
}
if (key === 'bank_for_compensation') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Выберите банк' }]}
>
<Select
showSearch
placeholder={banksLoading ? 'Загрузка банков...' : 'Выберите банк'}
loading={banksLoading}
optionFilterProp="label"
filterOption={(input, opt) => (opt?.label ?? '').toString().toLowerCase().includes((input || '').toLowerCase())}
options={banks.map((b) => ({ value: b.value || b.bankname || b.label, label: b.label || b.bankname || b.value }))}
notFoundContent={banksLoading ? 'Загрузка...' : 'Банк не найден'}
/>
</Form.Item>
);
}
return (
<Form.Item
key={key}
name={key}
label={label}
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
>
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
</Form.Item>
);
})}
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
Сохранить изменения