From 56f0cdbdf76661724a9a0687d14609198e2498f2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:27:44 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/lead):=20=D0=BE=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B5=20=D1=83?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=91=D1=82=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=C2=AB=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20=E2=86=92=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=C2=BB=20=D0=BD=D0=B0=20=D0=B4=D0=B0=D1=88?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1971, два невыполненных пункта DoD. 1. Уведомления не было. Заявка с результата оценки только ложилась в trade_in_leads (docstring прямо называл уведомление «вне scope»). На проде 17.09: 4 заявки, notified_at пуст у всех, последняя 12.07. Теперь после ответа клиенту фоновая задача шлёт сообщение в support-топик тем же ботом, что и веб-чат поддержки, и при успехе ставит notified_at. Отказ Telegram не меняет ни ответ (200), ни сохранённый лид — только лог. Телефона в сообщении нет: копию в Telegram не стирает механизм удаления ПДн, поэтому туда идут id заявки, пользователь и id оценки. 2. Доли заявок от оценок не было нигде. Панель на продуктовом дашборде: лиды за 7 суток / успешные оценки за 7 суток (знаменатель — только outcome=ok: форма заявки показывается только при посчитанной оценке). Выражение проверено на боевом Prometheus 17.09: 0 / 91.008 = 0. Closes #1971 Co-Authored-By: Claude Opus 5 --- ops/metrics/grafana/dashboards/product.json | 23 +++++ tradein-mvp/backend/app/api/v1/lead.py | 66 ++++++++++++-- .../backend/tests/test_trade_in_lead.py | 87 ++++++++++++++++++- 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/ops/metrics/grafana/dashboards/product.json b/ops/metrics/grafana/dashboards/product.json index 025cd467..c67448c9 100644 --- a/ops/metrics/grafana/dashboards/product.json +++ b/ops/metrics/grafana/dashboards/product.json @@ -133,6 +133,29 @@ } }, + { + "type": "timeseries", + "title": "Конверсия оценка → заявка, скользящие 7 суток", + "description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 }, + "targets": [ + { + "refId": "A", + "expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))", + "legendFormat": "заявки / оценки" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [] + } + }, + { "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } }, { diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index f4738c45..5f8ea111 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -2,9 +2,16 @@ POST /api/v1/trade-in/lead — контактная заявка с результата оценки: телефон + явное согласие на обработку персональных данных. Persist в -trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей -SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только -persist + log; `notified_at` в таблице зарезервирован под будущую доставку. +trade_in_leads. + +Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид +уходит сообщением в support-топик Telegram — тот же бот и топик, что у веб-чата +поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и +никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ +Telegram не трогает ни ответ, ни лид — остаётся лог и пустой `notified_at` как +видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается +механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id — +телефон оператор берёт из trade_in_leads. IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой @@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta from typing import Annotated, Any, Literal from uuid import UUID -from fastapi import APIRouter, Depends, Header, HTTPException, Request +from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request from pydantic import BaseModel, Field, field_validator from sqlalchemy import text from sqlalchemy.orm import Session +from app.api.v1.support import _bot_configured from app.api.v1.trade_in import _assert_estimate_access from app.core.config import settings -from app.core.db import get_db +from app.core.db import SessionLocal, get_db from app.observability.metrics import LEADS +from app.services.tgbot.shared import get_telegram_client logger = logging.getLogger(__name__) @@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel): return value +async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None: + """Сообщает о заявке в support-топик и отмечает `notified_at` (#1971). + + Идёт после ответа клиенту, поэтому ретраи клиента — штатные воркерные, без + интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя: + сессия запроса к этому моменту уже закрыта. Любой отказ — только в лог. + """ + message = ( + "Новая заявка на трейд-ин\n" + f"id: {lead_id}\n" + f"пользователь: {username or '—'}\n" + f"оценка: {estimate_id or 'без привязки'}\n" + "Телефон — в trade_in_leads по id." + ) + try: + await get_telegram_client().send_message( + chat_id=settings.telegram_support_chat_id, + text=message, + message_thread_id=settings.telegram_support_topic_id or None, + ) + except Exception: + logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id) + return + + db = SessionLocal() + try: + db.execute( + text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"), + {"id": lead_id}, + ) + db.commit() + except Exception: + logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id) + finally: + db.close() + + @router.post("/lead") async def create_trade_in_lead( payload: TradeInLeadInput, request: Request, db: Annotated[Session, Depends(get_db)], + background_tasks: BackgroundTasks, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> dict[str, Any]: """Сохраняет лид (телефон + согласие) в trade_in_leads.""" @@ -185,6 +232,15 @@ async def create_trade_in_lead( _CONSENT_POLICY_VERSION, ) + if _bot_configured(): + background_tasks.add_task( + _notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user + ) + else: + logger.warning( + "trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"] + ) + return { "id": row["id"], "created_at": row["created_at"].isoformat(), diff --git a/tradein-mvp/backend/tests/test_trade_in_lead.py b/tradein-mvp/backend/tests/test_trade_in_lead.py index 8afd994f..2495efd7 100644 --- a/tradein-mvp/backend/tests/test_trade_in_lead.py +++ b/tradein-mvp/backend/tests/test_trade_in_lead.py @@ -21,7 +21,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from datetime import UTC, datetime from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest @@ -285,3 +285,88 @@ def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> ) assert r.status_code == 422, r.text assert not db.execute.called + + +# ── #1971: уведомление ответственному в support-топик ───────────────────────── + +_NOTIFY_LEAD_ID = "00000000-0000-0000-0000-000000000001" + + +@pytest.fixture +def telegram(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + """Бот настроен, клиент и сессия фоновой задачи подменены.""" + from app.api.v1 import lead as lead_module + + monkeypatch.setattr(lead_module.settings, "telegram_bot_token", "fake-token") + monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", -100123456789) + monkeypatch.setattr(lead_module.settings, "telegram_support_topic_id", 2) + fake_client = SimpleNamespace(send_message=AsyncMock(return_value={"message_id": 7})) + monkeypatch.setattr(lead_module, "get_telegram_client", lambda: fake_client) + notify_db = MagicMock() + monkeypatch.setattr(lead_module, "SessionLocal", lambda: notify_db) + return SimpleNamespace(client=fake_client, db=notify_db) + + +def test_lead_notifies_topic_without_phone_and_sets_notified_at( + client: TestClient, db: MagicMock, telegram: SimpleNamespace +) -> None: + db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID) + + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+7 (912) 345-67-89", "consent": True}, + headers={"X-Authenticated-User": "alice"}, + ) + assert r.status_code == 200, r.text + + telegram.client.send_message.assert_awaited_once() + kwargs = telegram.client.send_message.await_args.kwargs + assert kwargs["chat_id"] == -100123456789 + assert kwargs["message_thread_id"] == 2 + assert _NOTIFY_LEAD_ID in kwargs["text"] + assert "alice" in kwargs["text"] + # Телефон в Telegram не уходит ни в каком написании. + assert "9123456789" not in "".join(ch for ch in kwargs["text"] if ch.isdigit()) + + sql, params = telegram.db.execute.call_args.args + assert "SET notified_at = now()" in str(sql) + assert params == {"id": _NOTIFY_LEAD_ID} + assert telegram.db.commit.called + assert telegram.db.close.called + + +def test_lead_telegram_failure_keeps_200_and_notified_at_empty( + client: TestClient, db: MagicMock, telegram: SimpleNamespace +) -> None: + from app.services.tgbot.client import TelegramError + + db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID) + telegram.client.send_message.side_effect = TelegramError("boom") + + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+79123456789", "consent": True}, + headers={"X-Authenticated-User": "alice"}, + ) + assert r.status_code == 200, r.text + assert r.json()["id"] == _NOTIFY_LEAD_ID + assert db.commit.called # лид сохранён до попытки уведомления + telegram.client.send_message.assert_awaited_once() + assert not telegram.db.execute.called + + +def test_lead_bot_not_configured_skips_notification( + client: TestClient, + db: MagicMock, + telegram: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.api.v1 import lead as lead_module + + monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", 0) + db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID) + + r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True}) + assert r.status_code == 200, r.text + telegram.client.send_message.assert_not_awaited() + assert not telegram.db.execute.called -- 2.45.3