From 091befc9ffd88628847a3c3a1ce6f6e4c736a70f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:59:52 +0300 Subject: [PATCH 1/5] =?UTF-8?q?fix(tradein):=20=D0=B8=D0=B4=D0=B5=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D1=83=20(#3471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сеть Selectel -> api.telegram.org теряет заметную долю коротких запросов, поэтому браузерный ретрай/двойной клик/переотправка по таймауту при отправке в веб-чат поддержки создавали ВТОРУЮ строку в web_support_messages И второе зеркало в support-топике Telegram, а не только дубль в БД. Ключ идемпотентности (миграция 301, колонка idempotency_key + partial unique индекс (thread_id, idempotency_key) WHERE direction='in'): - явный заголовок Idempotency-Key от клиента, если он есть и валидной формы; - иначе детерминированный fallback-отпечаток sha256(identity|текст|минутное окно) — старые клиенты без заголовка продолжают работать без изменений. Pre-check резолвит тред по identity и ищет существующее inbound-сообщение с этим ключом ДО похода в Telegram (не только до записи в БД) — иначе повтор всё равно отправил бы второе зеркало, даже если бы вторая строка в БД не создавалась. Гонку двух одновременных запросов с одним ключом закрывает INSERT ... ON CONFLICT DO NOTHING на уникальном индексе в web_support_storage.record_inbound (не read-then-write), а не сам pre-check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG --- tradein-mvp/backend/app/api/v1/support.py | 91 ++++++++++ .../app/services/tgbot/web_support_storage.py | 66 ++++++- ...01_web_support_message_idempotency_key.sql | 53 ++++++ tradein-mvp/backend/tests/test_support.py | 171 +++++++++++++++++- 4 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql diff --git a/tradein-mvp/backend/app/api/v1/support.py b/tradein-mvp/backend/app/api/v1/support.py index bdaf5268..07406a23 100644 --- a/tradein-mvp/backend/app/api/v1/support.py +++ b/tradein-mvp/backend/app/api/v1/support.py @@ -62,6 +62,7 @@ import hashlib import logging import re import secrets +import time from datetime import UTC, datetime from typing import Annotated, Literal @@ -170,6 +171,14 @@ _SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попр # треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`. _LIST_MESSAGES_LIMIT = 200 +# Идемпотентность отправки (#3471 retry-storm) — см. `_resolve_idempotency_key`. +_IDEMPOTENCY_HEADER = "idempotency-key" +# Форма клиентского ключа — как у anon-токена (`_ANON_TOKEN_RE`): произвольная +# opaque-строка клиента, без пробелов/спецсимволов, которые попали бы в SQL-параметр +# как есть. Не матчится — считаем заголовок отсутствующим и уходим на fallback, +# а не пытаемся его "починить" (тот же принцип, что у `_read_anon_token`). +_CLIENT_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{8,128}\Z") + def _require_username(request: Request) -> str: """Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его @@ -345,8 +354,41 @@ def _rollback_quietly(db: Session) -> None: logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True) +def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str: + """Ключ идемпотентности inbound-отправки (#3471, миграция 301). + + Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key` — + предпочтительный путь: клиент генерирует ключ ОДИН раз на "намерение + отправить" и переиспользует его на любом ретрае (fetch retry / переотправка + после таймаута) независимо от того, что именно менялось в UI между + попытками. Без заголовка (старые клиенты, п.5 требования — они не должны + сломаться) используем детерминированный отпечаток sha256(identity, текст, + минутное окно): типичный повтор (двойной клик, ретрай браузера) укладывается + в секунды, минутное окно с запасом это покрывает, а любые два РАЗНЫХ по + смыслу сообщения с одинаковым текстом, отправленные намеренно с разницей в + пару минут, в одно не схлопнутся. + + Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ — + он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда + попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не + возвращается — жёсткое правило проекта "текст не в логах" (ПДн) при этом + не нарушается, даже если бы этот ключ где-то залогировали. + + Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от + случайного совпадения клиентского токена с fallback-хэшем) — дёшево и не + требует отдельной колонки. + """ + header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip() + if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header): + return f"client:{header}" + window = int(time.time() // 60) + fingerprint = f"{identity_key}|{text}|{window}" + return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest() + + @router.post("/support/messages", response_model=SupportMessageOut) async def send_support_message( + request: Request, payload: SupportMessageInput, username: Annotated[str, Depends(_require_username)], db: Annotated[Session, Depends(get_db)], @@ -380,6 +422,28 @@ async def send_support_message( if cooldown is not None: raise _too_many_failures_error(cooldown) + # Идемпотентность (#3471): pre-check ДО похода в Telegram — повтор с тем же + # ключом не должен слать второе зеркало в топик, не только не писать вторую + # строку в БД. Тред может ещё не существовать (это первая отправка этого + # ключа) — тогда сравнивать не с чем, идём в Telegram как обычно; сам факт + # "нет треда" здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке + # (см. H1) — если бы он уже был записан под этим ключом, тред уже был бы. + idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text) + existing_thread_id = storage.find_thread_id(db, username) + if existing_thread_id is not None: + existing = storage.find_inbound_by_idempotency_key( + db, thread_id=existing_thread_id, idempotency_key=idempotency_key + ) + if existing is not None: + logger.info( + "web support: repeat send (idempotency key already recorded) " + "username=%s thread_id=%d message_id=%s", + username, + existing_thread_id, + existing["id"], + ) + return SupportMessageOut(**existing) + # Общий клиент приложения (#tg-connection-resilience): на каждый запрос # свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк # до api.telegram.org перед каждой отправкой. Живёт в lifespan. @@ -438,6 +502,7 @@ async def send_support_message( text_body=payload.text, topic_message_id=topic_message_id, support_chat_id=settings.telegram_support_chat_id, + idempotency_key=idempotency_key, ) db.commit() except SQLAlchemyError: @@ -608,6 +673,31 @@ async def send_anon_support_message( raise _too_many_failures_error(cooldown) display_id = _anon_display_id(token) + + # Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной + # ветке. Ограничение специфично для анонимной ветки: если предыдущая + # попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и + # сейчас), identity_key каждый раз новый (случайный токен) и pre-check + # структурно не может найти прошлую попытку — это тот же класс проблемы, + # что и потеря куки в сети, вне scope этой правки. + idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text) + existing_thread_id = storage.find_thread_id(db, thread_key) + if existing_thread_id is not None: + existing = storage.find_inbound_by_idempotency_key( + db, thread_id=existing_thread_id, idempotency_key=idempotency_key + ) + if existing is not None: + logger.info( + "web support (anon): repeat send (idempotency key already recorded) " + "%s thread_id=%d message_id=%s", + display_id, + existing_thread_id, + existing["id"], + ) + if is_new_token: + _set_anon_cookie(response, token) + return SupportMessageOut(**existing) + # Общий клиент приложения (#tg-connection-resilience): на каждый запрос # свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк # до api.telegram.org перед каждой отправкой. Живёт в lifespan. @@ -657,6 +747,7 @@ async def send_anon_support_message( text_body=payload.text, topic_message_id=topic_message_id, support_chat_id=settings.telegram_support_chat_id, + idempotency_key=idempotency_key, ) db.commit() except SQLAlchemyError: diff --git a/tradein-mvp/backend/app/services/tgbot/web_support_storage.py b/tradein-mvp/backend/app/services/tgbot/web_support_storage.py index e1b87a23..feb9ec2b 100644 --- a/tradein-mvp/backend/app/services/tgbot/web_support_storage.py +++ b/tradein-mvp/backend/app/services/tgbot/web_support_storage.py @@ -61,6 +61,36 @@ def get_or_create_thread(db: Session, username: str) -> int: return int(row[0]) +def find_inbound_by_idempotency_key( + db: Session, *, thread_id: int, idempotency_key: str +) -> dict[str, Any] | None: + """Уже записанное inbound-сообщение с этим ключом идемпотентности в треде, + если есть (#3471). Вызывается ИЗ `app.api.v1.support` ДО похода в Telegram + (`send_support_message` / `send_anon_support_message`) — повтор с тем же + ключом не должен создавать второе зеркало в топике, а не только вторую + строку в БД. `thread_id`, а не username/anon-token: таблица не хранит + identity напрямую, а тред уже гарантированно существует к моменту, когда + этот ключ мог быть записан (тред создаётся ДО `record_inbound`, см. H1 в + докстринге `app.api.v1.support`).""" + row = ( + db.execute( + text( + """ + SELECT id, direction, text_body, operator_tg_id, created_at + FROM web_support_messages + WHERE thread_id = CAST(:thread_id AS bigint) + AND direction = 'in' + AND idempotency_key = :idempotency_key + """ + ), + {"thread_id": thread_id, "idempotency_key": idempotency_key}, + ) + .mappings() + .one_or_none() + ) + return dict(row) if row is not None else None + + def record_inbound( db: Session, *, @@ -68,6 +98,7 @@ def record_inbound( text_body: str, topic_message_id: int | None, support_chat_id: int | None, + idempotency_key: str | None = None, ) -> dict[str, Any]: """Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` — id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора. @@ -75,18 +106,33 @@ def record_inbound( review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ support-группе — если группу когда-нибудь сменят/пересоздадут, Telegram message_id стартует заново с 1 в новом чате и может совпасть с числом из - старого — без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188).""" + старого — без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188). + + `idempotency_key` (#3471, миграция 301) — вызывающая сторона (`app.api.v1.support`) + делает SELECT-затем-действие pre-check ДО Telegram-похода (см. + `find_inbound_by_idempotency_key`), но этот pre-check САМ ПО СЕБЕ гонку не + закрывает (TOCTOU): два запроса с одним ключом могут пройти его одновременно + и оба уйти в Telegram. Последняя линия защиты — здесь: `INSERT ... ON + CONFLICT (thread_id, idempotency_key) DO NOTHING` на partial unique индексе + (мигр. 301, тот же predicate). Если конфликт всё же случился, проигравший + НЕ создаёт вторую строку — читает уже вставленную и возвращает её, так что + оба запроса-конкурента получают ОДИН и тот же id. `idempotency_key=None` + (дефолт) ведёт себя как раньше: NULL никогда не конфликтует сам с собой в + partial-индексе (WHERE idempotency_key IS NOT NULL), INSERT всегда проходит.""" row = ( db.execute( text( """ INSERT INTO web_support_messages (thread_id, direction, text_body, topic_message_id, - support_chat_id, operator_tg_id, created_at) + support_chat_id, operator_tg_id, idempotency_key, created_at) VALUES (CAST(:thread_id AS bigint), 'in', :text_body, CAST(:topic_message_id AS bigint), - CAST(:support_chat_id AS bigint), NULL, NOW()) + CAST(:support_chat_id AS bigint), NULL, :idempotency_key, NOW()) + ON CONFLICT (thread_id, idempotency_key) + WHERE idempotency_key IS NOT NULL AND direction = 'in' + DO NOTHING RETURNING id, direction, text_body, operator_tg_id, created_at """ ), @@ -95,12 +141,22 @@ def record_inbound( "text_body": text_body, "topic_message_id": topic_message_id, "support_chat_id": support_chat_id, + "idempotency_key": idempotency_key, }, ) .mappings() - .one() + .one_or_none() ) - return dict(row) + if row is not None: + return dict(row) + # Конфликт пойман индексом (см. докстринг выше) — idempotency_key точно не + # NULL здесь: при NULL partial-индекс в конфликт не участвует вообще. + assert idempotency_key is not None + existing = find_inbound_by_idempotency_key( + db, thread_id=thread_id, idempotency_key=idempotency_key + ) + assert existing is not None # конфликт по индексу гарантирует наличие строки + return existing def find_thread_by_topic_message( diff --git a/tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql b/tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql new file mode 100644 index 00000000..40b6771b --- /dev/null +++ b/tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql @@ -0,0 +1,53 @@ +-- 301_web_support_message_idempotency_key.sql +-- Идемпотентность отправки веб-сообщения в поддержку (#3471 retry-storm): +-- канал Selectel -> api.telegram.org теряет заметную долю коротких запросов +-- (см. app/api/v1/support.py, _INTERACTIVE_SEND_TIMEOUT_S), поэтому повтор +-- клиента (fetch-ретрай/двойной клик/переотправка по таймауту) — обычное +-- дело. Раньше повтор создавал ВТОРУЮ строку в web_support_messages и ВТОРОЕ +-- зеркало в support-топике. +-- +-- idempotency_key — client-provided (заголовок Idempotency-Key) ИЛИ +-- детерминированный fallback-отпечаток sha256(identity|текст|минутное окно) +-- для клиентов без заголовка (app/api/v1/support.py::_resolve_idempotency_key). +-- Всегда opaque-строка (клиентский токен или hex-хэш) — ТЕЛО СООБЩЕНИЯ сюда +-- никогда не попадает в открытом виде, только его отпечаток. +-- +-- Scoped к (thread_id, direction='in'): идемпотентность осмысленна только для +-- inbound (клиентских) сообщений — direction='out' (ответ оператора) её не +-- требует, там уже есть своя partial-уникальность на topic_message_id (187). +-- thread_id, а не username/anon-token: таблица не хранит identity напрямую, +-- а тред уже гарантированно создан (`get_or_create_thread`) к моменту INSERT +-- (см. H1 в app/api/v1/support.py — тред создаётся ДО record_inbound). +-- +-- Гонку двух одновременных запросов с одним ключом закрывает САМ уникальный +-- индекс + `INSERT ... ON CONFLICT DO NOTHING` в +-- web_support_storage.record_inbound (не read-then-write) — на конфликте +-- вызывающая сторона дочитывает уже вставленную строку и возвращает её тем +-- же ответом (тот же id), а не создаёт вторую строку. +-- +-- IDEMPOTENCY: ADD COLUMN/CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- Зависимости: 187_web_support_chat.sql (таблица web_support_messages). + +BEGIN; + +ALTER TABLE web_support_messages + ADD COLUMN IF NOT EXISTS idempotency_key text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'web_support_messages_idempotency_key_len_chk' + ) THEN + ALTER TABLE web_support_messages + ADD CONSTRAINT web_support_messages_idempotency_key_len_chk + CHECK (idempotency_key IS NULL OR char_length(idempotency_key) BETWEEN 1 AND 160); + END IF; +END $$; + +COMMENT ON COLUMN web_support_messages.idempotency_key IS 'Ключ идемпотентности inbound-отправки (#3471) — client-provided заголовок Idempotency-Key ("client:...") ИЛИ sha256-отпечаток thread+текст+минутное окно ("auto:...") для клиентов без заголовка. NULL для direction=''out'' и для строк до этой миграции.'; + +CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_thread_idempotency_uq + ON web_support_messages (thread_id, idempotency_key) + WHERE idempotency_key IS NOT NULL AND direction = 'in'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_support.py b/tradein-mvp/backend/tests/test_support.py index 604bcef5..3abb418b 100644 --- a/tradein-mvp/backend/tests/test_support.py +++ b/tradein-mvp/backend/tests/test_support.py @@ -76,6 +76,20 @@ class _FakeTelegramClient: return _FakeTelegramClient._response +@pytest.fixture(autouse=True) +def _no_existing_idempotent_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Дефолт для тестов, которые не проверяют идемпотентность (#3471) напрямую: + без этого автостаба pre-check в `support.py` дёргал бы РЕАЛЬНУЮ + storage-функцию против MagicMock `db` в каждом тесте, где замокан + `find_thread_id` для своих целей (rate-limit / DB-failure / anon-сценарии + и т.д.) — падало бы на MagicMock-результате, никак не связанное с тем, что + тест на самом деле проверяет. Тесты идемпотентности переопределяют это + через `_install_fake_storage` ниже.""" + monkeypatch.setattr( + support_module.storage, "find_inbound_by_idempotency_key", lambda *a, **kw: None + ) + + @pytest.fixture(autouse=True) def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any: _FakeTelegramClient.calls = [] @@ -238,7 +252,9 @@ def test_send_message_happy_path_mirrors_with_website_marker( monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create_thread) recorded = {} - def fake_record_inbound(db, *, thread_id, text_body, topic_message_id, support_chat_id): + def fake_record_inbound( + db, *, thread_id, text_body, topic_message_id, support_chat_id, idempotency_key=None + ): recorded.update( thread_id=thread_id, text_body=text_body, @@ -1052,3 +1068,156 @@ def test_anon_send_failures_trigger_cooldown_per_ip( blocked = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}) assert blocked.status_code == 429 assert len(_fake_telegram_client.calls) == calls_before # до Telegram не дошло +# ── idempotency (#3471) ────────────────────────────────────────────────────── + + +class _FakeIdempotentStorage: + """In-memory stand-in для `web_support_storage`, достаточный чтобы точно + воспроизвести идемпотентный контракт (thread_id, idempotency_key) БЕЗ + настоящей БД: unique-конфликт на повторном ключе внутри `record_inbound` + (см. миграцию 301 и storage.record_inbound docstring).""" + + def __init__(self) -> None: + self.threads: dict[str, int] = {} + self.messages: list[dict[str, Any]] = [] + self._next_id = 1 + + def find_thread_id(self, db: Any, username: str) -> int | None: + return self.threads.get(username) + + def get_or_create_thread(self, db: Any, username: str) -> int: + if username not in self.threads: + self.threads[username] = len(self.threads) + 1 + return self.threads[username] + + def find_inbound_by_idempotency_key( + self, db: Any, *, thread_id: int, idempotency_key: str + ) -> dict[str, Any] | None: + for m in self.messages: + if m["_thread_id"] == thread_id and m["_idempotency_key"] == idempotency_key: + return {k: v for k, v in m.items() if not k.startswith("_")} + return None + + def record_inbound( + self, + db: Any, + *, + thread_id: int, + text_body: str, + topic_message_id: int | None, + support_chat_id: int | None, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + # Тот же контракт, что и настоящий `INSERT ... ON CONFLICT DO NOTHING`: + # конфликт по (thread_id, idempotency_key) отдаёт УЖЕ существующую строку. + if idempotency_key is not None: + existing = self.find_inbound_by_idempotency_key( + db, thread_id=thread_id, idempotency_key=idempotency_key + ) + if existing is not None: + return existing + row = { + "id": self._next_id, + "direction": "in", + "text_body": text_body, + "operator_tg_id": None, + "created_at": "2026-09-12T00:00:00+00:00", + "_thread_id": thread_id, + "_idempotency_key": idempotency_key, + } + self._next_id += 1 + self.messages.append(row) + return {k: v for k, v in row.items() if not k.startswith("_")} + + +def _install_fake_storage(monkeypatch: pytest.MonkeyPatch) -> _FakeIdempotentStorage: + fake = _FakeIdempotentStorage() + monkeypatch.setattr(support_module.storage, "find_thread_id", fake.find_thread_id) + monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake.get_or_create_thread) + monkeypatch.setattr( + support_module.storage, + "find_inbound_by_idempotency_key", + fake.find_inbound_by_idempotency_key, + ) + monkeypatch.setattr(support_module.storage, "record_inbound", fake.record_inbound) + return fake + + +def test_repeat_send_with_same_idempotency_header_returns_same_id_single_mirror( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + """Повтор с тем же `Idempotency-Key` — тот же id, ОДНО зеркало в Telegram + (не только одна строка в БД, см. #3471 требование п.3).""" + _install_fake_storage(monkeypatch) + headers = {**_auth("kopylov"), "Idempotency-Key": "retry-abc123"} + + r1 = client.post( + "/api/v1/trade-in/support/messages", json={"text": "первое сообщение"}, headers=headers + ) + r2 = client.post( + "/api/v1/trade-in/support/messages", json={"text": "первое сообщение"}, headers=headers + ) + + assert r1.status_code == 200, r1.text + assert r2.status_code == 200, r2.text + assert r1.json()["id"] == r2.json()["id"] + # Ключевая проверка: повтор НЕ ушёл в Telegram второй раз. + assert len(_fake_telegram_client.calls) == 1 + + +def test_send_with_different_idempotency_keys_creates_different_messages( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + _install_fake_storage(monkeypatch) + r1 = client.post( + "/api/v1/trade-in/support/messages", + json={"text": "вопрос один"}, + headers={**_auth("kopylov"), "Idempotency-Key": "key-one-111"}, + ) + r2 = client.post( + "/api/v1/trade-in/support/messages", + json={"text": "вопрос два"}, + headers={**_auth("kopylov"), "Idempotency-Key": "key-two-222"}, + ) + + assert r1.status_code == 200, r1.text + assert r2.status_code == 200, r2.text + assert r1.json()["id"] != r2.json()["id"] + assert len(_fake_telegram_client.calls) == 2 + + +def test_repeat_send_without_idempotency_header_still_dedupes_via_fallback_window( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + """Требование п.5: отсутствие явного ключа не должно ломать старых клиентов — + сервер сам считает детерминированный fallback-отпечаток (thread+текст+минутное + окно), так что тот же повтор ТЕМ ЖЕ клиентом без заголовка тоже не создаёт + дубль. Время фиксируем, чтобы не зависеть от границы минутного окна в CI.""" + _install_fake_storage(monkeypatch) + monkeypatch.setattr(support_module.time, "time", lambda: 1_800_000_000.0) + + r1 = client.post( + "/api/v1/trade-in/support/messages", json={"text": "не могу войти"}, headers=_auth("bob") + ) + r2 = client.post( + "/api/v1/trade-in/support/messages", json={"text": "не могу войти"}, headers=_auth("bob") + ) + + assert r1.status_code == 200, r1.text + assert r2.status_code == 200, r2.text + assert r1.json()["id"] == r2.json()["id"] + assert len(_fake_telegram_client.calls) == 1 + + +def test_send_without_idempotency_header_first_call_still_succeeds( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + """Требование п.5 (не ломает старых клиентов): один запрос без заголовка + отрабатывает как раньше — 200, тред создан, зеркало отправлено ровно раз.""" + _install_fake_storage(monkeypatch) + r = client.post( + "/api/v1/trade-in/support/messages", json={"text": "просто вопрос"}, headers=_auth("carol") + ) + assert r.status_code == 200, r.text + assert r.json()["persisted"] is True + assert len(_fake_telegram_client.calls) == 1 From 1b595a00911747e6d755f9ff75cea9a77a81f6ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 15:02:23 +0300 Subject: [PATCH 2/5] =?UTF-8?q?fix(sql):=20lock=5Ftimeout=20=D1=83=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B0=20=D0=B8=D0=B4=D0=B5=D0=BC=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка миграций в CI требует его для блокирующего DDL: без ограничения ALTER встаёт в очередь за чужой сессией и уводит за собой все последующие обращения к таблице (#2752). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG --- .../data/sql/301_web_support_message_idempotency_key.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql b/tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql index 40b6771b..774a5bd3 100644 --- a/tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql +++ b/tradein-mvp/backend/data/sql/301_web_support_message_idempotency_key.sql @@ -30,6 +30,12 @@ BEGIN; +-- Блокирующий DDL не должен ждать чужую сессию бесконечно: без этого +-- ALTER встаёт в очередь за долгим запросом и уводит за собой ВСЕ +-- последующие обращения к таблице (#2752). Пять секунд — не успел взять +-- лок, деплой падает честно, а прод продолжает работать. +SET LOCAL lock_timeout = '5s'; + ALTER TABLE web_support_messages ADD COLUMN IF NOT EXISTS idempotency_key text; From 628f59dcc05189c5960a86a2cb840d943eb818aa Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 12 Sep 2026 12:05:18 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=D0=94=D0=BE=D0=BC=D0=9A=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=20=E2=80=94=20=D1=87=D0=B5=D1=82=D0=B2=D1=91=D1=80=D1=82=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D0=9C=D0=BE=D1=81=D0=BA=D0=B2=D1=8B=20(#3496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/local-avito-msk/collect.py | 259 +++++++++++++++++- 1 file changed, 257 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/scripts/local-avito-msk/collect.py b/tradein-mvp/scripts/local-avito-msk/collect.py index a0132cb1..30cd801d 100644 --- a/tradein-mvp/scripts/local-avito-msk/collect.py +++ b/tradein-mvp/scripts/local-avito-msk/collect.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1). +"""Локальный ручной сборщик SERP Авито/Циан/Яндекса/DomClick по Москве и МО (эпик #2989, трек 1). Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца @@ -59,6 +59,21 @@ from scraper_kit.providers.yandex.serp import ( # noqa: E402 _parse_gate_json, ) +# DomClick — тоже JSON BFF-ответ (Layer A прод-скрейпера), а не HTML SERP, как +# и Яндекс выше. Берём из кита чистую функцию извлечения JSON (_extract_json) +# и сам класс DomClickScraper — не ради транспорта (тот же приём с браузером +# владельца), а ради DomClickScraper._map_item: маппинг offer-item → ScrapedLot +# дублировать своим кодом было бы регрессивно. Маркеры блока — единый источник +# Layer A/B кита, см. докстринг DOMCLICK_BLOCK_MARKERS. +from scraper_kit.domclick_exceptions import ( # noqa: E402 + DOMCLICK_BLOCK_MARKERS, + DomClickBlockedError, +) +from scraper_kit.providers.domclick.serp import ( # noqa: E402 + DomClickScraper, + _extract_json, +) + # Вкладка, открытая у владельца: вторичка, Москва + МО. DEFAULT_AVITO_BASE_URL = ( "https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg" @@ -138,6 +153,37 @@ YANDEX_MAX_PAGES = 25 # штатно «дорезает» до 1500 и каждый коридор уезжает в truncated. YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500 +# DomClick: JSON BFF listing API, Москва (эпик #2989, трек 1). +# +# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот +# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ — +# 0d475b79-88de-4054-818c-37d8f9d0d440). Параметр aids (у ЕКБ 20561, сужает +# выдачу до конкретного агрегатора) для Москвы НЕ нужен — без него счётчик +# листинга точно совпадает с сайтом. +MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553" + +DEFAULT_DOMCLICK_BASE_URL = ( + "https://bff-search-web.domclick.ru/api/offers/v1" + f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat" + "&sort=qi&sort_dir=desc&limit=20&offset=0" +) + +# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную +# страницу (20 items), offset=2000 отвечает HTTP 400 +# {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации — +# 100 страниц по 20 штук = 2000 офферов на один набор фильтров. +DOMCLICK_PAGE_SIZE = 20 +DOMCLICK_MAX_PAGES = 100 + +# bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick. +# offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с +# именами вида "г. Говорово", а не "Москва" — гард по имени региона молча +# вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит: +# он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально +# всю московскую выдачу. +_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02 +_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97 + PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽ PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы PRICE_CEIL = 2_000_000_000 @@ -528,6 +574,197 @@ async def _yandex_wait_ready(page: Any) -> None: pass +# --- DomClick: JSON BFF (конвейер как у Яндекса — payload вместо DOM) --------- + + +def _domclick_build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str: + """URL коридора DomClick: sale_price__gte/__lte + offset (аналог _cian_build_url). + + page — 1-based, как у остальных платформ; offset у BFF — 0-based, поэтому + offset = (page - 1) * DOMCLICK_PAGE_SIZE. offset проставляется ВСЕГДА, в + т.ч. на первой странице (как priceMin/priceMax/page у Яндекса): старые + offset/sale_price__gte/sale_price__lte из base_url выкидываются и ставятся + заново, дублей ключей не остаётся. + """ + parts = urlsplit(base_url) + q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) + if k not in {"offset", "sale_price__gte", "sale_price__lte"}] + if lo is not None: + q.append(("sale_price__gte", str(int(lo)))) + if hi is not None: + q.append(("sale_price__lte", str(int(hi)))) + q.append(("offset", str((page - 1) * DOMCLICK_PAGE_SIZE))) + return urlunsplit( + (parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment) + ) + + +def _unescape_pre_entities(html: str) -> str: + """Снять HTML-экранирование текстового узла
, в который Chrome заворачивает
+    ответ ручки.
+
+    `page.content()` отдаёт СЕРИАЛИЗОВАННЫЙ документ, а не тело ответа: сериализатор
+    экранирует в текстовом узле ровно четыре вещи — `&`, `<`, `>` и неразрывный
+    пробел. Синтаксис JSON от этого не страдает (`&` внутри строки — валидная
+    строка), поэтому дефект тихий: `json.loads` проходит, а в описании и адресе
+    вместо «&» и неразрывного пробела оседают `&` и ` `. Замер на живой
+    выдаче Москвы: 29 ` ` на одной странице из двадцати карточек.
+
+    Кавычки в список не входят намеренно: сериализатор экранирует их только в
+    значениях атрибутов, а в текстовом узле `"` остаётся собой — и это важно, иначе
+    `\"` внутри JSON-строки превратился бы в невалидный escape и уронил разбор
+    страницы целиком.
+
+    Порядок фиксирован: `&` разворачивается ПОСЛЕДНИМ, иначе уже развёрнутый
+    амперсанд склеится со следующей последовательностью и даст второй разбор.
+    """
+    return (
+        html.replace("<", "<")
+        .replace(">", ">")
+        .replace(" ", " ")
+        .replace("&", "&")
+    )
+
+
+def _domclick_payload(scraper: Any, html: str) -> dict[str, Any] | None:
+    """JSON BFF-ответ из содержимого вкладки, с мемоизацией на один HTML.
+
+    parse_page дёргает extract_total_count и parse_cards по одному и тому же
+    html — разбираем один раз (тот же приём, что и _yandex_payload). Парсер не
+    свой: _extract_json — та же функция, которой пользуется прод-скрейпер
+    (providers/domclick/serp.py), со своим корректным порядком «сначала JSON,
+    потом маркеры блока» (#3267 — маркер может встретиться в тексте самого
+    объявления, подстрочный поиск ДО попытки распарсить JSON уже забанил живой
+    узел на 6 часов в проде 30.08).
+
+    К моменту вызова этой функции _guard уже прогнал тот же html через
+    _domclick_detect_block, и настоящий блок остановил бы сбор раньше —
+    DomClickBlockedError/ValueError здесь только тихо превращаются в None, как
+    и в yandex-ветке.
+    """
+    if getattr(scraper, "_payload_html", None) is html:
+        return scraper._payload
+    try:
+        payload: dict[str, Any] | None = _extract_json(_unescape_pre_entities(html))
+    except (DomClickBlockedError, ValueError):
+        payload = None
+    scraper._payload_html = html
+    scraper._payload = payload
+    return payload
+
+
+def _domclick_extract_total(scraper: Any, html: str) -> int | None:
+    """result.pagination.total — а НЕ отдельная count-ручка.
+
+    Замер живым запросом 12.09: pagination.total по Москве без фильтра комнат
+    = 23692, и это число реально достижимо постраничным обходом (offset до
+    1980, см. DOMCLICK_MAX_PAGES). У отдельной count-ручки другое число —
+    25898: оно включает дубли одного объекта у разных агентств, которые
+    пагинацией физически недостижимы. Если бы бисекция целилась в
+    count-ручку, она считала бы коридоры «недобранными» там, где добирать
+    нечего, и truncated/missed в notes врали бы.
+    """
+    payload = _domclick_payload(scraper, html)
+    if not payload:
+        return None
+    result = payload.get("result")
+    if not isinstance(result, dict):
+        return None
+    pagination = result.get("pagination")
+    if not isinstance(pagination, dict):
+        return None
+    total = pagination.get("total")
+    return int(total) if total is not None else None
+
+
+def _domclick_parse_cards(scraper: Any, html: str) -> list[Any]:
+    """result.items → ScrapedLot через DomClickScraper._map_item (кит, без своего маппинга).
+
+    Гео-гард — СВОЙ, по bbox Москвы+ТиНАО, а не scraper._is_geo_ok(): тот метод
+    кита захардкожен на offerRegionName == "Екатеринбург" и отбросил бы всю
+    московскую выдачу целиком. offerRegionName как критерий тоже не годится:
+    часть офферов Новой Москвы приходит с именами вида "г. Говорово", а не
+    "Москва" — гард по названию региона молча резал бы легитимные лоты.
+    Координаты — единственный признак, который не врёт.
+
+    last_raw_count фиксируется ДО геогарда (как raw-счётчик у Циан) — иначе
+    страница, где все 20 items легитимно оказались вне bbox, была бы
+    неотличима от блока/пустого ответа в parse_page.
+    """
+    payload = _domclick_payload(scraper, html)
+    scraper.last_raw_count = 0
+    if not payload:
+        return []
+    result = payload.get("result")
+    items = result.get("items") if isinstance(result, dict) else None
+    if not isinstance(items, list):
+        return []
+    scraper.last_raw_count = len(items)
+    lots = []
+    for item in items:
+        loc = item.get("location") or {}
+        # float() в try: прогон идёт часами без присмотра, и один оффер с мусором
+        # в координате уронил бы ВЕСЬ коллектор на середине коридора вместо того,
+        # чтобы выпасть одной карточкой. Нечисловая координата = лот без гео,
+        # пропускаем его так же, как отсутствующую.
+        try:
+            lat = float(loc["lat"])
+            lon = float(loc["lon"])
+        except (KeyError, TypeError, ValueError):
+            continue
+        if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX
+                and _MSK_LON_MIN <= lon <= _MSK_LON_MAX):
+            continue
+        lot = scraper._map_item(item)
+        if lot is not None:
+            lots.append(lot)
+    return lots
+
+
+def _domclick_detect_block(html: str) -> tuple[str, str] | None:
+    """QRATOR/капча DomClick: HTML вместо JSON BFF-ответа.
+
+    Порядок обязателен — сперва пробуем распарсить JSON, и только если не
+    вышло, ищем маркеры (тот же порядок и та же причина, что в докстринге
+    _extract_json в ките, #3267): маркер вроде "система защиты" или "captcha"
+    может встретиться в тексте самого объявления, и подстрочный поиск ДО
+    попытки распарсить JSON забанил живой узел на 6 часов в проде 30.08.
+    Валидный JSON нужной формы блок-страницей быть не может — QRATOR всегда
+    отдаёт HTML, поэтому успешный json.loads сам по себе доказывает «блока
+    нет», и маркеры уже не смотрим.
+
+    HTTP-400-подобное тело ({"statusCode":400,"error":"Bad Request"}, ответ на
+    offset за потолком пагинации) — валидный JSON без result.pagination, а не
+    блок: возвращаем None, конец коридора отловит parse_page по правилу
+    «count is None и 0 карточек». В штатном прогоне это тело недостижимо —
+    DOMCLICK_MAX_PAGES ограничивает offset числом ниже HTTP-400 порога.
+    """
+    start = html.find("{")
+    end = html.rfind("}")
+    if start != -1 and end != -1 and end > start:
+        try:
+            json.loads(html[start:end + 1])
+            return None
+        except json.JSONDecodeError:
+            pass
+    html_lower = html.lower()
+    for marker in DOMCLICK_BLOCK_MARKERS:
+        if marker in html_lower:
+            return "challenge", f"маркер блока DomClick: {marker!r}"
+    return None
+
+
+async def _domclick_wait_ready(page: Any) -> None:
+    """BFF отдаёт JSON текстовым узлом в 
 (как gate-API Яндекса) — ждём его
+    появления коротко; на капче его не будет вовсе, и через 3 с отработает
+    _domclick_detect_block.
+    """
+    try:
+        await page.wait_for_selector("pre", timeout=3_000)
+    except Exception:  # noqa: BLE001 — блок/пустая страница разбирается ниже
+        pass
+
+
 @dataclass(frozen=True)
 class PlatformAdapter:
     """Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
@@ -566,6 +803,11 @@ class PlatformAdapter:
                 SimpleNamespace(avito_serp_ekb_only=False),  # type: ignore[arg-type]
                 target_city_slug="moskva",
             )
+        if self.name == "domclick":
+            # _map_item — чистая функция над dict (см. providers/domclick/serp.py),
+            # self._config/self._cookies там не читаются, поэтому пустой
+            # namespace достаточен — тот же приём, что и у CianScraper ниже.
+            return DomClickScraper(SimpleNamespace())  # type: ignore[arg-type]
         # glitchtip_dsn=None — отключает попытку sentry-репорта schema-regression
         # из _report_schema_regression (нет реального DSN в локальном прогоне).
         return CianScraper(SimpleNamespace(glitchtip_dsn=None))  # type: ignore[arg-type]
@@ -609,6 +851,18 @@ ADAPTERS: dict[str, PlatformAdapter] = {
         wait_ready=_yandex_wait_ready,
         default_target=YANDEX_TARGET_COUNT,
     ),
+    "domclick": PlatformAdapter(
+        name="domclick",
+        table="domclick_cards",
+        default_base_url=DEFAULT_DOMCLICK_BASE_URL,
+        page_size=DOMCLICK_PAGE_SIZE,
+        max_pages=DOMCLICK_MAX_PAGES,
+        build_url=_domclick_build_url,
+        extract_total_count=_domclick_extract_total,
+        parse_cards=_domclick_parse_cards,
+        detect_block=_domclick_detect_block,
+        wait_ready=_domclick_wait_ready,
+    ),
 }
 
 
@@ -1264,7 +1518,8 @@ async def collect(args: argparse.Namespace) -> int:
 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
     p = argparse.ArgumentParser(
         prog="collect.py",
-        description="Ручной сбор SERP Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.",
+        description="Ручной сбор SERP Авито/Циан/Яндекса/DomClick (вторичка, Москва+МО) в"
+                    " прод-схему msk_raw.",
     )
     p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
                    help="площадка сбора (дефолт avito)")

From d5c876e3d0b9c1ca87b9768eee92ce96ceaf90b9 Mon Sep 17 00:00:00 2001
From: bot-backend 
Date: Sat, 12 Sep 2026 15:24:21 +0300
Subject: [PATCH 4/5] =?UTF-8?q?fix(tradein):=20=D0=B2=D0=BA=D0=BB=D1=8E?=
 =?UTF-8?q?=D1=87=D0=B0=D0=B5=D0=BC=20idempotency-key=20=D0=BD=D0=B0=20?=
 =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5,=20=D1=87=D0=B8=D0=BD?=
 =?UTF-8?q?=D0=B8=D0=BC=20assert-crash=20=D0=B8=20=D1=87=D0=B5=D1=81=D1=82?=
 =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BA=D1=81=D1=82?=
 =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2=20(#3471)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Ревью PR #3495 нашло, что механизм был мёртвым кодом: фронт не отправлял
Idempotency-Key ни в одном запросе, весь прод-трафик шёл по ненадёжному
fallback-отпечатку. Плюс два "assert" в record_inbound после ON CONFLICT
давали AssertionError (не ловится except SQLAlchemyError) уже ПОСЛЕ
доставки в Telegram — под `python -O` assert и вовсе исчезает.

- useSupportChat.ts: useSendSupportMessage генерирует Idempotency-Key
  (crypto.randomUUID()) на намерение отправить, переиспользует его при
  повторной отправке ТОГО ЖЕ текста, сбрасывает на успехе.
- web_support_storage.record_inbound: assert -> явные ветки с логом;
  логируем отброшенный topic_message_id проигравшего гонку (не молча).
- Докстринги/комментарии переписаны честно: что именно закрывает
  pre-check (ответ клиенту потерян / двойной клик после успеха), а что
  НЕ закрывает (сетевую потерю на плече Selectel -> Telegram — там
  сообщение просто не доставлено, повтор это законная первая попытка).

Co-Authored-By: Claude Opus 5 
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
---
 tradein-mvp/backend/app/api/v1/support.py     | 45 +++++++----
 .../app/services/tgbot/web_support_storage.py | 74 +++++++++++++++++--
 .../frontend/src/lib/useSupportChat.ts        | 30 +++++++-
 3 files changed, 126 insertions(+), 23 deletions(-)

diff --git a/tradein-mvp/backend/app/api/v1/support.py b/tradein-mvp/backend/app/api/v1/support.py
index 07406a23..5183efbe 100644
--- a/tradein-mvp/backend/app/api/v1/support.py
+++ b/tradein-mvp/backend/app/api/v1/support.py
@@ -358,15 +358,25 @@ def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str)
     """Ключ идемпотентности inbound-отправки (#3471, миграция 301).
 
     Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key` —
-    предпочтительный путь: клиент генерирует ключ ОДИН раз на "намерение
+    ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
     отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
     после таймаута) независимо от того, что именно менялось в UI между
-    попытками. Без заголовка (старые клиенты, п.5 требования — они не должны
-    сломаться) используем детерминированный отпечаток sha256(identity, текст,
-    минутное окно): типичный повтор (двойной клик, ретрай браузера) укладывается
-    в секунды, минутное окно с запасом это покрывает, а любые два РАЗНЫХ по
-    смыслу сообщения с одинаковым текстом, отправленные намеренно с разницей в
-    пару минут, в одно не схлопнутся.
+    попытками (см. `useSendSupportMessage` во фронтенде — реальный трафик
+    обязан идти этим путём). Fallback без заголовка — детерминированный
+    отпечаток sha256(identity, текст, минутное окно) — существует ТОЛЬКО для
+    клиентов, которые заголовок не прислали (п.5 требования — старое поведение
+    не должно сломаться), и у него два честных изъяна, оба снимаются самим
+    фактом использования заголовка:
+      1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
+         же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
+         СХЛОПНУТСЯ в одно — второе будет молча проглочено, клиент получит id
+         первого, оператор не увидит второе сообщение вообще;
+      2. окно — не скользящие 60 секунд, а округление `time.time() // 60` вниз:
+         фактический срок дедупликации случаен от 0 до 60с в зависимости от
+         момента внутри минуты, и часть настоящих повторов (ретрай ровно на
+         границе окна) эту защиту не получит.
+    Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
+    (см. `useSendSupportMessage`).
 
     Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ —
     он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
@@ -422,12 +432,21 @@ async def send_support_message(
     if cooldown is not None:
         raise _too_many_failures_error(cooldown)
 
-    # Идемпотентность (#3471): pre-check ДО похода в Telegram — повтор с тем же
-    # ключом не должен слать второе зеркало в топик, не только не писать вторую
-    # строку в БД. Тред может ещё не существовать (это первая отправка этого
-    # ключа) — тогда сравнивать не с чем, идём в Telegram как обычно; сам факт
-    # "нет треда" здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке
-    # (см. H1) — если бы он уже был записан под этим ключом, тред уже был бы.
+    # Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
+    # переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
+    # успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
+    # плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
+    # `send_message` не удался, ключ нигде не записан, и повтор клиента после
+    # неудачи это законная первая попытка. Реально закрывается другой, тоже
+    # частый случай: доставка УЖЕ состоялась (Telegram принял, строка
+    # закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
+    # клиентский таймаут) или пользователь кликнул "отправить" второй раз по
+    # той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
+    # записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
+    # Тред может ещё не существовать (это первая попытка этого ключа) — тогда
+    # сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
+    # здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
+    # если бы сообщение под этим ключом уже было записано, тред уже был бы.
     idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
     existing_thread_id = storage.find_thread_id(db, username)
     if existing_thread_id is not None:
diff --git a/tradein-mvp/backend/app/services/tgbot/web_support_storage.py b/tradein-mvp/backend/app/services/tgbot/web_support_storage.py
index feb9ec2b..8d80e1f1 100644
--- a/tradein-mvp/backend/app/services/tgbot/web_support_storage.py
+++ b/tradein-mvp/backend/app/services/tgbot/web_support_storage.py
@@ -149,14 +149,74 @@ def record_inbound(
     )
     if row is not None:
         return dict(row)
-    # Конфликт пойман индексом (см. докстринг выше) — idempotency_key точно не
-    # NULL здесь: при NULL partial-индекс в конфликт не участвует вообще.
-    assert idempotency_key is not None
-    existing = find_inbound_by_idempotency_key(
-        db, thread_id=thread_id, idempotency_key=idempotency_key
+
+    # Конфликт пойман индексом — idempotency_key почти наверняка не NULL здесь
+    # (при NULL partial-индекс в конфликт не участвует), но НЕ `assert`: это
+    # прод-путь ПОСЛЕ уже доставленного в Telegram сообщения (см. H1 в
+    # app.api.v1.support), а `except SQLAlchemyError` в вызывающей стороне
+    # `AssertionError` не ловит — под `python -O` assert вдобавок исчезает
+    # молча и `existing` осталось бы `None`, что развалилось бы чуть ниже при
+    # сборке ответа. Вместо падения — явная ветка с логом и WORKING откатом.
+    existing = (
+        find_inbound_by_idempotency_key(db, thread_id=thread_id, idempotency_key=idempotency_key)
+        if idempotency_key is not None
+        else None
     )
-    assert existing is not None  # конфликт по индексу гарантирует наличие строки
-    return existing
+    if existing is not None:
+        # Ожидаемый случай гонки (#3471): проигравший запрос уже отправил своё
+        # СОБСТВЕННОЕ зеркало в Telegram (свой topic_message_id) до того, как
+        # обнаружил конфликт здесь — эта копия зеркала теперь осиротела
+        # (реплай оператора на неё никуда не смаршрутизируется, т.к. строки
+        # для неё в БД нет). Осознанно не чиним это в этом PR (см. коммент
+        # выше по коду), но фиксируем в логе, а не молчим — ровно то же самое,
+        # для чего уже есть `_warn_operator_message_not_recorded` в другом месте.
+        logger.warning(
+            "web support: гонка по idempotency_key — зеркало topic_message_id=%s "
+            "(thread_id=%d) отправлено, но НЕ записано, победила строка id=%d",
+            topic_message_id,
+            thread_id,
+            existing["id"],
+        )
+        return existing
+
+    # Крайний случай (в норме недостижим при READ COMMITTED, которую использует
+    # этот сервис): индекс сообщил о конфликте, но повторное чтение строку не
+    # нашло. Логируем и пишем БЕЗ идемпотентности — NULL-ключ никогда не
+    # конфликтует сам с собой (partial-индекс его не видит), INSERT гарантированно
+    # пройдёт; для этого одного сообщения дедупликация отключается, но клиент
+    # получает корректный ответ вместо 500 после уже состоявшейся доставки.
+    logger.warning(
+        "web support: ON CONFLICT сообщил о конфликте (thread_id=%d, "
+        "idempotency_key=%s), но повторное чтение строки её не нашло — "
+        "записываем без идемпотентности",
+        thread_id,
+        "" if idempotency_key is not None else None,
+    )
+    row = (
+        db.execute(
+            text(
+                """
+                INSERT INTO web_support_messages
+                    (thread_id, direction, text_body, topic_message_id,
+                     support_chat_id, operator_tg_id, idempotency_key, created_at)
+                VALUES
+                    (CAST(:thread_id AS bigint), 'in', :text_body,
+                     CAST(:topic_message_id AS bigint),
+                     CAST(:support_chat_id AS bigint), NULL, NULL, NOW())
+                RETURNING id, direction, text_body, operator_tg_id, created_at
+                """
+            ),
+            {
+                "thread_id": thread_id,
+                "text_body": text_body,
+                "topic_message_id": topic_message_id,
+                "support_chat_id": support_chat_id,
+            },
+        )
+        .mappings()
+        .one()
+    )
+    return dict(row)
 
 
 def find_thread_by_topic_message(
diff --git a/tradein-mvp/frontend/src/lib/useSupportChat.ts b/tradein-mvp/frontend/src/lib/useSupportChat.ts
index 209b2984..cdf712b5 100644
--- a/tradein-mvp/frontend/src/lib/useSupportChat.ts
+++ b/tradein-mvp/frontend/src/lib/useSupportChat.ts
@@ -42,6 +42,7 @@
  * setQueryData-merge path that would be pure incidental complexity here.
  */
 
+import { useRef } from "react";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 
 import { apiFetch, HTTPError } from "@/lib/api";
@@ -129,13 +130,36 @@ export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth")
 
 export function useSendSupportMessage(scope: SupportScope = "auth") {
   const queryClient = useQueryClient();
+  // Идемпотентность (#3471): ОДИН `Idempotency-Key` на все повторы ОДНОГО
+  // намерения отправить. Бэкенд пишет строку/ключ ТОЛЬКО ПОСЛЕ успешной
+  // доставки в Telegram, поэтому это не про потери на плече Selectel ->
+  // api.telegram.org (неудачный send там — законная первая попытка) — это
+  // про то, что доставка УЖЕ состоялась, а ответ до клиента не дошёл (обрыв
+  // на обратном пути / клиентский таймаут) или пользователь дважды нажал
+  // "отправить" по одной и той же ещё не отрисовавшейся отправке. Без
+  // заголовка единственная защита — backend-fallback (минутный
+  // hash-отпечаток, `_resolve_idempotency_key` в app/api/v1/support.py), а
+  // он ненадёжен для осмысленно повторяющихся коротких сообщений ("+", "ок",
+  // два "да" подряд). Намерение отождествляем с текстом: тот же текст подряд —
+  // это ретрай ЭТОГО намерения, переиспользуем ключ; новый текст — новое
+  // намерение, новый UUID.
+  const intentRef = useRef<{ text: string; key: string } | null>(null);
+
   return useMutation({
-    mutationFn: (text) =>
-      apiFetch(`${scopeBase(scope)}/messages`, {
+    mutationFn: (text) => {
+      if (!intentRef.current || intentRef.current.text !== text) {
+        intentRef.current = { text, key: crypto.randomUUID() };
+      }
+      return apiFetch(`${scopeBase(scope)}/messages`, {
         method: "POST",
+        headers: { "Idempotency-Key": intentRef.current.key },
         body: JSON.stringify({ text }),
-      }),
+      });
+    },
     onSuccess: () => {
+      // Успех закрывает намерение — следующая отправка (даже с тем же текстом)
+      // обязана получить НОВЫЙ ключ, иначе она молча схлопнется с этой.
+      intentRef.current = null;
       queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
     },
   });

From dfe7910bb77322477a762218d8debf7a9a42901f Mon Sep 17 00:00:00 2001
From: lekss361 
Date: Sat, 12 Sep 2026 12:26:09 +0000
Subject: [PATCH 5/5] =?UTF-8?q?=D0=94=D0=BE=D0=BC=D0=9A=D0=BB=D0=B8=D0=BA?=
 =?UTF-8?q?=20=D0=BA=D0=B0=D0=BA=20=D1=87=D0=B5=D1=82=D0=B2=D1=91=D1=80?=
 =?UTF-8?q?=D1=82=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?=
 =?UTF-8?q?=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BB=D0=B8=D0=B2=D0=BA=D0=B8?=
 =?UTF-8?q?=20=D0=BC=D0=BE=D1=81=D0=BA=D0=BE=D0=B2=D1=81=D0=BA=D0=BE=D0=B3?=
 =?UTF-8?q?=D0=BE=20=D1=81=D1=8B=D1=80=D1=8C=D1=8F=20=D0=B2=20listings=20(?=
 =?UTF-8?q?#3497)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../backend/app/tasks/msk_raw_import.py       | 31 +++++++++++++++++++
 .../backend/tests/test_msk_raw_import.py      | 22 +++++++++++++
 2 files changed, 53 insertions(+)

diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py
index 0496e6a5..18a8df25 100644
--- a/tradein-mvp/backend/app/tasks/msk_raw_import.py
+++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py
@@ -77,10 +77,21 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
 значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
 заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
 
+Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине:
+сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне
+сборщика, поэтому областных карточек в нём нет по построению. Регион читается
+ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр
+оставлен второй линией — на случай, если в ту же таблицу когда-нибудь приедет
+второй город. Замер по 5 024 карточкам первого прохода: единственное значение
+первого компонента — «Москва». Новая Москва приходит как «Москва, пос. Птичное,
+…», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты
+заполнены у 100% карточек.
+
 Запуск:
     python -m app.tasks.msk_raw_import --dry-run
     python -m app.tasks.msk_raw_import --limit 500
     python -m app.tasks.msk_raw_import --source yandex
+    python -m app.tasks.msk_raw_import --source domclick
     python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
     python -m app.tasks.msk_raw_import --source avito --allow-unfiltered  # аварийный
 """
@@ -160,6 +171,7 @@ SOURCE_VIEWS = {
     "cian": "msk_raw.cian_latest",
     "avito": "msk_raw.avito_latest",
     "yandex": "msk_raw.yandex_latest",
+    "domclick": "msk_raw.domclick_latest",
 }
 
 _PAGE_SQL = """
@@ -485,11 +497,30 @@ def is_moscow_yandex_address(address: str | None) -> bool:
     return len(parts) > 1 and parts[1] == "Москва"
 
 
+def is_moscow_domclick_address(address: str | None) -> bool:
+    """У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
+
+    Источник собран запросом с GUID-ом Москвы в параметре address и дополнительно
+    отфильтрован по bbox на стороне сборщика, так что областных карточек в сырье
+    и не должно быть. Фильтр здесь — вторая линия: сменится GUID в сборщике или
+    появится второй город в той же таблице — импорт не потащит его в Москву
+    молча. Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно
+    одно значение, «Москва», областных нет ни одной.
+
+    Новая Москва отдельным значением НЕ приходит: «Москва, x. Ильичевка, …»,
+    «Москва, пос. Птичное, …» — посёлок стоит ВТОРЫМ компонентом, первый всегда
+    город, что совпадает с кодом региона 77.
+    """
+    parts = [part.strip() for part in (address or "").split(",")]
+    return bool(parts) and parts[0] == "Москва"
+
+
 # Источники, у которых город виден в самой карточке. Ключ отсутствует —
 # источник про город молчит, и без пред-геокода писать его нельзя (avito).
 CITY_FILTERS = {
     "cian": is_moscow_address,
     "yandex": is_moscow_yandex_address,
+    "domclick": is_moscow_domclick_address,
 }
 
 
diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py
index 4042a7a0..b9e7ad58 100644
--- a/tradein-mvp/backend/tests/test_msk_raw_import.py
+++ b/tradein-mvp/backend/tests/test_msk_raw_import.py
@@ -461,3 +461,25 @@ def test_geocode_is_refused_for_yandex(captured: list[dict[str, Any]]) -> None:
         mod.import_msk_raw(
             _FakeSession([{"id": 1, "payload": _yandex()}]), source="yandex", geocode=True
         )
+
+
+def test_domclick_moscow_is_recognised_without_geocode() -> None:
+    """Регион ДомКлика — первый компонент адреса; Новая Москва тоже Москва."""
+    assert mod.is_moscow_domclick_address("Москва, улица Нижние Мнёвники, 16 к2")
+    assert mod.is_moscow_domclick_address("Москва, пос. Птичное, Лесная улица, 82")
+    assert mod.is_moscow_domclick_address("Москва, x. Ильичевка, хутор Ильичёвка, 1")
+
+
+def test_domclick_non_moscow_is_rejected() -> None:
+    """Вторая линия обороны: GUID в сборщике сменится — импорт не смолчит."""
+    assert not mod.is_moscow_domclick_address("Московская область, Химки, 9 Мая, 21к3")
+    assert not mod.is_moscow_domclick_address("Екатеринбург, улица Ясная, 2")
+    # «Москва» должна быть отдельным компонентом, а не префиксом другого слова.
+    assert not mod.is_moscow_domclick_address("Московский, 1-я улица, 5")
+    assert not mod.is_moscow_domclick_address("")
+    assert not mod.is_moscow_domclick_address(None)
+
+
+def test_domclick_view_is_wired_into_source_views() -> None:
+    assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest"
+    assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address